[
  {
    "path": ".dockerignore",
    "content": "# Build artifacts\nbuild/\ntmp/\n*.exe\n*.dll\n*.so\n*.dylib\n\n# UI build artifacts (will be built in container)\nui/node_modules/\nui/dist/\n\n# Embedded UI dist (will be copied from ui-builder stage)\ninternal/server/dist/\n\n# Development files\n.git/\n.gitignore\n.air.toml\n.vscode/\n.idea/\n*.md\n!README.md\n\n# Test files\n*_test.go\n**/*_test.go\n\n# Config and secrets\n*.env\n*.local\nconfig.yml\nconfig.yaml\n\n# OS files\n.DS_Store\nThumbs.db\n\n# Docker files (not needed inside container)\nDockerfile\ndocker-compose*.yml\n.dockerignore\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    name: Test\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n\n      - name: Create placeholders for embed\n        run: |\n          mkdir -p internal/server/dist && touch internal/server/dist/.gitkeep\n\n      - name: Run tests\n        run: CGO_ENABLED=0 go test -v ./...\n\n  lint:\n    name: Lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n\n      - name: Create placeholders for embed\n        run: |\n          mkdir -p internal/server/dist && touch internal/server/dist/.gitkeep\n\n      - name: Run staticcheck\n        uses: dominikh/staticcheck-action@v1\n        with:\n          version: \"latest\"\n        env:\n          CGO_ENABLED: \"0\"\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*.*.*\"\n\npermissions:\n  contents: write\n  packages: write\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"1.25.4\"\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"22\"\n\n      - name: Build UI\n        run: |\n          cd ui && npm install && npm run build\n          cd ..\n          mkdir -p internal/server/dist\n          rm -rf internal/server/dist/*\n          cp -r ui/dist/* internal/server/dist/\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v6\n        with:\n          distribution: goreleaser\n          version: \"~> v2\"\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}\n\n      - name: Extract version\n        id: version\n        run: echo \"VERSION=${GITHUB_REF#refs/tags/v}\" >> $GITHUB_OUTPUT\n\n      - name: Prepare latest zips\n        run: |\n          mkdir -p latest\n          cp dist/vget_${{ steps.version.outputs.VERSION }}_darwin_amd64.zip latest/vget-darwin-amd64.zip\n          cp dist/vget_${{ steps.version.outputs.VERSION }}_darwin_arm64.zip latest/vget-darwin-arm64.zip\n          cp dist/vget_${{ steps.version.outputs.VERSION }}_linux_amd64.zip latest/vget-linux-amd64.zip\n          cp dist/vget_${{ steps.version.outputs.VERSION }}_linux_arm64.zip latest/vget-linux-arm64.zip\n          cp dist/vget_${{ steps.version.outputs.VERSION }}_windows_amd64.zip latest/vget-windows-amd64.zip\n\n      - name: Upload latest zips\n        uses: softprops/action-gh-release@v2\n        with:\n          files: |\n            latest/vget-darwin-amd64.zip\n            latest/vget-darwin-arm64.zip\n            latest/vget-linux-amd64.zip\n            latest/vget-linux-arm64.zip\n            latest/vget-windows-amd64.zip\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  docker-amd64:\n    if: ${{ !contains(github.ref_name, '-') }}\n    runs-on: ubuntu-latest\n    outputs:\n      digest: ${{ steps.build.outputs.digest }}\n    steps:\n      - name: Free disk space\n        run: |\n          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL\n          sudo docker image prune --all --force\n\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build and push by digest\n        id: build\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/vget/Dockerfile\n          platforms: linux/amd64\n          outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true\n\n  docker-arm64:\n    if: ${{ !contains(github.ref_name, '-') }}\n    runs-on: ubuntu-24.04-arm\n    outputs:\n      digest: ${{ steps.build.outputs.digest }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build and push by digest\n        id: build\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/vget/Dockerfile.arm64\n          platforms: linux/arm64\n          outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true\n\n  docker-manifest:\n    if: ${{ !contains(github.ref_name, '-') }}\n    runs-on: ubuntu-latest\n    needs: [docker-amd64, docker-arm64]\n    steps:\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository }}\n          tags: |\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=raw,value=latest\n\n      - name: Create and push manifest\n        run: |\n          for tag in $(echo \"${{ steps.meta.outputs.tags }}\" | tr ',' '\\n'); do\n            docker buildx imagetools create -t \"$tag\" \\\n              \"ghcr.io/${{ github.repository }}@${{ needs.docker-amd64.outputs.digest }}\" \\\n              \"ghcr.io/${{ github.repository }}@${{ needs.docker-arm64.outputs.digest }}\"\n          done\n"
  },
  {
    "path": ".gitignore",
    "content": "# Build output\n/build/\n/dist/\n/internal/server/dist/\n\n# Binary\n/vget\n\n# IDE\n.idea/\n*.swp\n*.swo\n\n# OS\n.DS_Store\nThumbs.db\n\n# Test output\ncoverage.out\n*.test\n\n# Temporary files\n*.tmp\n.vget-meta.json\n/tmp/\n/downloads/\n"
  },
  {
    "path": ".goreleaser.yaml",
    "content": "version: 2\n\nproject_name: vget\n\nbefore:\n  hooks:\n    - go mod tidy\n\nbuilds:\n  - main: ./cmd/vget\n    binary: vget\n    env:\n      - CGO_ENABLED=0\n    ldflags:\n      - -s -w\n      - -X github.com/guiyumin/vget/internal/core/version.Version={{.Version}}\n      - -X github.com/guiyumin/vget/internal/core/version.Commit={{.Commit}}\n      - -X github.com/guiyumin/vget/internal/core/version.Date={{.Date}}\n    goos:\n      - darwin\n      - linux\n      - windows\n    goarch:\n      - amd64\n      - arm64\n    ignore:\n      - goos: windows\n        goarch: arm64\n\narchives:\n  - formats:\n      - zip\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- .Version }}_\n      {{- .Os }}_\n      {{- .Arch }}\n      {{- if .Arm }}v{{ .Arm }}{{ end }}\n    files:\n      - README.md\n      - LICENSE*\n\nchecksum:\n  name_template: \"checksums.txt\"\n  algorithm: sha256\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^test:\"\n      - \"^chore:\"\n      - \"^ci:\"\n      - Merge pull request\n      - Merge branch\n\nrelease:\n  github:\n    owner: guiyumin\n    name: vget\n  draft: false\n  prerelease: auto\n  name_template: \"v{{.Version}}\"\n\nbrews:\n  - repository:\n      owner: guiyumin\n      name: homebrew-tap\n      token: \"{{ .Env.TAP_GITHUB_TOKEN }}\"\n    homepage: \"https://github.com/guiyumin/vget\"\n    description: \"Media downloader CLI for various platforms\"\n    license: \"Apache-2.0\"\n    directory: Formula\n    commit_author:\n      name: goreleaserbot\n      email: bot@goreleaser.com\n    commit_msg_template: \"{{ .ProjectName }}: update to {{ .Tag }}\"\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"makefile.configureOnOpen\": false,\n  \"diffEditor.renderSideBySide\": false,\n  \"diffEditor.hideUnchangedRegions.enabled\": false,\n  \"gopls\": {\n    \"build.env\": {\n      \"CGO_ENABLED\": \"0\"\n    }\n  }\n}\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Build Commands\n\n```bash\n# Build\ngo build ./cmd/vget\n\n# Build to specific directory\ngo build -o build/vget ./cmd/vget\n\n# Run directly\ngo run ./cmd/vget\n\n# Build with version info (for releases)\ngo build -ldflags \"-X github.com/guiyumin/vget/internal/version.Version=1.0.0\" ./cmd/vget\n```\n\n## Architecture\n\nvget is a media downloader CLI built with Go. It uses Cobra for command parsing and Bubbletea for interactive TUI elements (spinners, progress bars).\n\n### Core Flow\n\n1. **CLI Layer** (`internal/cli/`) - Cobra commands parse flags and dispatch to handlers\n2. **Extractor Layer** (`internal/extractor/`) - URL matching and media metadata extraction\n3. **Downloader Layer** (`internal/downloader/`) - HTTP download with Bubbletea progress TUI\n\n### Media Types\n\nThe `MediaType` enum in `internal/extractor/extractor.go` defines supported media types:\n\n- `MediaTypeVideo` - Video files (Twitter, YouTube, etc.)\n- `MediaTypeAudio` - Audio files (podcasts)\n- `MediaTypePDF` - PDF documents\n- `MediaTypeEPUB` - EPUB ebooks\n- `MediaTypeMOBI` - MOBI ebooks\n- `MediaTypeAZW` - AZW ebooks\n- `MediaTypeUnknown` - Fallback (treated as video)\n\nEach type has specific terminal output formatting in `internal/cli/extract.go`.\n\n### Extractor Pattern\n\nTo add support for a new site, implement the `Extractor` interface in `internal/extractor/`:\n\n```go\ntype Extractor interface {\n    Name() string\n    Match(url string) bool\n    Extract(url string) (*VideoInfo, error)\n}\n```\n\nSet the appropriate `MediaType` in the returned `VideoInfo`:\n\n```go\nreturn &VideoInfo{\n    ID:        \"...\",\n    Title:     \"...\",\n    MediaType: MediaTypeAudio, // or MediaTypeVideo, etc.\n    Formats:   []Format{...},\n}, nil\n```\n\nExtractors are auto-registered via `init()` functions. See `xiaoyuzhou.go` or `twitter.go` for examples.\n\n### Commands\n\n- `vget <url>` - Download media from URL\n- `vget init` - Interactive config wizard (TUI)\n- `vget update` - Self-update to latest version\n- `vget search --podcast <query>` - Search Xiaoyuzhou podcasts\n- `vget ls <remote>:<path>` - List WebDAV remote directory\n- `vget config show` - Show current configuration\n- `vget config set <key> <value>` - Set config value (non-interactive)\n- `vget config get <key>` - Get config value\n- `vget config webdav ...` - Manage WebDAV servers\n\n### i18n\n\nTranslations are embedded YAML files in `internal/i18n/locales/`. Supported: en, zh, jp, kr, es, fr, de.\n\nAccess translations via `i18n.T(langCode)` which returns a `*Translations` struct with typed fields.\n\n### Config\n\nUser config lives in `~/.config/vget/config.yml`. Two ways to configure:\n\n1. **Interactive (TUI):** `vget init` - Bubbletea wizard for first-time setup\n2. **Non-interactive:** `vget config set <key> <value>` - For scripting/Docker\n\nSupported keys for `vget config set`:\n\n- `language` - Language code (en, zh, jp, kr, es, fr, de)\n- `output_dir` - Default download directory\n- `format` - Preferred format (mp4, webm, best)\n- `quality` - Default quality (1080p, 720p, best)\n- `twitter.auth_token` - Twitter auth for NSFW content\n\n**IMPORTANT:** Config is read fresh on every command execution (not cached at startup). This is intentional and MUST be preserved:\n\n- Enables config changes without restart\n- Critical for Docker UX (no container restart needed)\n- Never change this behavior\n\n### Xiaohongshu (XHS) Extractor\n\nThe XHS extractor (`internal/extractor/xiaohongshu.go`) uses browser automation:\n\n- **Browser**: Rod's auto-downloaded Chromium (NOT system Chrome)\n- **Binary location**: `~/.cache/rod/browser/`\n- **User data**: `~/.config/vget/browser/` (persistent, shared by all extractors)\n- **Stealth**: Uses `go-rod/stealth` for anti-bot detection\n\n**Important**: Never use system Chrome profiles with browser automation - it can corrupt session data.\n\n### Self-Update\n\n`internal/updater/` uses go-selfupdate to fetch releases from GitHub (`guiyumin/vget`). Version is set in `internal/version/version.go`.\n\n# My Rules\n\n- **MUST** USE ./build AS THE BUILD OUTPUT DIRECTORY\n- **MUST** USE CGO_ENABLED=0 when building vget cli locally\n- **MUST NOT** RUN npm run dev in @ui\n- **MUST NOT** VERIFY or TEST your work, since I will test and verify it\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2025 Yumin\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License."
  },
  {
    "path": "Makefile",
    "content": ".PHONY: build build-ui build-metal build-cuda build-nocgo build-whisper push version patch minor major\n\nBUILD_DIR := ./build\nVERSION_FILE := internal/core/version/version.go\nUI_DIR := ./ui\nSERVER_DIST := ./internal/server/dist\n\n# Get current version from latest git tag (strips 'v' prefix)\nCURRENT_VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo \"0.0.0\")\n\n# Get whisper.cpp module path\nWHISPER_PATH := $(shell go list -m -f '{{.Dir}}' github.com/ggerganov/whisper.cpp/bindings/go 2>/dev/null)\n\nbuild-ui:\n\tcd $(UI_DIR) && npm install && npm run build\n\trm -rf $(SERVER_DIST)/*\n\tcp -r $(UI_DIR)/dist/* $(SERVER_DIST)/\n\n# Build whisper.cpp static library\nbuild-whisper:\n\t@if [ -z \"$(WHISPER_PATH)\" ]; then \\\n\t\techo \"Error: whisper.cpp module not found. Run 'go mod download' first.\"; \\\n\t\texit 1; \\\n\tfi\n\tcd \"$(WHISPER_PATH)\" && make whisper\n\t@echo \"whisper.cpp library built at $(WHISPER_PATH)/libwhisper.a\"\n\n# Standard build with CGO for whisper.cpp (CPU only)\n# Requires: make build-whisper (run once)\nbuild: build-ui\n\tCGO_ENABLED=1 \\\n\tC_INCLUDE_PATH=\"$(WHISPER_PATH)\" \\\n\tLIBRARY_PATH=\"$(WHISPER_PATH)\" \\\n\tgo build -o $(BUILD_DIR)/vget ./cmd/vget\n\tCGO_ENABLED=1 \\\n\tC_INCLUDE_PATH=\"$(WHISPER_PATH)\" \\\n\tLIBRARY_PATH=\"$(WHISPER_PATH)\" \\\n\tgo build -o $(BUILD_DIR)/vget-server ./cmd/vget-server\n\n# macOS with Metal acceleration (Apple Silicon)\n# Requires: WHISPER_METAL=1 make build-whisper (run once)\nbuild-metal: build-ui\n\tCGO_ENABLED=1 \\\n\tC_INCLUDE_PATH=\"$(WHISPER_PATH)\" \\\n\tLIBRARY_PATH=\"$(WHISPER_PATH)\" \\\n\tgo build -tags metal -o $(BUILD_DIR)/vget ./cmd/vget\n\tCGO_ENABLED=1 \\\n\tC_INCLUDE_PATH=\"$(WHISPER_PATH)\" \\\n\tLIBRARY_PATH=\"$(WHISPER_PATH)\" \\\n\tgo build -tags metal -o $(BUILD_DIR)/vget-server ./cmd/vget-server\n\n# Linux with CUDA acceleration (NVIDIA GPU)\n# Requires: GGML_CUDA=1 make build-whisper (run once)\nbuild-cuda: build-ui\n\tCGO_ENABLED=1 \\\n\tC_INCLUDE_PATH=\"$(WHISPER_PATH)\" \\\n\tLIBRARY_PATH=\"$(WHISPER_PATH)\" \\\n\tCGO_CFLAGS=\"-I/usr/local/cuda/include\" \\\n\tCGO_LDFLAGS=\"-L/usr/local/cuda/lib64\" \\\n\tgo build -tags cuda -o $(BUILD_DIR)/vget ./cmd/vget\n\tCGO_ENABLED=1 \\\n\tC_INCLUDE_PATH=\"$(WHISPER_PATH)\" \\\n\tLIBRARY_PATH=\"$(WHISPER_PATH)\" \\\n\tCGO_CFLAGS=\"-I/usr/local/cuda/include\" \\\n\tCGO_LDFLAGS=\"-L/usr/local/cuda/lib64\" \\\n\tgo build -tags cuda -o $(BUILD_DIR)/vget-server ./cmd/vget-server\n\n# Build without CGO (uses embedded whisper.cpp binary)\nbuild-nocgo: build-ui\n\tCGO_ENABLED=0 go build -o $(BUILD_DIR)/vget ./cmd/vget\n\tCGO_ENABLED=0 go build -o $(BUILD_DIR)/vget-server ./cmd/vget-server\n\npush:\n\tgit push origin main --tags\n\n# Version bump: make version <patch|minor|major>\nversion:\n\t@if [ -z \"$(filter patch minor major,$(MAKECMDGOALS))\" ]; then \\\n\t\techo \"Usage: make version <patch|minor|major>\"; \\\n\t\techo \"Current version: $(CURRENT_VERSION)\"; \\\n\t\texit 1; \\\n\tfi\n\npatch minor major: version\n\t@TYPE=$@ && \\\n\techo \"Current version: $(CURRENT_VERSION)\" && \\\n\tNEW_VERSION=$$(echo \"$(CURRENT_VERSION)\" | awk -F. -v type=\"$$TYPE\" '{ \\\n\t\tsplit($$3, parts, \"-\"); \\\n\t\tpatch = parts[1]; \\\n\t\tif (index($$3, \"-\") > 0) { print $$1\".\"$$2\".\"patch } \\\n\t\telse if (type == \"major\") { print $$1+1\".0.0\" } \\\n\t\telse if (type == \"minor\") { print $$1\".\"$$2+1\".0\" } \\\n\t\telse { print $$1\".\"$$2\".\"$$3+1 } \\\n\t}') && \\\n\tBUILD_DATE=$$(date -u +\"%Y-%m-%d\") && \\\n\techo \"New version: $$NEW_VERSION\" && \\\n\techo \"Build date: $$BUILD_DATE\" && \\\n\tsed -i '' 's/Version = \".*\"/Version = \"'$$NEW_VERSION'\"/' $(VERSION_FILE) && \\\n\tsed -i '' 's/Date    = \".*\"/Date    = \"'$$BUILD_DATE'\"/' $(VERSION_FILE) && \\\n\tgit add $(VERSION_FILE) && \\\n\tgit commit -m \"chore: bump version to v$$NEW_VERSION\" && \\\n\tgit tag \"v$$NEW_VERSION\" && \\\n\techo \"Created tag v$$NEW_VERSION\" && \\\n\techo \"Run 'make push' to push changes and trigger release\"\n"
  },
  {
    "path": "README.md",
    "content": "# vget\n\nVersatile downloader for audio, video, podcasts, PDFs and more. Available as CLI and Docker\n\n[简体中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md) | [Español](README_es.md) | [Français](README_fr.md) | [Deutsch](README_de.md)\n\n## Installation\n\n### macOS\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-darwin-arm64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Linux / WSL\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-linux-amd64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Windows\n\nDownload `vget-windows-amd64.zip` from [Releases](https://github.com/guiyumin/vget/releases/latest), extract it, and add to your PATH.\n\n## Screenshots\n\n### Download Progress\n\n![Download Progress](screenshots/pikpak_download.png)\n\n### Docker Server UI\n\n![](screenshots/vget_server_ui.png)\n\n## Docker\n\n```bash\ndocker run -d -p 8080:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget:latest\n```\n\n## Supported Sources\n\nSee [sites.md](sites.md) for the full list of supported sites.\n\n## Commands\n\n| Command                                | Description                              |\n| -------------------------------------- | ---------------------------------------- |\n| `vget [url]`                           | Download media (`-o`, `-q`, `--info`)    |\n| `vget ls <remote>:<path>`              | List remote directory (`--json`)         |\n| `vget init`                            | Interactive config wizard                |\n| `vget update`                          | Self-update (use `sudo` on Mac/Linux)    |\n| `vget search --podcast <query>`        | Search podcasts                          |\n| `vget completion [shell]`              | Generate shell completion script         |\n| `vget config show`                     | Show config                              |\n| `vget config set <key> <value>`        | Set config value (non-interactive)       |\n| `vget config get <key>`                | Get config value                         |\n| `vget config path`                     | Show config file path                    |\n| `vget config webdav list`              | List configured WebDAV servers           |\n| `vget config webdav add <name>`        | Add a WebDAV server                      |\n| `vget config webdav show <name>`       | Show server details                      |\n| `vget config webdav delete <name>`     | Delete a server                          |\n| `vget telegram login --import-desktop` | Import Telegram session from desktop app |\n\n### Examples\n\n```bash\nvget https://twitter.com/user/status/123456789\nvget https://www.xiaoyuzhoufm.com/episode/abc123\nvget https://www.xiaohongshu.com/explore/abc123  # XHS video/image\nvget https://example.com/video -o my_video.mp4\nvget --info https://example.com/video\nvget search --podcast \"tech news\"\nvget pikpak:/path/to/file.mp4              # WebDAV download\nvget ls pikpak:/Movies                     # List remote directory\n```\n\n## Configuration\n\nConfig file location:\n\n| OS          | Path                        |\n| ----------- | --------------------------- |\n| macOS/Linux | `~/.config/vget/config.yml` |\n| Windows     | `%APPDATA%\\vget\\config.yml` |\n\nRun `vget init` to create the config file interactively, or create it manually:\n\n```yaml\nlanguage: en # en, zh, jp, kr, es, fr, de\n```\n\n**Note:** Config is read fresh on every command. No restart required after changes (useful for Docker).\n\n## Updating\n\nTo update vget to the latest version:\n\n**macOS / Linux:**\n\n```bash\nsudo vget update\n```\n\n**Windows (run PowerShell as Administrator):**\n\n```powershell\nvget update\n```\n\n## Languages\n\nvget supports multiple languages:\n\n- English (en)\n- 中文 (zh)\n- 日本語 (jp)\n- 한국어 (kr)\n- Español (es)\n- Français (fr)\n- Deutsch (de)\n\n## License\n\nApache License 2.0\n"
  },
  {
    "path": "README_de.md",
    "content": "# vget\n\nVielseitiger Downloader für Audio, Video, Podcasts, PDFs und mehr. Verfügbar als CLI und Docker.\n\n[English](README.md) | [简体中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md) | [Español](README_es.md) | [Français](README_fr.md)\n\n## Installation\n\n### macOS\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-darwin-arm64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Linux / WSL\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-linux-amd64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Windows\n\nLaden Sie `vget-windows-amd64.zip` von [Releases](https://github.com/guiyumin/vget/releases/latest) herunter, entpacken Sie es und fügen Sie es zum PATH hinzu.\n\n## Screenshots\n\n### Download-Fortschritt\n\n![Download-Fortschritt](screenshots/pikpak_download.png)\n\n### Docker Server-Benutzeroberfläche\n\n![](screenshots/vget_server_ui.png)\n\n## Docker\n\n```bash\ndocker run -d -p 8080:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget:latest\n```\n\n## Unterstützte Quellen\n\nSiehe [sites.md](sites.md) für die vollständige Liste der unterstützten Seiten.\n\n## Befehle\n\n| Befehl                             | Beschreibung                          |\n|------------------------------------|---------------------------------------|\n| `vget [url]`                       | Medien herunterladen (`-o`, `-q`, `--info`) |\n| `vget ls <remote>:<path>`          | Remote-Verzeichnis auflisten (`--json`) |\n| `vget init`                        | Interaktiver Konfigurationsassistent  |\n| `vget update`                      | Aktualisieren (`sudo` auf Mac/Linux)  |\n| `vget search --podcast <query>`    | Podcasts suchen                       |\n| `vget completion [shell]`          | Shell-Vervollständigung generieren    |\n| `vget config show`                 | Konfiguration anzeigen                |\n| `vget config set <key> <value>`    | Konfigurationswert setzen (nicht interaktiv) |\n| `vget config get <key>`            | Konfigurationswert abrufen            |\n| `vget config path`                 | Konfigurationsdateipfad anzeigen      |\n| `vget config webdav list`          | Konfigurierte WebDAV-Server auflisten |\n| `vget config webdav add <name>`    | WebDAV-Server hinzufügen              |\n| `vget config webdav show <name>`   | Serverdetails anzeigen                |\n| `vget config webdav delete <name>` | Server löschen                        |\n| `vget telegram login --import-desktop` | Telegram-Sitzung von Desktop-App importieren |\n\n### Beispiele\n\n```bash\nvget https://twitter.com/user/status/123456789\nvget https://www.xiaoyuzhoufm.com/episode/abc123\nvget https://example.com/video -o mein_video.mp4\nvget --info https://example.com/video\nvget search --podcast \"tech news\"\nvget pikpak:/path/to/file.mp4              # WebDAV-Download\nvget ls pikpak:/Movies                     # Remote-Verzeichnis auflisten\n```\n\n## Konfiguration\n\nSpeicherort der Konfigurationsdatei:\n\n| OS          | Pfad                        |\n| ----------- | --------------------------- |\n| macOS/Linux | `~/.config/vget/config.yml` |\n| Windows     | `%APPDATA%\\vget\\config.yml` |\n\nFühren Sie `vget init` aus, um die Konfigurationsdatei interaktiv zu erstellen, oder erstellen Sie sie manuell:\n\n```yaml\nlanguage: de # en, zh, jp, kr, es, fr, de\n```\n\n**Hinweis:** Die Konfiguration wird bei jedem Befehl neu gelesen. Kein Neustart nach Änderungen erforderlich (nützlich für Docker).\n\n## Aktualisierung\n\nUm vget auf die neueste Version zu aktualisieren:\n\n**macOS / Linux:**\n```bash\nsudo vget update\n```\n\n**Windows (PowerShell als Administrator ausführen):**\n```powershell\nvget update\n```\n\n## Sprachen\n\nvget unterstützt mehrere Sprachen:\n\n- English (en)\n- 中文 (zh)\n- 日本語 (jp)\n- 한국어 (kr)\n- Español (es)\n- Français (fr)\n- Deutsch (de)\n\n## Lizenz\n\nApache License 2.0\n"
  },
  {
    "path": "README_es.md",
    "content": "# vget\n\nDescargador versátil para audio, video, podcasts, PDFs y más. Disponible como CLI y Docker.\n\n[English](README.md) | [简体中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md) | [Français](README_fr.md) | [Deutsch](README_de.md)\n\n## Instalación\n\n### macOS\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-darwin-arm64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Linux / WSL\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-linux-amd64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Windows\n\nDescarga `vget-windows-amd64.zip` desde [Releases](https://github.com/guiyumin/vget/releases/latest), extráelo y agrégalo al PATH.\n\n## Capturas de pantalla\n\n### Progreso de descarga\n\n![Progreso de descarga](screenshots/pikpak_download.png)\n\n### Interfaz del servidor Docker\n\n![](screenshots/vget_server_ui.png)\n\n## Docker\n\n```bash\ndocker run -d -p 8080:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget:latest\n```\n\n## Fuentes compatibles\n\nConsulta [sites.md](sites.md) para la lista completa de sitios compatibles.\n\n## Comandos\n\n| Comando                            | Descripción                           |\n|------------------------------------|---------------------------------------|\n| `vget [url]`                       | Descargar medios (`-o`, `-q`, `--info`) |\n| `vget ls <remote>:<path>`          | Listar directorio remoto (`--json`)   |\n| `vget init`                        | Asistente de configuración interactivo |\n| `vget update`                      | Actualizar (usar `sudo` en Mac/Linux) |\n| `vget search --podcast <query>`    | Buscar podcasts                       |\n| `vget completion [shell]`          | Generar script de autocompletado      |\n| `vget config show`                 | Mostrar configuración                 |\n| `vget config set <key> <value>`    | Establecer valor de config (no interactivo) |\n| `vget config get <key>`            | Obtener valor de configuración        |\n| `vget config path`                 | Mostrar ruta del archivo de config    |\n| `vget config webdav list`          | Listar servidores WebDAV configurados |\n| `vget config webdav add <name>`    | Agregar servidor WebDAV               |\n| `vget config webdav show <name>`   | Mostrar detalles del servidor         |\n| `vget config webdav delete <name>` | Eliminar servidor                     |\n| `vget telegram login --import-desktop` | Importar sesión de Telegram desde la app de escritorio |\n\n### Ejemplos\n\n```bash\nvget https://twitter.com/user/status/123456789\nvget https://www.xiaoyuzhoufm.com/episode/abc123\nvget https://example.com/video -o mi_video.mp4\nvget --info https://example.com/video\nvget search --podcast \"tech news\"\nvget pikpak:/path/to/file.mp4              # Descarga WebDAV\nvget ls pikpak:/Movies                     # Listar directorio remoto\n```\n\n## Configuración\n\nUbicación del archivo de configuración:\n\n| SO          | Ruta                        |\n| ----------- | --------------------------- |\n| macOS/Linux | `~/.config/vget/config.yml` |\n| Windows     | `%APPDATA%\\vget\\config.yml` |\n\nEjecuta `vget init` para crear el archivo de configuración interactivamente, o créalo manualmente:\n\n```yaml\nlanguage: es # en, zh, jp, kr, es, fr, de\n```\n\n**Nota:** La configuración se lee en cada comando. No se requiere reinicio después de cambios (útil para Docker).\n\n## Actualización\n\nPara actualizar vget a la última versión:\n\n**macOS / Linux:**\n```bash\nsudo vget update\n```\n\n**Windows (ejecutar PowerShell como Administrador):**\n```powershell\nvget update\n```\n\n## Idiomas\n\nvget soporta múltiples idiomas:\n\n- English (en)\n- 中文 (zh)\n- 日本語 (jp)\n- 한국어 (kr)\n- Español (es)\n- Français (fr)\n- Deutsch (de)\n\n## Licencia\n\nApache License 2.0\n"
  },
  {
    "path": "README_fr.md",
    "content": "# vget\n\nTéléchargeur polyvalent pour audio, vidéo, podcasts, PDFs et plus. Disponible en CLI et Docker.\n\n[English](README.md) | [简体中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md) | [Español](README_es.md) | [Deutsch](README_de.md)\n\n## Installation\n\n### macOS\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-darwin-arm64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Linux / WSL\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-linux-amd64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Windows\n\nTéléchargez `vget-windows-amd64.zip` depuis [Releases](https://github.com/guiyumin/vget/releases/latest), extrayez-le et ajoutez-le au PATH.\n\n## Captures d'écran\n\n### Progression du téléchargement\n\n![Progression du téléchargement](screenshots/pikpak_download.png)\n\n### Interface serveur Docker\n\n![](screenshots/vget_server_ui.png)\n\n## Docker\n\n```bash\ndocker run -d -p 8080:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget:latest\n```\n\n## Sources prises en charge\n\nConsultez [sites.md](sites.md) pour la liste complète des sites pris en charge.\n\n## Commandes\n\n| Commande                           | Description                           |\n|------------------------------------|---------------------------------------|\n| `vget [url]`                       | Télécharger des médias (`-o`, `-q`, `--info`) |\n| `vget ls <remote>:<path>`          | Lister un répertoire distant (`--json`) |\n| `vget init`                        | Assistant de configuration interactif |\n| `vget update`                      | Mise à jour (`sudo` sur Mac/Linux)    |\n| `vget search --podcast <query>`    | Rechercher des podcasts               |\n| `vget completion [shell]`          | Générer un script d'autocomplétion    |\n| `vget config show`                 | Afficher la configuration             |\n| `vget config set <key> <value>`    | Définir une valeur de config (non interactif) |\n| `vget config get <key>`            | Obtenir une valeur de configuration   |\n| `vget config path`                 | Afficher le chemin du fichier config  |\n| `vget config webdav list`          | Lister les serveurs WebDAV configurés |\n| `vget config webdav add <name>`    | Ajouter un serveur WebDAV             |\n| `vget config webdav show <name>`   | Afficher les détails du serveur       |\n| `vget config webdav delete <name>` | Supprimer un serveur                  |\n| `vget telegram login --import-desktop` | Importer la session Telegram depuis l'app de bureau |\n\n### Exemples\n\n```bash\nvget https://twitter.com/user/status/123456789\nvget https://www.xiaoyuzhoufm.com/episode/abc123\nvget https://example.com/video -o ma_video.mp4\nvget --info https://example.com/video\nvget search --podcast \"tech news\"\nvget pikpak:/path/to/file.mp4              # Téléchargement WebDAV\nvget ls pikpak:/Movies                     # Lister un répertoire distant\n```\n\n## Configuration\n\nEmplacement du fichier de configuration :\n\n| OS          | Chemin                      |\n| ----------- | --------------------------- |\n| macOS/Linux | `~/.config/vget/config.yml` |\n| Windows     | `%APPDATA%\\vget\\config.yml` |\n\nExécutez `vget init` pour créer le fichier de configuration de manière interactive, ou créez-le manuellement :\n\n```yaml\nlanguage: fr # en, zh, jp, kr, es, fr, de\n```\n\n**Note :** La configuration est lue à chaque commande. Pas de redémarrage nécessaire après modification (utile pour Docker).\n\n## Mise à jour\n\nPour mettre à jour vget vers la dernière version :\n\n**macOS / Linux :**\n```bash\nsudo vget update\n```\n\n**Windows (exécuter PowerShell en tant qu'Administrateur) :**\n```powershell\nvget update\n```\n\n## Langues\n\nvget prend en charge plusieurs langues :\n\n- English (en)\n- 中文 (zh)\n- 日本語 (jp)\n- 한국어 (kr)\n- Español (es)\n- Français (fr)\n- Deutsch (de)\n\n## Licence\n\nApache License 2.0\n"
  },
  {
    "path": "README_jp.md",
    "content": "# vget\n\nオーディオ、ビデオ、ポッドキャスト、PDFなどをダウンロードする多機能ツール。CLI と Docker で利用可能。\n\n[English](README.md) | [简体中文](README_zh.md) | [한국어](README_kr.md) | [Español](README_es.md) | [Français](README_fr.md) | [Deutsch](README_de.md)\n\n## インストール\n\n### macOS\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-darwin-arm64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Linux / WSL\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-linux-amd64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Windows\n\n[Releases](https://github.com/guiyumin/vget/releases/latest) から `vget-windows-amd64.zip` をダウンロードし、解凍して PATH に追加してください。\n\n## スクリーンショット\n\n### ダウンロード進捗\n\n![ダウンロード進捗](screenshots/pikpak_download.png)\n\n### Docker サーバー UI\n\n![](screenshots/vget_server_ui.png)\n\n## Docker\n\n```bash\ndocker run -d -p 8080:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget:latest\n```\n\n## 対応ソース\n\n対応サイトの一覧は [sites.md](sites.md) をご覧ください。\n\n## コマンド\n\n| コマンド                           | 説明                                  |\n|------------------------------------|---------------------------------------|\n| `vget [url]`                       | メディアをダウンロード (`-o`, `-q`, `--info`) |\n| `vget ls <remote>:<path>`          | リモートディレクトリを一覧表示 (`--json`) |\n| `vget init`                        | 対話式設定ウィザード                  |\n| `vget update`                      | 自動更新（Mac/Linux は `sudo` が必要）|\n| `vget search --podcast <query>`    | ポッドキャスト検索                    |\n| `vget completion [shell]`          | シェル補完スクリプトを生成            |\n| `vget config show`                 | 設定を表示                            |\n| `vget config set <key> <value>`    | 設定値を設定（非対話式）              |\n| `vget config get <key>`            | 設定値を取得                          |\n| `vget config path`                 | 設定ファイルのパスを表示              |\n| `vget config webdav list`          | 設定済み WebDAV サーバー一覧          |\n| `vget config webdav add <name>`    | WebDAV サーバーを追加                 |\n| `vget config webdav show <name>`   | サーバー詳細を表示                    |\n| `vget config webdav delete <name>` | サーバーを削除                        |\n| `vget telegram login --import-desktop` | デスクトップアプリから Telegram セッションをインポート |\n\n### 例\n\n```bash\nvget https://twitter.com/user/status/123456789\nvget https://www.xiaoyuzhoufm.com/episode/abc123\nvget https://example.com/video -o my_video.mp4\nvget --info https://example.com/video\nvget search --podcast \"tech news\"\nvget pikpak:/path/to/file.mp4              # WebDAV ダウンロード\nvget ls pikpak:/Movies                     # リモートディレクトリを一覧表示\n```\n\n## 設定\n\n設定ファイルの場所：\n\n| OS          | パス                        |\n| ----------- | --------------------------- |\n| macOS/Linux | `~/.config/vget/config.yml` |\n| Windows     | `%APPDATA%\\vget\\config.yml` |\n\n`vget init` で対話的に設定ファイルを作成するか、手動で作成してください：\n\n```yaml\nlanguage: jp # en, zh, jp, kr, es, fr, de\n```\n\n**注意：** 設定はコマンド実行ごとに読み込まれます。変更後の再起動は不要です（Docker に便利）。\n\n## 更新\n\nvget を最新バージョンに更新：\n\n**macOS / Linux:**\n```bash\nsudo vget update\n```\n\n**Windows（管理者として PowerShell を実行）:**\n```powershell\nvget update\n```\n\n## 言語\n\nvget は複数の言語をサポートしています：\n\n- English (en)\n- 中文 (zh)\n- 日本語 (jp)\n- 한국어 (kr)\n- Español (es)\n- Français (fr)\n- Deutsch (de)\n\n## ライセンス\n\nApache License 2.0\n"
  },
  {
    "path": "README_kr.md",
    "content": "# vget\n\n오디오, 비디오, 팟캐스트, PDF 등을 다운로드하는 다목적 도구. CLI 및 Docker로 사용 가능.\n\n[English](README.md) | [简体中文](README_zh.md) | [日本語](README_jp.md) | [Español](README_es.md) | [Français](README_fr.md) | [Deutsch](README_de.md)\n\n## 설치\n\n### macOS\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-darwin-arm64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Linux / WSL\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-linux-amd64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Windows\n\n[Releases](https://github.com/guiyumin/vget/releases/latest)에서 `vget-windows-amd64.zip`을 다운로드하고 압축을 푼 후 PATH에 추가하세요.\n\n## 스크린샷\n\n### 다운로드 진행률\n\n![다운로드 진행률](screenshots/pikpak_download.png)\n\n### Docker 서버 UI\n\n![](screenshots/vget_server_ui.png)\n\n## Docker\n\n```bash\ndocker run -d -p 8080:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget:latest\n```\n\n## 지원 소스\n\n지원 사이트 전체 목록은 [sites.md](sites.md)를 참조하세요.\n\n## 명령어\n\n| 명령어                             | 설명                                  |\n|------------------------------------|---------------------------------------|\n| `vget [url]`                       | 미디어 다운로드 (`-o`, `-q`, `--info`) |\n| `vget ls <remote>:<path>`          | 원격 디렉토리 목록 (`--json`)         |\n| `vget init`                        | 대화형 설정 마법사                    |\n| `vget update`                      | 자동 업데이트 (Mac/Linux는 `sudo` 필요) |\n| `vget search --podcast <query>`    | 팟캐스트 검색                         |\n| `vget completion [shell]`          | 쉘 자동완성 스크립트 생성             |\n| `vget config show`                 | 설정 표시                             |\n| `vget config set <key> <value>`    | 설정 값 지정 (비대화형)               |\n| `vget config get <key>`            | 설정 값 가져오기                      |\n| `vget config path`                 | 설정 파일 경로 표시                   |\n| `vget config webdav list`          | 설정된 WebDAV 서버 목록               |\n| `vget config webdav add <name>`    | WebDAV 서버 추가                      |\n| `vget config webdav show <name>`   | 서버 상세 정보 표시                   |\n| `vget config webdav delete <name>` | 서버 삭제                             |\n| `vget telegram login --import-desktop` | 데스크톱 앱에서 Telegram 세션 가져오기 |\n\n### 예시\n\n```bash\nvget https://twitter.com/user/status/123456789\nvget https://www.xiaoyuzhoufm.com/episode/abc123\nvget https://example.com/video -o my_video.mp4\nvget --info https://example.com/video\nvget search --podcast \"tech news\"\nvget pikpak:/path/to/file.mp4              # WebDAV 다운로드\nvget ls pikpak:/Movies                     # 원격 디렉토리 목록\n```\n\n## 설정\n\n설정 파일 위치:\n\n| OS          | 경로                        |\n| ----------- | --------------------------- |\n| macOS/Linux | `~/.config/vget/config.yml` |\n| Windows     | `%APPDATA%\\vget\\config.yml` |\n\n`vget init`으로 대화형으로 설정 파일을 생성하거나 수동으로 생성하세요:\n\n```yaml\nlanguage: kr # en, zh, jp, kr, es, fr, de\n```\n\n**참고:** 설정은 명령 실행 시마다 새로 읽습니다. 변경 후 재시작이 필요 없습니다 (Docker에 유용).\n\n## 업데이트\n\nvget을 최신 버전으로 업데이트:\n\n**macOS / Linux:**\n```bash\nsudo vget update\n```\n\n**Windows (관리자 권한으로 PowerShell 실행):**\n```powershell\nvget update\n```\n\n## 언어\n\nvget은 여러 언어를 지원합니다:\n\n- English (en)\n- 中文 (zh)\n- 日本語 (jp)\n- 한국어 (kr)\n- Español (es)\n- Français (fr)\n- Deutsch (de)\n\n## 라이선스\n\nApache License 2.0\n"
  },
  {
    "path": "README_zh.md",
    "content": "# vget\n\n多功能下载工具，支持音频、视频、播客、PDF等。提供 CLI 和 Docker 两种方式。\n\n[English](README.md) | [日本語](README_jp.md) | [한국어](README_kr.md) | [Español](README_es.md) | [Français](README_fr.md) | [Deutsch](README_de.md)\n\n## 安装\n\n### macOS\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-darwin-arm64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Linux / WSL\n\n```bash\ncurl -fsSL https://github.com/guiyumin/vget/releases/latest/download/vget-linux-amd64.zip -o vget.zip\nunzip vget.zip\nsudo mv vget /usr/local/bin/\nrm vget.zip\n```\n\n### Windows\n\n从 [Releases](https://github.com/guiyumin/vget/releases/latest) 下载 `vget-windows-amd64.zip`，解压后添加到系统 PATH。\n\n## 截图\n\n### 下载进度\n\n![下载进度](screenshots/pikpak_download.png)\n\n### Docker 服务器界面\n\n![](screenshots/vget_server_ui.png)\n\n## Docker\n\n```bash\ndocker run -d -p 8080:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget:latest\n```\n\n## 支持的来源\n\n查看 [sites.md](sites.md) 获取完整的支持网站列表。\n\n## 命令\n\n| 命令                               | 描述                                  |\n|------------------------------------|---------------------------------------|\n| `vget [url]`                       | 下载媒体 (`-o`, `-q`, `--info`)       |\n| `vget ls <remote>:<path>`          | 列出远程目录 (`--json`)               |\n| `vget init`                        | 交互式配置向导                        |\n| `vget update`                      | 自动更新（Mac/Linux 需使用 `sudo`）   |\n| `vget search --podcast <query>`    | 搜索播客                              |\n| `vget completion [shell]`          | 生成 shell 补全脚本                   |\n| `vget config show`                 | 显示配置                              |\n| `vget config set <key> <value>`    | 设置配置值（非交互式）                |\n| `vget config get <key>`            | 获取配置值                            |\n| `vget config path`                 | 显示配置文件路径                      |\n| `vget config webdav list`          | 列出已配置的 WebDAV 服务器            |\n| `vget config webdav add <name>`    | 添加 WebDAV 服务器                    |\n| `vget config webdav show <name>`   | 显示服务器详情                        |\n| `vget config webdav delete <name>` | 删除服务器                            |\n| `vget telegram login --import-desktop` | 从桌面应用导入 Telegram 会话      |\n| `vget kuaidi100 <单号>`            | 查询快递物流信息（需配置快递100 API） |\n\n### 示例\n\n```bash\nvget https://twitter.com/user/status/123456789\nvget https://www.xiaoyuzhoufm.com/episode/abc123\nvget https://www.xiaohongshu.com/explore/abc123  # 小红书视频/图片\nvget https://example.com/video -o my_video.mp4\nvget --info https://example.com/video\nvget search --podcast \"科技\"\nvget pikpak:/path/to/file.mp4              # WebDAV 下载\nvget ls pikpak:/Movies                     # 列出远程目录\n```\n\n## 配置\n\n配置文件位置：\n\n| 操作系统    | 路径                        |\n| ----------- | --------------------------- |\n| macOS/Linux | `~/.config/vget/config.yml` |\n| Windows     | `%APPDATA%\\vget\\config.yml` |\n\n运行 `vget init` 交互式创建配置文件，或手动创建：\n\n```yaml\nlanguage: zh # en, zh, jp, kr, es, fr, de\n```\n\n**注意：** 配置文件在每次命令执行时重新读取，修改后无需重启（适用于 Docker）。\n\n## 更新\n\n将 vget 更新到最新版本：\n\n**macOS / Linux:**\n```bash\nsudo vget update\n```\n\n**Windows（以管理员身份运行 PowerShell）:**\n```powershell\nvget update\n```\n\n## 语言\n\nvget 支持多种语言：\n\n- English (en)\n- 中文 (zh)\n- 日本語 (jp)\n- 한국어 (kr)\n- Español (es)\n- Français (fr)\n- Deutsch (de)\n\n## 代理 / 翻墙\n\n如果你需要翻墙（绕过 GFW），推荐使用 Clash。\n\n**Clash 有两种模式：**\n\n1. **系统代理模式** - 设置系统级 HTTP/HTTPS 代理。支持系统代理的应用会自动使用。\n2. **TUN 模式** - 创建虚拟网卡，在网络层捕获所有流量。\n\n**推荐使用 TUN 模式**：开启后，所有应用的流量都会自动经过 Clash，无需任何配置。vget 会自动走代理，无需额外设置。\n\n**如果使用系统代理模式**：Clash 会设置 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量，vget 会自动读取并使用这些代理设置。\n\n简而言之：**只要 Clash 正常运行，vget 就能正常工作**，无需在 vget 中配置代理。\n\n## 许可证\n\nApache License 2.0\n"
  },
  {
    "path": "TODO.md",
    "content": "# TODO\n\n## Tomorrow's Tasks\n\n3. [x] kuaidi100 - Bring Your Own Key (API is expensive)\n\n## Features\n\n- [x] `vget init` command\n  - Language preference\n  - Default output directory\n  - Default format/quality\n- [x] Self update\n- [x] m3u8 streaming support\n- [x] Bulk download from txt file\n  - Read URLs from txt file\n  - Sequential or parallel processing\n- [x] Format/quality selection (`-q` flag)\n- [x] Audio extraction (podcasts)\n- [ ] Resume interrupted downloads\n- [ ] Retry on failure\n- [x] Progress bar with speed/ETA\n- [ ] Quiet/verbose modes\n- [ ] Dry run mode\n- [ ] More extractors (YouTube, TikTok, etc.)\n- [ ] Playlist support\n- [x] Concurrent downloads\n- [ ] Rate limiting\n- [x] Cookie/auth support\n- [ ] Metadata embedding\n  - Audio (MP3/M4A): ID3 tags - title, artist, album, cover art\n  - Video (MP4): title, description, thumbnail\n  - Auto-fill from source (podcast name, episode title, artwork)\n  - Media players (Apple Music, VLC, etc.) would then show this info instead of just the filename.\n- [x] `vget server` - HTTP server mode\n  - REST API for remote downloads\n  - Run as background daemon (`vget server start -d`)\n  - Web UI for submitting URLs\n  - systemd service installation (`vget server install`)\n- [x] WebDAV client integration\n  - Connect to PikPak, other WebDAV-compatible cloud storage\n  - Download files from cloud (`vget <remote>:<path>`)\n  - Browse and select files with TUI (`vget ls <remote>:<path>`)\n\n## Extractors\n\n- [x] Twitter/X\n- [x] Xiaoyuzhou (小宇宙) podcasts\n  - [x] Episode download\n  - [x] Search (`vget search --podcast <query>`)\n  - [x] Podcast listing (all episodes)\n- [x] YouTube (Docker only, uses yt-dlp/youtube-dl)\n- [ ] TikTok\n- [x] Apple Podcasts\n- [x] Xiaohongshu (小红书/RED)\n  - Requires browser automation (Rod) + cookie auth\n  - Reference: [xpzouying/xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (7.2k stars, stable 1+ year)\n  - Extraction approach:\n    - Navigate to `https://www.xiaohongshu.com/explore/{feedID}?xsec_token=...`\n    - Extract `window.__INITIAL_STATE__.note.noteDetailMap` via JS\n    - Parse JSON for images (`urlDefault`) and video URLs\n  - Feasibility: Moderate effort, more achievable than Instagram\n  - Note: yt-dlp also has extractor but frequently breaks due to bot detection\n\n## Tracking (Versatile Get)\n\n- [ ] FedEx tracking\n  - [ ] Scraping (default, no setup)\n  - [ ] API mode (user provides own keys in config.yml)\n- [ ] UPS tracking\n  - [ ] Scraping (default, no setup)\n  - [ ] API mode (user provides own keys in config.yml)\n- [ ] USPS tracking\n  - [ ] Scraping (default, no setup)\n  - [ ] API mode (user provides own keys in config.yml)\n  - [ ] kuaidi100 - Bring Your Own Key (API is expensive)\n\n## DevOps\n\n- [x] GoReleaser + GitHub Actions for tagged releases\n- [x] Dockerfile for NAS deployment\n  - Multi-stage build for minimal image\n  - Support for Synology/QNAP/TrueNAS\n  - compose.yml with NAS path examples\n"
  },
  {
    "path": "cmd/vget/main.go",
    "content": "package main\n\nimport (\n\t\"os\"\n\n\t\"github.com/guiyumin/vget/internal/cli\"\n)\n\nfunc main() {\n\tif err := cli.Execute(); err != nil {\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/vget-server/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/version\"\n\t\"github.com/guiyumin/vget/internal/server\"\n)\n\nfunc main() {\n\t// Command-line flags\n\tport := flag.Int(\"port\", 0, \"HTTP listen port (default: 8080)\")\n\toutput := flag.String(\"output\", \"\", \"output directory for downloads\")\n\tshowVersion := flag.Bool(\"version\", false, \"show version\")\n\tflag.Parse()\n\n\tif *showVersion {\n\t\tfmt.Printf(\"vget-server %s\\n\", version.Version)\n\t\treturn\n\t}\n\n\t// Load configuration\n\tcfg := config.LoadOrDefault()\n\n\t// Resolve port (flag > config > default)\n\tserverPort := *port\n\tif serverPort == 0 {\n\t\tif cfg.Server.Port > 0 {\n\t\t\tserverPort = cfg.Server.Port\n\t\t} else {\n\t\t\tserverPort = 8080\n\t\t}\n\t}\n\n\t// Resolve output directory (flag > config > default)\n\toutputDir := *output\n\tif outputDir == \"\" {\n\t\tif cfg.OutputDir != \"\" {\n\t\t\toutputDir = cfg.OutputDir\n\t\t} else {\n\t\t\toutputDir = config.DefaultDownloadDir()\n\t\t}\n\t}\n\n\t// Expand ~ in path\n\tif len(outputDir) >= 2 && outputDir[:2] == \"~/\" {\n\t\thome, _ := os.UserHomeDir()\n\t\toutputDir = filepath.Join(home, outputDir[2:])\n\t}\n\n\t// Resolve max concurrent (config > default)\n\tmaxConcurrent := cfg.Server.MaxConcurrent\n\tif maxConcurrent <= 0 {\n\t\tmaxConcurrent = 10\n\t}\n\n\t// Get API key from config\n\tapiKey := cfg.Server.APIKey\n\n\t// Create and start server\n\tsrv := server.NewServer(serverPort, outputDir, apiKey, maxConcurrent)\n\n\t// Handle graceful shutdown\n\tsigChan := make(chan os.Signal, 1)\n\tsignal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)\n\n\tgo func() {\n\t\t<-sigChan\n\t\tlog.Println(\"Shutting down server...\")\n\t\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\t\tdefer cancel()\n\t\tsrv.Stop(ctx)\n\t}()\n\n\tlog.Printf(\"Starting vget server on port %d\", serverPort)\n\tlog.Printf(\"Output directory: %s\", outputDir)\n\n\tif err := srv.Start(); err != nil {\n\t\tlog.Fatalf(\"Server error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "compose.yml",
    "content": "# vget Docker Compose\n#\n# Image variants (choose based on your system):\n#   ghcr.io/guiyumin/vget:latest      - No models, downloads on first use (~200MB)\n#   ghcr.io/guiyumin/vget:full-small  - Whisper Small bundled (~500MB)\n#   ghcr.io/guiyumin/vget:full-medium - Whisper Medium bundled (~1.5GB)\n#   ghcr.io/guiyumin/vget:full-large  - Whisper Large V3 Turbo bundled (~1.8GB)\n#\n# Whisper models support 99 languages including Chinese, Japanese, Korean.\n#\n# Configure via .env file or environment variables:\n#   VGET_PORT=8080              # Web UI port\n#   VGET_DOWNLOADS=./downloads  # Download directory\n#   VGET_CONFIG=./config        # Config directory\n#   TZ=Asia/Shanghai            # Timezone\n#\n# Example paths by NAS:\n#   Synology:  /volume1/vget/downloads\n#   QNAP:      /share/vget/downloads\n#   Unraid:    /mnt/user/vget/downloads\n#   TrueNAS:   /mnt/pool/vget/downloads\n\nservices:\n  vget:\n    # Change to :full-small, :full-medium, or :full-large based on your RAM/GPU\n    image: ghcr.io/guiyumin/vget:latest\n    container_name: vget\n    restart: unless-stopped\n    ports:\n      - \"${VGET_PORT:-8080}:8080\"\n    volumes:\n      - ${VGET_DOWNLOADS:?Set VGET_DOWNLOADS in .env}:/home/vget/downloads\n      - ${VGET_CONFIG:?Set VGET_CONFIG in .env}:/home/vget/.config/vget\n    environment:\n      - TZ=${TZ:-Asia/Shanghai}\n    # Required for NAS systems (Synology, QNAP, etc.) to allow ffmpeg thread creation\n    security_opt:\n      - seccomp:unconfined\n"
  },
  {
    "path": "docker/vget/Dockerfile",
    "content": "# vget Docker Image\n\n# Build stage for UI\nFROM node:22-slim AS ui-builder\nWORKDIR /app/ui\nCOPY ui/package*.json ./\nRUN npm ci\nCOPY ui/ ./\nRUN npm run build\n\n# Go builder stage\nFROM golang:1.25-bookworm AS go-builder\n\nWORKDIR /app\n\nCOPY go.mod go.sum ./\nRUN go mod download\n\nCOPY . .\nCOPY --from=ui-builder /app/ui/dist ./internal/server/dist\n\nRUN CGO_ENABLED=0 go build -ldflags=\"-s -w\" -o /vget-server ./cmd/vget-server\n\n# Runtime stage\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    ca-certificates \\\n    chromium \\\n    fonts-noto-cjk \\\n    fonts-noto-color-emoji \\\n    python3 \\\n    python3-pip \\\n    python3-venv \\\n    ffmpeg \\\n    nodejs \\\n    gosu \\\n    curl \\\n    bzip2 \\\n    && rm -rf /var/lib/apt/lists/*\n\nRUN pip3 install --no-cache-dir --break-system-packages \\\n    yt-dlp \\\n    youtube-dl\n\nRUN (getent group 1000 >/dev/null || groupadd -g 1000 vget) && \\\n    (id -u 1000 >/dev/null 2>&1 || useradd -u 1000 -g 1000 -m -d /home/vget vget) && \\\n    mkdir -p /home/vget/downloads /home/vget/.config/vget && \\\n    chown -R 1000:1000 /home/vget\n\nCOPY --from=go-builder /vget-server /usr/local/bin/vget-server\n\nENV ROD_BROWSER=/usr/bin/chromium\n\nCOPY docker/vget/entrypoint.sh /usr/local/bin/\nRUN chmod +x /usr/local/bin/entrypoint.sh\n\nWORKDIR /home/vget\nEXPOSE 8080\nVOLUME [\"/home/vget/downloads\", \"/home/vget/.config/vget\"]\n\nENTRYPOINT [\"entrypoint.sh\"]\nCMD []\n"
  },
  {
    "path": "docker/vget/Dockerfile.arm64",
    "content": "# vget Docker Image for ARM64 (Apple Silicon, Raspberry Pi, ARM servers)\n\n# Build stage for UI\nFROM node:22-slim AS ui-builder\nWORKDIR /app/ui\nCOPY ui/package*.json ./\nRUN npm ci\nCOPY ui/ ./\nRUN npm run build\n\n# Go builder stage\nFROM golang:1.25-bookworm AS go-builder\n\nWORKDIR /app\n\nCOPY go.mod go.sum ./\nRUN go mod download\n\nCOPY . .\nCOPY --from=ui-builder /app/ui/dist ./internal/server/dist\n\nRUN CGO_ENABLED=0 go build -ldflags=\"-s -w\" -o /vget-server ./cmd/vget-server\n\n# Runtime stage\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    ca-certificates \\\n    chromium \\\n    fonts-noto-cjk \\\n    fonts-noto-color-emoji \\\n    python3 \\\n    python3-pip \\\n    python3-venv \\\n    ffmpeg \\\n    nodejs \\\n    gosu \\\n    curl \\\n    bzip2 \\\n    && rm -rf /var/lib/apt/lists/*\n\nRUN pip3 install --no-cache-dir --break-system-packages \\\n    yt-dlp \\\n    youtube-dl\n\nRUN (getent group 1000 >/dev/null || groupadd -g 1000 vget) && \\\n    (id -u 1000 >/dev/null 2>&1 || useradd -u 1000 -g 1000 -m -d /home/vget vget) && \\\n    mkdir -p /home/vget/downloads /home/vget/.config/vget && \\\n    chown -R 1000:1000 /home/vget\n\nCOPY --from=go-builder /vget-server /usr/local/bin/vget-server\n\nENV ROD_BROWSER=/usr/bin/chromium\n\nCOPY docker/vget/entrypoint-arm64.sh /usr/local/bin/entrypoint.sh\nRUN chmod +x /usr/local/bin/entrypoint.sh\n\nWORKDIR /home/vget\nEXPOSE 8080\nVOLUME [\"/home/vget/downloads\", \"/home/vget/.config/vget\"]\n\nENTRYPOINT [\"entrypoint.sh\"]\nCMD []\n"
  },
  {
    "path": "docker/vget/entrypoint-arm64.sh",
    "content": "#!/bin/bash\nset -e\n\n# Fix ownership of mounted volumes if running as root\nif [ \"$(id -u)\" = \"0\" ]; then\n    chown -R 1000:1000 /home/vget/downloads /home/vget/.config/vget\n    exec gosu 1000:1000 vget-server \"$@\"\nelse\n    exec vget-server \"$@\"\nfi\n"
  },
  {
    "path": "docker/vget/entrypoint.sh",
    "content": "#!/bin/bash\nset -e\n\n# Fix ownership of mounted volumes if running as root\nif [ \"$(id -u)\" = \"0\" ]; then\n    chown -R 1000:1000 /home/vget/downloads /home/vget/.config/vget\n    exec gosu 1000:1000 vget-server \"$@\"\nelse\n    exec vget-server \"$@\"\nfi\n"
  },
  {
    "path": "docs/FAQs.md",
    "content": "# FAQs & Troubleshooting\n\n## FFmpeg Merge Failed: thread_create failed\n\n**Error message:**\n\n```\nffmpeg merge failed: thread_create failed: Operation not permitted.\nTry to increase 'ulimit -v' or decrease 'ulimit -s'.\n```\n\n**Cause:** This is a system resource limitation, not a vget bug. FFmpeg cannot create threads due to OS-level restrictions on your system.\n\n**Common scenarios:**\n\n- Running in a Docker container with restricted resources\n- VPS or shared hosting with strict ulimit settings\n- Systems with low thread/memory limits\n\n**Solutions:**\n\n### If running in Docker\n\nAdd ulimit settings to your container:\n\n**compose.yml:**\n\n```yaml\nservices:\n  vget:\n    image: your-vget-image\n    ulimits:\n      nproc: 65535\n      nofile:\n        soft: 65535\n        hard: 65535\n```\n\n**docker run:**\n\n```bash\ndocker run --ulimit nproc=65535 --ulimit nofile=65535:65535 your-vget-image\n```\n\n### If running on bare Linux\n\nAdjust ulimit settings before running vget:\n\n```bash\n# Reduce stack size\nulimit -s 8192\n\n# Or increase virtual memory limit\nulimit -v unlimited\n```\n\nTo make changes permanent, edit `/etc/security/limits.conf`:\n\n```\n*    soft    nproc     65535\n*    hard    nproc     65535\n*    soft    nofile    65535\n*    hard    nofile    65535\n```\n\n### Alternative workaround\n\nIf you cannot change system limits, download video and audio separately without merging (if supported by the source).\n"
  },
  {
    "path": "docs/PRD.md",
    "content": "# vget – Product Requirement Document (PRD)\n\n**Version:** 1.2\n**Author:** Yumin\n**Language:** Golang\n**UI:** Bubble Tea (TUI)\n**Purpose:** A modern, multi-source video downloader with elegant CLI & TUI.\n\n---\n\n## 1. Product Vision & Core Positioning\n\n### One-Line Vision\n\n**vget:** A modern, minimalist, high-speed video downloader that works like wget, with a beautiful Bubble Tea TUI. Starting with X/Twitter, expanding to more platforms.\n\n### Core Philosophy\n\nvget's core value is not \"protocol-level innovation\", but rather:\n\n- **Ultimate user experience** - Simple CLI, beautiful TUI\n- **Single binary distribution** - No Python/Node dependencies\n- **Clean architecture** - Extensible extractor system\n- **Modern developer experience** - Golang + Bubble Tea + Worker Pool\n\n### Why Not Just Use yt-dlp?\n\n| Aspect       | yt-dlp       | vget                  |\n| ------------ | ------------ | --------------------- |\n| Installation | Python + pip | Single binary         |\n| UI           | CLI only     | CLI + Bubble Tea TUI  |\n| Complexity   | 500+ flags   | Minimal, opinionated  |\n| Focus        | 1000+ sites  | Quality over quantity |\n\nvget aims to be the \"modern wget for videos\" - simple, fast, beautiful.\n\n---\n\n## 2. Product Goals\n\n### 2.1 MVP Goals (v0.1 - Twitter Focus)\n\n**Target:** Working Twitter/X video downloader\n\n- [x] Project structure setup\n- [x] Twitter/X extractor (native Go, no yt-dlp dependency) ✅\n  - Bearer token + guest token authentication\n  - Tweet API parsing\n  - Video variant extraction (multiple qualities)\n- [ ] Direct MP4 downloader with progress bar\n- [ ] HLS (.m3u8) support (Twitter uses this for some videos)\n- [x] Simple CLI: `vget <twitter-url>` ✅\n- [x] Auto-select best quality ✅\n- [ ] Basic retry on failure\n\n### 2.2 v0.2 Goals\n\n- [x] Multi-threaded segmented downloads (range requests) ✅ **Implemented**\n- [x] Output filename customization (`-o`) ✅\n\n### 2.3 v0.3 Goals\n\n- Bubble Tea TUI (`vget --ui`)\n- More platform extractors (based on demand)\n- Optional yt-dlp bridge for unsupported sites\n\n---\n\n## 3. User Experience (UX) Goals\n\n### CLI Minimalism\n\n```bash\nvget https://example.com/video\n```\n\n### TUI Mode (Bubble Tea)\n\n```bash\nvget --ui URL\n```\n\n### Display Features\n\n- Per-thread speed\n- Total speed\n- ETA\n- Progress bar\n- Task queue\n- Pause/Resume capability\n- Download history\n\n### Automatic Content Type Detection\n\n```\nURL → Extractor → (MP4 / HLS / DASH / Playlist)\n```\n\n**Fully automatic:** Users don't need to think about the underlying protocol.\n\n---\n\n## 4. Feature Specification\n\n### 4.1 Downloader Engine (Core)\n\n| Feature               | Description                                           | Status         |\n| --------------------- | ----------------------------------------------------- | -------------- |\n| Multi-Stream Download | HTTP Range requests with parallel streams (default 8) | ✅ Implemented |\n| Concurrent Download   | goroutine + worker pool pattern                       | ✅ Implemented |\n| Chunk-based Transfer  | 16MB chunks with 128KB buffers per stream             | ✅ Implemented |\n| Progress Display      | Real-time speed, ETA, elapsed time, avg speed         | ✅ Implemented |\n| Auto Retry            | Exponential backoff retry (5 retries per chunk)       | ✅ Implemented |\n| File Merge            | Merge multiple segments into MP4                      | Planned        |\n| Verification          | Support md5/sha256 (optional)                         | Planned        |\n| Speed Limit           | Throttle mode (optional)                              | Planned        |\n| Download Queue        | Multiple simultaneous tasks                           | Planned        |\n\n#### Multi-Stream Download Architecture (Implemented)\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    MultiStreamConfig                         │\n│  Streams: 8 (parallel connections)                          │\n│  ChunkSize: 16MB (per chunk)                                │\n│  BufferSize: 128KB (per stream read buffer)                 │\n└─────────────────────────────────────────────────────────────┘\n                              │\n                              ▼\n┌─────────────────────────────────────────────────────────────┐\n│                 HEAD Request (Check Support)                 │\n│  - Get Content-Length                                       │\n│  - Check Accept-Ranges: bytes                               │\n└─────────────────────────────────────────────────────────────┘\n                              │\n              ┌───────────────┴───────────────┐\n              ▼                               ▼\n    [Range Supported]                [Range Not Supported]\n              │                               │\n              ▼                               ▼\n┌─────────────────────────┐       ┌─────────────────────────┐\n│   Calculate Chunks      │       │  Single-Stream Fallback │\n│   File ÷ ChunkSize      │       │  (128KB buffer)         │\n└─────────────────────────┘       └─────────────────────────┘\n              │\n              ▼\n┌─────────────────────────────────────────────────────────────┐\n│                    Worker Pool (8 workers)                   │\n│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐   │\n│  │ W1  │ │ W2  │ │ W3  │ │ W4  │ │ W5  │ │ W6  │ │ W7  │...│\n│  └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘   │\n│     │       │       │       │       │       │       │       │\n│     ▼       ▼       ▼       ▼       ▼       ▼       ▼       │\n│  Range:  Range:  Range:  Range:  Range:  Range:  Range:     │\n│  0-16M   16M-32M 32M-48M ...                                │\n└─────────────────────────────────────────────────────────────┘\n              │\n              ▼\n┌─────────────────────────────────────────────────────────────┐\n│              file.WriteAt(data, offset)                      │\n│              (Thread-safe positional writes)                 │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Performance Comparison:**\n\n| Metric         | Before (Single Stream) | After (Multi-Stream)     |\n| -------------- | ---------------------- | ------------------------ |\n| Streams        | 1                      | 8 (configurable)         |\n| Buffer         | 32KB                   | 128KB per stream         |\n| Typical Speed  | ~10-20 MB/s            | ~50-80 MB/s              |\n| WebDAV Support | Basic                  | Full with Range requests |\n\n### 4.2 Extractor Layer (URL Parsing)\n\n#### Extractor Interface\n\n```go\ntype Extractor interface {\n    // Match returns true if this extractor can handle the URL\n    Match(url string) bool\n    // Extract returns video info (title, formats, etc.)\n    Extract(url string) (*VideoInfo, error)\n}\n\ntype VideoInfo struct {\n    ID       string\n    Title    string\n    Formats  []Format  // Multiple qualities available\n    Duration int\n}\n\ntype Format struct {\n    URL      string\n    Quality  string    // \"1080p\", \"720p\", etc.\n    Ext      string    // \"mp4\", \"m3u8\"\n    Width    int\n    Height   int\n    Bitrate  int\n}\n```\n\n#### Supported Extractors\n\n| Extractor     | Status | Notes                    |\n| ------------- | ------ | ------------------------ |\n| Twitter/X     | MVP    | Native Go implementation |\n| Direct MP4    | MVP    | Content-Type detection   |\n| HLS           | MVP    | m3u8 parsing             |\n| DASH          | v0.2   | mpd XML parsing          |\n| yt-dlp bridge | v0.3   | Optional fallback        |\n\n#### Twitter/X Extractor Details\n\n```\nURL: https://x.com/user/status/123456789\n           ↓\n    Extract tweet ID\n           ↓\n    Get guest token (POST /1.1/guest/activate.json)\n           ↓\n    Fetch tweet (GET /1.1/statuses/show/{id}.json)\n           ↓\n    Parse extended_entities.media[].video_info.variants\n           ↓\n    Return VideoInfo with all quality options\n```\n\n### 4.3 CLI Specification\n\n```bash\n# Basic download\nvget <url>\n\n# Specify quality\nvget -q 1080p <url>\n\n# Segment thread count\nvget -t 32 <url>\n\n# Output filename\nvget -o out.mp4 <url>\n\n# Cookie\nvget --cookies cookies.txt <url>\n\n# Custom headers\nvget -H \"Referer: https://xxx\" <url>\n\n# Parse only, don't download\nvget --info <url>\n\n# Configuration management\nvget init                           # Interactive config wizard (TUI)\nvget config show                    # Show current config\nvget config set language en         # Set config value (non-interactive)\nvget config get language            # Get config value\n```\n\n### 4.4 TUI (Bubble Tea) Design\n\n#### Components\n\n- Header (speed, ETA)\n- Global progress bar\n- Per-thread speed bars\n- Error messages\n- Undo/Pause/Resume controls\n- Log window\n- Task queue\n\n#### Keyboard Shortcuts\n\n| Key     | Function     |\n| ------- | ------------ |\n| `space` | Pause/Resume |\n| `p`     | Pause        |\n| `r`     | Retry        |\n| `q`     | Quit         |\n| `↑↓`    | Switch tasks |\n\n#### TUI Aesthetic\n\n- lipgloss + Nord theme\n- Clean and minimalist\n- Style similar to glow, gh-dash, gum\n\n---\n\n## 5. Architecture Design\n\n```\n/cmd/vget\n    main.go              # Entry point, CLI parsing\n/internal\n    /cli\n        root.go          # Main command & WebDAV download handler\n        config.go        # Config management commands\n        extract.go       # Extraction with spinner\n        ls.go            # Directory listing command\n        search.go        # Search command\n        completion.go    # Shell completion\n    /extractor\n        extractor.go     # Extractor interface & media types\n        twitter.go       # Twitter/X extractor\n        xiaoyuzhou.go    # Xiaoyuzhou podcast extractor\n        instagram.go     # Instagram extractor\n        tiktok.go        # TikTok extractor\n        xiaohongshu.go   # Xiaohongshu extractor\n        registry.go      # Extractor registration & matching\n    /downloader\n        downloader.go    # Download interface\n        progress.go      # Progress tracking & Bubble Tea TUI\n        multistream.go   # Multi-stream parallel downloader ✅ NEW\n        utils.go         # Helper functions\n    /webdav\n        client.go        # WebDAV client with Range request support ✅ NEW\n    /config\n        config.go        # User configuration & WebDAV servers\n                         # IMPORTANT: Config is read fresh per-command (no restart needed)\n    /i18n\n        i18n.go          # Internationalization\n        /locales/*.yml   # Translation files (en, zh, jp, kr, es, fr, de)\n    /updater\n        updater.go       # Self-update functionality\n    /version\n        version.go       # Version info\n```\n\n---\n\n## 6. Technical Implementation Details\n\n### 6.1 Extractor Logic\n\n**Pseudocode:**\n\n```\nif url endsWith .mp4 → MP4Extractor\nif content-type == application/vnd.apple.mpegurl → HLSExtractor\nif content-type == application/dash+xml → DASHExtractor\nif URL contains \"playlist\" → PlaylistExtractor\n```\n\n#### HLS Flow\n\n1. Download m3u8\n2. Find master playlist\n3. Select highest bitrate\n4. Parse TS segments\n5. Build task list in order\n\n#### DASH Flow\n\n1. Download mpd XML\n2. Extract mediaBaseURL + segmentTemplate\n3. Select a Representation\n4. Generate task list for all segments\n\n### 6.2 Downloader Engine (Implemented)\n\n**Multi-Stream Configuration:**\n\n```go\ntype MultiStreamConfig struct {\n    Streams    int   // Number of parallel streams (default 8)\n    ChunkSize  int64 // Size of each chunk (default 16MB)\n    BufferSize int   // Buffer size per stream (default 128KB)\n}\n```\n\n**Worker Pool Pattern:**\n\n```go\n// Create chunk channel and feed all chunks\nchunkChan := make(chan chunk, len(chunks))\nfor _, c := range chunks {\n    chunkChan <- c\n}\nclose(chunkChan)\n\n// Start N worker goroutines\nfor i := 0; i < config.Streams; i++ {\n    go func() {\n        for c := range chunkChan {\n            downloadChunk(ctx, client, url, file, c, state)\n        }\n    }()\n}\n```\n\n**Chunk Download with Range Requests:**\n\n```go\nreq.Header.Set(\"Range\", fmt.Sprintf(\"bytes=%d-%d\", chunk.start, chunk.end))\n// ...\nfile.WriteAt(data, offset)  // Thread-safe positional write\n```\n\n**Progress Tracking:**\n\n```go\ntype downloadState struct {\n    current     int64       // Atomic counter across all streams\n    total       int64\n    speed       float64     // Real-time speed\n    startTime   time.Time\n    endTime     time.Time\n    finalSpeed  float64     // Average speed at completion\n}\n```\n\n### 6.3 WebDAV Support (Implemented)\n\n**Features:**\n\n- Remote path syntax: `vget pikpak:/path/to/file.mp4`\n- Full URL syntax: `vget webdav://user:pass@host/path`\n- Multi-stream parallel downloads with HTTP Range requests\n- Automatic fallback to single-stream if Range not supported\n- Directory listing: `vget ls pikpak:/movies`\n\n**WebDAV Client Architecture:**\n\n```go\ntype Client struct {\n    client   *webdav.Client  // go-webdav for PROPFIND/etc\n    baseURL  string\n    username string\n    password string\n}\n\n// Methods\nfunc (c *Client) Stat(ctx, path) (*FileInfo, error)\nfunc (c *Client) List(ctx, path) ([]FileInfo, error)\nfunc (c *Client) Open(ctx, path) (io.ReadCloser, int64, error)\nfunc (c *Client) GetFileURL(path) string      // For Range requests\nfunc (c *Client) GetAuthHeader() string       // Basic Auth header\nfunc (c *Client) SupportsRangeRequests(ctx, path) (bool, error)\n```\n\n**Download Flow:**\n\n```\npikpak:/movies/video.mp4\n        │\n        ▼\n┌─────────────────────┐\n│  Load config.yml    │\n│  Get server creds   │\n└─────────────────────┘\n        │\n        ▼\n┌─────────────────────┐\n│  client.Stat()      │\n│  Get file size      │\n└─────────────────────┘\n        │\n        ▼\n┌─────────────────────┐\n│  HEAD request       │\n│  Check Range support│\n└─────────────────────┘\n        │\n        ▼\n┌─────────────────────┐\n│  Multi-stream DL    │\n│  8 parallel streams │\n└─────────────────────┘\n```\n\n### 6.4 Merge (mp4 / ts / m4s)\n\n**HLS:**\n\n```bash\ncat part*.ts | ffmpeg -i - -c copy out.mp4\n```\n\n**DASH:**\n\n- mp4box or pure Go mux (can be supported after v1)\n\n---\n\n## 7. Future Roadmap\n\n### TODO\n\n- **Optimize download speed for WebDAV/PikPak** - Current multi-stream implementation is significantly slower than rclone. Target: 30MB/s for PikPak. Investigate:\n  - Connection reuse / keep-alive\n  - Chunk size tuning\n  - Number of parallel streams\n  - Buffer sizes\n  - TCP tuning\n\n### v1 (MVP)\n\n- MP4 / HLS / DASH download\n- CLI\n- TUI\n- Multi-threaded segmentation\n- Resume support\n- Auto quality detection\n\n### v1.5\n\n- Multi-task queue\n- History records\n- Graceful pause/resume\n\n### v2\n\n- Plugin system (extractor plugins)\n- `.vget/plugins/*.wasm` for custom site loaders\n\n### v3\n\n- Distributed downloading\n- Integration with S3 / OSS / R2\n- Become a true \"media download platform\"\n\n---\n\n## 8. Success Metrics\n\n| Metric         | Target                                 |\n| -------------- | -------------------------------------- |\n| GitHub Stars   | 1,000 (first month) / 5,000 (6 months) |\n| CLI Installs   | 5K+                                    |\n| TUI Open Rate  | > 40%                                  |\n| Issue Feedback | > 20 (community engagement)            |\n| Pull Requests  | At least 5 external contributors       |\n\n---\n\n## 9. Top Selling Points (Highlight in README)\n\n- **Modern video downloader**\n- **Fast, concurrent, resumable**\n- **HLS & DASH built-in**\n- **Beautiful Bubble Tea TUI**\n- **Cross-platform single binary**\n- **Plugin ecosystem (future)**\n\n---\n\n## 10. README Sample\n\n```\nvget\n----\nA modern, blazing-fast video downloader for the command line.\nSupports MP4, HLS (m3u8), DASH (mpd), multi-thread downloads,\nresume, cookies, proxies, and a beautiful Bubble Tea-powered TUI.\n\nUsage:\n  vget <url>            # auto detect and download\n  vget --ui <url>       # open interactive TUI\n  vget -t 32 <url>      # 32-thread segmented download\n  vget -q 1080p <url>   # choose quality (HLS/DASH)\n  vget --cookies c.txt  # cookie support\n```\n"
  },
  {
    "path": "docs/YOUTUBE_NOTES.md",
    "content": "# YouTube Support Notes\n\n## Status: Delegated to yt-dlp (Docker Only)\n\nAfter extensive research and failed attempts, we've concluded that building a native Go YouTube extractor is not viable.\n\n## What We Tried (2025-12-04)\n\n### The Go Implementation Worked... Briefly\n\n- Browser automation (Rod + stealth) captured BotGuard tokens\n- Innertube API with iOS client returned unencrypted stream URLs (no cipher)\n- Separate video/audio streams downloaded and merged with ffmpeg\n\n### Then It Broke\n\n1. **BotGuard Detection** - YouTube's anti-bot (Error 153) detected rod/stealth automation\n2. **IP Binding** - Stream URLs are bound to the requesting IP; VPNs/IPv6 cause 403s\n3. **Rate Limiting** - Heavy testing flagged our IP/session; even new IPs didn't help\n4. **Constant Changes** - YouTube updates anti-bot weekly; we can't keep up\n\n## Why We Don't Build Our Own Extractor\n\n### The Problems Are Real\n\n1. **Aggressive Anti-Bot Detection**\n   - PO Tokens (Proof of Origin) require JavaScript execution\n   - N parameter challenge requires solving obfuscated JS functions\n   - SAPISID hash authentication with rotating signatures\n   - Client version checks that change frequently\n   - Rate limiting that bans IPs quickly\n\n2. **Constantly Moving Target**\n   - YouTube updates their anti-bot mechanisms weekly\n   - yt-dlp has 1000+ contributors constantly reverse-engineering changes\n   - A solo developer cannot keep up with Google's anti-bot team\n\n3. **IP Bans Are Inevitable**\n   - Even with all the right tokens and signatures, YouTube rate-limits aggressively\n   - Residential IPs get banned after moderate usage\n   - Datacenter IPs are blocked almost immediately\n\n4. **Resource Requirements**\n   - Requires JavaScript runtime (Node.js/Deno) for challenge solving\n   - Needs rotating residential proxies ($$$)\n   - Cookie/session management is complex\n\n## Our Solution\n\nWe delegate YouTube extraction to **yt-dlp** and **youtube-dl**, but only in Docker:\n\n- **In Docker**: vget shells out to yt-dlp/youtube-dl\n- **Outside Docker**: vget shows an error suggesting Docker usage\n\n### Why Docker Only?\n\n1. Windows/Mac users won't have Python installed\n2. Bundling yt-dlp in the Go binary is impractical\n3. Docker image includes all dependencies (Python, ffmpeg, Node.js)\n4. NAS users (Synology, QNAP, Unraid) commonly use Docker\n\n## User Responsibilities\n\n**IMPORTANT**: Users must provide their own infrastructure:\n\n1. **Residential Proxy / Rotating IPs** - YouTube will ban datacenter IPs and rate-limit residential IPs. Users need to configure their own proxy solution. **This is not optional for sustained usage.**\n\n2. **Cookies (Optional)** - For age-restricted or premium content, users can mount a cookies file.\n\n3. **Rate Limiting** - Users should use `--sleep-interval` with yt-dlp to avoid bans.\n\n## Usage\n\n```bash\n# Basic usage (user handles proxy externally)\ndocker run -v ~/downloads:/downloads guiyumin/vget \"https://youtube.com/watch?v=xxx\"\n\n# With proxy configured in environment\ndocker run -e HTTP_PROXY=http://proxy:port -v ~/downloads:/downloads guiyumin/vget \"https://youtube.com/watch?v=xxx\"\n\n# With cookies file for premium/age-restricted content\ndocker run -v ~/downloads:/downloads -v ~/cookies.txt:/home/vget/cookies.txt guiyumin/vget \"https://youtube.com/watch?v=xxx\"\n```\n\n## Alternatives for Users\n\nIf Docker isn't an option, users should use yt-dlp directly:\n\n```bash\n# Install yt-dlp\npip install yt-dlp\n\n# Download video\nyt-dlp \"https://youtube.com/watch?v=xxx\"\n\n# With proxy\nyt-dlp --proxy http://proxy:port \"https://youtube.com/watch?v=xxx\"\n```\n\n## Old Troubleshooting (For Reference)\n\nThese were issues with our native Go implementation:\n\n### 403 on download\n1. Clear browser profile: `rm -rf ~/.config/vget/browser/`\n2. Disable IPv6: `sudo networksetup -setv6off Wi-Fi`\n3. Try a different network/IP\n4. Wait for rate limiting to expire (24-48 hours)\n\n### No POToken captured\n- YouTube detecting automation (Error 153)\n- go-rod/stealth needs constant updates\n\n### IP mismatch\n- VPN must tunnel ALL traffic (not just browser)\n- Disable IPv6 to force IPv4\n- Browser, API call, and download must use same IP\n\n## References\n\n- [yt-dlp GitHub](https://github.com/yt-dlp/yt-dlp)\n- [youtube-dl GitHub](https://github.com/ytdl-org/youtube-dl)\n- [yt-dlp Wiki: Rate Limiting](https://github.com/yt-dlp/yt-dlp/wiki/Extractors#this-content-isnt-available-try-again-later)\n\n## Lessons Learned\n\n1. Don't fight Google's anti-bot team alone\n2. Leverage existing open-source solutions (yt-dlp has 1000+ contributors)\n3. Make infrastructure (proxies, IPs) the user's responsibility\n4. Docker is the right abstraction for complex dependencies\n5. Know when to give up and delegate\n"
  },
  {
    "path": "docs/bilibili-port-plan.md",
    "content": "# BBDown Go Port Plan\n\nThis document outlines the plan for porting [BBDown](https://github.com/nilaoda/BBDown) (a C# Bilibili downloader) to Go, integrating it into vget.\n\n## Overview\n\n**BBDown** is a comprehensive Bilibili downloader with ~6,500 lines of C# code. Key capabilities:\n\n- Download videos, anime (Bangumi), courses (Cheese), playlists\n- Multiple quality levels (144P to 8K) and codecs (AVC, HEVC, AV1)\n- Audio formats: AAC, FLAC, Dolby Atmos, E-AC-3\n- Authentication via QR code or cookie/token\n- Subtitles, danmaku (bullet comments), cover images\n- FFmpeg/MP4Box muxing integration\n\n## Architecture Comparison\n\n### BBDown (C#)\n\n```\nBBDown/                          # CLI Application\n├── Program.cs                   # Entry point (897 lines)\n├── CommandLineInvoker.cs        # CLI parsing\n├── BBDownUtil.cs                # URL parsing utilities\n├── BBDownDownloadUtil.cs        # HTTP download logic\n├── BBDownMuxer.cs               # Audio/video muxing\n├── BBDownLoginUtil.cs           # QR code login\n└── Model/                       # Data models\n\nBBDown.Core/                     # Core library\n├── Parser.cs                    # API response parsing (467 lines)\n├── AppHelper.cs                 # gRPC/Protobuf for APP API\n├── Config.cs                    # Global configuration\n├── FetcherFactory.cs            # Content type routing\n├── IFetcher.cs                  # Fetcher interface\n├── Entity/                      # Data models\n├── Fetcher/                     # Content type handlers\n│   ├── NormalInfoFetcher.cs     # Regular videos\n│   ├── BangumiInfoFetcher.cs    # Anime\n│   ├── CheeseInfoFetcher.cs     # Courses\n│   └── ...                      # Others\n└── Util/                        # HTTP, subtitles, danmaku\n```\n\n### vget Target (Go)\n\n```\ninternal/extractor/\n├── bilibili.go                  # Main extractor + interface\n├── bilibili_api.go              # API client (WEB/TV/APP/INTL)\n├── bilibili_parser.go           # Stream parsing\n├── bilibili_auth.go             # Authentication (QR, cookie)\n├── bilibili_fetcher.go          # Fetcher interface + factory\n├── bilibili_fetcher_normal.go   # Regular videos\n├── bilibili_fetcher_bangumi.go  # Anime\n├── bilibili_fetcher_cheese.go   # Courses\n├── bilibili_fetcher_space.go    # User uploads\n├── bilibili_fetcher_list.go     # Playlists/collections\n├── bilibili_subtitle.go         # Subtitle processing\n├── bilibili_danmaku.go          # Bullet comments\n└── bilibili_proto/              # Generated protobuf (for APP API)\n```\n\n## Bilibili API Structure\n\nBBDown supports 4 different APIs for accessing content:\n\n| API  | Endpoint             | Auth Method         | Use Case              |\n| ---- | -------------------- | ------------------- | --------------------- |\n| WEB  | api.bilibili.com     | Cookie (SESSDATA)   | Standard access       |\n| TV   | api.snm0516.aisee.tv | App key + signature | Unrestricted streams  |\n| APP  | grpc.biliapi.net     | gRPC + Protobuf     | FLAC, Dolby, 8K       |\n| INTL | api.biliintl.com     | Similar to WEB      | International content |\n\n### WBI Signature (WEB API)\n\nBilibili uses a dynamic signature scheme called WBI:\n\n1. Extract `img_key` and `sub_key` from website HTML\n2. Combine and reorder using a fixed mapping table\n3. Generate MD5 signature of parameters + key\n4. Keys rotate periodically (need to refresh)\n\n### APP API (gRPC)\n\nThe APP API uses Protocol Buffers with custom headers:\n\n- Device info (Dalvik, Android version)\n- Access key authentication\n- Protobuf request/response encoding\n\n## Implementation Phases\n\n### Phase 1: Foundation (Priority: High)\n\n**Goal**: Basic video download for regular Bilibili videos\n\n#### 1.1 Data Models\n\n```go\n// internal/extractor/bilibili.go\n\ntype BilibiliVideoInfo struct {\n    AID       int64    // av number\n    BVID      string   // BV number\n    CID       int64    // cid (for video stream)\n    Title     string\n    Desc      string\n    Pic       string   // cover URL\n    Duration  int64\n    Pages     []Page   // multi-part videos\n}\n\ntype Page struct {\n    CID       int64\n    Page      int\n    Title     string\n    Duration  int64\n}\n\ntype VideoStream struct {\n    Quality   int      // 127=8K, 120=4K, 116=1080P60, etc.\n    Codec     string   // avc, hevc, av1\n    URL       string\n    Bandwidth int64\n    Width     int\n    Height    int\n}\n\ntype AudioStream struct {\n    Quality   int      // 30280=320kbps, 30232=128kbps, 30216=64kbps\n    Codec     string   // mp4a, flac, ec-3\n    URL       string\n    Bandwidth int64\n}\n```\n\n#### 1.2 URL Pattern Matching\n\n```go\n// Match patterns:\n// - https://www.bilibili.com/video/BV1xx411c7mD\n// - https://www.bilibili.com/video/av170001\n// - https://b23.tv/BV1xx411c7mD (short URL)\n// - bilibili://video/170001\n\nfunc (e *BilibiliExtractor) Match(url string) bool {\n    patterns := []string{\n        `bilibili\\.com/video/(BV[\\w]+|av\\d+)`,\n        `b23\\.tv/(BV[\\w]+|av\\d+|\\w+)`,\n        `bilibili://video/\\d+`,\n    }\n    // ...\n}\n```\n\n#### 1.3 BV/AV ID Conversion\n\n```go\n// AV to BV and vice versa (algorithm from BBDown)\n// BV is base58-like encoding of AV number\n\nconst table = \"fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF\"\nvar s = []int{11, 10, 3, 8, 4, 6}\nconst xor = 177451812\nconst add = 8728348608\n\nfunc BV2AV(bv string) int64 { ... }\nfunc AV2BV(av int64) string { ... }\n```\n\n#### 1.4 WEB API Client\n\n```go\n// internal/extractor/bilibili_api.go\n\ntype BilibiliClient struct {\n    httpClient *http.Client\n    cookie     string\n    wbi        *WBIKeys  // signature keys\n}\n\nfunc (c *BilibiliClient) GetVideoInfo(bvid string) (*BilibiliVideoInfo, error) {\n    // GET https://api.bilibili.com/x/web-interface/view?bvid=xxx\n}\n\nfunc (c *BilibiliClient) GetPlayURL(bvid string, cid int64, qn int) (*PlayURLResponse, error) {\n    // GET https://api.bilibili.com/x/player/wbi/playurl?bvid=xxx&cid=xxx&qn=xxx\n    // Requires WBI signature\n}\n```\n\n#### 1.5 Stream Extraction\n\n```go\n// Parse playurl API response to extract video/audio streams\nfunc (c *BilibiliClient) ExtractStreams(resp *PlayURLResponse) ([]VideoStream, []AudioStream, error) {\n    // Handle both DASH (video+audio separate) and legacy FLV formats\n}\n```\n\n### Phase 2: Authentication (Priority: High)\n\n**Goal**: Support both QR code login and manual cookie input, in both CLI and UI.\n\n#### 2.1 Authentication Architecture\n\n```\ninternal/\n├── extractor/\n│   └── bilibili_auth.go       # Core auth logic (API calls, token storage)\n├── cli/\n│   └── bilibili_login.go      # CLI: ASCII QR + cookie prompt\n└── ui/                        # (existing React UI)\n    └── components/\n        └── BilibiliLogin.tsx  # UI: Image QR + cookie input field\n```\n\n#### 2.2 Core Auth Module\n\n```go\n// internal/extractor/bilibili_auth.go\n\ntype BilibiliAuth struct {\n    configPath string  // ~/.config/vget/bilibili.json\n}\n\ntype BilibiliCredentials struct {\n    SESSDATA  string    `json:\"sessdata\"`\n    BiliJCT   string    `json:\"bili_jct\"`\n    DedeUserID string   `json:\"dede_user_id\"`\n    ExpiresAt time.Time `json:\"expires_at\"`\n}\n\n// QR Code Login Flow\ntype QRLoginSession struct {\n    URL       string  // QR code content URL\n    QRCodeKey string  // Key for polling status\n}\n\nfunc (a *BilibiliAuth) GenerateQRCode() (*QRLoginSession, error) {\n    // GET https://passport.bilibili.com/x/passport-login/web/qrcode/generate\n    // Returns: { data: { url: \"...\", qrcode_key: \"...\" } }\n}\n\ntype QRStatus int\nconst (\n    QRWaiting   QRStatus = 86101  // Not scanned yet\n    QRScanned   QRStatus = 86090  // Scanned, waiting confirm\n    QRExpired   QRStatus = 86038  // QR code expired\n    QRConfirmed QRStatus = 0      // Success\n)\n\nfunc (a *BilibiliAuth) PollQRStatus(qrcodeKey string) (QRStatus, *BilibiliCredentials, error) {\n    // GET https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=xxx\n    // Returns status code and credentials on success\n}\n\n// Manual Cookie\nfunc (a *BilibiliAuth) SetCookie(cookie string) (*BilibiliCredentials, error) {\n    // Parse cookie string: \"SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx\"\n    // Validate by calling user info API\n    // Save to config file\n}\n\n// Token Storage\nfunc (a *BilibiliAuth) SaveCredentials(creds *BilibiliCredentials) error {\n    // Save to ~/.config/vget/bilibili.json\n}\n\nfunc (a *BilibiliAuth) LoadCredentials() (*BilibiliCredentials, error) {\n    // Load from ~/.config/vget/bilibili.json\n    // Return nil if not found or expired\n}\n\nfunc (a *BilibiliAuth) GetCookieString() string {\n    // Return formatted cookie for HTTP requests\n    // \"SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx\"\n}\n```\n\n#### 2.3 CLI Login Interface\n\n```go\n// internal/cli/bilibili_login.go\n\n// Command: vget login bilibili\nfunc BilibiliLoginCmd() *cobra.Command {\n    cmd := &cobra.Command{\n        Use:   \"bilibili\",\n        Short: \"Login to Bilibili\",\n    }\n\n    cmd.AddCommand(\n        bilibiliQRLoginCmd(),     // vget login bilibili qr\n        bilibiliCookieLoginCmd(), // vget login bilibili cookie\n    )\n\n    return cmd\n}\n\n// QR Login in Terminal\nfunc bilibiliQRLogin() error {\n    auth := extractor.NewBilibiliAuth()\n\n    // 1. Generate QR code\n    session, _ := auth.GenerateQRCode()\n\n    // 2. Display ASCII QR in terminal\n    qr, _ := qrcode.New(session.URL, qrcode.Medium)\n    fmt.Println(qr.ToSmallString(false))\n    fmt.Println(\"Scan with Bilibili app, or open:\", session.URL)\n\n    // 3. Poll for confirmation\n    for {\n        status, creds, _ := auth.PollQRStatus(session.QRCodeKey)\n        switch status {\n        case extractor.QRWaiting:\n            // Show spinner\n        case extractor.QRScanned:\n            fmt.Println(\"Scanned! Please confirm in app...\")\n        case extractor.QRExpired:\n            return errors.New(\"QR code expired\")\n        case extractor.QRConfirmed:\n            auth.SaveCredentials(creds)\n            fmt.Println(\"Login successful!\")\n            return nil\n        }\n        time.Sleep(time.Second)\n    }\n}\n\n// Cookie Login in Terminal\nfunc bilibiliCookieLogin() error {\n    fmt.Println(\"Enter your Bilibili cookie (SESSDATA=xxx; bili_jct=xxx):\")\n    reader := bufio.NewReader(os.Stdin)\n    cookie, _ := reader.ReadString('\\n')\n\n    auth := extractor.NewBilibiliAuth()\n    creds, err := auth.SetCookie(strings.TrimSpace(cookie))\n    if err != nil {\n        return fmt.Errorf(\"invalid cookie: %w\", err)\n    }\n\n    fmt.Printf(\"Login successful! User ID: %s\\n\", creds.DedeUserID)\n    return nil\n}\n```\n\n#### 2.4 UI Login Interface\n\n```typescript\n// ui/components/BilibiliLogin.tsx\n\ninterface QRLoginState {\n  qrUrl: string;\n  qrCodeKey: string;\n  status: 'waiting' | 'scanned' | 'expired' | 'success';\n}\n\nexport function BilibiliLogin() {\n  const [mode, setMode] = useState<'qr' | 'cookie'>('qr');\n  const [qrState, setQrState] = useState<QRLoginState | null>(null);\n  const [cookie, setCookie] = useState('');\n\n  // QR Code Login\n  async function startQRLogin() {\n    const session = await api.bilibili.generateQR();\n    setQrState({ qrUrl: session.url, qrCodeKey: session.qrcode_key, status: 'waiting' });\n    pollQRStatus(session.qrcode_key);\n  }\n\n  async function pollQRStatus(key: string) {\n    const interval = setInterval(async () => {\n      const result = await api.bilibili.pollQR(key);\n      if (result.status === 'confirmed') {\n        clearInterval(interval);\n        setQrState(s => ({ ...s!, status: 'success' }));\n      } else if (result.status === 'expired') {\n        clearInterval(interval);\n        setQrState(s => ({ ...s!, status: 'expired' }));\n      } else if (result.status === 'scanned') {\n        setQrState(s => ({ ...s!, status: 'scanned' }));\n      }\n    }, 1000);\n  }\n\n  // Cookie Login\n  async function submitCookie() {\n    await api.bilibili.setCookie(cookie);\n  }\n\n  return (\n    <div>\n      <Tabs value={mode} onChange={setMode}>\n        <Tab value=\"qr\">QR Code</Tab>\n        <Tab value=\"cookie\">Cookie</Tab>\n      </Tabs>\n\n      {mode === 'qr' && (\n        <div>\n          {qrState ? (\n            <>\n              <QRCodeImage value={qrState.qrUrl} />\n              <StatusText status={qrState.status} />\n            </>\n          ) : (\n            <Button onClick={startQRLogin}>Generate QR Code</Button>\n          )}\n        </div>\n      )}\n\n      {mode === 'cookie' && (\n        <div>\n          <p>Get cookie from browser DevTools → Application → Cookies</p>\n          <TextArea\n            placeholder=\"SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx\"\n            value={cookie}\n            onChange={setCookie}\n          />\n          <Button onClick={submitCookie}>Save Cookie</Button>\n        </div>\n      )}\n    </div>\n  );\n}\n```\n\n#### 2.5 API Endpoints for UI\n\n```go\n// internal/server/bilibili_routes.go\n\nfunc RegisterBilibiliRoutes(r *mux.Router) {\n    r.HandleFunc(\"/api/bilibili/qr/generate\", handleGenerateQR).Methods(\"POST\")\n    r.HandleFunc(\"/api/bilibili/qr/poll\", handlePollQR).Methods(\"GET\")\n    r.HandleFunc(\"/api/bilibili/cookie\", handleSetCookie).Methods(\"POST\")\n    r.HandleFunc(\"/api/bilibili/status\", handleAuthStatus).Methods(\"GET\")\n}\n\nfunc handleGenerateQR(w http.ResponseWriter, r *http.Request) {\n    auth := extractor.NewBilibiliAuth()\n    session, _ := auth.GenerateQRCode()\n    json.NewEncoder(w).Encode(session)\n}\n\nfunc handlePollQR(w http.ResponseWriter, r *http.Request) {\n    key := r.URL.Query().Get(\"qrcode_key\")\n    auth := extractor.NewBilibiliAuth()\n    status, creds, _ := auth.PollQRStatus(key)\n\n    if status == extractor.QRConfirmed {\n        auth.SaveCredentials(creds)\n    }\n\n    json.NewEncoder(w).Encode(map[string]interface{}{\n        \"status\": statusToString(status),\n    })\n}\n\nfunc handleSetCookie(w http.ResponseWriter, r *http.Request) {\n    var req struct{ Cookie string }\n    json.NewDecoder(r.Body).Decode(&req)\n\n    auth := extractor.NewBilibiliAuth()\n    creds, err := auth.SetCookie(req.Cookie)\n    if err != nil {\n        http.Error(w, err.Error(), 400)\n        return\n    }\n\n    json.NewEncoder(w).Encode(map[string]string{\n        \"user_id\": creds.DedeUserID,\n    })\n}\n```\n\n#### 2.6 CLI Commands Summary\n\n```bash\n# QR Code login (shows ASCII QR in terminal)\nvget login bilibili qr\n\n# Cookie login (interactive prompt)\nvget login bilibili cookie\n\n# Cookie login (non-interactive, for scripts)\nvget login bilibili cookie --value \"SESSDATA=xxx; bili_jct=xxx\"\n\n# Check login status\nvget login bilibili status\n\n# Logout (remove saved credentials)\nvget login bilibili logout\n```\n\n#### 2.7 Credential Storage\n\n```json\n// ~/.config/vget/bilibili.json\n{\n  \"sessdata\": \"abc123...\",\n  \"bili_jct\": \"def456...\",\n  \"dede_user_id\": \"12345678\",\n  \"expires_at\": \"2024-06-01T00:00:00Z\"\n}\n```\n\n#### 2.8 Dependencies\n\n| Package | Purpose |\n|---------|---------|\n| `github.com/skip2/go-qrcode` | Generate QR code for terminal (ASCII) |\n| `github.com/mdp/qrterminal/v3` | Alternative: better terminal QR rendering |\n\n### Phase 3: Extended Content Types (Priority: Medium)\n\n#### 3.1 Fetcher Interface\n\n```go\n// internal/extractor/bilibili_fetcher.go\n\ntype BilibiliFetcher interface {\n    Name() string\n    Match(id string) bool\n    Fetch(id string, client *BilibiliClient) ([]*BilibiliVideoInfo, error)\n}\n\n// Factory function\nfunc CreateFetcher(id string) BilibiliFetcher {\n    switch {\n    case strings.HasPrefix(id, \"ep\"):\n        return &BangumiFetcher{}\n    case strings.HasPrefix(id, \"ss\"):\n        return &BangumiFetcher{}\n    case strings.HasPrefix(id, \"cheese:\"):\n        return &CheeseFetcher{}\n    case strings.HasPrefix(id, \"mid\"):\n        return &SpaceFetcher{}\n    default:\n        return &NormalFetcher{}\n    }\n}\n```\n\n#### 3.2 Anime (Bangumi) Fetcher\n\n```go\n// internal/extractor/bilibili_fetcher_bangumi.go\n\n// Handles:\n// - https://www.bilibili.com/bangumi/play/ep123\n// - https://www.bilibili.com/bangumi/play/ss456\n// - https://www.bilibili.com/bangumi/media/md789\n\ntype BangumiFetcher struct{}\n\nfunc (f *BangumiFetcher) Fetch(id string, client *BilibiliClient) ([]*BilibiliVideoInfo, error) {\n    // GET https://api.bilibili.com/pgc/view/web/season?ep_id=xxx\n    // or season_id for ss/md IDs\n}\n```\n\n#### 3.3 Course (Cheese) Fetcher\n\n```go\n// internal/extractor/bilibili_fetcher_cheese.go\n\n// Handles paid courses:\n// - https://www.bilibili.com/cheese/play/ep123\n\ntype CheeseFetcher struct{}\n```\n\n#### 3.4 User Space Fetcher\n\n```go\n// internal/extractor/bilibili_fetcher_space.go\n\n// Download all videos from a user:\n// - mid123456 (user ID)\n\ntype SpaceFetcher struct{}\n```\n\n### Phase 4: APP API & Advanced Quality (Priority: Medium)\n\n#### 4.1 Protobuf Setup\n\n```bash\n# Generate Go code from proto files\nprotoc --go_out=. --go-grpc_out=. bilibili_proto/*.proto\n```\n\nProto files to port from BBDown:\n\n- `bilibili.app.playurl.v1.proto`\n- `bilibili.pgc.gateway.player.v2.proto`\n- Various header and metadata protos\n\n#### 4.2 APP API Client\n\n```go\n// internal/extractor/bilibili_api_app.go\n\ntype BilibiliAppClient struct {\n    grpcClient *grpc.ClientConn\n    accessKey  string\n}\n\nfunc (c *BilibiliAppClient) GetPlayView(aid, cid int64) (*PlayViewReply, error) {\n    // gRPC call to grpc.biliapi.net\n    // Required for FLAC, Dolby Atmos, 8K HDR\n}\n```\n\n### Phase 5: Post-Processing (Priority: Low)\n\n#### 5.1 Subtitle Processing\n\n```go\n// internal/extractor/bilibili_subtitle.go\n\n// Download and convert subtitles\nfunc (c *BilibiliClient) GetSubtitles(bvid string, cid int64) ([]Subtitle, error) {\n    // Parse from video info API\n    // Convert BCC (Bilibili) format to SRT\n}\n```\n\n#### 5.2 Danmaku (Bullet Comments)\n\n```go\n// internal/extractor/bilibili_danmaku.go\n\n// Download danmaku in various formats\nfunc (c *BilibiliClient) GetDanmaku(cid int64, format string) ([]byte, error) {\n    // Formats: xml, protobuf, ass\n    // GET https://api.bilibili.com/x/v1/dm/list.so?oid=xxx\n}\n```\n\n## Quality Access by Account Type\n\n| 画质 | 未登录 | 已登录 | 大会员 |\n|------|--------|--------|--------|\n| 360P | ✅ | ✅ | ✅ |\n| 480P | ✅ | ✅ | ✅ |\n| 720P | ⚠️ | ✅ | ✅ |\n| 1080P | ❌ | ✅ | ✅ |\n| 1080P+ | ❌ | ❌ | ✅ |\n| 4K | ❌ | ❌ | ✅ |\n| 8K | ❌ | ❌ | ✅ |\n| HDR | ❌ | ❌ | ✅ |\n| 杜比视界 | ❌ | ❌ | ✅ |\n\n## Quality Priority Mapping\n\n```go\nvar QualityMap = map[int]string{\n    127: \"8K\",\n    126: \"Dolby Vision\",\n    125: \"HDR\",\n    120: \"4K\",\n    116: \"1080P60\",\n    112: \"1080P+\",\n    80:  \"1080P\",\n    74:  \"720P60\",\n    64:  \"720P\",\n    32:  \"480P\",\n    16:  \"360P\",\n    6:   \"144P (for mobile)\",\n}\n\nvar AudioQualityMap = map[int]string{\n    30280: \"320kbps\",\n    30232: \"128kbps\",\n    30216: \"64kbps\",\n    30250: \"Dolby Atmos\",\n    30251: \"Hi-Res\",\n}\n```\n\n## Integration with vget\n\n### CLI Commands\n\n```bash\n# Basic download\nvget https://www.bilibili.com/video/BV1xx411c7mD\n\n# With quality selection\nvget -q 1080p https://www.bilibili.com/video/BV1xx411c7mD\n\n# With authentication\nvget --bilibili-cookie \"SESSDATA=xxx\" https://www.bilibili.com/video/BV1xx411c7mD\n\n# Download specific pages (multi-part video)\nvget -p 1-5 https://www.bilibili.com/video/BV1xx411c7mD\n\n# Download anime series\nvget https://www.bilibili.com/bangumi/play/ss12345\n```\n\n### Config Integration\n\n```yaml\n# ~/.config/vget/config.yml\nbilibili:\n  cookie: \"SESSDATA=xxx; bili_jct=xxx\"\n  quality: \"1080p\"\n  audio_quality: \"320kbps\"\n  download_subtitle: true\n  download_danmaku: false\n  prefer_hevc: true\n```\n\n## Dependencies\n\n| Package                      | Purpose                      |\n| ---------------------------- | ---------------------------- |\n| `google.golang.org/protobuf` | Protobuf for APP API         |\n| `google.golang.org/grpc`     | gRPC client for APP API      |\n| `github.com/skip2/go-qrcode` | QR code generation for login |\n\n## Estimated Scope\n\n| Phase                    | Go Lines (Est.) | Priority |\n| ------------------------ | --------------- | -------- |\n| Phase 1: Foundation      | ~1,500          | High     |\n| Phase 2: Authentication  | ~400            | High     |\n| Phase 3: Extended Types  | ~800            | Medium   |\n| Phase 4: APP API         | ~1,200          | Medium   |\n| Phase 5: Post-Processing | ~600            | Low      |\n| **Total**                | **~4,500**      | -        |\n\n## Implementation Order\n\n1. **bilibili.go** - Extractor interface, URL matching, ID conversion\n2. **bilibili_api.go** - WEB API client, WBI signature\n3. **bilibili_parser.go** - Stream extraction from API responses\n4. **bilibili_fetcher_normal.go** - Regular video support\n5. **bilibili_auth.go** - Cookie parsing, token management\n6. **bilibili_fetcher_bangumi.go** - Anime support\n7. **bilibili_fetcher_cheese.go** - Course support\n8. **bilibili_proto/** - Protobuf definitions (copy from BBDown)\n9. **bilibili_api_app.go** - APP API for advanced quality\n10. **bilibili_subtitle.go** - Subtitle download/conversion\n11. **bilibili_danmaku.go** - Danmaku support\n\n## 8K Video Download Technical Details\n\n### Quality Codes (qn)\n\n| qn  | Quality      | Requirements           |\n| --- | ------------ | ---------------------- |\n| 127 | 8K 超高清    | 大会员 + DASH + HEVC/AV1 |\n| 126 | 杜比视界     | 大会员 + DASH          |\n| 125 | HDR 真彩     | 大会员 + DASH          |\n| 120 | 4K 超清      | 大会员 + DASH          |\n| 116 | 1080P 60帧   | 大会员                 |\n| 112 | 1080P 高码率 | 大会员                 |\n| 80  | 1080P        | 登录                   |\n| 64  | 720P         | 登录                   |\n| 32  | 480P         | -                      |\n| 16  | 360P         | -                      |\n\n### 8K Requirements\n\n| Requirement | Details |\n|------------|---------|\n| **Account** | 大会员 (Premium membership) required |\n| **Format** | DASH only (no MP4/FLV for 8K) |\n| **Codec** | HEVC or AV1 only (AVC not supported for 8K) |\n| **fnval** | `1024` (8K flag) or `4048` (all DASH streams) |\n| **fourk** | `1` (enable 4K/8K negotiation) |\n\n### API Comparison for 8K\n\n| API | Endpoint | 8K Support | Auth Method | Best For |\n|-----|----------|-----------|-------------|----------|\n| **APP API** | `grpc.biliapi.net` (gRPC) | ✅ Full | access_token | 8K, FLAC, Dolby |\n| **TV API** | `api.snm0516.aisee.tv` | ✅ Yes | access_key + signature | Alternative |\n| **WEB API** | `api.bilibili.com` | ⚠️ Limited | Cookie + WBI signature | Standard use |\n| **INTL API** | `api.biliintl.com` | ❌ No | Cookie | International |\n\n### APP API (Recommended for 8K)\n\nThe APP API uses gRPC with Protobuf, same as Bilibili mobile app:\n\n```go\n// Request structure\nPlayViewReq {\n    Aid:             int64,   // Video AV number\n    Cid:             int64,   // Content ID\n    Qn:              127,     // Always request 8K\n    Fnval:           4048,    // DASH with all options\n    Fourk:           true,    // Enable 4K/8K\n    PreferCodecType: CodeAV1, // or CodeHEVC\n}\n\n// Endpoints\nRegular:  grpc.biliapi.net/bilibili.app.playurl.v1.PlayURL/PlayView\nBangumi:  app.bilibili.com/bilibili.pgc.gateway.player.v2.PlayURL/PlayView\n```\n\n### fnval Bitmask\n\n```go\nconst (\n    FnvalMP4     = 1     // MP4 format\n    FnvalDASH    = 16    // DASH format\n    FnvalHDR     = 64    // HDR support\n    FnvalDolby   = 256   // Dolby audio\n    FnvalDolbyVision = 512  // Dolby Vision\n    Fnval8K      = 1024  // 8K resolution\n    FnvalAV1     = 2048  // AV1 codec\n)\n\n// Common combinations\nFnvalAll = 4048  // 16 | 64 | 256 | 512 | 1024 | 2048\n```\n\n### Codec Support\n\n| Codec | Code | 8K Support | Notes |\n|-------|------|------------|-------|\n| AV1   | 13   | ✅ Yes | Best compression, newer |\n| HEVC  | 12   | ✅ Yes | Default for bangumi |\n| AVC   | 7    | ❌ No | Legacy, max 4K |\n\n### Authentication for 8K\n\n```bash\n# Method 1: APP API with TV login token\nbbdown logintv                    # Get access_token\nbbdown <url> -app                 # Use APP API\n\n# Method 2: Cookie (WEB API, limited)\nbbdown login                      # QR code login\nbbdown <url> --cookie <cookie>    # Use WEB API\n```\n\n### Implementation Priority\n\nFor vget Bilibili support:\n\n1. **Phase 1**: WEB API with Cookie (covers most content up to 1080P)\n2. **Phase 2**: TV API for 4K content\n3. **Phase 3**: APP API (gRPC) for 8K, FLAC, Dolby\n\n## References\n\n- [BBDown Source](https://github.com/nilaoda/BBDown)\n- [Bilibili API Documentation](https://github.com/SocialSisterYi/bilibili-API-collect) (unofficial)\n- [Video Stream URL API](https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/video/videostream_url.md)\n- [BV/AV Conversion Algorithm](https://www.zhihu.com/question/381784377)\n- [WBI Signature Mechanism](https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md)\n"
  },
  {
    "path": "docs/bugfix/docker-browser-launch.md",
    "content": "# Docker Browser Launch Hang Bug\n\n## Problem\n\nBrowser-based extractors (m3u8 detection, XHS, etc.) would hang indefinitely in Docker while working fine on macOS CLI.\n\n**Symptoms:**\n- CLI on Mac: Works\n- CLI in Docker: Hangs at \"Trying to detecting m3u8 stream...\"\n- Server in Docker: Same hang\n\n## Root Cause\n\nThe `go-rod` library wasn't using the `ROD_BROWSER` environment variable to locate the system Chromium. Instead, it was attempting to download its own browser binary, which would hang in the containerized environment.\n\nEven though `ROD_BROWSER=/usr/bin/chromium` was set in the Dockerfile, rod's `launcher.New()` doesn't automatically use this env var - it needs explicit `Bin()` call.\n\n## Solution\n\nIn `internal/extractor/browser.go`, explicitly set the browser binary path:\n\n```go\nfunc (e *BrowserExtractor) createLauncher(headless bool) *launcher.Launcher {\n    // Check for ROD_BROWSER env var (set in Docker)\n    browserPath := os.Getenv(\"ROD_BROWSER\")\n\n    l := launcher.New().\n        Headless(headless).\n        // ... other options\n\n    // Explicitly set browser path if provided (required for Docker)\n    if browserPath != \"\" {\n        l = l.Bin(browserPath)\n    }\n\n    return l\n}\n```\n\n## Additional Changes\n\n1. **Switched from Alpine to Debian** - Alpine's musl libc and Chromium package caused compatibility issues. Debian's glibc-based Chromium is more stable.\n\n2. **Added Chrome flags** for better headless stability:\n   - `disable-software-rasterizer`\n   - `disable-extensions`\n   - `disable-background-networking`\n   - `window-size=1920,1080`\n   - Custom user-agent to avoid bot detection\n\n## Files Changed\n\n- `internal/extractor/browser.go` - Added explicit `Bin()` call and Chrome flags\n- `Dockerfile` - Switched to Debian bookworm-slim\n- `docker/entrypoint.sh` - Changed from `su-exec` to `gosu` (Debian equivalent)\n"
  },
  {
    "path": "docs/homebrew-distribution.md",
    "content": "# Homebrew Distribution\n\nThis document explains how to distribute vget via Homebrew.\n\n## Option 1: Own Tap (Recommended)\n\nUse your own Homebrew tap for instant updates with no PR reviews.\n\n### Setup\n\n1. Create GitHub repo: `guiyumin/homebrew-tap`\n\n2. Add `Formula/vget.rb`:\n\n```ruby\nclass Vget < Formula\n  desc \"Media downloader CLI for various platforms\"\n  homepage \"https://github.com/guiyumin/vget\"\n  url \"https://github.com/guiyumin/vget/archive/refs/tags/v0.9.2.tar.gz\"\n  sha256 \"bf5228673cfd080ac8f0e9d0ee05e875fc5bfcde342ae5fd615c5d2a23181ab3\"\n  license \"Apache-2.0\"\n\n  depends_on \"go\" => :build\n\n  def install\n    ldflags = \"-s -w -X github.com/guiyumin/vget/internal/version.Version=#{version}\"\n    system \"go\", \"build\", *std_go_args(ldflags: ldflags), \"./cmd/vget\"\n  end\n\n  test do\n    assert_match version.to_s, shell_output(\"#{bin}/vget --version\")\n  end\nend\n```\n\n3. Users install with:\n\n```bash\nbrew tap guiyumin/tap\nbrew install vget\n```\n\nOr in one command:\n\n```bash\nbrew install guiyumin/tap/vget\n```\n\n### Automate Updates with GoReleaser\n\nAdd to `.goreleaser.yaml`:\n\n```yaml\nbrews:\n  - repository:\n      owner: guiyumin\n      name: homebrew-tap\n    homepage: \"https://github.com/guiyumin/vget\"\n    description: \"Media downloader CLI for various platforms\"\n    license: \"Apache-2.0\"\n    directory: Formula\n```\n\nThis automatically updates your tap on every GitHub release.\n\n## Option 2: homebrew-core (Official)\n\nSubmit to the official Homebrew repository for `brew install vget` (no tap needed).\n\n### Requirements\n\n- 30+ GitHub stars (vget has 300+ ✓)\n- Stable versioned releases ✓\n- Open source license ✓\n\n### Submission Steps\n\n```bash\n# 1. Fork homebrew-core on GitHub, then clone\ngit clone https://github.com/YOUR_USERNAME/homebrew-core.git\ncd homebrew-core\n\n# 2. Create the formula\nmkdir -p Formula/v\n# Add Formula/v/vget.rb (same content as above)\n\n# 3. Test locally\nbrew install --build-from-source ./Formula/v/vget.rb\nbrew test vget\nbrew audit --strict --new vget\n\n# 4. Commit and push\ngit checkout -b vget\ngit add Formula/v/vget.rb\ngit commit -m \"vget: new formula\"\ngit push origin vget\n\n# 5. Open PR to Homebrew/homebrew-core\n```\n\nPR title: `vget 0.9.2 (new formula)`\n\n### Updating Versions\n\nAfter initial approval, update with:\n\n```bash\nbrew bump-formula-pr --url https://github.com/guiyumin/vget/archive/refs/tags/vX.Y.Z.tar.gz vget\n```\n\nOr automate with GitHub Actions (`.github/workflows/homebrew.yml`):\n\n```yaml\nname: Bump Homebrew Formula\n\non:\n  release:\n    types: [published]\n\njobs:\n  bump-formula:\n    runs-on: macos-latest\n    steps:\n      - name: Bump formula\n        uses: mislav/bump-homebrew-formula-action@v3\n        with:\n          formula-name: vget\n        env:\n          COMMITTER_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}\n```\n\nSetup: Create a GitHub PAT with `public_repo` scope and add as `HOMEBREW_GITHUB_TOKEN` secret.\n\nVersion bump PRs are auto-merged by BrewTestBot in ~5-15 minutes (no human review).\n\n## Comparison\n\n| Approach | Review Time | User Command |\n|----------|-------------|--------------|\n| Own tap | Instant | `brew install guiyumin/tap/vget` |\n| homebrew-core | ~5-15 min (auto-merge) | `brew install vget` |\n"
  },
  {
    "path": "docs/http-server-mode.md",
    "content": "# HTTP Server Mode (`vget server`)\n\n## Overview\n\nHTTP server mode that accepts download requests via API, with an embedded WebUI for job monitoring.\n\n**Commands:**\n\n```bash\nvget server start              # Foreground, port 8080\nvget server start -d           # Background daemon, port 8080\nvget server start -p 9000      # Custom port\nvget server start -d -p 9000 -o ~/downloads\nvget server stop               # Stop daemon\nvget server restart            # Restart server\nvget server status             # Check if running\nvget server logs               # View recent logs\nvget server logs -f            # Follow logs (tail -f)\n```\n\n- Listens on port 8080 by default (override with `-p`)\n- `-d` runs as background daemon\n- WebUI available at `http://localhost:8080/`\n- API accepts URLs via HTTP POST\n- Supports video, audio, and image downloads via extractors\n\n## WebUI\n\nThe server includes an embedded React SPA for job monitoring:\n\n- Real-time job status updates (polling)\n- Download form to submit URLs\n- Progress bars for active downloads\n- Cancel button for queued/downloading jobs\n- Configuration panel for output directory\n- i18n support (zh, en, jp, kr, es, fr, de)\n- Dark theme\n\nAccess at `http://localhost:8080/` when server is running.\n\n## Configuration\n\n**CLI Flags:**\n| Flag | Default | Description |\n|------|---------|-------------|\n| `-p, --port` | 8080 | HTTP listen port |\n| `-o, --output` | config default or `~/Downloads` | Output directory |\n| `-d, --daemon` | false | Run in background |\n\n**Config file (`~/.config/vget/config.yml`):**\n\n```yaml\noutput_dir: ~/Downloads/vget\n\nserver:\n  port: 8080\n  max_concurrent: 10\n  api_key: \"optional-secret-key\"\n```\n\n**Priority order for output directory:** CLI flag `-o` > `output_dir` in config > default (`~/Downloads/vget`)\n\n## API Reference\n\n### Response Structure\n\nAll endpoints return a consistent JSON structure:\n\n```json\n{\n  \"code\": 200,\n  \"data\": { ... },\n  \"message\": \"description\"\n}\n```\n\n### Endpoints\n\n#### `GET /health`\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"status\": \"ok\",\n    \"version\": \"v0.7.1\"\n  },\n  \"message\": \"everything is good\"\n}\n```\n\n#### `POST /download`\n\n```json\n// Request\n{\n  \"url\": \"https://twitter.com/...\",\n  \"filename\": \"optional.mp4\",\n  \"return_file\": false\n}\n\n// Response (return_file=false)\n{\n  \"code\": 200,\n  \"data\": {\n    \"id\": \"abc123\",\n    \"status\": \"queued\"\n  },\n  \"message\": \"download started\"\n}\n\n// Response (return_file=true)\n// Returns file directly with Content-Disposition header\n```\n\n#### `GET /status/:id`\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"id\": \"abc123\",\n    \"status\": \"downloading\",\n    \"progress\": 45.5,\n    \"filename\": \"video.mp4\"\n  },\n  \"message\": \"downloading\"\n}\n```\n\n#### `GET /jobs`\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"jobs\": [\n      { \"id\": \"abc123\", \"url\": \"...\", \"status\": \"completed\" },\n      {\n        \"id\": \"def456\",\n        \"url\": \"...\",\n        \"status\": \"downloading\",\n        \"progress\": 67.2\n      }\n    ]\n  },\n  \"message\": \"2 jobs found\"\n}\n```\n\n#### `DELETE /jobs/:id`\n\n```json\n{\n  \"code\": 200,\n  \"data\": { \"id\": \"def456\" },\n  \"message\": \"job cancelled\"\n}\n```\n\n#### `GET /config`\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"output_dir\": \"/path/to/downloads\"\n  },\n  \"message\": \"config retrieved\"\n}\n```\n\n#### `PUT /config`\n\nUpdate server configuration at runtime.\n\n```json\n// Request\n{\n  \"output_dir\": \"/new/path/to/downloads\"\n}\n\n// Response\n{\n  \"code\": 200,\n  \"data\": {\n    \"output_dir\": \"/new/path/to/downloads\"\n  },\n  \"message\": \"config updated\"\n}\n```\n\n#### `GET /i18n`\n\nGet UI translations for the configured language.\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"language\": \"zh\",\n    \"ui\": { ... },\n    \"server\": { ... },\n    \"config_exists\": true\n  },\n  \"message\": \"translations retrieved\"\n}\n```\n\n### Authentication\n\nOptional API key authentication via header `X-API-Key`. If `api_key` is set in config, all API requests must include it. The WebUI and `/health` endpoint are accessible without authentication.\n\n## Daemon Mode\n\n```bash\nvget server start -d       # Start daemon\nvget server stop           # Stop daemon\nvget server restart        # Restart daemon\nvget server status         # Check if running\nvget server logs -f        # Follow logs\n```\n\n- PID stored in `~/.config/vget/serve.pid`\n- Logs written to `~/.config/vget/serve.log`\n\n## Development\n\n### Running in Dev Mode\n\nFor UI development with hot reload:\n\n**Terminal 1 - Go server (API on :8080):**\n\n```bash\ngo run ./cmd/vget server start\n```\n\n**Terminal 2 - Vite dev server (UI on :5173):**\n\n```bash\ncd ui && npm run dev\n```\n\nOpen `http://localhost:5173` - Vite proxies API calls to the Go server.\n\n### Building\n\n```bash\n# Build UI and Go binary\nmake build\n\n# Or manually:\ncd ui && npm install && npm run build\ncp -r ui/dist/* internal/server/dist/\ngo build -o build/vget ./cmd/vget\n```\n\n## Architecture\n\n```\nui/                          # React SPA source\ninternal/server/\n├── server.go                # HTTP server, handlers, download logic\n├── job.go                   # Job queue, worker pool\n├── embed.go                 # go:embed for UI\n└── dist/                    # Built UI (embedded)\ninternal/cli/server.go       # Cobra commands, daemon management, service install\ninternal/config/config.go    # ServerConfig struct\n```\n\n### Internal Flow\n\n```\nHTTP Request\n    ↓\nLogging middleware\n    ↓\nAuth middleware (check X-API-Key if configured, skip for /health and UI)\n    ↓\nRoute to handler\n    ↓\nPOST /download → Add job to queue → Return job ID\n    ↓\nWorker pool (max_concurrent workers, default 10)\n    ↓\nWorker picks job → extractor.Match(url) → ext.Extract(url) → download with progress\n    ↓\nUpdate job status (queued → downloading → completed/failed/cancelled)\n    ↓\nAuto-cleanup completed/failed/cancelled jobs after 1 hour (runs every 10 minutes)\n```\n\n### Supported Media Types\n\nThe server uses the extractor system to handle different media:\n\n- **Video** (Twitter, YouTube, etc.) - Selects best format (prefers with audio, then highest bitrate)\n- **Audio** (podcasts, music)\n- **Images** (downloads all images from multi-image posts)\n\nFor unsupported URLs, falls back to `sites.yml` config or generic browser extractor.\n\n## Usage Examples\n\n**Start server:**\n\n```bash\nvget server start -p 9000 -o ~/Downloads/vget\nvget server start -d  # Run in background\n```\n\n**Download via API:**\n\n```bash\n# Queue download\ncurl -X POST http://localhost:8080/download \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"url\": \"https://twitter.com/user/status/123\"}'\n\n# Download and return file directly\ncurl -X POST http://localhost:8080/download \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"url\": \"https://...\", \"return_file\": true}' \\\n  -o video.mp4\n\n# Check status\ncurl http://localhost:8080/status/abc123\n\n# List all jobs\ncurl http://localhost:8080/jobs\n\n# Cancel job\ncurl -X DELETE http://localhost:8080/jobs/abc123\n\n# Get/update config\ncurl http://localhost:8080/config\ncurl -X PUT http://localhost:8080/config \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"output_dir\": \"/new/path\"}'\n```\n\n## Job Queue Details\n\n- Job queue buffer size: 100 jobs\n- Jobs have unique 16-character hex IDs\n- Job statuses: `queued`, `downloading`, `completed`, `failed`, `cancelled`\n- Progress tracking via callback during download\n- Context-based cancellation support\n\n---\n\n## Future Enhancements\n\n- WebSocket for real-time progress updates (currently uses polling)\n- Webhook notifications on completion\n- Multi-user support with separate queues\n- Download scheduling (see below)\n\n---\n\n## Download Scheduling (Planned)\n\nSchedule downloads to run at specific times or on recurring intervals.\n\n### Features\n\n**One-time scheduled downloads:**\n\n- Schedule a download to start at a specific datetime\n- Use case: Queue large downloads for off-peak hours\n\n**Recurring downloads (cron-style):**\n\n- Standard cron expressions for repeat scheduling\n- Use case: Automatically fetch new podcast episodes, YouTube channel updates\n\n**Time-window restrictions:**\n\n- Limit downloads to specific time windows\n- Use case: Bandwidth management, only download during night hours\n\n### API Endpoints\n\n#### `POST /api/v1/schedules`\n\nCreate a new schedule.\n\n```json\n// Request\n{\n  \"url\": \"https://example.com/video\",\n  \"schedule\": \"0 2 * * *\",          // cron expression (2 AM daily)\n  \"name\": \"Daily backup video\",      // optional, human-readable name\n  \"enabled\": true,\n  \"options\": {\n    \"format\": \"best\",\n    \"output_dir\": \"/downloads/scheduled\"\n  }\n}\n\n// Response\n{\n  \"code\": 200,\n  \"data\": {\n    \"id\": \"sch_abc123\",\n    \"url\": \"https://example.com/video\",\n    \"schedule\": \"0 2 * * *\",\n    \"next_run\": \"2025-01-15T02:00:00Z\",\n    \"enabled\": true\n  },\n  \"message\": \"schedule created\"\n}\n```\n\n**Cron expression format:** `minute hour day month weekday`\n| Expression | Description |\n|------------|-------------|\n| `0 2 * * *` | Every day at 2:00 AM |\n| `0 */6 * * *` | Every 6 hours |\n| `0 8 * * 1` | Every Monday at 8:00 AM |\n| `30 22 * * 5` | Every Friday at 10:30 PM |\n\n**One-time schedule:** Use `run_at` instead of `schedule`:\n\n```json\n{\n  \"url\": \"https://example.com/large-file\",\n  \"run_at\": \"2025-01-15T03:00:00Z\"\n}\n```\n\n#### `GET /api/v1/schedules`\n\nList all schedules.\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"schedules\": [\n      {\n        \"id\": \"sch_abc123\",\n        \"name\": \"Daily podcast\",\n        \"url\": \"https://...\",\n        \"schedule\": \"0 6 * * *\",\n        \"next_run\": \"2025-01-15T06:00:00Z\",\n        \"last_run\": \"2025-01-14T06:00:00Z\",\n        \"last_status\": \"completed\",\n        \"enabled\": true\n      }\n    ]\n  },\n  \"message\": \"1 schedule found\"\n}\n```\n\n#### `GET /api/v1/schedules/:id`\n\nGet schedule details and history.\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"id\": \"sch_abc123\",\n    \"name\": \"Daily podcast\",\n    \"url\": \"https://...\",\n    \"schedule\": \"0 6 * * *\",\n    \"enabled\": true,\n    \"next_run\": \"2025-01-15T06:00:00Z\",\n    \"history\": [\n      {\n        \"run_at\": \"2025-01-14T06:00:00Z\",\n        \"status\": \"completed\",\n        \"job_id\": \"job_xyz\"\n      },\n      {\n        \"run_at\": \"2025-01-13T06:00:00Z\",\n        \"status\": \"completed\",\n        \"job_id\": \"job_abc\"\n      }\n    ]\n  },\n  \"message\": \"schedule found\"\n}\n```\n\n#### `PUT /api/v1/schedules/:id`\n\nUpdate a schedule.\n\n```json\n// Request\n{\n  \"schedule\": \"0 3 * * *\",\n  \"enabled\": false\n}\n\n// Response\n{\n  \"code\": 200,\n  \"data\": {\"id\": \"sch_abc123\"},\n  \"message\": \"schedule updated\"\n}\n```\n\n#### `DELETE /api/v1/schedules/:id`\n\nDelete a schedule.\n\n```json\n{\n  \"code\": 200,\n  \"data\": { \"id\": \"sch_abc123\" },\n  \"message\": \"schedule deleted\"\n}\n```\n\n#### `POST /api/v1/schedules/:id/run`\n\nTrigger a scheduled download immediately (outside of schedule).\n\n```json\n{\n  \"code\": 200,\n  \"data\": {\n    \"id\": \"sch_abc123\",\n    \"job_id\": \"job_xyz789\"\n  },\n  \"message\": \"schedule triggered\"\n}\n```\n\n### Configuration\n\n```yaml\n# ~/.config/vget/config.yml\nserver:\n  scheduling:\n    enabled: true\n    max_schedules: 50 # max number of schedules\n    history_retention: 30 # days to keep run history\n    time_window: # optional global restriction\n      start: \"01:00\" # downloads only between 1 AM\n      end: \"06:00\" # and 6 AM\n```\n\n### Persistence\n\n- Schedules stored in `~/.config/vget/schedules.json`\n- Survives server restarts\n- Run history kept for configured retention period\n\n### WebUI Integration\n\n- New \"Schedules\" tab in the dashboard\n- Create/edit/delete schedules via UI\n- View upcoming runs and execution history\n- Toggle schedules on/off\n\n### Implementation Notes\n\n- Uses `robfig/cron/v3` library for cron parsing and scheduling\n- Schedules are evaluated on server startup and when modified\n- Scheduled jobs enter the same job queue as manual downloads\n- If server is stopped during scheduled time, missed runs are skipped (no catch-up)\n\n---\n\n## Service Installation\n\nOne-command installation for NAS and Linux servers.\n\n### Commands\n\n```bash\nsudo vget server install      # Install as systemd service (interactive)\nsudo vget server install -y   # Install with defaults (non-interactive)\nsudo vget server uninstall    # Remove service\nvget server install --help    # Show options\n```\n\n### Interactive TUI Flow\n\nWhen running `sudo vget server install`, a Bubbletea TUI guides the user:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                                                             │\n│   vget service installer                                    │\n│                                                             │\n│   This will install vget as a system service:               │\n│                                                             │\n│   ✓ Copy binary to /usr/local/bin/vget                      │\n│   ✓ Create systemd service at /etc/systemd/system/          │\n│   ✓ Enable auto-start on boot                               │\n│   ✓ Start the vget server                                   │\n│                                                             │\n│   Service configuration:                                    │\n│   ┌─────────────────────────────────────────────────────┐   │\n│   │  Port:        8080                                  │   │\n│   │  Output dir:  /var/lib/vget/downloads               │   │\n│   │  Run as user: vget                                  │   │\n│   └─────────────────────────────────────────────────────┘   │\n│                                                             │\n│   [ Configure ]    [ Install ]    [ Cancel ]                │\n│                                                             │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### Configuration Screen (if \"Configure\" selected)\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                                                             │\n│   Service Configuration                                     │\n│                                                             │\n│   Port:              8080                                   │\n│   Output directory:  /var/lib/vget/downloads                │\n│   Run as user:       vget  (will be created if needed)      │\n│   API key:           (none)                                 │\n│                                                             │\n│   Use arrow keys to navigate, Enter to edit                 │\n│                                                             │\n│   [ Back ]    [ Save & Install ]                            │\n│                                                             │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### What `vget server install` Does\n\n1. **Pre-flight checks**\n   - Verify running as root (prompt for sudo if not)\n   - Check if systemd is available\n   - Check if service already exists (offer reinstall/update)\n\n2. **Setup**\n   - Create `vget` system user (if configured to run as non-root)\n   - Create output directory with proper permissions\n   - Copy binary to `/usr/local/bin/vget`\n\n3. **Service installation**\n   - Write service file to `/etc/systemd/system/vget.service`\n   - Write config to `/etc/vget/config.yml`\n   - Run `systemctl daemon-reload`\n   - Run `systemctl enable vget`\n   - Run `systemctl start vget`\n\n4. **Success screen**\n   ```\n   ┌─────────────────────────────────────────────────────────────┐\n   │                                                             │\n   │   ✓ vget service installed successfully!                   │\n   │                                                             │\n   │   WebUI:    http://localhost:8080                           │\n   │   Status:   sudo systemctl status vget                      │\n   │   Logs:     sudo journalctl -u vget -f                      │\n   │   Stop:     sudo systemctl stop vget                        │\n   │   Remove:   sudo vget server uninstall                      │\n   │                                                             │\n   └─────────────────────────────────────────────────────────────┘\n   ```\n\n### Generated systemd Service File\n\n```ini\n# /etc/systemd/system/vget.service\n[Unit]\nDescription=vget media downloader server\nAfter=network.target\n\n[Service]\nType=simple\nUser=vget\nGroup=vget\nExecStart=/usr/local/bin/vget server start --config /etc/vget/config.yml\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\n```\n\n### CLI Flags (non-interactive mode)\n\n```bash\n# Skip TUI, use defaults\nsudo vget server install -y\n\n# Custom configuration\nsudo vget server install -p 9000 -o /data/downloads -u root\n\n# Uninstall\nsudo vget server uninstall\n```\n\n### Platform Support\n\nCurrently only Linux with systemd is supported.\n\nOn unsupported platforms, the command exits immediately with a helpful message:\n\n```\n$ vget server install\n\nvget server install is only supported on Linux with systemd.\n\nTo run vget as a service on macOS, see:\nhttps://github.com/guiyumin/vget/blob/main/docs/manual-service-setup.md\n\n$ echo $?\n0\n```\n\nNo TUI is shown - just a clear message and clean exit.\n"
  },
  {
    "path": "docs/multi-binary-architecture.md",
    "content": "# Multi-Binary Architecture\n\n## Overview\n\nvget is split into separate binaries with a shared core module:\n\n| Binary | Purpose | Distribution |\n|--------|---------|--------------|\n| `vget` | CLI tool | GitHub Releases (all platforms) |\n| `vget-server` | HTTP server + Web UI | GitHub Releases + Docker Image |\n| `vget-desktop` | Desktop GUI (PySide6) | Separate private repo |\n\n## Current Structure\n\n```\ncmd/\n  vget/main.go              # CLI entry point\n  vget-server/main.go       # Server entry point\n\ninternal/\n  core/                     # Shared by all binaries\n    config/                 # Config file management\n    downloader/             # Download logic, progress callbacks\n    extractor/              # URL matching, media extraction\n    i18n/                   # Translations\n    tracker/                # Package tracking (kuaidi100)\n    version/                # Version info\n    webdav/                 # WebDAV client\n\n  cli/                      # CLI-specific (Cobra + Bubbletea TUI)\n  server/                   # Server-specific (HTTP + job queue + embedded UI)\n  updater/                  # Self-update (CLI only)\n```\n\n## Build Commands\n\n```bash\n# CLI only\ngo build -o build/vget ./cmd/vget\n\n# Server (works on all platforms)\ngo build -o build/vget-server ./cmd/vget-server\n\n# Both\ngo build ./cmd/...\n```\n\n## Binary Comparison\n\n| Binary | Size | Contains |\n|--------|------|----------|\n| `vget` | ~28 MB | CLI commands, Bubbletea TUI, extractors, downloaders |\n| `vget-server` | ~25 MB | HTTP server, embedded Web UI, extractors, downloaders |\n\nThe server binary is smaller because it doesn't include CLI components (Cobra commands, Bubbletea TUI).\n\n## Docker\n\nThe Docker image uses `vget-server` directly:\n\n```dockerfile\n# Build\nRUN go build -ldflags=\"-s -w\" -o /vget-server ./cmd/vget-server\n\n# Run\nENTRYPOINT [\"entrypoint.sh\"]  # Runs vget-server\n```\n\n## vget-server CLI\n\n```bash\n# Start server with defaults (port 8080)\nvget-server\n\n# Custom port\nvget-server -port 9000\n\n# Custom output directory\nvget-server -output /path/to/downloads\n\n# Show version\nvget-server -version\n```\n\nConfiguration is read from `~/.config/vget/config.yml` (same as CLI).\n\n## Release Artifacts\n\n| Platform | CLI | Server |\n|----------|-----|--------|\n| Linux amd64 | vget-linux-amd64 | vget-server-linux-amd64 |\n| Linux arm64 | vget-linux-arm64 | vget-server-linux-arm64 |\n| macOS amd64 | vget-darwin-amd64 | vget-server-darwin-amd64 |\n| macOS arm64 | vget-darwin-arm64 | vget-server-darwin-arm64 |\n| Windows | vget-windows-amd64.exe | vget-server-windows-amd64.exe |\n| Docker | - | guiyumin/vget |\n\n## Desktop App\n\nThe desktop app (`vget-desktop`) is maintained in a separate private repository. It is built with PySide6, the official Python binding for Qt 6.\n"
  },
  {
    "path": "docs/seedbox.md",
    "content": "# Seedbox Support\n\n## Overview\n\nExtend vget to support seedboxes as remote torrent clients. Unlike NAS mode (dispatch only), seedbox mode includes:\n1. **Dispatch** - Send magnet/torrent to seedbox\n2. **Browse** - List files on seedbox\n3. **Download** - Fetch completed files to NAS/local via HTTP/HTTPS or SFTP\n\n## Motivation\n\nSeedboxes are remote servers with high-bandwidth connections, commonly used for:\n- Fast torrent downloads (datacenter speeds)\n- Maintaining seed ratios on private trackers\n- Avoiding ISP throttling/detection of P2P traffic\n\nUsers want to:\n1. Send torrents to seedbox from vget\n2. Browse what's on the seedbox\n3. Download completed files via HTTP/HTTPS (looks like normal web traffic to ISP)\n\n## Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│  vget (Docker or CLI)                                               │\n│                                                                     │\n│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐             │\n│  │   Dispatch  │    │   Browse    │    │  Download   │             │\n│  │   Torrent   │    │   Files     │    │   Files     │             │\n│  └──────┬──────┘    └──────┬──────┘    └──────┬──────┘             │\n└─────────┼──────────────────┼──────────────────┼─────────────────────┘\n          │                  │                  │\n          │ RPC/API          │ SFTP/HTTP        │ HTTP/SFTP\n          ▼                  ▼                  ▼\n┌─────────────────────────────────────────────────────────────────────┐\n│  Seedbox                                                            │\n│                                                                     │\n│  ┌─────────────────┐    ┌─────────────────┐                        │\n│  │ Torrent Client  │    │   File Server   │                        │\n│  │ - rTorrent      │    │ - nginx/HTTP    │                        │\n│  │ - Deluge        │    │ - SFTP (SSH)    │                        │\n│  │ - qBittorrent   │    │                 │                        │\n│  │ - Transmission  │    │                 │                        │\n│  └─────────────────┘    └─────────────────┘                        │\n│           │                     │                                   │\n│           ▼                     ▼                                   │\n│  ┌─────────────────────────────────────────┐                       │\n│  │           /downloads/                    │                       │\n│  │  ├── movie.mkv                          │                       │\n│  │  ├── album/                             │                       │\n│  │  │   ├── 01-track.flac                  │                       │\n│  │  │   └── 02-track.flac                  │                       │\n│  │  └── series/                            │                       │\n│  └─────────────────────────────────────────┘                       │\n└─────────────────────────────────────────────────────────────────────┘\n          │\n          │ HTTP/HTTPS or SFTP\n          ▼\n┌─────────────────────────────────────────────────────────────────────┐\n│  Destination (NAS or Local)                                         │\n│  /volume1/downloads/ or ~/Downloads/                                │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\n## Supported Torrent Clients\n\n### Existing (from NAS mode)\n| Client | Protocol | Default Port | Status |\n|--------|----------|--------------|--------|\n| Transmission | JSON-RPC | 9091 | Done |\n| qBittorrent | REST API | 8080 | Done |\n| Synology DS | REST API | 5000/5001 | Done |\n\n### New (for seedbox)\n| Client | Protocol | Default Port | Status |\n|--------|----------|--------------|--------|\n| rTorrent | XML-RPC | 8080 (via ruTorrent) | TODO |\n| Deluge | JSON-RPC | 8112 | TODO |\n\n### rTorrent (XML-RPC)\n\nMost common on seedboxes. Usually accessed via:\n- ruTorrent web UI (PHP frontend)\n- Direct XML-RPC endpoint (often `/RPC2` or `/rutorrent/plugins/httprpc/action.php`)\n\n```go\n// internal/torrent/rtorrent.go\ntype RTorrentClient struct {\n    endpoint string  // e.g., \"https://seedbox.example.com/RPC2\"\n    username string\n    password string\n}\n\n// XML-RPC methods:\n// - load.raw_start (add torrent from base64 data)\n// - load.start (add torrent from URL/magnet)\n// - d.multicall2 (list torrents)\n// - d.name, d.size_bytes, d.completed_bytes, d.ratio, etc.\n```\n\n### Deluge (JSON-RPC)\n\nPopular alternative with web UI.\n\n```go\n// internal/torrent/deluge.go\ntype DelugeClient struct {\n    host     string  // e.g., \"seedbox.example.com:8112\"\n    password string  // Deluge uses single password, no username\n    useTLS   bool\n}\n\n// JSON-RPC methods (via /json endpoint):\n// - auth.login\n// - core.add_torrent_magnet\n// - core.add_torrent_url\n// - core.get_torrents_status\n```\n\n## File Access Methods\n\n### HTTP/HTTPS (Primary)\n\nMost seedboxes run nginx/apache serving the downloads directory. This is ideal because:\n- Looks like normal web traffic to ISP\n- No P2P protocol detection\n- Often faster than SFTP for large files\n- Resume support via Range headers\n\n```\nSeedbox URL: https://user.seedbox.io/downloads/\n            https://user.seedbox.io/downloads/movie.mkv\n            https://user.seedbox.io/downloads/album/01-track.flac\n```\n\n**Implementation:**\n```go\n// internal/seedbox/http.go\ntype HTTPFileServer struct {\n    baseURL  string  // e.g., \"https://user.seedbox.io/downloads/\"\n    username string  // HTTP Basic Auth\n    password string\n}\n\nfunc (h *HTTPFileServer) List(path string) ([]FileInfo, error)\nfunc (h *HTTPFileServer) Download(remotePath, localPath string, progress func(int64, int64)) error\n```\n\n**Directory listing:** Parse HTML index page or use JSON index if available.\n\n### SFTP (Alternative)\n\nUniversal fallback - every seedbox has SSH access.\n\n```go\n// internal/seedbox/sftp.go\ntype SFTPFileServer struct {\n    host       string  // e.g., \"seedbox.example.com:22\"\n    username   string\n    password   string  // or privateKey\n    privateKey string  // path to SSH key\n    basePath   string  // e.g., \"/home/user/downloads\"\n}\n\nfunc (s *SFTPFileServer) List(path string) ([]FileInfo, error)\nfunc (s *SFTPFileServer) Download(remotePath, localPath string, progress func(int64, int64)) error\n```\n\n**Library:** Use `github.com/pkg/sftp` with `golang.org/x/crypto/ssh`\n\n### FileInfo Structure\n\n```go\n// internal/seedbox/types.go\ntype FileInfo struct {\n    Name    string    `json:\"name\"`\n    Path    string    `json:\"path\"`     // relative path from base\n    Size    int64     `json:\"size\"`\n    IsDir   bool      `json:\"isDir\"`\n    ModTime time.Time `json:\"modTime\"`\n}\n```\n\n## UI Design\n\n### Seedbox Page (Web UI)\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  Seedbox                                              [Settings]│\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  ┌─── Add Torrent ───────────────────────────────────────────┐ │\n│  │                                                           │ │\n│  │  [Magnet link or .torrent URL                        ]   │ │\n│  │                                                           │ │\n│  │  [ ] Start paused                      [Send to Seedbox] │ │\n│  └───────────────────────────────────────────────────────────┘ │\n│                                                                 │\n│  ┌─── Browse Files ──────────────────────────────────────────┐ │\n│  │                                                           │ │\n│  │  Path: /downloads/                            [Refresh]   │ │\n│  │  ─────────────────────────────────────────────────────── │ │\n│  │  [ ] Name                          Size        Modified   │ │\n│  │  ─────────────────────────────────────────────────────── │ │\n│  │  [ ] 📁 movies/                     -          2024-01-15│ │\n│  │  [ ] 📁 music/                      -          2024-01-14│ │\n│  │  [x] 📄 ubuntu-24.04.iso           4.7 GB     2024-01-13│ │\n│  │  [ ] 📄 document.pdf               2.3 MB     2024-01-12│ │\n│  │  ─────────────────────────────────────────────────────── │ │\n│  │                                                           │ │\n│  │  Selected: 1 file (4.7 GB)                               │ │\n│  │                                                           │ │\n│  │  Download to: [/volume1/downloads     ▼]  [Download]     │ │\n│  └───────────────────────────────────────────────────────────┘ │\n│                                                                 │\n│  ┌─── Active Torrents ───────────────────────────────────────┐ │\n│  │                                                           │ │\n│  │  ubuntu-24.04.iso                                        │ │\n│  │  ████████████████████████████░░░░░░  85%  12.3 MB/s      │ │\n│  │                                                           │ │\n│  │  archlinux-2024.01.01.iso                                │ │\n│  │  ████████████████████████████████████  100%  Seeding     │ │\n│  │                                                           │ │\n│  └───────────────────────────────────────────────────────────┘ │\n│                                                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Settings Modal\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  Seedbox Settings                                          [X] │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  Torrent Client                                                 │\n│  ┌─────────────────────────────────────────────────────────┐   │\n│  │  Client:    [rTorrent (ruTorrent)  ▼]                   │   │\n│  │  RPC URL:   [https://my.seedbox.io/rutorrent/plugins/ht]│   │\n│  │  Username:  [myuser                  ]                   │   │\n│  │  Password:  [••••••••                ]                   │   │\n│  │                                    [Test Connection]     │   │\n│  └─────────────────────────────────────────────────────────┘   │\n│                                                                 │\n│  File Access                                                    │\n│  ┌─────────────────────────────────────────────────────────┐   │\n│  │  Method:    (•) HTTP/HTTPS    ( ) SFTP                  │   │\n│  │                                                         │   │\n│  │  ── HTTP/HTTPS Settings ──                              │   │\n│  │  Base URL:  [https://my.seedbox.io/downloads/       ]   │   │\n│  │  Username:  [myuser                  ]                   │   │\n│  │  Password:  [••••••••                ]                   │   │\n│  │                                                         │   │\n│  │  ── SFTP Settings (if selected) ──                      │   │\n│  │  Host:      [my.seedbox.io:22        ]                   │   │\n│  │  Username:  [myuser                  ]                   │   │\n│  │  Auth:      (•) Password  ( ) SSH Key                   │   │\n│  │  Password:  [••••••••                ]                   │   │\n│  │  Base Path: [/home/myuser/downloads  ]                   │   │\n│  │                                    [Test Connection]     │   │\n│  └─────────────────────────────────────────────────────────┘   │\n│                                                                 │\n│  Default Download Location                                      │\n│  ┌─────────────────────────────────────────────────────────┐   │\n│  │  Path:      [/volume1/downloads      ]                   │   │\n│  └─────────────────────────────────────────────────────────┘   │\n│                                                                 │\n│                              [Cancel]  [Save]                   │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n## API Endpoints\n\n### Torrent Dispatch\n```\nPOST /api/seedbox/torrent\n{\n  \"magnet\": \"magnet:?xt=urn:btih:...\",\n  // or\n  \"torrentUrl\": \"https://example.com/file.torrent\",\n  \"startPaused\": false\n}\n```\n\n### List Torrents\n```\nGET /api/seedbox/torrents\n\nResponse:\n{\n  \"torrents\": [\n    {\n      \"id\": \"abc123\",\n      \"name\": \"ubuntu-24.04.iso\",\n      \"size\": 5000000000,\n      \"downloaded\": 4250000000,\n      \"progress\": 85,\n      \"speed\": 12900000,\n      \"status\": \"downloading\",  // downloading, seeding, paused, error\n      \"ratio\": 0.5\n    }\n  ]\n}\n```\n\n### Browse Files\n```\nGET /api/seedbox/files?path=/downloads/\n\nResponse:\n{\n  \"path\": \"/downloads/\",\n  \"files\": [\n    {\"name\": \"movies\", \"path\": \"/downloads/movies/\", \"isDir\": true, \"size\": 0, \"modTime\": \"...\"},\n    {\"name\": \"ubuntu.iso\", \"path\": \"/downloads/ubuntu.iso\", \"isDir\": false, \"size\": 5000000000, \"modTime\": \"...\"}\n  ]\n}\n```\n\n### Download Files\n```\nPOST /api/seedbox/download\n{\n  \"files\": [\n    \"/downloads/ubuntu.iso\",\n    \"/downloads/movies/\"\n  ],\n  \"destination\": \"/volume1/downloads\"\n}\n\nResponse:\n{\n  \"taskId\": \"download-123\",\n  \"status\": \"started\"\n}\n```\n\n### Download Progress\n```\nGET /api/seedbox/download/download-123\n\nResponse:\n{\n  \"taskId\": \"download-123\",\n  \"status\": \"in_progress\",  // in_progress, completed, failed\n  \"files\": [\n    {\"path\": \"/downloads/ubuntu.iso\", \"progress\": 45, \"speed\": 50000000}\n  ],\n  \"totalSize\": 5000000000,\n  \"downloaded\": 2250000000\n}\n```\n\n## Configuration\n\n### Config File Structure\n\n```yaml\n# ~/.config/vget/config.yml\n\nseedbox:\n  enabled: true\n\n  # Torrent client settings\n  client: rtorrent  # rtorrent, deluge, qbittorrent, transmission\n  clientHost: \"https://my.seedbox.io/rutorrent/plugins/httprpc/action.php\"\n  clientUsername: \"myuser\"\n  clientPassword: \"secret\"\n\n  # File access settings\n  fileAccess: http  # http or sftp\n\n  # HTTP settings (when fileAccess: http)\n  httpBaseURL: \"https://my.seedbox.io/downloads/\"\n  httpUsername: \"myuser\"\n  httpPassword: \"secret\"\n\n  # SFTP settings (when fileAccess: sftp)\n  sftpHost: \"my.seedbox.io:22\"\n  sftpUsername: \"myuser\"\n  sftpPassword: \"secret\"\n  sftpPrivateKey: \"\"  # path to SSH key, alternative to password\n  sftpBasePath: \"/home/myuser/downloads\"\n\n  # Download settings\n  defaultDownloadPath: \"/volume1/downloads\"\n```\n\n### Go Config Struct\n\n```go\n// internal/core/config/config.go\n\ntype SeedboxConfig struct {\n    Enabled bool `yaml:\"enabled\"`\n\n    // Torrent client\n    Client         string `yaml:\"client\"`          // rtorrent, deluge, qbittorrent, transmission\n    ClientHost     string `yaml:\"clientHost\"`\n    ClientUsername string `yaml:\"clientUsername\"`\n    ClientPassword string `yaml:\"clientPassword\"`\n\n    // File access\n    FileAccess string `yaml:\"fileAccess\"`  // http or sftp\n\n    // HTTP settings\n    HTTPBaseURL  string `yaml:\"httpBaseURL\"`\n    HTTPUsername string `yaml:\"httpUsername\"`\n    HTTPPassword string `yaml:\"httpPassword\"`\n\n    // SFTP settings\n    SFTPHost       string `yaml:\"sftpHost\"`\n    SFTPUsername   string `yaml:\"sftpUsername\"`\n    SFTPPassword   string `yaml:\"sftpPassword\"`\n    SFTPPrivateKey string `yaml:\"sftpPrivateKey\"`\n    SFTPBasePath   string `yaml:\"sftpBasePath\"`\n\n    // Download settings\n    DefaultDownloadPath string `yaml:\"defaultDownloadPath\"`\n}\n```\n\n## Implementation Plan\n\n### Phase 1: New Torrent Clients\n- [ ] `internal/torrent/rtorrent.go` - rTorrent XML-RPC client\n- [ ] `internal/torrent/deluge.go` - Deluge JSON-RPC client\n- [ ] Add to client factory in `internal/torrent/client.go`\n- [ ] Test with Docker containers\n\n### Phase 2: File Access Layer\n- [ ] `internal/seedbox/types.go` - FileInfo, interfaces\n- [ ] `internal/seedbox/http.go` - HTTP/HTTPS file browser/downloader\n- [ ] `internal/seedbox/sftp.go` - SFTP file browser/downloader\n- [ ] `internal/seedbox/manager.go` - Factory and download task management\n\n### Phase 3: Backend API\n- [ ] Add SeedboxConfig to `internal/core/config/config.go`\n- [ ] `internal/server/seedbox.go` - API handlers\n- [ ] Register routes in `internal/server/server.go`\n- [ ] Download task queue with progress tracking\n\n### Phase 4: Frontend UI\n- [ ] `ui/src/pages/SeedboxPage.tsx` - Main page component\n- [ ] `ui/src/components/SeedboxTorrent.tsx` - Add torrent form\n- [ ] `ui/src/components/SeedboxBrowser.tsx` - File browser\n- [ ] `ui/src/components/SeedboxDownloads.tsx` - Download progress\n- [ ] `ui/src/components/SeedboxSettings.tsx` - Settings modal\n- [ ] Add to sidebar, routes, translations\n\n### Phase 5: Polish\n- [ ] i18n translations (all locales)\n- [ ] Error handling and retry logic\n- [ ] Connection testing in settings\n- [ ] Documentation\n\n## Testing\n\n### Local Testing with Docker\n\n```bash\n# rTorrent with ruTorrent\ndocker run -d --name rutorrent \\\n  -p 8080:8080 \\\n  -p 45000:45000 \\\n  crazymax/rtorrent-rutorrent\n\n# Deluge\ndocker run -d --name deluge \\\n  -p 8112:8112 \\\n  linuxserver/deluge\n\n# nginx for HTTP file serving (simulate seedbox HTTP)\ndocker run -d --name nginx-files \\\n  -p 8081:80 \\\n  -v /tmp/downloads:/usr/share/nginx/html:ro \\\n  nginx\n```\n\n### Test Files\nUse legal test torrents (Ubuntu, Arch Linux ISOs, etc.)\n\n## Security Considerations\n\n- Credentials stored in config file (same as other sensitive data)\n- HTTPS strongly recommended for HTTP file access\n- SSH key authentication preferred over password for SFTP\n- No auto-discovery to avoid network scanning\n\n## Differences from NAS Mode\n\n| Aspect | NAS Mode | Seedbox Mode |\n|--------|----------|--------------|\n| Location | Local network | Remote server |\n| Dispatch | Yes | Yes |\n| Browse files | No (use NAS UI) | Yes |\n| Download back | No (already local) | Yes (HTTP/SFTP) |\n| ISP visibility | N/A | Hidden (HTTP looks normal) |\n| Speed | LAN speed | Internet speed |\n| Use case | Home NAS | Remote seedbox |\n\n## Future Enhancements (Not Planned)\n\n- Automatic sync (watch folder)\n- Webhook notifications on completion\n- Multiple seedbox profiles\n- Bandwidth scheduling\n- Integration with Plex/Jellyfin for auto-scan\n"
  },
  {
    "path": "docs/tauri.md",
    "content": "# vget Desktop App - Tauri Implementation Plan\n\n## Goal\nBuild a desktop app version of vget using Tauri 2.0 with React frontend and Rust backend, matching the tech stack from maily.\n\n---\n\n## Tech Stack (Aligned with maily)\n\n### Frontend\n- **React** 19.x\n- **Vite** 7.x with `@vitejs/plugin-react`\n- **TanStack Router** (file-based routing)\n- **Tailwind CSS** 4.x via `@tailwindcss/vite`\n- **shadcn/ui** (new-york style) with Radix primitives\n- **Zustand** 5.x for state management\n- **lucide-react** for icons\n- **sonner** for toasts\n- **bun** as package manager\n\n### Backend (Rust)\n- **Tauri** 2.0\n- **tokio** for async runtime\n- **reqwest** for HTTP client\n- **serde** + serde_json + serde_yaml\n- **rusqlite** for local database (download history)\n- **dirs** for config paths\n- **tauri-plugin-dialog** for file dialogs\n- **tauri-plugin-updater** for auto-updates\n- **tauri-plugin-process** for app control\n\n### vget-specific Rust crates\n- **m3u8-rs** for HLS parsing\n- **aes** + **cbc** for HLS decryption\n- **chromiumoxide** for browser automation (Xiaohongshu)\n- **ffmpeg-sidecar** for video/audio merging\n\n---\n\n## Project Structure\n\n```\nvget-desktop/\n├── package.json\n├── bun.lock\n├── vite.config.ts\n├── tsconfig.json\n├── components.json          # shadcn/ui config\n├── index.html\n├── Makefile\n│\n├── src/                     # React frontend\n│   ├── main.tsx\n│   ├── index.css\n│   ├── routeTree.gen.ts    # Auto-generated by TanStack\n│   ├── routes/\n│   │   ├── __root.tsx\n│   │   ├── index.tsx       # Main download page\n│   │   ├── history.tsx     # Download history\n│   │   └── settings.tsx    # Configuration\n│   ├── components/\n│   │   ├── ui/             # shadcn/ui components\n│   │   ├── URLInput.tsx\n│   │   ├── DownloadCard.tsx\n│   │   ├── ProgressBar.tsx\n│   │   ├── FormatSelector.tsx\n│   │   └── UpdateNotification.tsx\n│   ├── stores/\n│   │   ├── downloads.ts    # Download queue state\n│   │   └── config.ts       # App config state\n│   ├── hooks/\n│   │   └── useDownload.ts\n│   └── lib/\n│       └── utils.ts\n│\n├── src-tauri/\n│   ├── Cargo.toml\n│   ├── tauri.conf.json\n│   ├── build.rs\n│   ├── icons/\n│   └── src/\n│       ├── main.rs\n│       ├── lib.rs          # Tauri commands registration\n│       ├── config.rs       # Config management\n│       ├── db.rs           # SQLite for history\n│       ├── extractor/      # Site extractors\n│       │   ├── mod.rs      # Extractor trait + registry\n│       │   ├── types.rs    # Media types\n│       │   ├── direct.rs\n│       │   ├── m3u8.rs\n│       │   ├── twitter.rs\n│       │   ├── bilibili.rs\n│       │   ├── xiaoyuzhou.rs\n│       │   ├── itunes.rs\n│       │   ├── xiaohongshu.rs\n│       │   └── youtube.rs\n│       ├── downloader/\n│       │   ├── mod.rs\n│       │   ├── simple.rs\n│       │   ├── multistream.rs\n│       │   └── hls.rs\n│       └── ffmpeg.rs\n│\n└── public/\n    └── favicon.ico\n```\n\n---\n\n## Implementation Phases\n\n### Phase 1: Project Setup\n1. Create new Tauri 2.0 project with React + TypeScript template\n2. Configure Vite with TanStack Router plugin and Tailwind\n3. Setup shadcn/ui with new-york style\n4. Create basic Rust module structure\n5. Configure tauri.conf.json with app metadata\n\n**Files to create:**\n- `package.json`, `vite.config.ts`, `tsconfig.json`\n- `components.json`, `src/index.css`\n- `src-tauri/Cargo.toml`, `src-tauri/tauri.conf.json`\n- Basic route files and lib.rs\n\n### Phase 2: Core Types & Direct Downloads\n1. Define Rust types: `Media`, `VideoMedia`, `AudioMedia`, `ImageMedia`, `Format`\n2. Implement `Extractor` trait and registry pattern\n3. Implement `DirectExtractor` (file URL detection)\n4. Implement simple HTTP downloader with progress events\n5. Create frontend: URL input, progress display, basic settings\n\n**Tauri commands:**\n```rust\n#[tauri::command]\nasync fn extract_media(url: String) -> Result<MediaInfo, String>\n\n#[tauri::command]\nasync fn start_download(url: String, output_path: String, format_id: Option<String>) -> Result<String, String>\n\n#[tauri::command]\nfn cancel_download(job_id: String) -> Result<(), String>\n\n#[tauri::command]\nfn get_config() -> Result<Config, String>\n\n#[tauri::command]\nfn save_config(config: Config) -> Result<(), String>\n```\n\n### Phase 3: Core Extractors\n1. **M3U8Extractor** - HLS stream detection\n2. **TwitterExtractor** - Syndication API + GraphQL fallback\n3. **BilibiliExtractor** - WBI signing, DASH streams\n4. **XiaoyuzhouExtractor** - Podcast episodes\n5. **iTunesExtractor** - Apple Podcasts\n\n### Phase 4: Advanced Downloaders\n1. **Multi-stream downloader** - 12 parallel streams with resume\n2. **HLS downloader** - M3U8 parsing, segment download, AES decryption\n3. **FFmpeg integration** - Bundle ffmpeg-sidecar for merging\n\n### Phase 5: Browser Automation\n1. Integrate chromiumoxide for browser automation\n2. Implement stealth techniques (anti-bot detection)\n3. **XiaohongshuExtractor** - Browser-based extraction\n4. Cookie persistence for authenticated sites\n\n### Phase 6: Polish & Distribution\n1. Download history with SQLite\n2. Auto-updater configuration\n3. i18n support (start with en, zh)\n4. Cross-platform testing and builds\n5. GitHub releases setup\n\n---\n\n## Key Tauri Commands (Full List)\n\n```rust\n// Extraction\nextract_media(url: String) -> Result<MediaInfo, String>\n\n// Downloads\nstart_download(url: String, output_path: String, format_id: Option<String>) -> Result<String, String>\ncancel_download(job_id: String) -> Result<(), String>\nget_download_status(job_id: String) -> Result<DownloadStatus, String>\n\n// Config\nget_config() -> Result<Config, String>\nsave_config(config: Config) -> Result<(), String>\nselect_output_directory() -> Result<Option<String>, String>\n\n// History\nlist_downloads(limit: usize, offset: usize) -> Result<Vec<DownloadRecord>, String>\nclear_history() -> Result<(), String>\n```\n\n**Events (Rust → Frontend):**\n- `download-progress` - Progress updates\n- `download-complete` - Download finished\n- `download-error` - Download failed\n\n---\n\n## Rust Dependencies (Cargo.toml)\n\n```toml\n[dependencies]\ntauri = { version = \"2\", features = [] }\ntauri-plugin-dialog = \"2\"\ntauri-plugin-updater = \"2\"\ntauri-plugin-process = \"2\"\ntauri-plugin-opener = \"2\"\n\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nserde_yaml = \"0.9\"\n\ntokio = { version = \"1\", features = [\"rt-multi-thread\", \"sync\", \"time\", \"fs\"] }\nreqwest = { version = \"0.12\", features = [\"json\", \"cookies\", \"gzip\", \"stream\"] }\n\nrusqlite = { version = \"0.32\", features = [\"bundled\"] }\ndirs = \"6\"\nuuid = { version = \"1\", features = [\"v4\", \"serde\"] }\nchrono = { version = \"0.4\", features = [\"serde\"] }\n\n# HLS\nm3u8-rs = \"6\"\naes = \"0.8\"\ncbc = \"0.1\"\nhex = \"0.4\"\n\n# FFmpeg\nffmpeg-sidecar = \"2\"\n\n# Browser automation (for Xiaohongshu)\nchromiumoxide = { version = \"0.7\", optional = true }\n\n[features]\ndefault = []\nbrowser = [\"chromiumoxide\"]\n```\n\n---\n\n## Frontend Dependencies (package.json)\n\n```json\n{\n  \"dependencies\": {\n    \"@tauri-apps/api\": \"^2\",\n    \"@tauri-apps/plugin-dialog\": \"^2\",\n    \"@tauri-apps/plugin-updater\": \"^2\",\n    \"@tauri-apps/plugin-process\": \"^2\",\n    \"@tanstack/react-router\": \"^1.147\",\n    \"zustand\": \"^5\",\n    \"react\": \"^19\",\n    \"react-dom\": \"^19\",\n    \"lucide-react\": \"^0.562\",\n    \"sonner\": \"^2\",\n    \"tailwindcss\": \"^4.1\",\n    \"class-variance-authority\": \"^0.7\",\n    \"clsx\": \"^2.1\",\n    \"tailwind-merge\": \"^3.4\"\n  },\n  \"devDependencies\": {\n    \"@tanstack/router-plugin\": \"^1.149\",\n    \"@tauri-apps/cli\": \"^2\",\n    \"@tailwindcss/vite\": \"^4.1\",\n    \"@vitejs/plugin-react\": \"^4.6\",\n    \"typescript\": \"~5.8\",\n    \"vite\": \"^7\"\n  }\n}\n```\n\n---\n\n## Extractor Priority (What to Build First)\n\n| Order | Extractor | Effort | Notes |\n|-------|-----------|--------|-------|\n| 1 | Direct | Low | Foundation - file URL detection |\n| 2 | M3U8 | Medium | HLS support, needed by others |\n| 3 | Twitter | Medium | Popular, API-based |\n| 4 | Bilibili | Medium | WBI signing complexity |\n| 5 | Xiaoyuzhou | Low | Simple HTML parsing |\n| 6 | iTunes | Low | RSS/JSON API |\n| 7 | YouTube | Medium | yt-dlp subprocess wrapper |\n| 8 | Xiaohongshu | High | Browser automation required |\n\n---\n\n## Verification\n\n1. **Build test:** `cd src-tauri && cargo build`\n2. **Dev mode:** `bun tauri dev`\n3. **Test download:** Paste a Twitter/Bilibili URL and verify download completes\n4. **Test progress:** Verify real-time progress updates in UI\n5. **Test config:** Change output directory, verify persistence\n6. **Production build:** `bun tauri build`\n\n---\n\n## Release Strategy\n\n### Separate Release Cycles\n\nCLI and Desktop have independent release cycles to allow focused development on either without forcing synchronized releases.\n\n### Git Tags\n\n| Product | Version Tags | Latest Tag | Example |\n|---------|--------------|------------|---------|\n| CLI | `v0.x.x` | `latest` | `v0.12.13` |\n| Desktop | `desktop-v0.x.x` | `desktop-latest` | `desktop-v0.1.0` |\n\n### Auto-Updater Endpoints\n\n- **CLI**: Checks `https://github.com/guiyumin/vget/releases/download/latest/...`\n- **Desktop**: Checks `https://github.com/guiyumin/vget/releases/download/desktop-latest/latest.json`\n\n### Release Workflow\n\n**CLI Release:**\n```bash\n# 1. Update version in internal/version/version.go\n# 2. Commit and tag\ngit tag v0.12.13\ngit push origin v0.12.13\n\n# 3. Update 'latest' tag\ngit tag -f latest\ngit push -f origin latest\n```\n\n**Desktop Release:**\n```bash\n# 1. Update version in tauri/src-tauri/tauri.conf.json\n# 2. Commit and tag\ngit tag desktop-v0.1.0\ngit push origin desktop-v0.1.0\n\n# 3. Update 'desktop-latest' tag\ngit tag -f desktop-latest\ngit push -f origin desktop-latest\n```\n\n### Benefits\n\n1. **Independent cycles** - Release CLI bug fixes without waiting for Desktop features\n2. **Focused development** - Work on one product at a time without pressure\n3. **Backward compatibility** - Existing CLI users continue getting updates from `latest`\n4. **Clear separation** - Easy to track which version is which\n5. **GitHub Actions** - Can have separate workflows triggered by tag patterns:\n   - `v*` → Build CLI binaries\n   - `desktop-v*` → Build Desktop app bundles\n\n### GitHub Actions Setup (Recommended)\n\n```yaml\n# .github/workflows/release-cli.yml\non:\n  push:\n    tags:\n      - 'v*'\n\n# .github/workflows/release-desktop.yml\non:\n  push:\n    tags:\n      - 'desktop-v*'\n```\n\n---\n\n## Shared Config Directory\n\nCLI and Desktop share the same config directory at `~/.config/vget/`. This ensures settings sync between both apps.\n\n### Directory Structure\n\n```\n~/.config/vget/\n├── config.yml              # Main configuration (shared by CLI & Desktop)\n├── update.key              # Desktop auto-updater signing key (private)\n├── update.key.pub          # Desktop auto-updater signing key (public)\n├── auth.json               # Authentication tokens\n├── xhs_cookies.json        # Xiaohongshu browser cookies\n├── youtube_session.json    # YouTube session data\n└── telegram/               # Telegram MTProto session\n    └── session\n```\n\n### config.yml Format\n\n```yaml\n# vget configuration file\n# Shared between CLI and Desktop\n\n# Basic settings\nlanguage: zh                          # en, zh, jp, kr, es, fr, de\noutput_dir: /Users/yumin/Downloads/vget\nformat: mp4                           # mp4, webm, best\nquality: best                         # best, 1080p, 720p, 480p\n\n# WebDAV servers (for cloud storage like PikPak)\nwebdavServers:\n    pikpak:\n        url: https://dav.mypikpak.com\n        username: your_username\n        password: your_password\n\n# Twitter/X authentication\ntwitter:\n    auth_token: your_auth_token       # Required for NSFW content\n\n# Bilibili authentication\nbilibili:\n    cookie: SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx\n\n# Server mode settings (CLI only)\nserver:\n    max_concurrent: 10\n\n# Express tracking (CLI only)\nexpress:\n    kuaidi100:\n        customer: xxx\n        key: xxx\n```\n\n### Config File Locations by Platform\n\n| Platform | Path |\n|----------|------|\n| macOS | `~/.config/vget/config.yml` |\n| Linux | `~/.config/vget/config.yml` |\n| Windows | `%USERPROFILE%\\.config\\vget\\config.yml` |\n\n**Note:** Desktop does NOT use `~/Library/Application Support/` on macOS to maintain compatibility with CLI.\n\n### Rust Config Struct\n\n```rust\n// src-tauri/src/config.rs\n\npub struct Config {\n    pub language: String,\n    pub output_dir: String,\n    pub format: String,\n    pub quality: String,\n    #[serde(rename = \"webdavServers\")]\n    pub webdav_servers: HashMap<String, WebDAVServer>,\n    pub twitter: TwitterConfig,\n    pub bilibili: BilibiliConfig,\n    pub server: ServerConfig,\n    pub express: ExpressConfig,\n}\n\npub struct TwitterConfig {\n    pub auth_token: Option<String>,\n}\n\npub struct BilibiliConfig {\n    pub cookie: Option<String>,\n}\n\npub struct WebDAVServer {\n    pub url: String,\n    pub username: String,\n    pub password: String,\n}\n```\n\n### TypeScript Config Interface\n\n```typescript\n// src/components/settings/types.ts\n\ninterface Config {\n  language: string;\n  output_dir: string;\n  format: string;\n  quality: string;\n  webdav_servers: Record<string, WebDAVServer>;\n  twitter: { auth_token: string | null };\n  bilibili: { cookie: string | null };\n  server: { max_concurrent: number };\n  express: { kuaidi100: Kuaidi100Config | null };\n}\n```\n"
  },
  {
    "path": "docs/telegram.md",
    "content": "# Telegram Support\n\nImplementation plan for Telegram media download support in vget.\n\n## Overview\n\nvget aims to be an all-in-one media downloader. Telegram support is part of this vision, even though `tdl` (6k+ stars) exists as a dedicated tool.\n\n**Current**: Desktop session import using Telegram Desktop's API credentials.\n\n**Future**: Full CLI Telegram client capabilities (phone login, QR login, etc.).\n\n## Technical Background\n\n### How Telegram Auth Works\n\n```\napi_id + api_hash  =  identifies THE APP (vget)\nuser session       =  identifies THE USER's account\n```\n\n- Sessions are tied to the `api_id` they were created with\n- Desktop session import reuses existing login from Telegram Desktop\n- No phone/SMS verification needed if user has Desktop installed\n\n### API Credentials\n\nCurrently using Telegram Desktop's public credentials:\n\n```go\nconst (\n    TelegramDesktopAppID   = 2040\n    TelegramDesktopAppHash = \"b18441a1ff607e10a989891a5462e627\"\n)\n```\n\nThese are safe to use:\n- Already public (used by Telegram Desktop itself)\n- Used by many third-party tools (tdl, etc.)\n- Telegram cannot revoke without breaking Desktop app\n\nFuture: Register vget's own credentials for `--phone` login method.\n\n### Login Methods & Ban Risk\n\n| Method | API Credentials | Ban Risk | Why |\n|--------|-----------------|----------|-----|\n| `--import-desktop` | Desktop's (2040) | Low | Reusing session, same app identity |\n| `--phone` (future) | vget's own | **Zero** | Fresh session with registered app |\n| `--qr` (future) | vget's own | **Zero** | Fresh session with registered app |\n| `--bot-token` (future) | N/A | **Zero** | Bot tokens are inherently safe |\n\n## Dependencies\n\n```go\ngithub.com/gotd/td                    // Pure Go MTProto 2.0 implementation\ngithub.com/gotd/td/session/tdesktop   // Desktop session import\n```\n\n## Implementation Status\n\n### Phase 1: MVP (Implemented)\n\n#### 1. Session Management Commands\n\n```bash\nvget telegram login                  # Shows available login methods\nvget telegram login --import-desktop # Import from Telegram Desktop\nvget telegram logout                 # Clear stored session\nvget telegram status                 # Show login state\n```\n\n**Desktop import flow (`--import-desktop`):**\n- Reads Desktop's `tdata/` directory\n  - macOS: `~/Library/Application Support/Telegram Desktop/tdata/`\n  - Linux: `~/.local/share/TelegramDesktop/tdata/`\n  - Windows: `%APPDATA%/Telegram Desktop/tdata/`\n  - **Custom path**: Set via `vget config set telegram.tdata_path /path/to/tdata`\n- Imports session using Desktop's API credentials (2040)\n- Session stored in `~/.config/vget/telegram/desktop-session.json`\n\n#### Session Storage & Multi-Account\n\n**Session file layout:**\n```\n~/.config/vget/telegram/\n├── desktop-session.json        # Imported from Telegram Desktop (current)\n└── cli-sessions/               # Future: phone/QR login sessions\n    ├── account1.json\n    └── account2.json\n```\n\n**Current behavior:**\n- Desktop import stores session at `desktop-session.json`\n- If Desktop has multiple accounts, vget imports the **first/primary** account\n- Re-importing **overwrites** the previous session\n\n**Multi-account workflow (current):**\n1. Switch to desired account in Telegram Desktop\n2. Run `vget telegram login --import-desktop`\n3. vget now uses that account\n4. To switch: repeat steps 1-2\n\n**Future (full CLI client):**\n```bash\n# Phone login creates named session in cli-sessions/\nvget telegram login --phone --name work\nvget telegram login --phone --name personal\n\n# Use specific account\nvget --account work https://t.me/channel/123\n```\n\nFor now, Telegram Desktop manages multi-account; vget imports whichever is active.\n\n#### Future Login Methods\n\n| Flag | Description | Status |\n|------|-------------|--------|\n| `--import-desktop` | Import from Telegram Desktop | Implemented |\n| `--phone` | Phone + SMS/code verification | Planned |\n| `--qr` | QR code login (scan with mobile) | Planned |\n| `--bot-token` | Bot authentication | Planned |\n\n**Phone login flow (`--phone`):**\n1. User enters phone number\n2. Telegram sends verification code:\n   - **Primary**: In-app message to existing Telegram sessions (Desktop/mobile)\n   - **Fallback**: SMS (if no active sessions or user requests it)\n3. User enters code\n4. (Optional) Enters 2FA password if enabled\n5. Session created with vget's API credentials\n\n**QR login flow (`--qr`):**\n1. vget displays QR code in terminal\n2. User scans with Telegram mobile app\n3. Session created automatically\n4. No phone number or code needed\n\n**Bot token flow (`--bot-token`):**\n1. User provides bot token from @BotFather\n2. Authenticate as bot (limited permissions)\n3. Useful for downloading from public channels only\n\n#### 2. URL Parsing\n\nSupport these `t.me` formats:\n\n| Format | Example | Type |\n|--------|---------|------|\n| Public channel | `https://t.me/channel/123` | Public |\n| Private channel | `https://t.me/c/123456789/123` | Private |\n| User/bot post | `https://t.me/username/123` | Public |\n| Single from album | `https://t.me/channel/123?single` | Public |\n\n#### 3. Single Message Download\n\n```bash\nvget https://t.me/somechannel/456\n```\n\n- Extract media (video/audio/document) from one message\n- Download with progress bar (existing Bubbletea infrastructure)\n- Save to current directory or `-o` path\n\n#### 4. Media Type Detection\n\n```go\nMediaTypeVideo     // .mp4, .mov\nMediaTypeAudio     // .mp3, .ogg voice messages\nMediaTypeDocument  // .pdf, .zip, etc.\nMediaTypePhoto     // .jpg (lower priority)\n```\n\n### Phase 2: Nice-to-Have\n\n| Feature | Description |\n|---------|-------------|\n| Batch download | `vget https://t.me/channel/100-200` (range) |\n| Resume | Continue interrupted downloads |\n| Album support | Download all media from grouped messages |\n| Channel dump | `vget https://t.me/channel --all` |\n\n## File Structure\n\n```\ninternal/core/extractor/\n├── telegram.go              # Thin wrapper, registers extractor, re-exports\n├── telegram/\n│   ├── constants.go         # API credentials (DesktopAppID, DesktopAppHash)\n│   ├── parser.go            # URL parsing\n│   ├── session.go           # Session path/exists helpers\n│   ├── media.go             # Media extraction helpers\n│   ├── extractor.go         # Extractor implementation\n│   ├── download.go          # Download functionality + DownloadWithOptions\n│   └── takeout.go           # TakeoutSession for bulk downloads\n\ninternal/cli/\n├── telegram.go              # login/logout/status commands\n├── batch.go                 # Batch download with auto-takeout for Telegram\n```\n\n## vget vs tdl\n\n| Aspect | tdl | vget |\n|--------|-----|------|\n| Scope | Telegram-only | Multi-platform |\n| Features | Many advanced (batch, resume, takeout) | Simple + auto-takeout for batch |\n| Philosophy | Power tool | All-in-one simplicity |\n\n## Reference Implementation\n\nThe `tdl` project (github.com/iyear/tdl) was analyzed for patterns:\n\n### Worth Borrowing\n\n1. **URL Parsing** (`pkg/tmessage/parse.go`) - handles various t.me formats\n2. **Media Extraction** (`core/tmedia/media.go`) - unified media type abstraction\n3. **Middleware Pattern** - retry, recovery, flood-wait as composable layers\n\n### Skip for MVP\n\n- Iterator + Resume pattern (Phase 2)\n- Data Center pooling (overkill for single downloads)\n\n### Implemented from tdl\n\n- **Takeout mode** - auto-enabled for batch downloads (2+ Telegram URLs)\n\n## Protected/Restricted Content\n\n### Understanding `noforwards` Flag\n\nChannel owners can enable \"Restrict saving content\" which sets the `noforwards` flag on the channel or individual messages. This:\n- Disables \"Forward\" button in official apps\n- Disables \"Save\" button for media in official apps\n- Shows \"Saving content is restricted\" message\n\n### Why Downloads Still Work\n\n**Key insight**: `noforwards` is a **client-side UI restriction**, not an API-level restriction.\n\nThe official Telegram apps *choose* to respect this flag by hiding UI buttons. But at the API level:\n- If you have access to a message, you can read its content\n- If you can read the content, you can download attached media\n- The API does not block file downloads based on `noforwards`\n\nThis is how `tdl` and similar tools work - they use the Telegram Client API (MTProto) directly, bypassing the UI restrictions that official apps enforce.\n\n### How tdl Detects Protected Content\n\nFrom `tdl/core/forwarder/forwarder.go`:\n\n```go\nfunc protectedDialog(peer peers.Peer) bool {\n    switch p := peer.(type) {\n    case peers.Chat:\n        return p.Raw().GetNoforwards()\n    case peers.Channel:\n        return p.Raw().GetNoforwards()\n    }\n    return false\n}\n\nfunc protectedMessage(msg *tg.Message) bool {\n    return msg.GetNoforwards()\n}\n```\n\n### Operation Differences\n\n| Operation | Protected Content | How It Works |\n|-----------|-------------------|--------------|\n| **Download** | ✅ Works | Direct file access via API - `noforwards` doesn't apply |\n| **Forward** | ⚠️ Blocked by API | Must use \"clone\" mode (re-upload as new message) |\n\n### Takeout Mode for Bulk Downloads\n\nFor downloading many files, use Telegram's official \"Data Export\" feature via API:\n\n```go\n// From tdl/core/middlewares/takeout/takeout.go\nreq := &tg.AccountInitTakeoutSessionRequest{\n    MessageChannels:   true,\n    Files:             true,\n    FileMaxSize:       4000 * 1024 * 1024,  // 4GB limit\n}\n```\n\nTakeout sessions have **lower flood wait limits**, making bulk downloads faster and less likely to trigger rate limiting.\n\n### vget Implementation Notes\n\nFor vget's Telegram support:\n1. Desktop session import works for protected content - same API access as tdl\n2. No special handling needed - just download the file if user has message access\n3. Takeout mode is **auto-enabled** for batch downloads (2+ Telegram URLs)\n\n## Takeout Mode (Implemented)\n\n### What is Takeout?\n\nTakeout is Telegram's official \"Data Export\" API feature (`AccountInitTakeoutSession`). It's designed for users to export their own data with **relaxed rate limits**.\n\n| Without Takeout | With Takeout |\n|-----------------|--------------|\n| Normal flood wait limits | Lower flood wait limits |\n| More likely to get rate-limited on bulk downloads | Designed for bulk export |\n| Faster to hit `FLOOD_WAIT` errors | Can download more before limits |\n\n### vget Implementation\n\nTakeout is automatically enabled when batch downloading multiple Telegram URLs. No user flags needed.\n\n**Usage:**\n\n```bash\n# Single file - no takeout (not needed)\nvget https://t.me/channel/123\n\n# Batch mode with 2+ Telegram URLs - takeout auto-enabled\nvget -f urls.txt\n```\n\n**Implementation files:**\n\n| File | Purpose |\n|------|---------|\n| `telegram/takeout.go` | `TakeoutSession` struct with `Start()`, `Finish()`, `Middleware()` |\n| `telegram/download.go` | `DownloadWithOptions()` accepts `Takeout` bool |\n| `cli/root.go` | `runTelegramBatchDownload()` uses takeout internally |\n| `cli/batch.go` | Detects 2+ Telegram URLs → calls batch function |\n\n**Core takeout logic:**\n\n```go\n// internal/extractor/telegram/takeout.go\n\ntype TakeoutSession struct {\n    api       *tg.Client\n    takeoutID int64\n}\n\nfunc (t *TakeoutSession) Start(ctx context.Context) error {\n    req := &tg.AccountInitTakeoutSessionRequest{\n        Files:       true,\n        FileMaxSize: 4 * 1024 * 1024 * 1024, // 4GB\n    }\n    session, err := t.api.AccountInitTakeoutSession(ctx, req)\n    if err != nil {\n        return err\n    }\n    t.takeoutID = session.ID\n    return nil\n}\n\nfunc (t *TakeoutSession) Finish(ctx context.Context) error {\n    if t.takeoutID == 0 {\n        return nil\n    }\n    req := &tg.AccountFinishTakeoutSessionRequest{Success: true}\n    _, err := t.api.AccountFinishTakeoutSession(ctx, req)\n    return err\n}\n```\n\n**Batch download flow:**\n\n```\nurls.txt contains:\n  https://t.me/channel/1\n  https://t.me/channel/2\n  https://twitter.com/user/status/123\n\nvget -f urls.txt\n  ↓\nbatch.go separates URLs:\n  - telegramURLs: [t.me/1, t.me/2]  (2 URLs → use takeout)\n  - otherURLs: [twitter.com/...]\n  ↓\nrunTelegramBatchDownload(telegramURLs)\n  → Each download uses takeout session\n  ↓\nrunDownload() for other URLs\n```\n\n### Reference: How tdl Does It\n\ntdl uses a similar pattern with middleware wrapping:\n\n```go\n// Wrap all API calls with takeout session ID\nfunc (t takeout) Handle(next tg.Invoker) telegram.InvokeFunc {\n    return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {\n        return next.Invoke(ctx, &tg.InvokeWithTakeoutRequest{\n            TakeoutID: t.id,\n            Query:     nopDecoder{input},\n        }, output)\n    }\n}\n```\n\n## References\n\n- tdl source: https://github.com/iyear/tdl\n- gotd/td (MTProto library): https://github.com/gotd/td\n- Telegram Desktop session format: https://github.com/nickoala/tdesktop-session\n"
  },
  {
    "path": "docs/torrent-dispatch.md",
    "content": "# Torrent Dispatch Feature\n\n## Overview\n\nAllow vget to dispatch magnet links and .torrent files to remote torrent clients running on NAS devices or servers. vget does NOT download torrents itself - it only manages/dispatches jobs to existing torrent clients.\n\n## Motivation\n\nChinese users requested BitTorrent support. Many use private trackers (PT sites) and have NAS devices (Synology, QNAP, etc.) running 24/7 with torrent clients. They want to quickly send magnets to their NAS without opening the web UI.\n\n## Scope\n\n### In Scope\n- Send magnet links to remote torrent client\n- Send .torrent file URLs to remote torrent client\n- List active torrents (optional, for status checking)\n- Support multiple torrent clients: Transmission, qBittorrent, Synology Download Station\n\n### Out of Scope\n- Actually downloading torrents (use existing clients)\n- Torrent search/discovery\n- Auto-detection of NAS devices (unreliable)\n- Torrent client running inside vget Docker container\n\n## Architecture\n\n```\n┌─────────────────────────────────────────────────────────┐\n│  vget Docker (Web UI)                                   │\n│                                                         │\n│    Browser  →  POST /api/torrent  →  Torrent Library    │\n│                                                         │\n└─────────────────────┬───────────────────────────────────┘\n                      │ HTTP/RPC\n                      ▼\n┌─────────────────────────────────────────────────────────┐\n│  Remote Torrent Client (on NAS or server)               │\n│  - Transmission (RPC on port 9091)                      │\n│  - qBittorrent (Web API on port 8080)                   │\n│  - Synology Download Station (API on port 5000/5001)    │\n└─────────────────────────────────────────────────────────┘\n```\n\n## Implementation Status\n\n### Completed\n- [x] `internal/torrent/client.go` - Interface definition\n- [x] `internal/torrent/transmission.go` - Transmission RPC client\n- [x] `internal/torrent/qbittorrent.go` - qBittorrent Web API client\n- [x] `internal/torrent/synology.go` - Synology Download Station client\n- [x] `internal/core/config/config.go` - TorrentConfig struct added\n- [x] `internal/server/server.go` - API endpoints added\n- [x] `ui/src/routes/torrent.tsx` - Route file\n- [x] `ui/src/pages/TorrentPage.tsx` - Page component\n- [x] `ui/src/components/Torrent.tsx` - BT/Magnet submit component\n- [x] `ui/src/components/TorrentSettings.tsx` - Settings component\n- [x] `ui/src/components/Sidebar.tsx` - Menu item added\n- [x] `ui/src/utils/translations.ts` - Translation strings added\n- [x] `ui/src/utils/apis.ts` - API functions added\n- [x] `ui/src/context/AppContext.tsx` - torrentEnabled state added\n\n### TODO (Future)\n\n- [x] Add backend i18n translations (internal/i18n/locales/*.yml)\n- [ ] CLI support if users request it (vget bittorrent / bt / magnet / cili)\n\n## Configuration\n\n### Config File (~/.config/vget/config.yml)\n```yaml\ntorrent:\n  enabled: true\n  client: transmission\n  host: \"192.168.1.100:9091\"\n  username: \"admin\"\n  password: \"secret\"\n```\n\n### NOT in `vget init`\nTorrent config is optional and should NOT be part of the initial setup wizard. Users configure it through the Web UI settings page.\n\n## Supported Clients\n\n| Client | Default Port | Protocol | Notes |\n|--------|-------------|----------|-------|\n| Transmission | 9091 | JSON-RPC | Most common on Linux NAS |\n| qBittorrent | 8080 | REST API | Popular alternative |\n| Synology DS | 5000/5001 | REST API | Built into Synology NAS |\n\n## Testing\n\n### Local Testing (without NAS)\n```bash\n# Run Transmission in Docker\ndocker run -d --name transmission \\\n  -p 9091:9091 \\\n  -e USER=admin \\\n  -e PASS=admin \\\n  linuxserver/transmission\n\n# Run qBittorrent in Docker\ndocker run -d --name qbittorrent \\\n  -p 8080:8080 \\\n  linuxserver/qbittorrent\n```\n\n### Test Magnets\nUse legal test torrents:\n- Ubuntu ISO: `magnet:?xt=urn:btih:...` (search for current release)\n- Blender Open Movies\n\n## Security Considerations\n\n- Torrent client credentials stored in config file (same as other credentials)\n- HTTPS support for remote connections\n- No auto-discovery to avoid network scanning concerns\n\n## Future Enhancements (Not Planned)\n- Deluge support\n- Aria2 support (already has remote RPC)\n- QNAP Download Station\n- Torrent notifications via Telegram\n"
  },
  {
    "path": "docs/tui-file-browser.md",
    "content": "# TUI File Browser for Remote Paths\n\n## Overview\n\nWhen user runs `vget <remote>:/path/to/directory/`, instead of showing an error, vget displays an interactive TUI browser to navigate and select files for download.\n\n## Behavior\n\n```bash\nvget pikpak:/电影/          # Directory → Opens TUI browser\nvget pikpak:/电影/file.mkv  # File → Direct download\n```\n\n## Key Features\n\n### Navigation\n- `↑/↓` or `k/j`: Move cursor up/down\n- `Enter`: Enter directory / Download file\n- `b` or `Backspace` or `h`: Go up one directory\n- `q` or `Esc`: Quit without downloading\n\n### Display\n- Current path shown as header\n- Directories listed first (with 📁 icon), then files\n- File sizes displayed\n- Scrollable list with position indicator\n- Highlighted cursor row\n\n### Selection Behavior\n- Enter on directory: navigate into it (stay in TUI)\n- Enter on file: exit TUI and start download\n- User can navigate through multiple directory levels before selecting a file\n\n## Implementation\n\n### Files\n- `internal/cli/browse.go` - TUI browser component using Bubbletea\n- `internal/cli/root.go` - Integration point in `runWebDAVDownload()`\n\n### Flow\n1. User runs `vget pikpak:/电影/`\n2. vget detects it's a directory\n3. TUI browser opens showing directory contents\n4. User navigates and selects a file\n5. TUI closes and download begins\n"
  },
  {
    "path": "docs/webdav-browsing.md",
    "content": "# WebDAV File Browsing (Web UI)\n\n## Overview\n\nAdd file browsing capability to the vget Web UI for WebDAV remotes. Currently, browsing only works via CLI (`vget ls`). Users should be able to browse, navigate, and download files from WebDAV servers directly in the web interface.\n\n## Current State\n\n### What Works (CLI)\n```bash\nvget ls pikpak:/              # List root\nvget ls pikpak:/Movies        # List subdirectory\nvget pikpak:/Movies/film.mp4  # Download file\n```\n\n### What's Missing (Web UI)\n- No way to browse WebDAV files in the browser\n- Users must use CLI to discover file paths\n- No visual navigation of remote directories\n- No click-to-download functionality\n\n## Motivation\n\nThe web UI is designed for convenience - users shouldn't need to switch to CLI just to browse files. A file browser makes WebDAV support actually usable:\n\n1. **Discovery** - See what's available without memorizing paths\n2. **Navigation** - Click through directories naturally\n3. **Download** - Select files and download with one click\n4. **Mobile-friendly** - Browse from phone/tablet (no CLI)\n\n## Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  Web UI (Browser)                                               │\n│                                                                 │\n│  ┌─────────────────────────────────────────────────────────┐   │\n│  │  File Browser Component                                  │   │\n│  │  - Directory tree / breadcrumb navigation               │   │\n│  │  - File list with size, type                            │   │\n│  │  - Select & download actions                            │   │\n│  └─────────────────────────────────────────────────────────┘   │\n│                              │                                  │\n│                              │ fetch()                          │\n│                              ▼                                  │\n└─────────────────────────────────────────────────────────────────┘\n                               │\n                               │ HTTP API\n                               ▼\n┌─────────────────────────────────────────────────────────────────┐\n│  vget Backend (Go)                                              │\n│                                                                 │\n│  GET /api/webdav/list?remote=pikpak&path=/Movies                │\n│  POST /api/webdav/download                                      │\n│                                                                 │\n│  ┌─────────────────────────────────────────────────────────┐   │\n│  │  internal/core/webdav/client.go                         │   │\n│  │  - List() ✓ (already exists)                            │   │\n│  │  - Stat() ✓ (already exists)                            │   │\n│  │  - Open() ✓ (already exists)                            │   │\n│  └─────────────────────────────────────────────────────────┘   │\n│                              │                                  │\n│                              │ WebDAV (PROPFIND/GET)           │\n│                              ▼                                  │\n└─────────────────────────────────────────────────────────────────┘\n                               │\n                               ▼\n┌─────────────────────────────────────────────────────────────────┐\n│  WebDAV Server (PikPak, Alist, Synology, etc.)                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n## UI Design\n\n### WebDAV Page Layout\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  WebDAV                                               [Settings]│\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  Remote: [pikpak          ▼]                                    │\n│                                                                 │\n│  ┌─── File Browser ──────────────────────────────────────────┐ │\n│  │                                                           │ │\n│  │  📍 pikpak: / Movies / Action /                           │ │\n│  │  ─────────────────────────────────────────────────────── │ │\n│  │  [ ] Name                          Size        Modified   │ │\n│  │  ─────────────────────────────────────────────────────── │ │\n│  │      📁 ..                          -                     │ │\n│  │  [ ] 📁 Subtitles/                  -          2024-01-15│ │\n│  │  [x] 📄 movie-1080p.mkv            4.7 GB     2024-01-13│ │\n│  │  [ ] 📄 movie-720p.mkv             2.1 GB     2024-01-13│ │\n│  │  [x] 📄 movie.srt                  45 KB      2024-01-13│ │\n│  │  ─────────────────────────────────────────────────────── │ │\n│  │                                                           │ │\n│  │  Selected: 2 files (4.7 GB)                              │ │\n│  │                                                           │ │\n│  │  [Download Selected]  [Download All]                      │ │\n│  └───────────────────────────────────────────────────────────┘ │\n│                                                                 │\n│  ┌─── Active Downloads ──────────────────────────────────────┐ │\n│  │                                                           │ │\n│  │  movie-1080p.mkv                                         │ │\n│  │  ████████████████████░░░░░░░░░░░░  45%  23.5 MB/s        │ │\n│  │                                                           │ │\n│  │  movie.srt                                               │ │\n│  │  ████████████████████████████████  100%  Complete        │ │\n│  │                                                           │ │\n│  └───────────────────────────────────────────────────────────┘ │\n│                                                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Navigation Features\n\n1. **Breadcrumb** - Click any part to jump: `pikpak: / Movies / Action /`\n2. **Parent directory** - `📁 ..` row to go up one level\n3. **Click folder** - Navigate into subdirectory\n4. **Click file** - Preview info or start download\n5. **Checkbox select** - Multi-select for batch download\n\n### Remote Selector\n\nDropdown shows all configured WebDAV servers from config:\n```\n[pikpak          ▼]\n ├─ pikpak\n ├─ alist\n └─ synology\n```\n\n## API Endpoints\n\n### List Directory\n```\nGET /api/webdav/list?remote=pikpak&path=/Movies\n\nResponse:\n{\n  \"remote\": \"pikpak\",\n  \"path\": \"/Movies\",\n  \"files\": [\n    {\"name\": \"Action\", \"path\": \"/Movies/Action\", \"isDir\": true, \"size\": 0, \"modTime\": \"2024-01-15T10:30:00Z\"},\n    {\"name\": \"movie.mkv\", \"path\": \"/Movies/movie.mkv\", \"isDir\": false, \"size\": 5000000000, \"modTime\": \"2024-01-13T08:00:00Z\"}\n  ]\n}\n```\n\n### Get File Info\n```\nGET /api/webdav/info?remote=pikpak&path=/Movies/movie.mkv\n\nResponse:\n{\n  \"name\": \"movie.mkv\",\n  \"path\": \"/Movies/movie.mkv\",\n  \"isDir\": false,\n  \"size\": 5000000000,\n  \"modTime\": \"2024-01-13T08:00:00Z\",\n  \"downloadUrl\": \"pikpak:/Movies/movie.mkv\"\n}\n```\n\n### Download File(s)\n```\nPOST /api/webdav/download\n{\n  \"remote\": \"pikpak\",\n  \"files\": [\n    \"/Movies/movie.mkv\",\n    \"/Movies/movie.srt\"\n  ],\n  \"outputDir\": \"/downloads\"  // optional, uses default if not specified\n}\n\nResponse:\n{\n  \"taskId\": \"download-456\",\n  \"status\": \"started\",\n  \"files\": [\n    {\"path\": \"/Movies/movie.mkv\", \"status\": \"queued\"},\n    {\"path\": \"/Movies/movie.srt\", \"status\": \"queued\"}\n  ]\n}\n```\n\n### Download Progress\n```\nGET /api/webdav/download/download-456\n\nResponse:\n{\n  \"taskId\": \"download-456\",\n  \"status\": \"in_progress\",\n  \"files\": [\n    {\"path\": \"/Movies/movie.mkv\", \"progress\": 45, \"speed\": 24600000, \"status\": \"downloading\"},\n    {\"path\": \"/Movies/movie.srt\", \"progress\": 100, \"status\": \"completed\"}\n  ],\n  \"totalSize\": 5000045000,\n  \"downloaded\": 2250045000\n}\n```\n\n### List Configured Remotes\n```\nGET /api/webdav/remotes\n\nResponse:\n{\n  \"remotes\": [\n    {\"name\": \"pikpak\", \"url\": \"https://dav.pikpak.com\", \"hasAuth\": true},\n    {\"name\": \"alist\", \"url\": \"http://192.168.1.100:5244/dav\", \"hasAuth\": true}\n  ]\n}\n```\n\n## Backend Implementation\n\n### New Files\n```\ninternal/server/webdav_browse.go    # API handlers for browsing\n```\n\n### Handler Code (webdav_browse.go)\n\n```go\npackage server\n\nimport (\n    \"encoding/json\"\n    \"net/http\"\n\n    \"github.com/guiyumin/vget/internal/core/config\"\n    \"github.com/guiyumin/vget/internal/core/webdav\"\n)\n\n// GET /api/webdav/remotes\nfunc (s *Server) handleWebDAVRemotes(w http.ResponseWriter, r *http.Request) {\n    cfg := config.LoadOrDefault()\n\n    remotes := make([]map[string]interface{}, 0)\n    for name, server := range cfg.WebDAVServers {\n        remotes = append(remotes, map[string]interface{}{\n            \"name\":    name,\n            \"url\":     server.URL,\n            \"hasAuth\": server.Username != \"\",\n        })\n    }\n\n    json.NewEncoder(w).Encode(map[string]interface{}{\n        \"remotes\": remotes,\n    })\n}\n\n// GET /api/webdav/list?remote=xxx&path=/xxx\nfunc (s *Server) handleWebDAVList(w http.ResponseWriter, r *http.Request) {\n    remoteName := r.URL.Query().Get(\"remote\")\n    path := r.URL.Query().Get(\"path\")\n    if path == \"\" {\n        path = \"/\"\n    }\n\n    cfg := config.LoadOrDefault()\n    server := cfg.GetWebDAVServer(remoteName)\n    if server == nil {\n        http.Error(w, \"Remote not found\", http.StatusNotFound)\n        return\n    }\n\n    client, err := webdav.NewClientFromConfig(server)\n    if err != nil {\n        http.Error(w, err.Error(), http.StatusInternalServerError)\n        return\n    }\n\n    files, err := client.List(r.Context(), path)\n    if err != nil {\n        http.Error(w, err.Error(), http.StatusInternalServerError)\n        return\n    }\n\n    // Convert to JSON response format\n    // ... (format files array)\n\n    json.NewEncoder(w).Encode(map[string]interface{}{\n        \"remote\": remoteName,\n        \"path\":   path,\n        \"files\":  files,\n    })\n}\n\n// POST /api/webdav/download\nfunc (s *Server) handleWebDAVDownload(w http.ResponseWriter, r *http.Request) {\n    // Parse request body\n    // Queue download tasks\n    // Return task ID for progress tracking\n}\n```\n\n### Route Registration\n\n```go\n// internal/server/server.go\n\nfunc (s *Server) setupRoutes() {\n    // ... existing routes ...\n\n    // WebDAV browsing\n    s.router.HandleFunc(\"/api/webdav/remotes\", s.handleWebDAVRemotes).Methods(\"GET\")\n    s.router.HandleFunc(\"/api/webdav/list\", s.handleWebDAVList).Methods(\"GET\")\n    s.router.HandleFunc(\"/api/webdav/info\", s.handleWebDAVInfo).Methods(\"GET\")\n    s.router.HandleFunc(\"/api/webdav/download\", s.handleWebDAVDownload).Methods(\"POST\")\n    s.router.HandleFunc(\"/api/webdav/download/{taskId}\", s.handleWebDAVDownloadProgress).Methods(\"GET\")\n}\n```\n\n## Frontend Implementation\n\n### New Files\n```\nui/src/pages/WebDAVPage.tsx           # Main page\nui/src/components/WebDAVBrowser.tsx   # File browser component\nui/src/components/WebDAVDownloads.tsx # Download progress component\nui/src/routes/webdav.tsx              # Route definition\n```\n\n### Component Structure\n\n```tsx\n// ui/src/pages/WebDAVPage.tsx\n\nexport function WebDAVPage() {\n  const [selectedRemote, setSelectedRemote] = useState<string>(\"\");\n  const [currentPath, setCurrentPath] = useState<string>(\"/\");\n  const [files, setFiles] = useState<FileInfo[]>([]);\n  const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());\n  const [downloads, setDownloads] = useState<DownloadTask[]>([]);\n\n  return (\n    <div>\n      <RemoteSelector\n        value={selectedRemote}\n        onChange={setSelectedRemote}\n      />\n\n      <WebDAVBrowser\n        remote={selectedRemote}\n        path={currentPath}\n        files={files}\n        selectedFiles={selectedFiles}\n        onNavigate={setCurrentPath}\n        onSelect={handleSelect}\n        onDownload={handleDownload}\n      />\n\n      <WebDAVDownloads tasks={downloads} />\n    </div>\n  );\n}\n```\n\n### File Browser Component\n\n```tsx\n// ui/src/components/WebDAVBrowser.tsx\n\ninterface WebDAVBrowserProps {\n  remote: string;\n  path: string;\n  files: FileInfo[];\n  selectedFiles: Set<string>;\n  onNavigate: (path: string) => void;\n  onSelect: (path: string, selected: boolean) => void;\n  onDownload: (files: string[]) => void;\n}\n\nexport function WebDAVBrowser(props: WebDAVBrowserProps) {\n  // Breadcrumb navigation\n  const pathParts = props.path.split('/').filter(Boolean);\n\n  return (\n    <div className=\"webdav-browser\">\n      {/* Breadcrumb */}\n      <nav className=\"breadcrumb\">\n        <span onClick={() => props.onNavigate('/')}>{props.remote}:</span>\n        <span> / </span>\n        {pathParts.map((part, i) => (\n          <span key={i}>\n            <span onClick={() => props.onNavigate('/' + pathParts.slice(0, i + 1).join('/'))}>\n              {part}\n            </span>\n            <span> / </span>\n          </span>\n        ))}\n      </nav>\n\n      {/* File list */}\n      <table className=\"file-list\">\n        <thead>\n          <tr>\n            <th><input type=\"checkbox\" /></th>\n            <th>Name</th>\n            <th>Size</th>\n            <th>Modified</th>\n          </tr>\n        </thead>\n        <tbody>\n          {/* Parent directory */}\n          {props.path !== '/' && (\n            <tr onClick={() => props.onNavigate(getParentPath(props.path))}>\n              <td></td>\n              <td>📁 ..</td>\n              <td>-</td>\n              <td></td>\n            </tr>\n          )}\n\n          {/* Files and folders */}\n          {props.files.map(file => (\n            <tr key={file.path}>\n              <td>\n                {!file.isDir && (\n                  <input\n                    type=\"checkbox\"\n                    checked={props.selectedFiles.has(file.path)}\n                    onChange={(e) => props.onSelect(file.path, e.target.checked)}\n                  />\n                )}\n              </td>\n              <td onClick={() => file.isDir && props.onNavigate(file.path)}>\n                {file.isDir ? '📁' : '📄'} {file.name}\n              </td>\n              <td>{file.isDir ? '-' : formatSize(file.size)}</td>\n              <td>{formatDate(file.modTime)}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n\n      {/* Actions */}\n      <div className=\"actions\">\n        <span>Selected: {props.selectedFiles.size} files</span>\n        <button onClick={() => props.onDownload(Array.from(props.selectedFiles))}>\n          Download Selected\n        </button>\n      </div>\n    </div>\n  );\n}\n```\n\n## Translations\n\nAdd to all locale files:\n\n```yaml\n# internal/core/i18n/locales/en.yml\nwebdav_browser:\n  title: \"File Browser\"\n  select_remote: \"Select Remote\"\n  no_remotes: \"No WebDAV servers configured\"\n  add_remote: \"Add Server\"\n  empty_directory: \"Empty directory\"\n  parent_directory: \"Parent directory\"\n  download_selected: \"Download Selected\"\n  download_all: \"Download All\"\n  selected_count: \"Selected: %d files (%s)\"\n  downloading: \"Downloading\"\n  completed: \"Completed\"\n  failed: \"Failed\"\n```\n\n## Implementation Plan\n\n### Phase 1: Backend API\n- [ ] `internal/server/webdav_browse.go` - API handlers\n- [ ] Register routes in `internal/server/server.go`\n- [ ] Download task queue with progress tracking\n\n### Phase 2: Frontend Components\n- [ ] `ui/src/pages/WebDAVPage.tsx` - Main page\n- [ ] `ui/src/components/WebDAVBrowser.tsx` - File browser\n- [ ] `ui/src/components/WebDAVDownloads.tsx` - Download progress\n- [ ] Add to sidebar navigation\n- [ ] Add routes\n\n### Phase 3: Integration\n- [ ] Connect browser to download API\n- [ ] Progress polling / WebSocket for updates\n- [ ] Error handling (auth failures, network errors)\n\n### Phase 4: Polish\n- [ ] i18n translations (all locales)\n- [ ] Loading states and skeletons\n- [ ] Empty states\n- [ ] Mobile responsive design\n\n## Relationship to Seedbox Feature\n\nThe WebDAV browser and Seedbox browser share similar UI patterns:\n\n| Feature | WebDAV Browser | Seedbox Browser |\n|---------|---------------|-----------------|\n| Browse files | WebDAV PROPFIND | HTTP index / SFTP |\n| Download | WebDAV GET | HTTP GET / SFTP |\n| Backend | `webdav.Client` | `seedbox.HTTPFileServer` / `seedbox.SFTPFileServer` |\n| UI Component | Can share `FileBrowser` base component |\n\nConsider extracting a shared `FileBrowser` component that both features can use:\n\n```tsx\n// ui/src/components/FileBrowser.tsx (shared)\ninterface FileBrowserProps {\n  files: FileInfo[];\n  currentPath: string;\n  onNavigate: (path: string) => void;\n  onSelect: (paths: string[]) => void;\n  onDownload: (paths: string[]) => void;\n}\n```\n\n## Security Considerations\n\n- WebDAV credentials already stored in config (same security model)\n- API endpoints require same auth as other vget endpoints\n- No cross-remote access (can only browse configured remotes)\n- Path traversal protection (validate paths stay within remote)\n\n## Future Enhancements (Not Planned)\n\n- Upload files to WebDAV\n- Rename/delete files\n- Create directories\n- Search within remote\n- Favorites/bookmarks\n- Recent files history\n"
  },
  {
    "path": "docs/webdav.md",
    "content": "# WebDAV Support\n\nvget supports downloading files from WebDAV servers and browsing remote directories.\n\n## Configuration\n\n### Add a WebDAV Server\n\n```bash\nvget config webdav add <name>\n```\n\nInteractive prompts for:\n- WebDAV URL (e.g., `https://dav.example.com`)\n- Username (optional)\n- Password (masked input)\n\n### Manage Servers\n\n```bash\nvget config webdav list              # List all configured servers\nvget config webdav show <name>       # Show server details\nvget config webdav delete <name>     # Remove a server\n```\n\n## Commands\n\n### Download Files\n\n```bash\n# Using configured remote\nvget pikpak:/path/to/file.mp4\n\n# Using full URL with credentials\nvget webdav://user:pass@server.com/path/file.mp4\n\n# With output filename\nvget pikpak:/movies/video.mp4 -o my_video.mp4\n\n# Show file metadata\nvget --info pikpak:/movies/video.mp4\n```\n\n### List Directory\n\n```bash\nvget ls pikpak:/movies\nvget ls pikpak:/                      # List root directory\nvget ls pikpak:/movies --json         # JSON output for scripting\n```\n\nOutput format:\n```\npikpak:/movies\n  📁 Action/\n  📁 Comedy/\n  📄 movie.mp4                    1.5 GB\n  📄 readme.txt                   2.3 KB\n```\n\nJSON output (`--json`):\n```json\n[\n  {\"name\": \"Action\", \"path\": \"pikpak:/movies/Action\", \"is_dir\": true, \"size\": 0},\n  {\"name\": \"movie.mp4\", \"path\": \"pikpak:/movies/movie.mp4\", \"is_dir\": false, \"size\": 1610612736}\n]\n```\n\n### Bulk Download with Pipe\n\nUse `--json` with `jq` to download all files in a directory:\n\n```bash\n# Download all files (skip directories)\nvget ls pikpak:/movies --json | jq -r '.[] | select(.is_dir == false) | .path' | xargs -n1 vget\n\n# Download only files > 1GB\nvget ls pikpak:/movies --json | jq -r '.[] | select(.is_dir == false and .size > 1073741824) | .path' | xargs -n1 vget\n```\n\n### Command Behavior\n\n| Command | File | Directory |\n|---------|------|-----------|\n| `vget <url>` | Download | Error (use `vget ls`) |\n| `vget ls <url>` | Error (not a directory) | List contents |\n| `vget --info <url>` | Show metadata | Show metadata |\n\n## URL Schemes\n\n| Scheme | Protocol |\n|--------|----------|\n| `webdav://` | HTTPS (default) |\n| `webdav+http://` | HTTP (insecure) |\n| `https://` | HTTPS |\n\n## Shell Completion\n\nEnable tab completion for remote paths:\n\n```bash\n# Bash - add to ~/.bashrc\nsource <(vget completion bash)\n\n# Zsh - add to ~/.zshrc\nsource <(vget completion zsh)\n\n# Fish\nvget completion fish > ~/.config/fish/completions/vget.fish\n\n# PowerShell\nvget completion powershell >> $PROFILE\n```\n\nAfter setup, tab completion works for remote paths:\n```bash\nvget pikpak:/Mo<TAB>           # Completes to pikpak:/Movies/\nvget ls pikpak:/Movies/<TAB>   # Shows files in Movies/\n```\n\n## Examples\n\n```bash\n# Setup\nvget config webdav add pikpak\n# Enter URL: https://dav.pikpak.com\n# Enter username: user@example.com\n# Enter password: ****\n\n# Browse\nvget ls pikpak:/\nvget ls pikpak:/Movies\n\n# Download\nvget pikpak:/Movies/film.mp4\nvget pikpak:/Movies/film.mp4 -o ~/Downloads/film.mp4\n\n# Info\nvget --info pikpak:/Movies/film.mp4\n```\n"
  },
  {
    "path": "docs/xhs-mcp-analysis.md",
    "content": "# Xiaohongshu MCP Analysis\n\nAnalysis of [xpzouying/xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) for implementing a Xiaohongshu extractor in vget.\n\n## Browser Automation Stack\n\nThe project uses **Rod** for browser automation via Chrome DevTools Protocol (CDP):\n\n| Dependency | Purpose | Reputation |\n|------------|---------|------------|\n| `github.com/go-rod/rod` | Core browser automation library | ✅ 6k+ stars, actively maintained |\n| `github.com/go-rod/stealth` | Anti-detection measures | ✅ Same maintainer as Rod |\n| `github.com/xpzouying/headless_browser` | Thin wrapper (NOT recommended) | ⚠️ Personal lib, avoid |\n\n### About `xpzouying/headless_browser`\n\nThis is a thin wrapper (~100 lines) that:\n1. Wraps Rod with stealth mode enabled by default\n2. Adds cookie loading from JSON\n3. Provides simplified `NewPage()` API\n\n**We should NOT use this library.** Instead, use Rod + stealth directly.\n\n## How It Works\n\n### 1. Browser Launch\n\nRod can launch Chrome in multiple ways:\n\n```go\n// Option 1: Auto-download Chromium\nlauncher.New().MustLaunch()\n\n// Option 2: Use system Chrome (macOS)\nlauncher.New().\n    Bin(\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\").\n    Headless(false).\n    MustLaunch()\n\n// Option 3: Connect to existing Chrome with remote debugging\n// First: chrome --remote-debugging-port=9222\nrod.New().ControlURL(\"ws://127.0.0.1:9222\").MustConnect()\n```\n\n### 2. Data Extraction Strategy\n\nXiaohongshu embeds post data in `window.__INITIAL_STATE__` (server-side rendered). The MCP extracts it via JavaScript evaluation:\n\n**Source:** `xiaohongshu/feed_detail.go:38-46`\n\n```go\nresult := page.MustEval(`() => {\n    if (window.__INITIAL_STATE__ &&\n        window.__INITIAL_STATE__.note &&\n        window.__INITIAL_STATE__.note.noteDetailMap) {\n        const noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap;\n        return JSON.stringify(noteDetailMap);\n    }\n    return \"\";\n}`).String()\n```\n\n### 3. URL Format\n\nPost detail URL pattern:\n```\nhttps://www.xiaohongshu.com/explore/{feedID}?xsec_token={token}&xsec_source=pc_feed\n```\n\n**Source:** `xiaohongshu/feed_detail.go:72-74`\n\n## Data Structures\n\n### FeedDetail (Post Content)\n\n**Source:** `xiaohongshu/types.go:94-106`\n\n```go\ntype FeedDetail struct {\n    NoteID       string            `json:\"noteId\"`\n    XsecToken    string            `json:\"xsecToken\"`\n    Title        string            `json:\"title\"`\n    Desc         string            `json:\"desc\"`\n    Type         string            `json:\"type\"`      // \"normal\" (image) or \"video\"\n    Time         int64             `json:\"time\"`\n    IPLocation   string            `json:\"ipLocation\"`\n    User         User              `json:\"user\"`\n    InteractInfo InteractInfo      `json:\"interactInfo\"`\n    ImageList    []DetailImageInfo `json:\"imageList\"`\n}\n```\n\n### Image Info\n\n**Source:** `xiaohongshu/types.go:108-115`\n\n```go\ntype DetailImageInfo struct {\n    Width      int    `json:\"width\"`\n    Height     int    `json:\"height\"`\n    URLDefault string `json:\"urlDefault\"`  // <-- Direct image URL\n    URLPre     string `json:\"urlPre\"`\n    LivePhoto  bool   `json:\"livePhoto,omitempty\"`\n}\n```\n\n### Video Info\n\n**Source:** `xiaohongshu/types.go:76-84`\n\n```go\ntype Video struct {\n    Capa VideoCapability `json:\"capa\"`\n}\n\ntype VideoCapability struct {\n    Duration int `json:\"duration\"` // seconds\n}\n```\n\nNote: Video URL extraction requires deeper analysis - the MCP primarily handles image posts.\n\n## Cookie/Session Management\n\n**Source:** `xiaohongshu/cookies/`\n\n- Cookies stored in local file\n- Loaded on browser init\n- Saved after successful login (QR code flow)\n\n```go\n// Loading cookies on browser start\ncookieLoader := cookies.NewLoadCookie(cookiePath)\nif data, err := cookieLoader.LoadCookies(); err == nil {\n    opts = append(opts, headless_browser.WithCookies(string(data)))\n}\n```\n\n## Key Files Reference\n\n| File | Purpose |\n|------|---------|\n| `browser/browser.go` | Browser initialization wrapper |\n| `configs/browser.go` | Headless mode & binary path config |\n| `xiaohongshu/feed_detail.go` | Post detail extraction |\n| `xiaohongshu/types.go` | Data structure definitions |\n| `xiaohongshu/login.go` | QR code login flow |\n| `service.go` | High-level service orchestration |\n\n## vget Implementation (Completed)\n\nThe Xiaohongshu extractor has been implemented in vget. See `internal/extractor/xiaohongshu.go`.\n\n### Architecture\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    XiaohongshuExtractor                     │\n├─────────────────────────────────────────────────────────────┤\n│  Browser: Rod's auto-downloaded Chromium                    │\n│  Data Dir: ~/.config/vget/xhs-browser/ (persistent)         │\n│  Stealth: go-rod/stealth for anti-detection                 │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### Key Features\n\n1. **Isolated Browser** - Uses Rod's Chromium, NOT system Chrome\n   - Browser binary: `~/.cache/rod/browser/`\n   - User data: `~/.config/vget/browser/` (shared by all extractors)\n   - No interference with user's Chrome profiles\n\n2. **Login Wait** - If content requires login:\n   - Shows prompt in terminal\n   - Waits up to 2 minutes for QR code scan\n   - Saves cookies for future sessions\n\n3. **Persistent Sessions** - Browser data persists between runs\n   - Login state preserved\n   - No need to re-login each time\n\n4. **URL Support:**\n   ```\n   https://www.xiaohongshu.com/explore/{noteId}\n   https://www.xiaohongshu.com/discovery/item/{noteId}\n   https://xhslink.com/{shortCode} (auto-resolved)\n   ```\n\n5. **Media Extraction:**\n   - Images: `noteDetailMap[noteId].note.imageList[].urlDefault`\n   - Videos: `noteDetailMap[noteId].note.video.media.stream.h264[0].masterUrl`\n\n### Usage\n\n```bash\n# Download XHS video/images\nvget https://www.xiaohongshu.com/explore/abc123\n\n# Short URL also works\nvget https://xhslink.com/xyz\n```\n\n### Important: Browser Isolation\n\n**WARNING:** Never use system Chrome profiles with browser automation tools. This can corrupt session data.\n\nThe current implementation is safe:\n- Uses Rod's separate Chromium binary\n- Stores data in `~/.config/vget/xhs-browser/`\n- Completely isolated from system Chrome\n\n## Rod vs chromedp Comparison\n\nBoth are Go libraries for Chrome DevTools Protocol automation. Here's a detailed comparison:\n\n### Stats (as of Dec 2024)\n\n| Library | Stars | Age | Maintainer |\n|---------|-------|-----|------------|\n| chromedp | 12.5k | Older, more mature | Community |\n| go-rod | 6.3k | Newer | Single author (ysmood) |\n\n### API Style Comparison\n\n**Same task: Navigate and extract text**\n\n**chromedp:**\n```go\nctx, cancel := chromedp.NewContext(context.Background())\ndefer cancel()\n\nvar title string\nif err := chromedp.Run(ctx,\n    chromedp.Navigate(\"https://example.com\"),\n    chromedp.Title(&title),\n); err != nil {\n    log.Fatal(err)\n}\nfmt.Println(title)\n```\n\n**Rod:**\n```go\ntitle := rod.New().MustConnect().\n    MustPage(\"https://example.com\").\n    MustElement(\"title\").\n    MustText()\nfmt.Println(title)\n```\n\n**Same task: Set cookies**\n\n**chromedp:** (more verbose, requires lower-level API)\n```go\nopts := append(chromedp.DefaultExecAllocatorOptions[:],\n    chromedp.DisableGPU,\n)\nallocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)\ndefer cancel()\nctx, cancel := chromedp.NewContext(allocCtx)\ndefer cancel()\n// ... more setup needed for cookies\n```\n\n**Rod:**\n```go\npage := rod.New().MustConnect().MustPage()\npage.MustSetCookies(&proto.NetworkCookieParam{\n    Name:   \"cookie1\",\n    Value:  \"value1\",\n    Domain: \"example.com\",\n})\n```\n\n### Feature Comparison\n\n| Feature | chromedp | Rod |\n|---------|----------|-----|\n| API style | Verbose, context-based | Fluent, chainable |\n| Error handling | Returns errors | `Must*` methods panic, or use non-Must |\n| Stealth mode | ❌ No built-in | ✅ `go-rod/stealth` |\n| Auto-wait | Manual | ✅ Auto-wait elements |\n| Browser download | Manual | ✅ Auto-download |\n| iFrame handling | Complex | ✅ Simple |\n| Shadow DOM | Complex | ✅ Built-in |\n| Test coverage | Good | 100% enforced |\n\n### Stealth Mode (Critical for XHS)\n\n**chromedp:** No built-in stealth. Need to manually:\n- Override `navigator.webdriver`\n- Modify user-agent\n- Handle other fingerprinting\n\n**Rod:** Built-in stealth plugin:\n```go\nimport \"github.com/go-rod/stealth\"\npage := stealth.MustPage(browser)  // Handles anti-bot automatically\n```\n\n### When to Choose Each\n\n**Choose chromedp if:**\n- You need maximum stability (older, battle-tested)\n- You prefer explicit error handling everywhere\n- You're already using it in existing code\n- You don't need stealth/anti-bot features\n\n**Choose Rod if:**\n- You want cleaner, more readable code\n- You need stealth mode for scraping\n- You want auto-wait and auto-download features\n- You're building something new\n\n### Decision for vget: Use Rod + Stealth\n\nFor Xiaohongshu scraping, Rod is the better choice because:\n\n1. **Stealth is critical** - XHS has anti-bot detection, Rod has built-in stealth\n2. **Cleaner API** - Less boilerplate for our use case\n3. **Auto-wait** - `MustWaitDOMStable()` handles JS rendering\n4. **Good enough stability** - 6.3k stars, actively maintained\n\nIf we encounter issues with Rod, we can migrate to chromedp later - both use the same underlying CDP protocol.\n"
  },
  {
    "path": "docs/zsh-completion-limit.md",
    "content": "# Zsh Completion Limit\n\n## Issue\n\nWhen using zsh shell completion for remote paths (e.g., `pikpak:/电影/`), directories with many files (50+) caused the cursor to move to a new prompt line after displaying completions. This made it impossible to continue typing to filter or select a completion.\n\nDirectories with fewer files (< 30) worked correctly - the cursor stayed on the same line, allowing continued interaction.\n\n## Root Cause\n\nZsh has an internal behavior threshold related to the number of completion items displayed. When the completion list exceeds approximately 40-50 items, zsh redraws the prompt after showing completions, which moves the cursor to a new line.\n\nThis is a zsh behavior, not a Cobra or vget bug. Testing showed:\n- 20 items: cursor stays (works)\n- 30 items: cursor stays (works)\n- 50 items: cursor moves to new line (broken)\n\nThe exact threshold may vary based on terminal size or zsh configuration, but appears to be in the 40-50 range.\n\n## Solution\n\nLimit the number of completions returned to 15 items. This provides a safe margin below the threshold.\n\nUsers with directories containing more than 15 files can simply type a few additional characters to filter the results before pressing Tab.\n\n## Code Change\n\n```go\n// internal/cli/completion.go\n\n// Limit completions to avoid zsh prompt redraw issue with large lists\n// zsh redraws prompt when showing too many completions (threshold varies)\n// Limit to 15 for safe margin; users can type more chars to filter\nconst maxCompletions = 15\nif len(completions) > maxCompletions {\n    completions = completions[:maxCompletions]\n}\n```\n\n## Testing\n\n1. `pikpak:/` (root) - works, shows directories\n2. `pikpak:/电影/` (68 files) - now shows first 15, cursor stays on line\n3. `pikpak:/鱿鱼游戏/` (7 files) - works, shows all files\n4. Type partial name + Tab filters and completes correctly\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/guiyumin/vget\n\ngo 1.25.4\n\nrequire (\n\tcodeberg.org/gruf/go-ffmpreg v0.6.16\n\tgithub.com/charmbracelet/bubbles v0.21.0\n\tgithub.com/charmbracelet/bubbletea v1.3.6\n\tgithub.com/charmbracelet/lipgloss v1.1.0\n\tgithub.com/creativeprojects/go-selfupdate v1.5.1\n\tgithub.com/emersion/go-webdav v0.7.0\n\tgithub.com/fatih/color v1.18.0\n\tgithub.com/gin-gonic/gin v1.11.0\n\tgithub.com/go-rod/rod v0.116.2\n\tgithub.com/go-rod/stealth v0.4.9\n\tgithub.com/golang-jwt/jwt/v5 v5.3.0\n\tgithub.com/gotd/td v0.136.0\n\tgithub.com/mattn/go-runewidth v0.0.16\n\tgithub.com/nsf/termbox-go v1.1.1\n\tgithub.com/spf13/cobra v1.10.1\n\tgithub.com/tetratelabs/wazero v1.10.1\n\tgithub.com/yeqown/go-qrcode/v2 v2.2.5\n\tgolang.org/x/term v0.37.0\n\tgopkg.in/yaml.v3 v3.0.1\n\tmodernc.org/sqlite v1.44.3\n)\n\nrequire (\n\tcode.gitea.io/sdk/gitea v0.22.0 // indirect\n\tgithub.com/42wim/httpsig v1.2.3 // indirect\n\tgithub.com/Masterminds/semver/v3 v3.4.0 // indirect\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/bytedance/sonic v1.14.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.3.0 // indirect\n\tgithub.com/cenkalti/backoff/v4 v4.3.0 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect\n\tgithub.com/charmbracelet/harmonica v0.2.0 // indirect\n\tgithub.com/charmbracelet/x/ansi v0.9.3 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.13 // indirect\n\tgithub.com/charmbracelet/x/term v0.2.1 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/coder/websocket v1.8.14 // indirect\n\tgithub.com/davidmz/go-pageant v1.0.2 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.5 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.8 // indirect\n\tgithub.com/ghodss/yaml v1.0.0 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-faster/errors v0.7.1 // indirect\n\tgithub.com/go-faster/jx v1.2.0 // indirect\n\tgithub.com/go-faster/xor v1.0.0 // indirect\n\tgithub.com/go-faster/yaml v0.4.6 // indirect\n\tgithub.com/go-fed/httpsig v1.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.27.0 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/goccy/go-yaml v1.18.0 // indirect\n\tgithub.com/google/go-github/v30 v30.1.0 // indirect\n\tgithub.com/google/go-querystring v1.1.0 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/gotd/ige v0.2.2 // indirect\n\tgithub.com/gotd/neo v0.1.5 // indirect\n\tgithub.com/hashicorp/go-cleanhttp v0.5.2 // indirect\n\tgithub.com/hashicorp/go-retryablehttp v0.7.8 // indirect\n\tgithub.com/hashicorp/go-version v1.7.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/compress v1.18.2 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/termenv v0.16.0 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect\n\tgithub.com/ogen-go/ogen v1.16.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/quic-go/qpack v0.5.1 // indirect\n\tgithub.com/quic-go/quic-go v0.54.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/segmentio/asm v1.2.1 // indirect\n\tgithub.com/shopspring/decimal v1.4.0 // indirect\n\tgithub.com/spf13/pflag v1.0.9 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.0 // indirect\n\tgithub.com/ulikunitz/xz v0.5.14 // indirect\n\tgithub.com/xanzy/go-gitlab v0.115.0 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgithub.com/yeqown/reedsolomon v1.0.0 // indirect\n\tgithub.com/ysmood/fetchup v0.2.3 // indirect\n\tgithub.com/ysmood/goob v0.4.0 // indirect\n\tgithub.com/ysmood/got v0.40.0 // indirect\n\tgithub.com/ysmood/gson v0.7.3 // indirect\n\tgithub.com/ysmood/leakless v0.9.0 // indirect\n\tgo.opentelemetry.io/otel v1.38.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.38.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.38.0 // indirect\n\tgo.uber.org/atomic v1.11.0 // indirect\n\tgo.uber.org/mock v0.5.0 // indirect\n\tgo.uber.org/multierr v1.11.0 // indirect\n\tgo.uber.org/zap v1.27.1 // indirect\n\tgolang.org/x/arch v0.20.0 // indirect\n\tgolang.org/x/crypto v0.45.0 // indirect\n\tgolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect\n\tgolang.org/x/mod v0.30.0 // indirect\n\tgolang.org/x/net v0.47.0 // indirect\n\tgolang.org/x/oauth2 v0.30.0 // indirect\n\tgolang.org/x/sync v0.18.0 // indirect\n\tgolang.org/x/sys v0.38.0 // indirect\n\tgolang.org/x/text v0.31.0 // indirect\n\tgolang.org/x/time v0.12.0 // indirect\n\tgolang.org/x/tools v0.39.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.9 // indirect\n\tgopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect\n\tgopkg.in/yaml.v2 v2.4.0 // indirect\n\tmodernc.org/libc v1.67.6 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n\trsc.io/qr v0.2.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=\ncode.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=\ncodeberg.org/gruf/go-ffmpreg v0.6.16 h1:p3tK6usUHM8pn41ZyYE4MIaZiI6r/AptjP53pVZwRP8=\ncodeberg.org/gruf/go-ffmpreg v0.6.16/go.mod h1:hE0Dmx3cjI3ZgCUVFNi3H+0JgIz6Us27arY1ML0AxqU=\ngithub.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=\ngithub.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=\ngithub.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=\ngithub.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=\ngithub.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=\ngithub.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=\ngithub.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=\ngithub.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=\ngithub.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=\ngithub.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=\ngithub.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=\ngithub.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=\ngithub.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=\ngithub.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=\ngithub.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=\ngithub.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=\ngithub.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=\ngithub.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=\ngithub.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=\ngithub.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=\ngithub.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=\ngithub.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=\ngithub.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=\ngithub.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creativeprojects/go-selfupdate v1.5.1 h1:fuyEGFFfqcC8SxDGolcEPYPLXGQ9Mcrc5uRyRG2Mqnk=\ngithub.com/creativeprojects/go-selfupdate v1.5.1/go.mod h1:2uY75rP8z/D/PBuDn6mlBnzu+ysEmwOJfcgF8np0JIM=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=\ngithub.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=\ngithub.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=\ngithub.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=\ngithub.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=\ngithub.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=\ngithub.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=\ngithub.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=\ngithub.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=\ngithub.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=\ngithub.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=\ngithub.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=\ngithub.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=\ngithub.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=\ngithub.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=\ngithub.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=\ngithub.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=\ngithub.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=\ngithub.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=\ngithub.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=\ngithub.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=\ngithub.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=\ngithub.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=\ngithub.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=\ngithub.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=\ngithub.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=\ngithub.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=\ngithub.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=\ngithub.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=\ngithub.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=\ngithub.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=\ngithub.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=\ngithub.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=\ngithub.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=\ngithub.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=\ngithub.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=\ngithub.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=\ngithub.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=\ngithub.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=\ngithub.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=\ngithub.com/gotd/td v0.136.0 h1:f7vx/1rlvP59L5EKR820XpMRO2k267wW8/F0rAWbepc=\ngithub.com/gotd/td v0.136.0/go.mod h1:mStcqs/9FXhNhWnPTguptSwqkQbRIwXLw3SCSpzPJxM=\ngithub.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=\ngithub.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=\ngithub.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=\ngithub.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=\ngithub.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=\ngithub.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=\ngithub.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=\ngithub.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=\ngithub.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=\ngithub.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=\ngithub.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=\ngithub.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=\ngithub.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw=\ngithub.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=\ngithub.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=\ngithub.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=\ngithub.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=\ngithub.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=\ngithub.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=\ngithub.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=\ngithub.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=\ngithub.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=\ngithub.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=\ngithub.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=\ngithub.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=\ngithub.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=\ngithub.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=\ngithub.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=\ngithub.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=\ngithub.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=\ngithub.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=\ngithub.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=\ngithub.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=\ngithub.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=\ngithub.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=\ngithub.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=\ngithub.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=\ngithub.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=\ngithub.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=\ngithub.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=\ngithub.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=\ngithub.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=\ngithub.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=\ngithub.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=\ngithub.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=\ngithub.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=\ngithub.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=\ngithub.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=\ngithub.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=\ngithub.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=\ngo.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=\ngo.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=\ngo.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=\ngo.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=\ngo.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=\ngo.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=\ngo.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=\ngo.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=\ngo.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=\ngo.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=\ngo.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=\ngo.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngolang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=\ngolang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=\ngolang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=\ngolang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=\ngolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=\ngolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=\ngolang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=\ngolang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=\ngolang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=\ngolang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=\ngolang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=\ngolang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=\ngolang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=\ngolang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=\ngolang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=\ngolang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=\ngolang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=\ngolang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=\ngoogle.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=\nmodernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=\nmodernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=\nmodernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=\nmodernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=\nmodernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=\nmodernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nnhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=\nnhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=\nrsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=\nrsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=\n"
  },
  {
    "path": "internal/cli/batch.go",
    "content": "package cli\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/guiyumin/vget/internal/core/extractor\"\n)\n\n// runBatch reads URLs from a file and downloads each one\nfunc runBatch(filename string) error {\n\tfile, err := os.Open(filename)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\tvar urls []string\n\tvar invalidURLs []string\n\tscanner := bufio.NewScanner(file)\n\tlineNum := 0\n\tfor scanner.Scan() {\n\t\tlineNum++\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\t// Skip empty lines and comments\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\t\t// Normalize URL: add https:// if missing\n\t\tnormalized, err := extractor.NormalizeURL(line)\n\t\tif err != nil {\n\t\t\tinvalidURLs = append(invalidURLs, fmt.Sprintf(\"line %d: %s\", lineNum, line))\n\t\t\tcontinue\n\t\t}\n\t\turls = append(urls, normalized)\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\n\tif len(urls) == 0 {\n\t\tif len(invalidURLs) > 0 {\n\t\t\treturn fmt.Errorf(\"no valid URLs found in file (%d invalid)\", len(invalidURLs))\n\t\t}\n\t\treturn fmt.Errorf(\"no URLs found in file\")\n\t}\n\n\t// Warn about invalid URLs\n\tif len(invalidURLs) > 0 {\n\t\tfmt.Printf(\"\\033[33mWarning: %d invalid URL(s) skipped:\\033[0m\\n\", len(invalidURLs))\n\t\tfor _, u := range invalidURLs {\n\t\t\tfmt.Printf(\"  - %s\\n\", u)\n\t\t}\n\t\tfmt.Println()\n\t}\n\n\tfmt.Printf(\"Found %d URL(s) to download\\n\\n\", len(urls))\n\n\t// Separate Telegram URLs from other URLs\n\tvar telegramURLs, otherURLs []string\n\tfor _, url := range urls {\n\t\tif isTelegramURL(url) {\n\t\t\ttelegramURLs = append(telegramURLs, url)\n\t\t} else {\n\t\t\totherURLs = append(otherURLs, url)\n\t\t}\n\t}\n\n\tvar succeeded, failed int\n\tvar failedURLs []string\n\n\t// Handle Telegram URLs with batch function (uses takeout if multiple)\n\tif len(telegramURLs) >= 2 {\n\t\ts, f, fURLs := runTelegramBatchDownload(telegramURLs)\n\t\tsucceeded += s\n\t\tfailed += f\n\t\tfailedURLs = append(failedURLs, fURLs...)\n\t} else {\n\t\t// Single Telegram URL - use regular download\n\t\tfor _, url := range telegramURLs {\n\t\t\tfmt.Printf(\"[1/%d] %s\\n\", len(urls), truncateURL(url, 60))\n\t\t\tif err := runTelegramDownload(url, \"\"); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"  Error: %v\\n\", err)\n\t\t\t\tfailed++\n\t\t\t\tfailedURLs = append(failedURLs, url)\n\t\t\t} else {\n\t\t\t\tsucceeded++\n\t\t\t}\n\t\t\tfmt.Println()\n\t\t}\n\t}\n\n\t// Download other URLs\n\tstartIdx := len(telegramURLs) + 1\n\tfor i, url := range otherURLs {\n\t\tfmt.Printf(\"[%d/%d] %s\\n\", startIdx+i, len(urls), truncateURL(url, 60))\n\n\t\tif err := runDownload(url); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"  Error: %v\\n\", err)\n\t\t\tfailed++\n\t\t\tfailedURLs = append(failedURLs, url)\n\t\t} else {\n\t\t\tsucceeded++\n\t\t}\n\t\tfmt.Println()\n\t}\n\n\t// Print summary\n\tfmt.Println(\"----------------------------------------\")\n\tfmt.Printf(\"Completed: %d/%d\", succeeded, len(urls))\n\tif failed > 0 {\n\t\tfmt.Printf(\", Failed: %d\", failed)\n\t}\n\tfmt.Println()\n\n\t// List failed URLs if any\n\tif len(failedURLs) > 0 {\n\t\tfmt.Println(\"\\nFailed URLs:\")\n\t\tfor _, url := range failedURLs {\n\t\t\tfmt.Printf(\"  - %s\\n\", url)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// truncateURL shortens a URL for display\nfunc truncateURL(url string, maxLen int) string {\n\tif len(url) <= maxLen {\n\t\treturn url\n\t}\n\treturn url[:maxLen-3] + \"...\"\n}\n"
  },
  {
    "path": "internal/cli/browse.go",
    "content": "package cli\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/key\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/guiyumin/vget/internal/core/webdav\"\n)\n\nvar (\n\tbrowseTitleStyle     = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"86\"))\n\tbrowsePathStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n\tbrowseDirStyle       = lipgloss.NewStyle().Foreground(lipgloss.Color(\"33\"))  // blue for directories\n\tbrowseFileStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color(\"255\")) // white for files\n\tbrowseSelectedStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color(\"205\")).Bold(true)\n\tbrowseSizeStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n\tbrowseHelpStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n\tbrowseContainerStyle = lipgloss.NewStyle().Padding(1, 2)\n)\n\ntype browseModel struct {\n\tclient       *webdav.Client\n\tserverName   string\n\tcurrentPath  string\n\tentries      []webdav.FileInfo\n\tcursor       int\n\tscrollOffset int\n\twidth        int\n\theight       int\n\terr          error\n\tloading      bool\n\tdone         bool\n\tselectedFile string // Full path of selected file for download\n\tkeyBindings  browseKeyMap\n}\n\ntype browseKeyMap struct {\n\tUp    key.Binding\n\tDown  key.Binding\n\tEnter key.Binding\n\tBack  key.Binding\n\tQuit  key.Binding\n}\n\nfunc defaultBrowseKeyMap() browseKeyMap {\n\treturn browseKeyMap{\n\t\tUp: key.NewBinding(\n\t\t\tkey.WithKeys(\"up\", \"k\"),\n\t\t\tkey.WithHelp(\"↑/k\", \"up\"),\n\t\t),\n\t\tDown: key.NewBinding(\n\t\t\tkey.WithKeys(\"down\", \"j\"),\n\t\t\tkey.WithHelp(\"↓/j\", \"down\"),\n\t\t),\n\t\tEnter: key.NewBinding(\n\t\t\tkey.WithKeys(\"enter\"),\n\t\t\tkey.WithHelp(\"enter\", \"select\"),\n\t\t),\n\t\tBack: key.NewBinding(\n\t\t\tkey.WithKeys(\"backspace\", \"b\", \"left\", \"h\"),\n\t\t\tkey.WithHelp(\"b/backspace\", \"back\"),\n\t\t),\n\t\tQuit: key.NewBinding(\n\t\t\tkey.WithKeys(\"q\", \"esc\", \"ctrl+c\"),\n\t\t\tkey.WithHelp(\"q/esc\", \"quit\"),\n\t\t),\n\t}\n}\n\n// Message types\ntype loadedMsg struct {\n\tentries []webdav.FileInfo\n\terr     error\n}\n\nfunc newBrowseModel(client *webdav.Client, serverName, initialPath string) browseModel {\n\treturn browseModel{\n\t\tclient:      client,\n\t\tserverName:  serverName,\n\t\tcurrentPath: initialPath,\n\t\tloading:     true,\n\t\tkeyBindings: defaultBrowseKeyMap(),\n\t}\n}\n\nfunc (m browseModel) Init() tea.Cmd {\n\treturn m.loadDirectory()\n}\n\nfunc (m browseModel) loadDirectory() tea.Cmd {\n\treturn func() tea.Msg {\n\t\tctx := context.Background()\n\t\tentries, err := m.client.List(ctx, m.currentPath)\n\t\tif err != nil {\n\t\t\treturn loadedMsg{err: err}\n\t\t}\n\n\t\t// Sort: directories first, then alphabetically\n\t\tsort.Slice(entries, func(i, j int) bool {\n\t\t\tif entries[i].IsDir != entries[j].IsDir {\n\t\t\t\treturn entries[i].IsDir // directories first\n\t\t\t}\n\t\t\treturn strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)\n\t\t})\n\n\t\treturn loadedMsg{entries: entries}\n\t}\n}\n\nconst browseMaxVisibleLines = 20\n\nfunc (m browseModel) visibleLines() int {\n\tif m.height <= 0 {\n\t\treturn browseMaxVisibleLines\n\t}\n\t// Reserve: title (2) + path (2) + footer (3) + padding\n\tavailable := m.height - 10\n\tif available > browseMaxVisibleLines {\n\t\treturn browseMaxVisibleLines\n\t}\n\tif available < 5 {\n\t\treturn 5\n\t}\n\treturn available\n}\n\nfunc (m *browseModel) adjustScroll() {\n\tvisible := m.visibleLines()\n\tif m.cursor < m.scrollOffset {\n\t\tm.scrollOffset = m.cursor\n\t} else if m.cursor >= m.scrollOffset+visible {\n\t\tm.scrollOffset = m.cursor - visible + 1\n\t}\n}\n\nfunc (m browseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tm.width = msg.Width\n\t\tm.height = msg.Height\n\t\treturn m, nil\n\n\tcase loadedMsg:\n\t\tm.loading = false\n\t\tif msg.err != nil {\n\t\t\tm.err = msg.err\n\t\t\treturn m, nil\n\t\t}\n\t\tm.entries = msg.entries\n\t\tm.cursor = 0\n\t\tm.scrollOffset = 0\n\t\treturn m, nil\n\n\tcase tea.KeyMsg:\n\t\tif m.loading {\n\t\t\t// Only allow quit while loading\n\t\t\tif key.Matches(msg, m.keyBindings.Quit) {\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\t\t\treturn m, nil\n\t\t}\n\n\t\tif m.err != nil {\n\t\t\t// On error, allow quit or back\n\t\t\tif key.Matches(msg, m.keyBindings.Quit) {\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\t\t\tif key.Matches(msg, m.keyBindings.Back) {\n\t\t\t\t// Try to go back\n\t\t\t\treturn m.goUp()\n\t\t\t}\n\t\t\treturn m, nil\n\t\t}\n\n\t\tswitch {\n\t\tcase key.Matches(msg, m.keyBindings.Quit):\n\t\t\treturn m, tea.Quit\n\n\t\tcase key.Matches(msg, m.keyBindings.Up):\n\t\t\tif m.cursor > 0 {\n\t\t\t\tm.cursor--\n\t\t\t\tm.adjustScroll()\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Down):\n\t\t\tif m.cursor < len(m.entries)-1 {\n\t\t\t\tm.cursor++\n\t\t\t\tm.adjustScroll()\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Enter):\n\t\t\tif len(m.entries) == 0 {\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\t\tentry := m.entries[m.cursor]\n\t\t\tif entry.IsDir {\n\t\t\t\t// Navigate into directory\n\t\t\t\tm.currentPath = path.Join(m.currentPath, entry.Name)\n\t\t\t\tm.loading = true\n\t\t\t\tm.entries = nil\n\t\t\t\treturn m, m.loadDirectory()\n\t\t\t} else {\n\t\t\t\t// Select file for download\n\t\t\t\tm.selectedFile = path.Join(m.currentPath, entry.Name)\n\t\t\t\tm.done = true\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Back):\n\t\t\treturn m.goUp()\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (m browseModel) goUp() (tea.Model, tea.Cmd) {\n\tif m.currentPath == \"/\" {\n\t\treturn m, nil // Already at root\n\t}\n\tm.currentPath = path.Dir(m.currentPath)\n\tif m.currentPath == \".\" {\n\t\tm.currentPath = \"/\"\n\t}\n\tm.loading = true\n\tm.entries = nil\n\tm.err = nil\n\treturn m, m.loadDirectory()\n}\n\nfunc (m browseModel) View() string {\n\tvar b strings.Builder\n\n\t// Title\n\ttitle := fmt.Sprintf(\"%s:%s\", m.serverName, m.currentPath)\n\tb.WriteString(browseTitleStyle.Render(\"  Browse: \") + browsePathStyle.Render(title) + \"\\n\\n\")\n\n\tif m.loading {\n\t\tb.WriteString(\"  Loading...\\n\")\n\t} else if m.err != nil {\n\t\tb.WriteString(fmt.Sprintf(\"  Error: %v\\n\", m.err))\n\t\tb.WriteString(\"\\n  Press b to go back, q to quit\\n\")\n\t} else if len(m.entries) == 0 {\n\t\tb.WriteString(\"  (empty directory)\\n\")\n\t} else {\n\t\tvisible := m.visibleLines()\n\t\tendIdx := m.scrollOffset + visible\n\t\tif endIdx > len(m.entries) {\n\t\t\tendIdx = len(m.entries)\n\t\t}\n\n\t\tfor i := m.scrollOffset; i < endIdx; i++ {\n\t\t\tentry := m.entries[i]\n\n\t\t\t// Cursor indicator\n\t\t\tcursor := \"  \"\n\t\t\tif i == m.cursor {\n\t\t\t\tcursor = browseSelectedStyle.Render(\"> \")\n\t\t\t}\n\n\t\t\t// Icon and name\n\t\t\tvar icon, name, size string\n\t\t\tif entry.IsDir {\n\t\t\t\ticon = browseDirStyle.Render(\"📁 \")\n\t\t\t\tname = entry.Name + \"/\"\n\t\t\t\tif i == m.cursor {\n\t\t\t\t\tname = browseSelectedStyle.Render(name)\n\t\t\t\t} else {\n\t\t\t\t\tname = browseDirStyle.Render(name)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ticon = browseFileStyle.Render(\"📄 \")\n\t\t\t\tname = entry.Name\n\t\t\t\tif i == m.cursor {\n\t\t\t\t\tname = browseSelectedStyle.Render(name)\n\t\t\t\t} else {\n\t\t\t\t\tname = browseFileStyle.Render(name)\n\t\t\t\t}\n\t\t\t\tsize = browseSizeStyle.Render(fmt.Sprintf(\" (%s)\", formatSize(entry.Size)))\n\t\t\t}\n\n\t\t\tb.WriteString(fmt.Sprintf(\"%s%s%s%s\\n\", cursor, icon, name, size))\n\t\t}\n\n\t\t// Scroll indicator\n\t\tif len(m.entries) > visible {\n\t\t\tscrollInfo := fmt.Sprintf(\" (%d-%d of %d)\", m.scrollOffset+1, endIdx, len(m.entries))\n\t\t\tb.WriteString(browseSizeStyle.Render(scrollInfo) + \"\\n\")\n\t\t}\n\t}\n\n\tb.WriteString(\"\\n\")\n\n\t// Help text\n\thelp := \"↑/↓ navigate • enter select • b back • q quit\"\n\tb.WriteString(browseHelpStyle.Render(\"  \" + help) + \"\\n\")\n\n\tcontent := browseContainerStyle.Render(b.String())\n\n\tif m.width > 0 && m.height > 0 {\n\t\tcontent = lipgloss.Place(m.width, m.height, lipgloss.Left, lipgloss.Top, content)\n\t}\n\n\treturn content\n}\n\n// BrowseResult holds the result of browsing\ntype BrowseResult struct {\n\tSelectedFile string // Full remote path of selected file\n\tCancelled    bool   // User quit without selecting\n}\n\n// RunBrowseTUI runs the file browser TUI and returns the selected file path\nfunc RunBrowseTUI(client *webdav.Client, serverName, initialPath string) (*BrowseResult, error) {\n\tmodel := newBrowseModel(client, serverName, initialPath)\n\tp := tea.NewProgram(model, tea.WithAltScreen())\n\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tm := finalModel.(browseModel)\n\tif m.done && m.selectedFile != \"\" {\n\t\treturn &BrowseResult{SelectedFile: m.selectedFile}, nil\n\t}\n\n\treturn &BrowseResult{Cancelled: true}, nil\n}\n"
  },
  {
    "path": "internal/cli/completion.go",
    "content": "package cli\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/webdav\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar completionCmd = &cobra.Command{\n\tUse:   \"completion [bash|zsh|fish|powershell]\",\n\tShort: \"Generate shell completion script\",\n\tLong: `Generate shell completion script for vget.\n\nBash:\n  # Add to ~/.bashrc:\n  source <(vget completion bash)\n\n  # Or install to system:\n  vget completion bash > /etc/bash_completion.d/vget\n\nZsh:\n  # Add to ~/.zshrc:\n  source <(vget completion zsh)\n\n  # Or install to fpath:\n  vget completion zsh > \"${fpath[1]}/_vget\"\n\nFish:\n  vget completion fish > ~/.config/fish/completions/vget.fish\n\nPowerShell:\n  vget completion powershell >> $PROFILE\n`,\n\tArgs:      cobra.ExactArgs(1),\n\tValidArgs: []string{\"bash\", \"zsh\", \"fish\", \"powershell\"},\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\tswitch args[0] {\n\t\tcase \"bash\":\n\t\t\treturn rootCmd.GenBashCompletion(os.Stdout)\n\t\tcase \"zsh\":\n\t\t\treturn rootCmd.GenZshCompletion(os.Stdout)\n\t\tcase \"fish\":\n\t\t\treturn rootCmd.GenFishCompletion(os.Stdout, true)\n\t\tcase \"powershell\":\n\t\t\treturn rootCmd.GenPowerShellCompletion(os.Stdout)\n\t\tdefault:\n\t\t\treturn cmd.Help()\n\t\t}\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(completionCmd)\n\n\t// Enable dynamic completion for root command (for remote paths)\n\trootCmd.ValidArgsFunction = completeRemotePath\n}\n\n// unescapeShellPath removes common shell escape sequences\nfunc unescapeShellPath(s string) string {\n\t// Handle common shell escapes\n\ts = strings.ReplaceAll(s, \"\\\\ \", \" \")\n\ts = strings.ReplaceAll(s, \"\\\\[\", \"[\")\n\ts = strings.ReplaceAll(s, \"\\\\]\", \"]\")\n\ts = strings.ReplaceAll(s, \"\\\\(\", \"(\")\n\ts = strings.ReplaceAll(s, \"\\\\)\", \")\")\n\ts = strings.ReplaceAll(s, \"\\\\&\", \"&\")\n\ts = strings.ReplaceAll(s, \"\\\\'\", \"'\")\n\ts = strings.ReplaceAll(s, \"\\\\\\\"\", \"\\\"\")\n\treturn s\n}\n\n\n// completeRemotePath provides dynamic completion for WebDAV remote paths\nfunc completeRemotePath(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {\n\t// Only complete first argument (the URL)\n\tif len(args) > 0 {\n\t\treturn nil, cobra.ShellCompDirectiveNoFileComp\n\t}\n\n\t// If empty or no colon, suggest configured remotes\n\tif !strings.Contains(toComplete, \":\") {\n\t\treturn completeRemotes(toComplete)\n\t}\n\n\t// Has colon - complete remote path\n\treturn completeRemoteFiles(toComplete)\n}\n\n// completeRemotes returns configured remote names\nfunc completeRemotes(prefix string) ([]string, cobra.ShellCompDirective) {\n\tcfg := config.LoadOrDefault()\n\tvar completions []string\n\n\tfor name := range cfg.WebDAVServers {\n\t\tremote := name + \":\"\n\t\tif strings.HasPrefix(remote, prefix) {\n\t\t\tcompletions = append(completions, remote)\n\t\t}\n\t}\n\n\t// Also allow local file completion if no prefix or doesn't match remotes\n\tif len(completions) == 0 {\n\t\treturn nil, cobra.ShellCompDirectiveDefault\n\t}\n\n\treturn completions, cobra.ShellCompDirectiveNoSpace\n}\n\n// completeRemoteFiles queries WebDAV and returns matching paths\nfunc completeRemoteFiles(toComplete string) ([]string, cobra.ShellCompDirective) {\n\t// Parse remote:path\n\tif !webdav.IsRemotePath(toComplete) {\n\t\treturn nil, cobra.ShellCompDirectiveDefault\n\t}\n\n\tserverName, remotePath, err := webdav.ParseRemotePath(toComplete)\n\tif err != nil {\n\t\treturn nil, cobra.ShellCompDirectiveError\n\t}\n\n\tcfg := config.LoadOrDefault()\n\tserver := cfg.GetWebDAVServer(serverName)\n\tif server == nil {\n\t\treturn nil, cobra.ShellCompDirectiveError\n\t}\n\n\tclient, err := webdav.NewClientFromConfig(server)\n\tif err != nil {\n\t\treturn nil, cobra.ShellCompDirectiveError\n\t}\n\n\t// Unescape shell escapes in the path for proper comparison\n\tunescapedPath := unescapeShellPath(remotePath)\n\n\t// Determine directory to list and prefix to filter\n\tdirPath := filepath.Dir(unescapedPath)\n\tif dirPath == \".\" {\n\t\tdirPath = \"/\"\n\t}\n\tbaseName := filepath.Base(unescapedPath)\n\n\tctx := context.Background()\n\n\t// Check if the path ends with \"/\" OR if it's an existing directory\n\t// This handles the case where zsh strips the trailing slash\n\tif strings.HasSuffix(toComplete, \"/\") || strings.HasSuffix(unescapedPath, \"/\") {\n\t\tdirPath = strings.TrimSuffix(unescapedPath, \"/\")\n\t\tif dirPath == \"\" {\n\t\t\tdirPath = \"/\"\n\t\t}\n\t\tbaseName = \"\"\n\t} else {\n\t\t// Check if the path is an existing directory on the remote\n\t\tif info, err := client.Stat(ctx, unescapedPath); err == nil && info.IsDir {\n\t\t\t// It's a directory - list its contents\n\t\t\tdirPath = unescapedPath\n\t\t\tbaseName = \"\"\n\t\t}\n\t}\n\tfiles, err := client.List(ctx, dirPath)\n\tif err != nil {\n\t\treturn nil, cobra.ShellCompDirectiveError\n\t}\n\n\tvar completions []string\n\tprefix := serverName + \":\"\n\t// Ensure dirPath ends with exactly one slash\n\tif dirPath != \"/\" {\n\t\tprefix += strings.TrimSuffix(dirPath, \"/\") + \"/\"\n\t} else {\n\t\tprefix += \"/\"\n\t}\n\n\tfor _, f := range files {\n\t\tif baseName == \"\" || strings.HasPrefix(f.Name, baseName) {\n\t\t\t// Return the full path for completion\n\t\t\tcompletion := prefix + f.Name\n\t\t\tif f.IsDir {\n\t\t\t\tcompletion += \"/\"\n\t\t\t}\n\t\t\tcompletions = append(completions, completion)\n\t\t}\n\t}\n\n\tif len(completions) == 0 {\n\t\treturn nil, cobra.ShellCompDirectiveNoFileComp\n\t}\n\n\t// Limit completions to avoid zsh prompt redraw issue with large lists\n\t// zsh redraws prompt when showing too many completions (threshold varies)\n\t// Limit to 15 for safe margin; users can type more chars to filter\n\t// or press Enter to open the TUI browser for full navigation\n\tconst maxCompletions = 15\n\tif len(completions) > maxCompletions {\n\t\tcompletions = completions[:maxCompletions]\n\t}\n\n\t// Use ShellCompDirectiveNoFileComp to prevent falling back to file completion\n\t// Use ShellCompDirectiveNoSpace to not add space after directory completions\n\treturn completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace\n}\n"
  },
  {
    "path": "internal/cli/config.go",
    "content": "package cli\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n\t\"github.com/spf13/cobra\"\n\t\"golang.org/x/term\"\n)\n\nvar configCmd = &cobra.Command{\n\tUse:   \"config\",\n\tShort: \"Manage vget configuration\",\n\tLong:  \"View and modify vget settings, including WebDAV remotes\",\n}\n\n// vget config show - show current config\nvar configShowCmd = &cobra.Command{\n\tUse:   \"show\",\n\tShort: \"Show current configuration\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tcfg := config.LoadOrDefault()\n\n\t\tfmt.Println(\"Current configuration:\")\n\t\tfmt.Printf(\"  Language:  %s\\n\", cfg.Language)\n\t\tfmt.Printf(\"  OutputDir: %s\\n\", cfg.OutputDir)\n\t\tfmt.Printf(\"  Format:    %s\\n\", cfg.Format)\n\t\tfmt.Printf(\"  Quality:   %s\\n\", cfg.Quality)\n\t\tfmt.Printf(\"  Config:    %s\\n\", config.SavePath())\n\n\t\tif len(cfg.WebDAVServers) > 0 {\n\t\t\tfmt.Println(\"\\nWebDAV servers:\")\n\t\t\tfor name, server := range cfg.WebDAVServers {\n\t\t\t\tfmt.Printf(\"  %s:\\n\", name)\n\t\t\t\tfmt.Printf(\"    URL:      %s\\n\", server.URL)\n\t\t\t\tif server.Username != \"\" {\n\t\t\t\t\tfmt.Printf(\"    Username: %s\\n\", server.Username)\n\t\t\t\t\tfmt.Printf(\"    Password: %s\\n\", server.Password)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif cfg.Twitter.AuthToken != \"\" {\n\t\t\tfmt.Println(\"\\nTwitter:\")\n\t\t\tfmt.Printf(\"  auth_token: %s\\n\", cfg.Twitter.AuthToken)\n\t\t}\n\n\t\t// Show express tracking providers config\n\t\tif len(cfg.Express) > 0 {\n\t\t\tfmt.Println(\"\\nExpress Tracking:\")\n\t\t\tfor provider, providerCfg := range cfg.Express {\n\t\t\t\tfmt.Printf(\"  %s:\\n\", provider)\n\t\t\t\tfor key, value := range providerCfg {\n\t\t\t\t\tfmt.Printf(\"    %s: %s\\n\", key, value)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t},\n}\n\n// vget config path - show config file path\nvar configPathCmd = &cobra.Command{\n\tUse:   \"path\",\n\tShort: \"Show config file path\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tfmt.Println(config.SavePath())\n\t},\n}\n\n// vget config set KEY VALUE - set a config value\nvar configSetCmd = &cobra.Command{\n\tUse:   \"set <key> <value>\",\n\tShort: \"Set a configuration value\",\n\tLong: `Set a configuration value in config.yml.\n\nSupported keys:\n  language           Language code (en, zh, jp, kr, es, fr, de)\n  output_dir         Default download directory\n  format             Preferred format (mp4, webm, best)\n  quality            Default quality (1080p, 720p, best)\n  twitter.auth_token Twitter auth token for NSFW content\n  bilibili.cookie    Bilibili cookie for member-only content\n  server.port        Server listen port\n  server.max_concurrent  Max concurrent downloads\n  server.api_key     Server API key\n\nExpress tracking (dynamic keys):\n  express.<provider>.<key>  Set express provider config\n\n  Kuaidi100 example:\n    express.kuaidi100.key       API authorization key\n    express.kuaidi100.customer  Customer ID\n    express.kuaidi100.secret    Secret for delivery time API (optional)\n\nExamples:\n  vget config set language en\n  vget config set output_dir ~/Videos\n  vget config set twitter.auth_token YOUR_TOKEN\n  vget config set express.kuaidi100.key YOUR_KEY`,\n\tArgs: cobra.ExactArgs(2),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tkey := args[0]\n\t\tvalue := args[1]\n\n\t\tcfg := config.LoadOrDefault()\n\n\t\tif err := setConfigValue(cfg, key, value); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tif err := config.Save(cfg); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Failed to save config: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Printf(\"Set %s = %s\\n\", key, value)\n\t},\n}\n\n// vget config get KEY - get a config value\nvar configGetCmd = &cobra.Command{\n\tUse:   \"get <key>\",\n\tShort: \"Get a configuration value\",\n\tLong: `Get a configuration value from config.yml.\n\nExamples:\n  vget config get language\n  vget config get twitter.auth_token`,\n\tArgs: cobra.ExactArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tkey := args[0]\n\t\tcfg := config.LoadOrDefault()\n\n\t\tvalue, err := getConfigValue(cfg, key)\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Println(value)\n\t},\n}\n\n// vget config unset KEY - unset/clear a config value\nvar configUnsetCmd = &cobra.Command{\n\tUse:   \"unset <key>\",\n\tShort: \"Unset a configuration value\",\n\tLong: `Unset (clear) a configuration value in config.yml.\n\nSupported keys:\n  language           Reset to empty (uses default)\n  output_dir         Reset to empty (uses default)\n  format             Reset to empty (uses default)\n  quality            Reset to empty (uses default)\n  twitter.auth_token Clear Twitter auth token\n  bilibili.cookie    Clear Bilibili cookie\n  server.port        Reset to 0 (uses default)\n  server.max_concurrent  Reset to 0 (uses default)\n  server.api_key     Clear API key\n\nExpress tracking (dynamic keys):\n  express.<provider>.<key>  Clear express provider config value\n\nExamples:\n  vget config unset twitter.auth_token\n  vget config unset express.kuaidi100.key`,\n\tArgs: cobra.ExactArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tkey := args[0]\n\n\t\tcfg := config.LoadOrDefault()\n\n\t\tif err := unsetConfigValue(cfg, key); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tif err := config.Save(cfg); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Failed to save config: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Printf(\"Unset %s\\n\", key)\n\t},\n}\n\n// setConfigValue sets a config value by key\nfunc setConfigValue(cfg *config.Config, key, value string) error {\n\t// Handle express.<provider>.<key> pattern (e.g., express.kuaidi100.key)\n\tif strings.HasPrefix(key, \"express.\") {\n\t\tparts := strings.SplitN(key, \".\", 3)\n\t\tif len(parts) != 3 {\n\t\t\treturn fmt.Errorf(\"invalid express config key format: %s\\nUse: express.<provider>.<key> (e.g., express.kuaidi100.key)\", key)\n\t\t}\n\t\tprovider := parts[1]\n\t\tconfigKey := parts[2]\n\t\tcfg.SetExpressConfig(provider, configKey, value)\n\t\treturn nil\n\t}\n\n\tswitch key {\n\tcase \"language\":\n\t\tcfg.Language = value\n\tcase \"output_dir\":\n\t\tcfg.OutputDir = value\n\tcase \"format\":\n\t\tcfg.Format = value\n\tcase \"quality\":\n\t\tcfg.Quality = value\n\tcase \"twitter.auth_token\":\n\t\tcfg.Twitter.AuthToken = value\n\tcase \"bilibili.cookie\":\n\t\tcfg.Bilibili.Cookie = value\n\tcase \"server.port\":\n\t\tvar port int\n\t\tif _, err := fmt.Sscanf(value, \"%d\", &port); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid port number: %s\", value)\n\t\t}\n\t\tcfg.Server.Port = port\n\tcase \"server.max_concurrent\":\n\t\tvar n int\n\t\tif _, err := fmt.Sscanf(value, \"%d\", &n); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid number: %s\", value)\n\t\t}\n\t\tcfg.Server.MaxConcurrent = n\n\tcase \"server.api_key\":\n\t\tcfg.Server.APIKey = value\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown config key: %s\\nRun 'vget config set --help' to see supported keys\", key)\n\t}\n\treturn nil\n}\n\n// getConfigValue gets a config value by key\nfunc getConfigValue(cfg *config.Config, key string) (string, error) {\n\t// Handle express.<provider>.<key> pattern (e.g., express.kuaidi100.key)\n\tif strings.HasPrefix(key, \"express.\") {\n\t\tparts := strings.SplitN(key, \".\", 3)\n\t\tif len(parts) != 3 {\n\t\t\treturn \"\", fmt.Errorf(\"invalid express config key format: %s\\nUse: express.<provider>.<key> (e.g., express.kuaidi100.key)\", key)\n\t\t}\n\t\tprovider := parts[1]\n\t\tconfigKey := parts[2]\n\t\tproviderCfg := cfg.GetExpressConfig(provider)\n\t\tif providerCfg == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t\treturn providerCfg[configKey], nil\n\t}\n\n\tswitch key {\n\tcase \"language\":\n\t\treturn cfg.Language, nil\n\tcase \"output_dir\":\n\t\treturn cfg.OutputDir, nil\n\tcase \"format\":\n\t\treturn cfg.Format, nil\n\tcase \"quality\":\n\t\treturn cfg.Quality, nil\n\tcase \"twitter.auth_token\":\n\t\treturn cfg.Twitter.AuthToken, nil\n\tcase \"bilibili.cookie\":\n\t\treturn cfg.Bilibili.Cookie, nil\n\tcase \"server.port\":\n\t\treturn fmt.Sprintf(\"%d\", cfg.Server.Port), nil\n\tcase \"server.max_concurrent\":\n\t\treturn fmt.Sprintf(\"%d\", cfg.Server.MaxConcurrent), nil\n\tcase \"server.api_key\":\n\t\treturn cfg.Server.APIKey, nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unknown config key: %s\\nRun 'vget config get --help' to see supported keys\", key)\n\t}\n}\n\n// unsetConfigValue clears a config value by key\nfunc unsetConfigValue(cfg *config.Config, key string) error {\n\t// Handle express.<provider>.<key> pattern (e.g., express.kuaidi100.key)\n\tif strings.HasPrefix(key, \"express.\") {\n\t\tparts := strings.SplitN(key, \".\", 3)\n\t\tif len(parts) != 3 {\n\t\t\treturn fmt.Errorf(\"invalid express config key format: %s\\nUse: express.<provider>.<key> (e.g., express.kuaidi100.key)\", key)\n\t\t}\n\t\tprovider := parts[1]\n\t\tconfigKey := parts[2]\n\t\tcfg.DeleteExpressConfig(provider, configKey)\n\t\treturn nil\n\t}\n\n\tswitch key {\n\tcase \"language\":\n\t\tcfg.Language = \"\"\n\tcase \"output_dir\":\n\t\tcfg.OutputDir = \"\"\n\tcase \"format\":\n\t\tcfg.Format = \"\"\n\tcase \"quality\":\n\t\tcfg.Quality = \"\"\n\tcase \"twitter.auth_token\":\n\t\tcfg.Twitter.AuthToken = \"\"\n\tcase \"bilibili.cookie\":\n\t\tcfg.Bilibili.Cookie = \"\"\n\tcase \"server.port\":\n\t\tcfg.Server.Port = 0\n\tcase \"server.max_concurrent\":\n\t\tcfg.Server.MaxConcurrent = 0\n\tcase \"server.api_key\":\n\t\tcfg.Server.APIKey = \"\"\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown config key: %s\\nRun 'vget config unset --help' to see supported keys\", key)\n\t}\n\treturn nil\n}\n\n// --- WebDAV remote management ---\n\nvar configWebdavCmd = &cobra.Command{\n\tUse:     \"webdav\",\n\tShort:   \"Manage WebDAV remotes\",\n\tAliases: []string{\"remote\"},\n}\n\nvar configWebdavListCmd = &cobra.Command{\n\tUse:     \"list\",\n\tShort:   \"List configured WebDAV servers\",\n\tAliases: []string{\"ls\"},\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tcfg := config.LoadOrDefault()\n\t\tif len(cfg.WebDAVServers) == 0 {\n\t\t\tfmt.Println(\"No WebDAV servers configured.\")\n\t\t\tfmt.Println(\"Add one with: vget config webdav add <name>\")\n\t\t\treturn\n\t\t}\n\n\t\tfmt.Println(\"WebDAV servers:\")\n\t\tfor name, server := range cfg.WebDAVServers {\n\t\t\tif server.Username != \"\" {\n\t\t\t\tfmt.Printf(\"  %s: %s (user: %s)\\n\", name, server.URL, server.Username)\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"  %s: %s\\n\", name, server.URL)\n\t\t\t}\n\t\t}\n\t},\n}\n\nvar configWebdavAddCmd = &cobra.Command{\n\tUse:   \"add <name>\",\n\tShort: \"Add a new WebDAV server\",\n\tLong: `Add a new WebDAV server configuration.\n\nExamples:\n  vget config webdav add pikpak\n  vget config webdav add nextcloud\n\nAfter adding, download files like:\n  vget pikpak:/Movies/video.mp4`,\n\tArgs: cobra.ExactArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tname := args[0]\n\t\tcfg := config.LoadOrDefault()\n\n\t\tif cfg.GetWebDAVServer(name) != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"WebDAV server '%s' already exists.\\n\", name)\n\t\t\tfmt.Fprintf(os.Stderr, \"Delete it first: vget config webdav delete %s\\n\", name)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\treader := bufio.NewReader(os.Stdin)\n\n\t\t// Get URL\n\t\tfmt.Print(\"WebDAV URL: \")\n\t\turlStr, _ := reader.ReadString('\\n')\n\t\turlStr = strings.TrimSpace(urlStr)\n\t\tif urlStr == \"\" {\n\t\t\tfmt.Fprintln(os.Stderr, \"URL is required\")\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\t// Get username\n\t\tfmt.Print(\"Username (enter to skip): \")\n\t\tusername, _ := reader.ReadString('\\n')\n\t\tusername = strings.TrimSpace(username)\n\n\t\t// Get password\n\t\tvar password string\n\t\tif username != \"\" {\n\t\t\tfmt.Print(\"Password: \")\n\t\t\tpasswordBytes, err := term.ReadPassword(int(syscall.Stdin))\n\t\t\tfmt.Println()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"Failed to read password: %v\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tpassword = string(passwordBytes)\n\t\t}\n\n\t\tcfg.SetWebDAVServer(name, config.WebDAVServer{\n\t\t\tURL:      urlStr,\n\t\t\tUsername: username,\n\t\t\tPassword: password,\n\t\t})\n\n\t\tif err := config.Save(cfg); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Failed to save: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Printf(\"\\nWebDAV server '%s' added.\\n\", name)\n\t\tfmt.Printf(\"Usage: vget %s:/path/to/file.mp4\\n\", name)\n\t},\n}\n\nvar configWebdavDeleteCmd = &cobra.Command{\n\tUse:     \"delete <name>\",\n\tShort:   \"Delete a WebDAV server\",\n\tAliases: []string{\"rm\", \"remove\"},\n\tArgs:    cobra.ExactArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tname := args[0]\n\t\tcfg := config.LoadOrDefault()\n\n\t\tif cfg.GetWebDAVServer(name) == nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"WebDAV server '%s' not found.\\n\", name)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tcfg.DeleteWebDAVServer(name)\n\n\t\tif err := config.Save(cfg); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Failed to save: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Printf(\"WebDAV server '%s' deleted.\\n\", name)\n\t},\n}\n\nvar configWebdavShowCmd = &cobra.Command{\n\tUse:   \"show <name>\",\n\tShort: \"Show details of a WebDAV server\",\n\tArgs:  cobra.ExactArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tname := args[0]\n\t\tcfg := config.LoadOrDefault()\n\n\t\tserver := cfg.GetWebDAVServer(name)\n\t\tif server == nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"WebDAV server '%s' not found.\\n\", name)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Printf(\"Name:     %s\\n\", name)\n\t\tfmt.Printf(\"URL:      %s\\n\", server.URL)\n\t\tif server.Username != \"\" {\n\t\t\tfmt.Printf(\"Username: %s\\n\", server.Username)\n\t\t\tfmt.Printf(\"Password: %s\\n\", strings.Repeat(\"*\", len(server.Password)))\n\t\t}\n\t},\n}\n\n// --- Twitter auth management ---\n\nvar configTwitterCmd = &cobra.Command{\n\tUse:        \"twitter\",\n\tShort:      \"Manage Twitter/X authentication (deprecated)\",\n\tDeprecated: \"use 'vget config set twitter.auth_token <value>' instead\",\n}\n\nvar configTwitterSetCmd = &cobra.Command{\n\tUse:        \"set\",\n\tShort:      \"Set Twitter auth token (deprecated)\",\n\tDeprecated: \"use 'vget config set twitter.auth_token <value>' instead\",\n\tLong: `DEPRECATED: Use 'vget config set twitter.auth_token <value>' instead.\n\nSet Twitter authentication token to download age-restricted content.\n\nTo get your auth_token:\n  1. Open x.com in your browser and log in\n  2. Open DevTools (F12) → Application → Cookies → x.com\n  3. Find 'auth_token' and copy its value\n\nNew syntax:\n  vget config set twitter.auth_token YOUR_AUTH_TOKEN`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tcfg := config.LoadOrDefault()\n\t\tt := i18n.T(cfg.Language)\n\n\t\t// Show deprecation warning and exit\n\t\tfmt.Fprintf(os.Stderr, \"⚠️  %s\\n\", t.Twitter.DeprecatedSet)\n\t\tfmt.Fprintf(os.Stderr, \"   %s\\n\", t.Twitter.DeprecatedUseNew)\n\t\tos.Exit(1)\n\t},\n}\n\nvar configTwitterClearCmd = &cobra.Command{\n\tUse:        \"clear\",\n\tShort:      \"Remove Twitter authentication (deprecated)\",\n\tDeprecated: \"use 'vget config unset twitter.auth_token' instead\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tcfg := config.LoadOrDefault()\n\t\tt := i18n.T(cfg.Language)\n\n\t\t// Show deprecation warning and exit\n\t\tfmt.Fprintf(os.Stderr, \"⚠️  %s\\n\", t.Twitter.DeprecatedClear)\n\t\tfmt.Fprintf(os.Stderr, \"   %s\\n\", t.Twitter.DeprecatedUseNewUnset)\n\t\tos.Exit(1)\n\t},\n}\n\nfunc init() {\n\t// config subcommands\n\tconfigCmd.AddCommand(configShowCmd)\n\tconfigCmd.AddCommand(configPathCmd)\n\tconfigCmd.AddCommand(configSetCmd)\n\tconfigCmd.AddCommand(configGetCmd)\n\tconfigCmd.AddCommand(configUnsetCmd)\n\n\t// config webdav subcommands\n\tconfigWebdavCmd.AddCommand(configWebdavListCmd)\n\tconfigWebdavCmd.AddCommand(configWebdavAddCmd)\n\tconfigWebdavCmd.AddCommand(configWebdavDeleteCmd)\n\tconfigWebdavCmd.AddCommand(configWebdavShowCmd)\n\tconfigCmd.AddCommand(configWebdavCmd)\n\n\t// config twitter subcommands\n\tconfigTwitterSetCmd.Flags().String(\"token\", \"\", \"auth_token value\")\n\tconfigTwitterCmd.AddCommand(configTwitterSetCmd)\n\tconfigTwitterCmd.AddCommand(configTwitterClearCmd)\n\tconfigCmd.AddCommand(configTwitterCmd)\n\n\trootCmd.AddCommand(configCmd)\n}"
  },
  {
    "path": "internal/cli/extract.go",
    "content": "package cli\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/guiyumin/vget/internal/core/extractor\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n)\n\nvar (\n\textractInfoStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color(\"86\"))\n\textractDoneStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color(\"42\"))\n\textractErrStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color(\"196\"))\n\textractHintStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color(\"248\"))\n\textractMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(\"33\")) // blue for info messages\n)\n\n// extractState holds extraction state\ntype extractState struct {\n\tmu     sync.RWMutex\n\tdone   bool\n\terr    error\n\tresult extractor.Media\n}\n\nfunc (s *extractState) setDone(result extractor.Media) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.done = true\n\ts.result = result\n}\n\nfunc (s *extractState) setError(err error) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.err = err\n\ts.done = true\n}\n\nfunc (s *extractState) get() (bool, extractor.Media, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\treturn s.done, s.result, s.err\n}\n\ntype extractTickMsg time.Time\n\ntype extractModel struct {\n\tspinner spinner.Model\n\tt       *i18n.Translations\n\turl     string\n\tstate   *extractState\n}\n\nfunc newExtractModel(url, lang string, state *extractState) extractModel {\n\ts := spinner.New()\n\ts.Spinner = spinner.Dot\n\ts.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(\"205\"))\n\n\treturn extractModel{\n\t\tspinner: s,\n\t\tt:       i18n.T(lang),\n\t\turl:     url,\n\t\tstate:   state,\n\t}\n}\n\nfunc extractTickCmd() tea.Cmd {\n\treturn tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {\n\t\treturn extractTickMsg(t)\n\t})\n}\n\nfunc (m extractModel) Init() tea.Cmd {\n\treturn tea.Batch(m.spinner.Tick, extractTickCmd())\n}\n\nfunc (m extractModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\", \"q\":\n\t\t\treturn m, tea.Quit\n\t\t}\n\n\tcase spinner.TickMsg:\n\t\tvar cmd tea.Cmd\n\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\treturn m, cmd\n\n\tcase extractTickMsg:\n\t\tdone, _, _ := m.state.get()\n\t\tif done {\n\t\t\treturn m, tea.Quit\n\t\t}\n\t\treturn m, extractTickCmd()\n\t}\n\n\treturn m, nil\n}\n\nfunc (m extractModel) View() string {\n\tdone, result, err := m.state.get()\n\n\tif err != nil {\n\t\t// Special handling for YouTube Docker requirement (info message, not error)\n\t\tvar ytErr *extractor.YouTubeDockerRequiredError\n\t\tif errors.As(err, &ytErr) {\n\t\t\treturn fmt.Sprintf(\"\\n  %s %s\\n\\n  %s\\n    docker run -d -p <port>:8080 -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget\\n\\n  %s\\n    docker run --rm -v ~/downloads:/home/vget/downloads ghcr.io/guiyumin/vget \\\"%s\\\"\\n\\n\",\n\t\t\t\textractMessageStyle.Render(\"ℹ\"),\n\t\t\t\tm.t.YouTube.DockerRequired,\n\t\t\t\textractHintStyle.Render(m.t.YouTube.DockerHintServer),\n\t\t\t\textractHintStyle.Render(m.t.YouTube.DockerHintCLI),\n\t\t\t\tytErr.URL,\n\t\t\t)\n\t\t}\n\t\treturn fmt.Sprintf(\"\\n  %s %s: %v\\n\\n\",\n\t\t\textractErrStyle.Render(\"✗\"),\n\t\t\tm.t.Errors.ExtractionFailed,\n\t\t\terr,\n\t\t)\n\t}\n\n\tif done && result != nil {\n\t\tvar s string\n\t\ts += fmt.Sprintf(\"\\n  %s %s\\n\", extractDoneStyle.Render(\"✓\"), m.t.Download.Completed)\n\t\ts += fmt.Sprintf(\"  ID: %s\\n\\n\", extractInfoStyle.Render(result.GetID()))\n\n\t\t// Display based on media type\n\t\tswitch media := result.(type) {\n\t\tcase *extractor.VideoMedia:\n\t\t\ts += fmt.Sprintf(\"  %s:\\n\", m.t.Download.FormatsAvailable)\n\t\t\tfor _, f := range media.Formats {\n\t\t\t\ts += fmt.Sprintf(\"    • %s %dx%d (%s)\\n\", f.Quality, f.Width, f.Height, f.Ext)\n\t\t\t}\n\t\t\ts += \"\\n\"\n\t\t\ts += fmt.Sprintf(\"  %s\\n\\n\", extractHintStyle.Render(m.t.Download.QualityHint))\n\n\t\tcase *extractor.AudioMedia:\n\t\t\ts += fmt.Sprintf(\"  Audio: %s\\n\\n\", media.Ext)\n\n\t\tcase *extractor.ImageMedia:\n\t\t\ts += fmt.Sprintf(\"  Images (%d):\\n\", len(media.Images))\n\t\t\tfor i, img := range media.Images {\n\t\t\t\tif img.Width > 0 && img.Height > 0 {\n\t\t\t\t\ts += fmt.Sprintf(\"    • [%d] %dx%d (%s)\\n\", i+1, img.Width, img.Height, img.Ext)\n\t\t\t\t} else {\n\t\t\t\t\ts += fmt.Sprintf(\"    • [%d] %s\\n\", i+1, img.Ext)\n\t\t\t\t}\n\t\t\t}\n\t\t\ts += \"\\n\"\n\t\t}\n\n\t\treturn s\n\t}\n\n\treturn fmt.Sprintf(\"\\n  %s %s: %s\\n\\n\",\n\t\tm.spinner.View(),\n\t\tm.t.Download.Extracting,\n\t\textractInfoStyle.Render(m.url),\n\t)\n}\n\n// runExtractWithSpinner runs extraction with a spinner TUI\nfunc runExtractWithSpinner(ext extractor.Extractor, url, lang string) (extractor.Media, error) {\n\tstate := &extractState{}\n\n\t// Start extraction in background\n\tgo func() {\n\t\tresult, err := ext.Extract(url)\n\t\tif err != nil {\n\t\t\tstate.setError(err)\n\t\t} else {\n\t\t\tstate.setDone(result)\n\t\t}\n\t}()\n\n\tmodel := newExtractModel(url, lang, state)\n\tp := tea.NewProgram(model)\n\t_, err := p.Run()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdone, result, extractErr := state.get()\n\tif extractErr != nil {\n\t\treturn nil, extractErr\n\t}\n\tif !done {\n\t\treturn nil, fmt.Errorf(\"extraction cancelled\")\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/cli/init.go",
    "content": "package cli\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar initCmd = &cobra.Command{\n\tUse:   \"init\",\n\tShort: \"Create vget config file\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t// Run interactive wizard (loads existing config as defaults if present)\n\t\tcfg, err := config.RunInitWizard()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Save config\n\t\tif err := config.Save(cfg); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfmt.Printf(\"\\nSaved %s\\n\", config.SavePath())\n\t\treturn nil\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(initCmd)\n}\n"
  },
  {
    "path": "internal/cli/kuaidi100.go",
    "content": "package cli\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/fatih/color\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/tracker\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tkuaidi100Courier string // --courier flag for courier code\n)\n\nvar kuaidi100Cmd = &cobra.Command{\n\tUse:   \"kuaidi100 <tracking_number>\",\n\tShort: \"Track package via kuaidi100 API\",\n\tLong: `Track package delivery status using kuaidi100 API.\n\nExamples:\n  vget kuaidi100 73123456789              # Auto-detect courier\n  vget kuaidi100 73123456789 -c yt        # Track YTO Express package\n  vget kuaidi100 SF1234567890 -c sf       # Track SF Express package\n\nSupported courier codes:\n  sf       - 顺丰速运 (SF Express)\n  yt       - 圆通速递 (YTO Express)\n  sto      - 申通快递 (STO Express)\n  zto      - 中通快递 (ZTO Express)\n  yd       - 韵达快递 (Yunda Express)\n  jt       - 极兔速递 (JiTu Express)\n  jd       - 京东物流 (JD Logistics)\n  ems      - EMS\n  yzgn     - 邮政国内 (China Post)\n  dbwl     - 德邦物流 (Deppon)\n  anneng   - 安能物流 (Anneng)\n  best     - 百世快递 (Best Express)\n  kuayue   - 跨越速运 (Kuayue)\n\nConfiguration:\n  Set your kuaidi100 API credentials:\n  vget config set express.kuaidi100.key <your_key>\n  vget config set express.kuaidi100.customer <your_customer_id>\n\n  Get credentials at: https://api.kuaidi100.com/manager/v2/myinfo/enterprise`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runKuaidi100,\n}\n\nfunc init() {\n\tkuaidi100Cmd.Flags().StringVarP(&kuaidi100Courier, \"courier\", \"c\", \"auto\", \"Courier company code (e.g., sf, yt, zto, or auto for auto-detect)\")\n\trootCmd.AddCommand(kuaidi100Cmd)\n}\n\nfunc runKuaidi100(cmd *cobra.Command, args []string) error {\n\ttrackingNumber := args[0]\n\n\t// Load config\n\tcfg := config.LoadOrDefault()\n\n\t// Get kuaidi100 credentials from express config\n\texpressCfg := cfg.GetExpressConfig(\"kuaidi100\")\n\tif expressCfg == nil || expressCfg[\"key\"] == \"\" || expressCfg[\"customer\"] == \"\" {\n\t\tfmt.Fprintln(os.Stderr, color.RedString(\"Error: kuaidi100 API credentials not configured\"))\n\t\tfmt.Fprintln(os.Stderr, \"\")\n\t\tfmt.Fprintln(os.Stderr, \"Please set your credentials:\")\n\t\tfmt.Fprintln(os.Stderr, \"  vget config set express.kuaidi100.key <your_key>\")\n\t\tfmt.Fprintln(os.Stderr, \"  vget config set express.kuaidi100.customer <your_customer_id>\")\n\t\tfmt.Fprintln(os.Stderr, \"\")\n\t\tfmt.Fprintln(os.Stderr, \"Get your credentials at: https://api.kuaidi100.com/manager/v2/myinfo/enterprise\")\n\t\treturn fmt.Errorf(\"missing kuaidi100 credentials\")\n\t}\n\n\t// Create tracker\n\tt := tracker.NewKuaidi100Tracker(expressCfg[\"key\"], expressCfg[\"customer\"])\n\n\t// Convert courier alias to kuaidi100 code\n\tcourierCode := tracker.GetCourierCode(kuaidi100Courier)\n\n\t// Get courier info for display\n\tcourierInfo := tracker.GetCourierInfo(kuaidi100Courier)\n\tif courierInfo != nil {\n\t\tfmt.Printf(\"Courier: %s (%s)\\n\", courierInfo.Name, courierCode)\n\t} else if kuaidi100Courier != \"auto\" {\n\t\tfmt.Printf(\"Courier: %s\\n\", courierCode)\n\t}\n\tfmt.Printf(\"Tracking: %s\\n\\n\", trackingNumber)\n\n\t// Track the package\n\tresult, err := t.Track(courierCode, trackingNumber)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"tracking failed: %w\", err)\n\t}\n\n\t// Display results\n\tprintKuaidi100Result(result)\n\n\treturn nil\n}\n\nfunc printKuaidi100Result(result *tracker.TrackingResponse) {\n\tbold := color.New(color.Bold)\n\tgreen := color.New(color.FgGreen)\n\tyellow := color.New(color.FgYellow)\n\tcyan := color.New(color.FgCyan)\n\n\t// Status\n\tbold.Printf(\"Status: \")\n\tif result.IsDelivered() {\n\t\tgreen.Println(result.StateDescription() + \" ✓\")\n\t} else {\n\t\tyellow.Println(result.StateDescription())\n\t}\n\n\tfmt.Println()\n\n\t// Tracking events\n\tif len(result.Data) == 0 {\n\t\tfmt.Println(\"No tracking information available yet.\")\n\t\treturn\n\t}\n\n\tbold.Println(\"Tracking History:\")\n\tfmt.Println(strings.Repeat(\"-\", 60))\n\n\tfor i, event := range result.Data {\n\t\t// Time\n\t\ttimeStr := event.Ftime\n\t\tif timeStr == \"\" {\n\t\t\ttimeStr = event.Time\n\t\t}\n\t\tcyan.Printf(\"[%s]\", timeStr)\n\t\tfmt.Println()\n\n\t\t// Context/description\n\t\tfmt.Printf(\"  %s\", event.Context)\n\n\t\t// Location if available\n\t\tif event.Location != \"\" {\n\t\t\tfmt.Printf(\" (%s)\", event.Location)\n\t\t} else if event.AreaName != \"\" {\n\t\t\tfmt.Printf(\" (%s)\", event.AreaName)\n\t\t}\n\t\tfmt.Println()\n\n\t\t// Add separator except for last item\n\t\tif i < len(result.Data)-1 {\n\t\t\tfmt.Println()\n\t\t}\n\t}\n\n\tfmt.Println(strings.Repeat(\"-\", 60))\n}\n"
  },
  {
    "path": "internal/cli/login/bilibili.go",
    "content": "package login\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/site/bilibili\"\n\t\"github.com/spf13/cobra\"\n\tqrcode \"github.com/yeqown/go-qrcode/v2\"\n)\n\n// Bilibili styles\nvar (\n\tbiliTitleStyle = lipgloss.NewStyle().\n\t\t\tBold(true).\n\t\t\tForeground(lipgloss.Color(\"#00A1D6\")) // Bilibili blue\n\n\tbiliStepStyle = lipgloss.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"252\"))\n\n\tbiliKeyStyle = lipgloss.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"#00A1D6\")).\n\t\t\tBold(true)\n\n\tbiliHelpStyle = lipgloss.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"245\"))\n\n\tbiliSuccessStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(lipgloss.Color(\"82\"))\n\n\tbiliErrorStyle = lipgloss.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"196\"))\n)\n\n// BilibiliCmd returns the bilibili login command\nfunc BilibiliCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"bilibili\",\n\t\tShort: \"Login to Bilibili\",\n\t\tLong:  \"Login to Bilibili to download member-only or VIP content.\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runLoginSelector()\n\t\t},\n\t}\n\n\tcmd.AddCommand(bilibiliQRCmd())\n\tcmd.AddCommand(bilibiliCookieCmd())\n\tcmd.AddCommand(bilibiliStatusCmd())\n\n\treturn cmd\n}\n\n// BilibiliLogoutCmd returns the bilibili logout command\nfunc BilibiliLogoutCmd() *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:   \"bilibili\",\n\t\tShort: \"Clear Bilibili credentials\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tcfg := config.LoadOrDefault()\n\t\t\tcfg.Bilibili.Cookie = \"\"\n\t\t\tif err := config.Save(cfg); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to save config: %w\", err)\n\t\t\t}\n\t\t\tfmt.Println(\"✓ Bilibili credentials cleared\")\n\t\t\treturn nil\n\t\t},\n\t}\n}\n\nfunc bilibiliQRCmd() *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:   \"qr\",\n\t\tShort: \"Login via QR code\",\n\t\tLong:  \"Login to Bilibili by scanning a QR code with the Bilibili mobile app.\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runQRLogin()\n\t\t},\n\t}\n}\n\nfunc bilibiliCookieCmd() *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:   \"cookie\",\n\t\tShort: \"Login via browser cookie\",\n\t\tLong: `Login to Bilibili by pasting your cookie from browser.\n\nTo get your cookie:\n  1. Open bilibili.com in browser and log in\n  2. Press F12 to open DevTools\n  3. Go to Application tab\n  4. Find Cookies → bilibili.com\n  5. Copy SESSDATA, bili_jct, DedeUserID values`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runCookieLogin()\n\t\t},\n\t}\n}\n\nfunc bilibiliStatusCmd() *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:   \"status\",\n\t\tShort: \"Check Bilibili login status\",\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tcfg := config.LoadOrDefault()\n\t\t\tif cfg.Bilibili.Cookie != \"\" && strings.Contains(cfg.Bilibili.Cookie, \"SESSDATA\") {\n\t\t\t\tfmt.Println(\"✓ Bilibili: logged in\")\n\t\t\t} else {\n\t\t\t\tfmt.Println(\"✗ Bilibili: not logged in\")\n\t\t\t}\n\t\t},\n\t}\n}\n\n// Login Method Selector TUI\n\ntype loginMethod int\n\nconst (\n\tmethodQR loginMethod = iota\n\tmethodCookie\n)\n\ntype selectorModel struct {\n\tchoices   []string\n\tcursor    int\n\tselected  loginMethod\n\tcancelled bool\n}\n\nfunc newSelectorModel() selectorModel {\n\treturn selectorModel{\n\t\tchoices: []string{\n\t\t\t\"扫码登录\",\n\t\t\t\"Cookie 登录\",\n\t\t},\n\t\tcursor: 0,\n\t}\n}\n\nfunc (m selectorModel) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\", \"esc\", \"q\":\n\t\t\tm.cancelled = true\n\t\t\treturn m, tea.Quit\n\t\tcase \"up\", \"k\":\n\t\t\tif m.cursor > 0 {\n\t\t\t\tm.cursor--\n\t\t\t}\n\t\tcase \"down\", \"j\":\n\t\t\tif m.cursor < len(m.choices)-1 {\n\t\t\t\tm.cursor++\n\t\t\t}\n\t\tcase \"enter\", \" \":\n\t\t\tm.selected = loginMethod(m.cursor)\n\t\t\treturn m, tea.Quit\n\t\t}\n\t}\n\treturn m, nil\n}\n\nfunc (m selectorModel) View() string {\n\tvar b strings.Builder\n\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliTitleStyle.Render(\"  ━━━ Bilibili 登录 ━━━\"))\n\tb.WriteString(\"\\n\\n\")\n\tb.WriteString(biliStepStyle.Render(\"  请选择登录方式：\"))\n\tb.WriteString(\"\\n\\n\")\n\n\tfor i, choice := range m.choices {\n\t\tcursor := \"  \"\n\t\tstyle := biliStepStyle\n\t\tif m.cursor == i {\n\t\t\tcursor = biliKeyStyle.Render(\"▸ \")\n\t\t\tstyle = biliKeyStyle\n\t\t}\n\t\tb.WriteString(\"  \")\n\t\tb.WriteString(cursor)\n\t\tb.WriteString(style.Render(choice))\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliHelpStyle.Render(\"  ↑/↓ 选择 • Enter 确认 • q 取消\"))\n\tb.WriteString(\"\\n\")\n\n\treturn b.String()\n}\n\nfunc runLoginSelector() error {\n\tm := newSelectorModel()\n\tp := tea.NewProgram(m)\n\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult := finalModel.(selectorModel)\n\tif result.cancelled {\n\t\tfmt.Println(\"  已取消\")\n\t\treturn nil\n\t}\n\n\tswitch result.selected {\n\tcase methodQR:\n\t\treturn runQRLogin()\n\tcase methodCookie:\n\t\treturn runCookieLogin()\n\t}\n\n\treturn nil\n}\n\n// Cookie Login TUI\n\ntype cookieLoginModel struct {\n\tinputs    []textinput.Model\n\tfocused   int\n\tsaved     bool\n\tcancelled bool\n\terror     string\n}\n\nfunc newCookieLoginModel() cookieLoginModel {\n\tinputs := make([]textinput.Model, 3)\n\n\t// SESSDATA input\n\tinputs[0] = textinput.New()\n\tinputs[0].Placeholder = \"粘贴 SESSDATA 值...\"\n\tinputs[0].CharLimit = 500\n\tinputs[0].Width = 50\n\tinputs[0].Prompt = \"  SESSDATA    > \"\n\tinputs[0].PromptStyle = biliKeyStyle\n\tinputs[0].Focus()\n\n\t// bili_jct input\n\tinputs[1] = textinput.New()\n\tinputs[1].Placeholder = \"粘贴 bili_jct 值...\"\n\tinputs[1].CharLimit = 100\n\tinputs[1].Width = 50\n\tinputs[1].Prompt = \"  bili_jct    > \"\n\tinputs[1].PromptStyle = biliKeyStyle\n\n\t// DedeUserID input\n\tinputs[2] = textinput.New()\n\tinputs[2].Placeholder = \"粘贴 DedeUserID 值...\"\n\tinputs[2].CharLimit = 50\n\tinputs[2].Width = 50\n\tinputs[2].Prompt = \"  DedeUserID  > \"\n\tinputs[2].PromptStyle = biliKeyStyle\n\n\t// Load existing cookie if any\n\tcfg := config.LoadOrDefault()\n\tif cfg.Bilibili.Cookie != \"\" {\n\t\tfor part := range strings.SplitSeq(cfg.Bilibili.Cookie, \";\") {\n\t\t\tpart = strings.TrimSpace(part)\n\t\t\tif val, ok := strings.CutPrefix(part, \"SESSDATA=\"); ok {\n\t\t\t\tinputs[0].SetValue(val)\n\t\t\t} else if val, ok := strings.CutPrefix(part, \"bili_jct=\"); ok {\n\t\t\t\tinputs[1].SetValue(val)\n\t\t\t} else if val, ok := strings.CutPrefix(part, \"DedeUserID=\"); ok {\n\t\t\t\tinputs[2].SetValue(val)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn cookieLoginModel{\n\t\tinputs:  inputs,\n\t\tfocused: 0,\n\t}\n}\n\nfunc (m cookieLoginModel) Init() tea.Cmd {\n\treturn textinput.Blink\n}\n\nfunc (m cookieLoginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tvar cmds []tea.Cmd\n\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\", \"esc\":\n\t\t\tm.cancelled = true\n\t\t\treturn m, tea.Quit\n\n\t\tcase \"tab\", \"down\":\n\t\t\tm.inputs[m.focused].Blur()\n\t\t\tm.focused = (m.focused + 1) % len(m.inputs)\n\t\t\tm.inputs[m.focused].Focus()\n\t\t\treturn m, textinput.Blink\n\n\t\tcase \"shift+tab\", \"up\":\n\t\t\tm.inputs[m.focused].Blur()\n\t\t\tm.focused--\n\t\t\tif m.focused < 0 {\n\t\t\t\tm.focused = len(m.inputs) - 1\n\t\t\t}\n\t\t\tm.inputs[m.focused].Focus()\n\t\t\treturn m, textinput.Blink\n\n\t\tcase \"enter\":\n\t\t\tif m.focused < len(m.inputs)-1 {\n\t\t\t\tm.inputs[m.focused].Blur()\n\t\t\t\tm.focused++\n\t\t\t\tm.inputs[m.focused].Focus()\n\t\t\t\treturn m, textinput.Blink\n\t\t\t}\n\n\t\t\tsessdata := strings.TrimSpace(m.inputs[0].Value())\n\t\t\tbiliJct := strings.TrimSpace(m.inputs[1].Value())\n\t\t\tdedeUserID := strings.TrimSpace(m.inputs[2].Value())\n\n\t\t\tif sessdata == \"\" {\n\t\t\t\tm.error = \"SESSDATA 不能为空\"\n\t\t\t\tm.focused = 0\n\t\t\t\tm.inputs[0].Focus()\n\t\t\t\treturn m, textinput.Blink\n\t\t\t}\n\n\t\t\tcookie := fmt.Sprintf(\"SESSDATA=%s; bili_jct=%s; DedeUserID=%s\", sessdata, biliJct, dedeUserID)\n\n\t\t\tcfg := config.LoadOrDefault()\n\t\t\tcfg.Bilibili.Cookie = cookie\n\t\t\tif err := config.Save(cfg); err != nil {\n\t\t\t\tm.error = fmt.Sprintf(\"保存失败: %v\", err)\n\t\t\t\treturn m, nil\n\t\t\t}\n\n\t\t\tm.saved = true\n\t\t\treturn m, tea.Quit\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tm.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)\n\tcmds = append(cmds, cmd)\n\tm.error = \"\"\n\n\treturn m, tea.Batch(cmds...)\n}\n\nfunc (m cookieLoginModel) View() string {\n\tvar b strings.Builder\n\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliTitleStyle.Render(\"  ━━━ Bilibili 登录 ━━━\"))\n\tb.WriteString(\"\\n\\n\")\n\n\tb.WriteString(biliTitleStyle.Render(\"  获取 Cookie 的方法：\"))\n\tb.WriteString(\"\\n\\n\")\n\tb.WriteString(biliStepStyle.Render(\"  1. 在浏览器中打开 \"))\n\tb.WriteString(biliKeyStyle.Render(\"bilibili.com\"))\n\tb.WriteString(biliStepStyle.Render(\" 并登录\"))\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliStepStyle.Render(\"  2. 按 \"))\n\tb.WriteString(biliKeyStyle.Render(\"F12\"))\n\tb.WriteString(biliStepStyle.Render(\" 打开开发者工具\"))\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliStepStyle.Render(\"  3. 点击顶部「\"))\n\tb.WriteString(biliKeyStyle.Render(\"Application\"))\n\tb.WriteString(biliStepStyle.Render(\"」或「\"))\n\tb.WriteString(biliKeyStyle.Render(\"应用\"))\n\tb.WriteString(biliStepStyle.Render(\"」标签\"))\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliStepStyle.Render(\"  4. 左侧展开 \"))\n\tb.WriteString(biliKeyStyle.Render(\"Cookies\"))\n\tb.WriteString(biliStepStyle.Render(\" → 点击 \"))\n\tb.WriteString(biliKeyStyle.Render(\"bilibili.com\"))\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliStepStyle.Render(\"  5. 分别复制以下三个值:\"))\n\tb.WriteString(\"\\n\\n\")\n\n\tb.WriteString(biliHelpStyle.Render(\"  ─────────────────────────────────────────────────────────\"))\n\tb.WriteString(\"\\n\\n\")\n\n\tfor i, input := range m.inputs {\n\t\tb.WriteString(input.View())\n\t\tif i < len(m.inputs)-1 {\n\t\t\tb.WriteString(\"\\n\")\n\t\t}\n\t}\n\tb.WriteString(\"\\n\")\n\n\tif m.error != \"\" {\n\t\tb.WriteString(\"\\n\")\n\t\tb.WriteString(biliErrorStyle.Render(\"  ✗ \" + m.error))\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliHelpStyle.Render(\"  Tab/↓ 下一项 • Shift+Tab/↑ 上一项 • Enter 保存 • Esc 取消\"))\n\tb.WriteString(\"\\n\")\n\n\treturn b.String()\n}\n\nfunc runCookieLogin() error {\n\tm := newCookieLoginModel()\n\tp := tea.NewProgram(m)\n\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult := finalModel.(cookieLoginModel)\n\tif result.cancelled {\n\t\tfmt.Println(\"  已取消\")\n\t\treturn nil\n\t}\n\n\tif result.saved {\n\t\tfmt.Println(biliSuccessStyle.Render(\"  ✓ Bilibili Cookie 已保存\"))\n\t}\n\n\treturn nil\n}\n\n// QR Login TUI\n\ntype qrLoginState int\n\nconst (\n\tqrStateGenerating qrLoginState = iota\n\tqrStateWaiting\n\tqrStateScanned\n\tqrStateSuccess\n\tqrStateExpired\n\tqrStateError\n)\n\ntype qrLoginModel struct {\n\tauth      *bilibili.Auth\n\tsession   *bilibili.QRSession\n\tstate     qrLoginState\n\tspinner   spinner.Model\n\tusername  string\n\terror     string\n\tcancelled bool\n}\n\ntype qrPollMsg struct {\n\tstatus bilibili.QRStatus\n\tcreds  *bilibili.Credentials\n\terr    error\n}\n\ntype qrGeneratedMsg struct {\n\tsession *bilibili.QRSession\n\terr     error\n}\n\nfunc newQRLoginModel() qrLoginModel {\n\ts := spinner.New()\n\ts.Spinner = spinner.Dot\n\ts.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(\"#00A1D6\"))\n\n\treturn qrLoginModel{\n\t\tauth:    bilibili.NewAuth(),\n\t\tstate:   qrStateGenerating,\n\t\tspinner: s,\n\t}\n}\n\nfunc (m qrLoginModel) Init() tea.Cmd {\n\treturn tea.Batch(\n\t\tm.spinner.Tick,\n\t\tm.generateQR,\n\t)\n}\n\nfunc (m qrLoginModel) generateQR() tea.Msg {\n\tsession, err := m.auth.GenerateQRCode()\n\treturn qrGeneratedMsg{session: session, err: err}\n}\n\nfunc (m qrLoginModel) pollStatus() tea.Cmd {\n\treturn func() tea.Msg {\n\t\ttime.Sleep(time.Second)\n\t\tstatus, creds, err := m.auth.PollQRStatus(m.session.QRCodeKey)\n\t\treturn qrPollMsg{status: status, creds: creds, err: err}\n\t}\n}\n\nfunc (m qrLoginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\", \"esc\", \"q\":\n\t\t\tm.cancelled = true\n\t\t\treturn m, tea.Quit\n\t\tcase \"r\":\n\t\t\tif m.state == qrStateExpired || m.state == qrStateError {\n\t\t\t\tm.state = qrStateGenerating\n\t\t\t\tm.error = \"\"\n\t\t\t\treturn m, m.generateQR\n\t\t\t}\n\t\t}\n\n\tcase spinner.TickMsg:\n\t\tvar cmd tea.Cmd\n\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\treturn m, cmd\n\n\tcase qrGeneratedMsg:\n\t\tif msg.err != nil {\n\t\t\tm.state = qrStateError\n\t\t\tm.error = msg.err.Error()\n\t\t\treturn m, nil\n\t\t}\n\t\tm.session = msg.session\n\t\tm.state = qrStateWaiting\n\t\tprintQRCode(m.session.URL)\n\t\treturn m, m.pollStatus()\n\n\tcase qrPollMsg:\n\t\tif msg.err != nil {\n\t\t\tm.state = qrStateError\n\t\t\tm.error = msg.err.Error()\n\t\t\treturn m, nil\n\t\t}\n\n\t\tswitch msg.status {\n\t\tcase bilibili.QRWaiting:\n\t\t\tm.state = qrStateWaiting\n\t\t\treturn m, m.pollStatus()\n\n\t\tcase bilibili.QRScanned:\n\t\t\tm.state = qrStateScanned\n\t\t\treturn m, m.pollStatus()\n\n\t\tcase bilibili.QRExpired:\n\t\t\tm.state = qrStateExpired\n\t\t\treturn m, nil\n\n\t\tcase bilibili.QRConfirmed:\n\t\t\tm.state = qrStateSuccess\n\t\t\tif err := m.auth.SaveCredentials(msg.creds); err != nil {\n\t\t\t\tm.state = qrStateError\n\t\t\t\tm.error = err.Error()\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\t\tusername, err := m.auth.ValidateCredentials(msg.creds)\n\t\t\tif err != nil {\n\t\t\t\tm.username = msg.creds.DedeUserID\n\t\t\t} else {\n\t\t\t\tm.username = username\n\t\t\t}\n\t\t\treturn m, tea.Quit\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (m qrLoginModel) View() string {\n\tvar b strings.Builder\n\n\tb.WriteString(\"\\n\")\n\tb.WriteString(biliTitleStyle.Render(\"  ━━━ Bilibili 扫码登录 ━━━\"))\n\tb.WriteString(\"\\n\\n\")\n\n\tswitch m.state {\n\tcase qrStateGenerating:\n\t\tb.WriteString(\"  \")\n\t\tb.WriteString(m.spinner.View())\n\t\tb.WriteString(\" 正在生成二维码...\\n\")\n\n\tcase qrStateWaiting:\n\t\tb.WriteString(biliStepStyle.Render(\"  请使用 Bilibili 客户端扫描上方二维码\"))\n\t\tb.WriteString(\"\\n\\n\")\n\t\tb.WriteString(\"  \")\n\t\tb.WriteString(m.spinner.View())\n\t\tb.WriteString(\" 等待扫码...\\n\")\n\n\tcase qrStateScanned:\n\t\tb.WriteString(biliSuccessStyle.Render(\"  ✓ 扫码成功！\"))\n\t\tb.WriteString(\"\\n\\n\")\n\t\tb.WriteString(\"  \")\n\t\tb.WriteString(m.spinner.View())\n\t\tb.WriteString(\" 请在手机上确认登录...\\n\")\n\n\tcase qrStateSuccess:\n\t\tb.WriteString(biliSuccessStyle.Render(\"  ✓ 登录成功！\"))\n\t\tb.WriteString(\"\\n\")\n\t\tif m.username != \"\" {\n\t\t\tb.WriteString(biliStepStyle.Render(fmt.Sprintf(\"  欢迎，%s\", m.username)))\n\t\t\tb.WriteString(\"\\n\")\n\t\t}\n\n\tcase qrStateExpired:\n\t\tb.WriteString(biliErrorStyle.Render(\"  ✗ 二维码已过期\"))\n\t\tb.WriteString(\"\\n\\n\")\n\t\tb.WriteString(biliHelpStyle.Render(\"  按 r 重新生成二维码，按 q 退出\"))\n\t\tb.WriteString(\"\\n\")\n\n\tcase qrStateError:\n\t\tb.WriteString(biliErrorStyle.Render(\"  ✗ 错误: \" + m.error))\n\t\tb.WriteString(\"\\n\\n\")\n\t\tb.WriteString(biliHelpStyle.Render(\"  按 r 重试，按 q 退出\"))\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\tif m.state != qrStateSuccess && m.state != qrStateExpired && m.state != qrStateError {\n\t\tb.WriteString(\"\\n\")\n\t\tb.WriteString(biliHelpStyle.Render(\"  按 q 或 Esc 取消\"))\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\treturn b.String()\n}\n\nfunc printQRCode(url string) {\n\tqr, err := qrcode.NewWith(url, qrcode.WithErrorCorrectionLevel(qrcode.ErrorCorrectionLow))\n\tif err != nil {\n\t\tfmt.Printf(\"  无法生成二维码: %v\\n\", err)\n\t\treturn\n\t}\n\n\tw := vGetCompactQRWriter()\n\tif err := qr.Save(w); err != nil {\n\t\tfmt.Printf(\"  无法生成二维码: %v\\n\", err)\n\t}\n\tw.Close()\n\n\tfmt.Println()\n\tfmt.Println(biliHelpStyle.Render(\"  或在浏览器打开: \" + url))\n\tfmt.Println()\n}\n\nfunc runQRLogin() error {\n\tm := newQRLoginModel()\n\tp := tea.NewProgram(m)\n\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult := finalModel.(qrLoginModel)\n\tif result.cancelled {\n\t\tfmt.Println(\"  已取消\")\n\t\treturn nil\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cli/login/qrwriter.go",
    "content": "package login\n\nimport (\n\t\"github.com/mattn/go-runewidth\"\n\ttermbox \"github.com/nsf/termbox-go\"\n\t\"github.com/yeqown/go-qrcode/v2\"\n)\n\n// compactQRWriter is a compact QR code writer using half-block characters\n// for 2x vertical compression (▀▄█ ) and 1 char per block horizontally.\ntype compactQRWriter struct {\n\tmatrix [][]bool\n\twidth  int\n\theight int\n}\n\nfunc vGetCompactQRWriter() *compactQRWriter {\n\treturn &compactQRWriter{}\n}\n\nfunc (w *compactQRWriter) Write(mat qrcode.Matrix) error {\n\tw.width = mat.Width()\n\tw.height = mat.Height()\n\tw.matrix = make([][]bool, w.height)\n\tfor y := 0; y < w.height; y++ {\n\t\tw.matrix[y] = make([]bool, w.width)\n\t}\n\n\tmat.Iterate(qrcode.IterDirection_ROW, func(x int, y int, v qrcode.QRValue) {\n\t\tw.matrix[y][x] = v.IsSet()\n\t})\n\n\treturn w.render()\n}\n\nfunc (w *compactQRWriter) Close() error {\n\ttermbox.Close()\n\treturn nil\n}\n\nfunc (w *compactQRWriter) getPixel(x, y int) bool {\n\tif x < 0 || x >= w.width || y < 0 || y >= w.height {\n\t\treturn false // outside bounds = white (quiet zone)\n\t}\n\treturn w.matrix[y][x]\n}\n\nfunc (w *compactQRWriter) render() error {\n\tif err := termbox.Init(); err != nil {\n\t\treturn err\n\t}\n\ttermbox.SetOutputMode(termbox.Output256)\n\n\tpadding := 2 // quiet zone\n\t// Calculate display dimensions (half height due to half-blocks)\n\tdisplayHeight := (w.height+2*padding+1)/2 + padding\n\tdisplayWidth := w.width + 2*padding\n\n\t// Pre-fill with white background\n\tfor y := 0; y < displayHeight+2; y++ {\n\t\tfor x := 0; x < displayWidth; x++ {\n\t\t\ttermbox.SetCell(x, y, ' ', termbox.ColorWhite, termbox.ColorWhite)\n\t\t}\n\t}\n\n\t// Draw QR code using half-block characters\n\t// Each terminal row represents 2 QR rows\n\tfor ty := 0; ty < displayHeight; ty++ {\n\t\tfor tx := 0; tx < displayWidth; tx++ {\n\t\t\t// Map terminal coords back to QR coords (accounting for padding)\n\t\t\tqrX := tx - padding\n\t\t\tqrY1 := ty*2 - padding*2     // top pixel\n\t\t\tqrY2 := ty*2 + 1 - padding*2 // bottom pixel\n\n\t\t\ttop := w.getPixel(qrX, qrY1)\n\t\t\tbot := w.getPixel(qrX, qrY2)\n\n\t\t\tvar ch rune\n\t\t\tvar fg, bg termbox.Attribute\n\n\t\t\t// QR: IsSet()=true means black module\n\t\t\tif top && bot {\n\t\t\t\t// Both black: full block\n\t\t\t\tch = '█'\n\t\t\t\tfg = termbox.ColorBlack\n\t\t\t\tbg = termbox.ColorWhite\n\t\t\t} else if !top && !bot {\n\t\t\t\t// Both white: space\n\t\t\t\tch = ' '\n\t\t\t\tfg = termbox.ColorWhite\n\t\t\t\tbg = termbox.ColorWhite\n\t\t\t} else if top && !bot {\n\t\t\t\t// Top black, bottom white: upper half block\n\t\t\t\tch = '▀'\n\t\t\t\tfg = termbox.ColorBlack\n\t\t\t\tbg = termbox.ColorWhite\n\t\t\t} else {\n\t\t\t\t// Top white, bottom black: lower half block\n\t\t\t\tch = '▄'\n\t\t\t\tfg = termbox.ColorBlack\n\t\t\t\tbg = termbox.ColorWhite\n\t\t\t}\n\n\t\t\ttermbox.SetCell(tx, ty, ch, fg, bg)\n\t\t}\n\t}\n\n\t// Print tip\n\ttip := \"扫码后，手机点击确认，成功后按任意键继续...\"\n\ttipY := displayHeight + 1\n\tx := 0\n\tfor _, r := range tip {\n\t\trw := runewidth.RuneWidth(r)\n\t\tif rw == 0 {\n\t\t\trw = 1\n\t\t}\n\t\ttermbox.SetCell(x, tipY, r, termbox.ColorDefault, termbox.ColorDefault)\n\t\tx += rw\n\t}\n\n\tif err := termbox.Flush(); err != nil {\n\t\treturn err\n\t}\n\n\t// Wait for key press\n\tfor {\n\t\tswitch ev := termbox.PollEvent(); ev.Type {\n\t\tcase termbox.EventKey:\n\t\t\treturn nil\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/cli/login.go",
    "content": "package cli\n\nimport (\n\t\"github.com/guiyumin/vget/internal/cli/login\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar loginCmd = &cobra.Command{\n\tUse:   \"login\",\n\tShort: \"Login to media platforms\",\n\tLong:  \"Login to various media platforms to download member-only content\",\n}\n\nvar logoutCmd = &cobra.Command{\n\tUse:   \"logout\",\n\tShort: \"Logout from media platforms\",\n\tLong:  \"Clear saved credentials for media platforms\",\n}\n\nfunc init() {\n\tloginCmd.AddCommand(login.BilibiliCmd())\n\tlogoutCmd.AddCommand(login.BilibiliLogoutCmd())\n\trootCmd.AddCommand(loginCmd)\n\trootCmd.AddCommand(logoutCmd)\n}\n"
  },
  {
    "path": "internal/cli/ls.go",
    "content": "package cli\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sort\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/webdav\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar jsonFlag bool\n\nvar lsCmd = &cobra.Command{\n\tUse:   \"ls <remote>:<path>\",\n\tShort: \"List files in a remote directory\",\n\tLong: `List files in a remote WebDAV directory.\n\nExamples:\n  vget ls pikpak:/\n  vget ls pikpak:/Movies`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: runLs,\n}\n\nfunc init() {\n\tlsCmd.Flags().BoolVar(&jsonFlag, \"json\", false, \"output as JSON\")\n\trootCmd.AddCommand(lsCmd)\n}\n\n// FileEntry represents a file or directory for JSON output\ntype FileEntry struct {\n\tName  string `json:\"name\"`\n\tPath  string `json:\"path\"`\n\tIsDir bool   `json:\"is_dir\"`\n\tSize  int64  `json:\"size\"`\n}\n\nfunc runLs(cmd *cobra.Command, args []string) error {\n\tremotePath := args[0]\n\tctx := context.Background()\n\tcfg := config.LoadOrDefault()\n\n\t// Check if it's a WebDAV remote path\n\tif !webdav.IsRemotePath(remotePath) && !webdav.IsWebDAVURL(remotePath) {\n\t\treturn fmt.Errorf(\"invalid remote path: %s\\nUse format: <remote>:<path> (e.g., pikpak:/Movies)\", remotePath)\n\t}\n\n\tvar client *webdav.Client\n\tvar dirPath string\n\tvar err error\n\n\tif webdav.IsRemotePath(remotePath) {\n\t\t// Parse remote:path format\n\t\tserverName, path, err := webdav.ParseRemotePath(remotePath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdirPath = path\n\t\tif dirPath == \"\" {\n\t\t\tdirPath = \"/\"\n\t\t}\n\n\t\tserver := cfg.GetWebDAVServer(serverName)\n\t\tif server == nil {\n\t\t\treturn fmt.Errorf(\"WebDAV server '%s' not found. Add it with 'vget config webdav add %s'\", serverName, serverName)\n\t\t}\n\n\t\tclient, err = webdav.NewClientFromConfig(server)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t\t}\n\t} else {\n\t\t// Full WebDAV URL\n\t\tclient, err = webdav.NewClient(remotePath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t\t}\n\n\t\tdirPath, err = webdav.ParseURL(remotePath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid WebDAV URL: %w\", err)\n\t\t}\n\t\tif dirPath == \"\" {\n\t\t\tdirPath = \"/\"\n\t\t}\n\t}\n\n\t// Check if path is a directory\n\tinfo, err := client.Stat(ctx, dirPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to access path: %w\", err)\n\t}\n\n\tif !info.IsDir {\n\t\treturn fmt.Errorf(\"'%s' is not a directory\", remotePath)\n\t}\n\n\t// List directory contents\n\tfiles, err := client.List(ctx, dirPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list directory: %w\", err)\n\t}\n\n\t// Sort: directories first, then files, alphabetically\n\tsort.Slice(files, func(i, j int) bool {\n\t\tif files[i].IsDir != files[j].IsDir {\n\t\t\treturn files[i].IsDir // dirs first\n\t\t}\n\t\treturn files[i].Name < files[j].Name\n\t})\n\n\t// Build remote path prefix for full paths\n\tremotePrefix := remotePath\n\tif remotePrefix[len(remotePrefix)-1] != '/' {\n\t\tremotePrefix += \"/\"\n\t}\n\n\t// JSON output\n\tif jsonFlag {\n\t\tentries := make([]FileEntry, len(files))\n\t\tfor i, f := range files {\n\t\t\tentries[i] = FileEntry{\n\t\t\t\tName:  f.Name,\n\t\t\t\tPath:  remotePrefix + f.Name,\n\t\t\t\tIsDir: f.IsDir,\n\t\t\t\tSize:  f.Size,\n\t\t\t}\n\t\t}\n\t\toutput, err := json.MarshalIndent(entries, \"\", \"  \")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to marshal JSON: %w\", err)\n\t\t}\n\t\tfmt.Println(string(output))\n\t\treturn nil\n\t}\n\n\t// Human-readable output\n\tif len(files) == 0 {\n\t\tfmt.Println(\"(empty directory)\")\n\t\treturn nil\n\t}\n\n\t// Print header\n\tfmt.Printf(\"%s\\n\", remotePath)\n\n\t// Print files\n\tfor _, f := range files {\n\t\tif f.IsDir {\n\t\t\tfmt.Printf(\"  📁 %s/\\n\", f.Name)\n\t\t} else {\n\t\t\tfmt.Printf(\"  📄 %-40s %s\\n\", f.Name, formatSize(f.Size))\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cli/root.go",
    "content": "package cli\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/downloader\"\n\t\"github.com/guiyumin/vget/internal/core/extractor\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n\t\"github.com/guiyumin/vget/internal/core/version\"\n\t\"github.com/guiyumin/vget/internal/core/webdav\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\toutput    string\n\tquality   string\n\tinfo      bool\n\tinputFile string\n\tvisible   bool\n)\n\nvar rootCmd = &cobra.Command{\n\tUse:     \"vget [url]\",\n\tShort:   \"Versatile command-line toolkit for downloading audio, video, podcasts, and more\",\n\tVersion: version.Version,\n\tArgs:    cobra.MaximumNArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\t// Batch mode: read URLs from file\n\t\tif inputFile != \"\" {\n\t\t\tif err := runBatch(inputFile); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tif len(args) == 0 {\n\t\t\tcmd.Help()\n\t\t\treturn\n\t\t}\n\t\tif err := runDownload(args[0]); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t},\n}\n\nfunc init() {\n\trootCmd.Flags().StringVarP(&output, \"output\", \"o\", \"\", \"output filename\")\n\trootCmd.Flags().StringVarP(&quality, \"quality\", \"q\", \"\", \"preferred quality (e.g., 1080p, 720p)\")\n\trootCmd.Flags().BoolVar(&info, \"info\", false, \"show video info without downloading\")\n\trootCmd.Flags().StringVarP(&inputFile, \"file\", \"f\", \"\", \"read URLs from file (one per line)\")\n\trootCmd.Flags().BoolVar(&visible, \"visible\", false, \"show browser window (for debugging)\")\n}\n\nfunc Execute() error {\n\treturn rootCmd.Execute()\n}\n\nfunc runDownload(url string) error {\n\tcfg := config.LoadOrDefault()\n\tt := i18n.T(cfg.Language)\n\n\t// Check for config file and warn if missing\n\tif !config.Exists() {\n\t\tfmt.Fprintf(os.Stderr, \"\\033[33m%s. Run 'vget init'.\\033[0m\\n\", t.Errors.ConfigNotFound)\n\t}\n\n\t// Handle WebDAV URLs specially\n\tif webdav.IsWebDAVURL(url) {\n\t\treturn runWebDAVDownload(url, cfg.Language)\n\t}\n\n\t// Handle Telegram URLs specially (requires authenticated client context for download)\n\tif isTelegramURL(url) {\n\t\treturn runTelegramDownload(url, output)\n\t}\n\n\t// Find matching extractor\n\text := extractor.Match(url)\n\tif ext == nil {\n\t\t// Try sites.yml for configured sites first\n\t\tsitesConfig, _ := config.LoadSites()\n\t\tif sitesConfig != nil {\n\t\t\tif site := sitesConfig.MatchSite(url); site != nil {\n\t\t\t\text = extractor.NewBrowserExtractor(site, visible)\n\t\t\t}\n\t\t}\n\t\t// Fall back to generic m3u8 detection for unknown sites\n\t\tif ext == nil {\n\t\t\text = extractor.NewGenericBrowserExtractor(visible)\n\t\t}\n\t}\n\n\t// Configure Twitter extractor with auth if available\n\tif twitterExt, ok := ext.(*extractor.TwitterExtractor); ok {\n\t\tif cfg.Twitter.AuthToken != \"\" {\n\t\t\ttwitterExt.SetAuth(cfg.Twitter.AuthToken)\n\t\t}\n\t}\n\n\t// Check Bilibili login status and prompt for confirmation if not logged in\n\tif bilibiliExt, ok := ext.(*extractor.BilibiliExtractor); ok {\n\t\t_ = bilibiliExt // Mark as used\n\t\tif cfg.Bilibili.Cookie == \"\" {\n\t\t\tif !confirmBilibiliNoLogin() {\n\t\t\t\treturn nil // User cancelled\n\t\t\t}\n\t\t}\n\t}\n\n\t// Extract media info with spinner\n\tmedia, err := runExtractWithSpinner(ext, url, cfg.Language)\n\tif err != nil {\n\t\t// YouTube Docker requirement is already displayed in the TUI, don't show again\n\t\tvar ytErr *extractor.YouTubeDockerRequiredError\n\t\tif errors.As(err, &ytErr) {\n\t\t\treturn nil // Message already shown, exit cleanly\n\t\t}\n\n\t\t// Handle Twitter-specific errors with translated messages\n\t\tvar twitterErr *extractor.TwitterError\n\t\tif errors.As(err, &twitterErr) {\n\t\t\tvar msg string\n\t\t\tswitch twitterErr.Code {\n\t\t\tcase extractor.TwitterErrorNSFW:\n\t\t\t\tmsg = t.Twitter.NsfwLoginRequired\n\t\t\tcase extractor.TwitterErrorProtected:\n\t\t\t\tmsg = t.Twitter.ProtectedTweet\n\t\t\tcase extractor.TwitterErrorUnavailable:\n\t\t\t\tmsg = t.Twitter.TweetUnavailable\n\t\t\tdefault:\n\t\t\t\tmsg = twitterErr.Message\n\t\t\t}\n\t\t\t// Show auth hint if not authenticated\n\t\t\tif cfg.Twitter.AuthToken == \"\" {\n\t\t\t\treturn fmt.Errorf(\"%s\\n%s\", msg, t.Twitter.AuthHint)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"%s\", msg)\n\t\t}\n\t\treturn err\n\t}\n\n\tdl := downloader.New(cfg.Language)\n\n\t// Handle based on media type\n\tswitch m := media.(type) {\n\tcase *extractor.YouTubeDirectDownload:\n\t\t// YouTube: let yt-dlp handle the entire download (Docker only)\n\t\tfmt.Printf(\"\\n  %s Downloading with yt-dlp...\\n\\n\", \"⬇\")\n\t\tif err := extractor.DownloadWithYtdlp(m.URL, cfg.OutputDir); err != nil {\n\t\t\treturn fmt.Errorf(\"yt-dlp download failed: %w\", err)\n\t\t}\n\t\tfmt.Printf(\"\\n  %s %s\\n\\n\", \"✓\", t.Download.Completed)\n\t\treturn nil\n\tcase *extractor.VideoMedia:\n\t\treturn downloadVideo(m, dl, t, cfg.Language, cfg.OutputDir)\n\tcase *extractor.AudioMedia:\n\t\treturn downloadAudio(m, dl, cfg.OutputDir)\n\tcase *extractor.ImageMedia:\n\t\treturn downloadImages(m, dl, cfg.OutputDir)\n\tcase *extractor.MultiVideoMedia:\n\t\treturn downloadMultiVideo(m, dl, t, cfg.Language, cfg.OutputDir)\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported media type\")\n\t}\n}\n\nfunc runWebDAVDownload(rawURL, lang string) error {\n\tctx := context.Background()\n\tcfg := config.LoadOrDefault()\n\n\tvar client *webdav.Client\n\tvar filePath string\n\tvar serverName string\n\tvar err error\n\n\t// Check if it's a remote path (e.g., \"pikpak:/path/to/file\")\n\tif webdav.IsRemotePath(rawURL) {\n\t\tserverName, filePath, err = webdav.ParseRemotePath(rawURL)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tserver := cfg.GetWebDAVServer(serverName)\n\t\tif server == nil {\n\t\t\treturn fmt.Errorf(\"WebDAV server '%s' not found. Add it with 'vget config webdav add %s'\", serverName, serverName)\n\t\t}\n\n\t\tclient, err = webdav.NewClientFromConfig(server)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t\t}\n\t} else {\n\t\t// Create WebDAV client from URL\n\t\tclient, err = webdav.NewClient(rawURL)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t\t}\n\n\t\t// Parse the file path from URL\n\t\tfilePath, err = webdav.ParseURL(rawURL)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid WebDAV URL: %w\", err)\n\t\t}\n\t\tserverName = \"webdav\" // Default name for direct URLs\n\t}\n\n\t// Get file info\n\tfileInfo, err := client.Stat(ctx, filePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get file info: %w\", err)\n\t}\n\n\t// If it's a directory, open the TUI browser\n\tif fileInfo.IsDir {\n\t\tresult, err := RunBrowseTUI(client, serverName, filePath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"browse failed: %w\", err)\n\t\t}\n\t\tif result.Cancelled {\n\t\t\treturn nil // User cancelled, no error\n\t\t}\n\t\t// User selected a file, update filePath and continue with download\n\t\tfilePath = result.SelectedFile\n\t\t// Re-fetch file info for the selected file\n\t\tfileInfo, err = client.Stat(ctx, filePath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get file info: %w\", err)\n\t\t}\n\t}\n\n\t// Determine output filename\n\toutputFile := output\n\tif outputFile == \"\" {\n\t\toutputFile = webdav.ExtractFilename(filePath)\n\t\t// Prepend outputDir if configured\n\t\tif cfg.OutputDir != \"\" {\n\t\t\toutputFile = filepath.Join(cfg.OutputDir, outputFile)\n\t\t}\n\t}\n\n\tfmt.Printf(\"  WebDAV: %s (%s)\\n\", fileInfo.Name, formatSize(fileInfo.Size))\n\n\t// Use multi-stream download for better performance\n\tfileURL := client.GetFileURL(filePath)\n\tauthHeader := client.GetAuthHeader()\n\tmsConfig := downloader.DefaultMultiStreamConfig()\n\n\treturn downloader.RunMultiStreamDownloadWithAuthTUI(\n\t\tfileURL,\n\t\tauthHeader,\n\t\toutputFile,\n\t\tfileInfo.Name,\n\t\tlang,\n\t\tfileInfo.Size,\n\t\tmsConfig,\n\t)\n}\n\nfunc formatSize(b int64) string {\n\tconst unit = 1024\n\tif b < unit {\n\t\treturn fmt.Sprintf(\"%d B\", b)\n\t}\n\tdiv, exp := int64(unit), 0\n\tfor n := b / unit; n >= unit; n /= unit {\n\t\tdiv *= unit\n\t\texp++\n\t}\n\treturn fmt.Sprintf(\"%.1f %cB\", float64(b)/float64(div), \"KMGTPE\"[exp])\n}\n\nfunc downloadMultiVideo(m *extractor.MultiVideoMedia, dl *downloader.Downloader, t *i18n.Translations, lang string, outputDir string) error {\n\t// Info only mode\n\tif info {\n\t\tfmt.Printf(\"  Videos (%d):\\n\", len(m.Videos))\n\t\tfor i, video := range m.Videos {\n\t\t\tfmt.Printf(\"    [%d] %s\\n\", i+1, video.Title)\n\t\t\tfor j, f := range video.Formats {\n\t\t\t\taudioInfo := \"\"\n\t\t\t\tif f.AudioURL != \"\" {\n\t\t\t\t\taudioInfo = \" [+audio]\"\n\t\t\t\t}\n\t\t\t\tfmt.Printf(\"        [%d.%d] %s %dx%d (%s)%s\\n\", i+1, j, f.Quality, f.Width, f.Height, f.Ext, audioInfo)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tfmt.Printf(\"  Downloading %d video(s)...\\n\", len(m.Videos))\n\n\tfor i, video := range m.Videos {\n\t\tfmt.Printf(\"\\n  [%d/%d] %s\\n\", i+1, len(m.Videos), video.Title)\n\t\t// Pass index for multi-video to avoid filename collisions\n\t\tif err := downloadVideoWithIndex(video, dl, t, lang, outputDir, i+1, len(m.Videos)); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to download video %d: %w\", i+1, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc downloadVideo(m *extractor.VideoMedia, dl *downloader.Downloader, t *i18n.Translations, lang string, outputDir string) error {\n\t// Info only mode\n\tif info {\n\t\tfor i, f := range m.Formats {\n\t\t\taudioInfo := \"\"\n\t\t\tif f.AudioURL != \"\" {\n\t\t\t\taudioInfo = \" [+audio]\"\n\t\t\t}\n\t\t\tfmt.Printf(\"  [%d] %s %dx%d (%s)%s\\n\", i, f.Quality, f.Width, f.Height, f.Ext, audioInfo)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// Select best format (or by quality flag)\n\tformat := selectVideoFormat(m.Formats, quality)\n\tif format == nil {\n\t\treturn fmt.Errorf(\"%s\", t.Download.NoFormats)\n\t}\n\n\tfmt.Printf(\"  %s: %s (%s)\\n\", t.Download.SelectedFormat, format.Quality, format.Ext)\n\n\t// Determine output filename\n\toutputFile := output\n\tif outputFile == \"\" {\n\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\t// For m3u8, output as .ts (MPEG-TS container)\n\t\text := format.Ext\n\t\tif ext == \"m3u8\" {\n\t\t\text = \"ts\"\n\t\t}\n\t\tif title != \"\" {\n\t\t\toutputFile = fmt.Sprintf(\"%s.%s\", title, ext)\n\t\t} else {\n\t\t\toutputFile = fmt.Sprintf(\"%s.%s\", m.ID, ext)\n\t\t}\n\t\t// Prepend outputDir if configured\n\t\tif outputDir != \"\" {\n\t\t\toutputFile = filepath.Join(outputDir, outputFile)\n\t\t}\n\t}\n\n\t// Use HLS downloader for m3u8 streams\n\tif format.Ext == \"m3u8\" {\n\t\t// Create directory with title to keep things organized\n\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\tif title == \"\" {\n\t\t\ttitle = m.ID\n\t\t}\n\t\t// Use outputDir as base if configured\n\t\tbaseDir := title\n\t\tif outputDir != \"\" {\n\t\t\tbaseDir = filepath.Join(outputDir, title)\n\t\t}\n\t\tif err := os.MkdirAll(baseDir, 0755); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create directory: %w\", err)\n\t\t}\n\t\t// Put output file inside the directory\n\t\toutputFile = filepath.Join(baseDir, filepath.Base(outputFile))\n\t\tfmt.Printf(\"  Output directory: %s/\\n\", baseDir)\n\t\treturn downloader.RunHLSDownloadWithHeadersTUI(format.URL, outputFile, m.ID, lang, format.Headers)\n\t}\n\n\t// Handle video+audio as separate downloads\n\tif format.AudioURL != \"\" {\n\t\treturn downloadVideoAndAudio(format, outputFile, m.ID, dl)\n\t}\n\n\t// Use headers if provided by the extractor\n\tif len(format.Headers) > 0 {\n\t\treturn dl.DownloadWithHeaders(format.URL, outputFile, m.ID, format.Headers)\n\t}\n\treturn dl.Download(format.URL, outputFile, m.ID)\n}\n\n// downloadVideoWithIndex downloads a video with an index suffix in the filename (for multi-video posts)\nfunc downloadVideoWithIndex(m *extractor.VideoMedia, dl *downloader.Downloader, t *i18n.Translations, lang string, outputDir string, index, total int) error {\n\t// Info only mode\n\tif info {\n\t\tfor i, f := range m.Formats {\n\t\t\taudioInfo := \"\"\n\t\t\tif f.AudioURL != \"\" {\n\t\t\t\taudioInfo = \" [+audio]\"\n\t\t\t}\n\t\t\tfmt.Printf(\"  [%d] %s %dx%d (%s)%s\\n\", i, f.Quality, f.Width, f.Height, f.Ext, audioInfo)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// Select best format (or by quality flag)\n\tformat := selectVideoFormat(m.Formats, quality)\n\tif format == nil {\n\t\treturn fmt.Errorf(\"%s\", t.Download.NoFormats)\n\t}\n\n\tfmt.Printf(\"  %s: %s (%s)\\n\", t.Download.SelectedFormat, format.Quality, format.Ext)\n\n\t// Determine output filename\n\toutputFile := output\n\tif outputFile == \"\" {\n\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\text := format.Ext\n\t\tif ext == \"m3u8\" {\n\t\t\text = \"ts\"\n\t\t}\n\t\tbaseName := title\n\t\tif baseName == \"\" {\n\t\t\tbaseName = m.ID\n\t\t}\n\t\t// Add index suffix for multi-video\n\t\tif total > 1 {\n\t\t\toutputFile = fmt.Sprintf(\"%s_%d.%s\", baseName, index, ext)\n\t\t} else {\n\t\t\toutputFile = fmt.Sprintf(\"%s.%s\", baseName, ext)\n\t\t}\n\t\t// Prepend outputDir if configured\n\t\tif outputDir != \"\" {\n\t\t\toutputFile = filepath.Join(outputDir, outputFile)\n\t\t}\n\t}\n\n\t// Use HLS downloader for m3u8 streams\n\tif format.Ext == \"m3u8\" {\n\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\tif title == \"\" {\n\t\t\ttitle = m.ID\n\t\t}\n\t\tbaseDir := title\n\t\tif outputDir != \"\" {\n\t\t\tbaseDir = filepath.Join(outputDir, title)\n\t\t}\n\t\tif err := os.MkdirAll(baseDir, 0755); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create directory: %w\", err)\n\t\t}\n\t\toutputFile = filepath.Join(baseDir, filepath.Base(outputFile))\n\t\tfmt.Printf(\"  Output directory: %s/\\n\", baseDir)\n\t\treturn downloader.RunHLSDownloadWithHeadersTUI(format.URL, outputFile, m.ID, lang, format.Headers)\n\t}\n\n\t// Handle video+audio as separate downloads\n\tif format.AudioURL != \"\" {\n\t\treturn downloadVideoAndAudio(format, outputFile, m.ID, dl)\n\t}\n\n\t// Use headers if provided by the extractor\n\tif len(format.Headers) > 0 {\n\t\treturn dl.DownloadWithHeaders(format.URL, outputFile, m.ID, format.Headers)\n\t}\n\treturn dl.Download(format.URL, outputFile, m.ID)\n}\n\n// downloadVideoAndAudio downloads video and audio as separate files, then merges them if ffmpeg is available\nfunc downloadVideoAndAudio(format *extractor.VideoFormat, outputFile, videoID string, dl *downloader.Downloader) error {\n\t// Determine audio extension based on video format\n\taudioExt := \"m4a\"\n\tif format.Ext == \"webm\" {\n\t\taudioExt = \"opus\"\n\t}\n\n\t// Build temp filenames for video and audio\n\text := filepath.Ext(outputFile)\n\tbaseName := strings.TrimSuffix(outputFile, ext)\n\tvideoFile := baseName + \"_video\" + ext\n\taudioFile := baseName + \"_audio.\" + audioExt\n\n\t// Download video with headers if provided\n\tfmt.Println(\"  Downloading video stream...\")\n\tvar err error\n\tif len(format.Headers) > 0 {\n\t\terr = dl.DownloadWithHeaders(format.URL, videoFile, videoID+\"-video\", format.Headers)\n\t} else {\n\t\terr = dl.Download(format.URL, videoFile, videoID+\"-video\")\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to download video: %w\", err)\n\t}\n\n\t// Download audio with headers if provided\n\tfmt.Println(\"  Downloading audio stream...\")\n\tif len(format.Headers) > 0 {\n\t\terr = dl.DownloadWithHeaders(format.AudioURL, audioFile, videoID+\"-audio\", format.Headers)\n\t} else {\n\t\terr = dl.Download(format.AudioURL, audioFile, videoID+\"-audio\")\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to download audio: %w\", err)\n\t}\n\n\t// Try to merge with ffmpeg if available\n\tif downloader.FFmpegAvailable() {\n\t\tfmt.Println(\"  Merging video and audio...\")\n\t\t// Merge to final output path and delete temp files on success\n\t\terr := downloader.MergeVideoAudio(videoFile, audioFile, outputFile, true)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"\\n  Warning: ffmpeg merge failed: %v\\n\", err)\n\t\t\tfmt.Printf(\"\\n  Downloaded separately:\\n\")\n\t\t\tfmt.Printf(\"    Video: %s\\n\", videoFile)\n\t\t\tfmt.Printf(\"    Audio: %s\\n\", audioFile)\n\t\t\tfmt.Printf(\"\\n  To merge manually:\\n\")\n\t\t\tfmt.Printf(\"    ffmpeg -i \\\"%s\\\" -i \\\"%s\\\" -c copy \\\"%s\\\"\\n\", videoFile, audioFile, outputFile)\n\t\t} else {\n\t\t\tfmt.Printf(\"\\n  Downloaded: %s\\n\", outputFile)\n\t\t}\n\t} else {\n\t\t// No ffmpeg, show manual command\n\t\tfmt.Printf(\"\\n  Downloaded:\\n\")\n\t\tfmt.Printf(\"    Video: %s\\n\", videoFile)\n\t\tfmt.Printf(\"    Audio: %s\\n\", audioFile)\n\t\tfmt.Printf(\"\\n  To merge with ffmpeg:\\n\")\n\t\tfmt.Printf(\"    ffmpeg -i \\\"%s\\\" -i \\\"%s\\\" -c copy \\\"%s\\\"\\n\", videoFile, audioFile, outputFile)\n\t}\n\n\treturn nil\n}\n\nfunc downloadAudio(m *extractor.AudioMedia, dl *downloader.Downloader, outputDir string) error {\n\t// Info only mode\n\tif info {\n\t\tfmt.Printf(\"  Audio: %s (%s)\\n\", m.Title, m.Ext)\n\t\treturn nil\n\t}\n\n\t// Determine output filename\n\toutputFile := output\n\tif outputFile == \"\" {\n\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\tif title != \"\" {\n\t\t\toutputFile = fmt.Sprintf(\"%s.%s\", title, m.Ext)\n\t\t} else {\n\t\t\toutputFile = fmt.Sprintf(\"%s.%s\", m.ID, m.Ext)\n\t\t}\n\t\t// Prepend outputDir if configured\n\t\tif outputDir != \"\" {\n\t\t\toutputFile = filepath.Join(outputDir, outputFile)\n\t\t}\n\t}\n\n\treturn dl.Download(m.URL, outputFile, m.ID)\n}\n\nfunc downloadImages(m *extractor.ImageMedia, dl *downloader.Downloader, outputDir string) error {\n\t// Info only mode\n\tif info {\n\t\tfmt.Printf(\"  Images (%d):\\n\", len(m.Images))\n\t\tfor i, img := range m.Images {\n\t\t\tfmt.Printf(\"    [%d] %dx%d (%s)\\n\", i+1, img.Width, img.Height, img.Ext)\n\t\t}\n\t\treturn nil\n\t}\n\n\tfmt.Printf(\"  Downloading %d image(s)...\\n\", len(m.Images))\n\n\tfor i, img := range m.Images {\n\t\tvar outputFile string\n\t\tif output != \"\" {\n\t\t\t// If custom output specified, add suffix for multiple images\n\t\t\tif len(m.Images) > 1 {\n\t\t\t\toutputFile = fmt.Sprintf(\"%s_%d.%s\", output, i+1, img.Ext)\n\t\t\t} else {\n\t\t\t\toutputFile = fmt.Sprintf(\"%s.%s\", output, img.Ext)\n\t\t\t}\n\t\t} else {\n\t\t\t// Use sanitized title or ID with index suffix\n\t\t\tbaseFilename := m.ID\n\t\t\tif title := extractor.SanitizeFilename(m.Title); title != \"\" {\n\t\t\t\tbaseFilename = title\n\t\t\t}\n\t\t\tif len(m.Images) > 1 {\n\t\t\t\toutputFile = fmt.Sprintf(\"%s_%d.%s\", baseFilename, i+1, img.Ext)\n\t\t\t} else {\n\t\t\t\toutputFile = fmt.Sprintf(\"%s.%s\", baseFilename, img.Ext)\n\t\t\t}\n\t\t\t// Prepend outputDir if configured\n\t\t\tif outputDir != \"\" {\n\t\t\t\toutputFile = filepath.Join(outputDir, outputFile)\n\t\t\t}\n\t\t}\n\n\t\tif err := dl.Download(img.URL, outputFile, m.ID); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to download image %d: %w\", i+1, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc selectVideoFormat(formats []extractor.VideoFormat, preferred string) *extractor.VideoFormat {\n\tif len(formats) == 0 {\n\t\treturn nil\n\t}\n\n\t// If quality specified, try to match\n\tif preferred != \"\" {\n\t\tfor i := range formats {\n\t\t\tif formats[i].Quality == preferred {\n\t\t\t\treturn &formats[i]\n\t\t\t}\n\t\t}\n\t\t// Also try partial match (e.g., \"1080\" matches \"1080p60\")\n\t\tfor i := range formats {\n\t\t\tif strings.Contains(formats[i].Quality, preferred) {\n\t\t\t\treturn &formats[i]\n\t\t\t}\n\t\t}\n\t}\n\n\t// Prefer highest quality adaptive format with audio (will download both files)\n\tvar bestWithAudio *extractor.VideoFormat\n\tfor i := range formats {\n\t\tf := &formats[i]\n\t\tif f.AudioURL != \"\" {\n\t\t\tif bestWithAudio == nil || f.Bitrate > bestWithAudio.Bitrate {\n\t\t\t\tbestWithAudio = f\n\t\t\t}\n\t\t}\n\t}\n\tif bestWithAudio != nil {\n\t\treturn bestWithAudio\n\t}\n\n\t// Then prefer combined formats (has audio, no separate download)\n\tfor i := range formats {\n\t\tif formats[i].AudioURL == \"\" && formats[i].Bitrate > 0 && formats[i].Ext != \"m3u8\" {\n\t\t\treturn &formats[i]\n\t\t}\n\t}\n\n\t// Fall back to HLS if nothing else\n\tfor i := range formats {\n\t\tif formats[i].Ext == \"m3u8\" {\n\t\t\treturn &formats[i]\n\t\t}\n\t}\n\n\t// Fall back to highest bitrate (may need ffmpeg merge)\n\tbest := &formats[0]\n\tfor i := range formats {\n\t\tif formats[i].Bitrate > best.Bitrate {\n\t\t\tbest = &formats[i]\n\t\t}\n\t}\n\treturn best\n}\n\n// isTelegramURL checks if the URL is a Telegram message URL\nfunc isTelegramURL(urlStr string) bool {\n\treturn strings.Contains(urlStr, \"t.me/\") || strings.Contains(urlStr, \"telegram.me/\")\n}\n\n// confirmBilibiliNoLogin prompts user to confirm download without login\nfunc confirmBilibiliNoLogin() bool {\n\tfmt.Println()\n\tfmt.Println(\"  \\033[33m未登录 Bilibili，只能下载 360P/480P 低清视频\\033[0m\")\n\tfmt.Println(\"  \\033[36m提示: 运行 'vget login bilibili' 登录后可下载更高清晰度\\033[0m\")\n\tfmt.Println()\n\tfmt.Print(\"  是否继续下载? [y/N]: \")\n\n\tvar response string\n\tfmt.Scanln(&response)\n\n\tresponse = strings.TrimSpace(strings.ToLower(response))\n\t// Default to no if empty, only continue on explicit yes\n\treturn response == \"y\" || response == \"yes\" || response == \"是\"\n}\n\n// runTelegramDownload handles a single Telegram media download with TUI progress\nfunc runTelegramDownload(urlStr, outputPath string) error {\n\tfmt.Println(\"  Connecting to Telegram...\")\n\n\tcfg := config.LoadOrDefault()\n\tlang := cfg.Language\n\n\tdownloadFn := func(url, output string, progressFn func(int64, int64)) (*downloader.TelegramDownloadResult, error) {\n\t\tresult, err := extractor.TelegramDownload(url, output, progressFn)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn &downloader.TelegramDownloadResult{\n\t\t\tTitle:    result.Title,\n\t\t\tFilename: result.Filename,\n\t\t\tSize:     result.Size,\n\t\t}, nil\n\t}\n\n\treturn downloader.RunTelegramDownloadTUI(urlStr, outputPath, lang, downloadFn)\n}\n\n// runTelegramBatchDownload handles multiple Telegram URLs with takeout mode for lower rate limits\nfunc runTelegramBatchDownload(urls []string) (succeeded, failed int, failedURLs []string) {\n\tif len(urls) == 0 {\n\t\treturn 0, 0, nil\n\t}\n\n\tfmt.Printf(\"  Using takeout mode for %d Telegram URLs\\n\\n\", len(urls))\n\n\tcfg := config.LoadOrDefault()\n\tlang := cfg.Language\n\n\tfor i, urlStr := range urls {\n\t\tfmt.Printf(\"  [%d/%d] %s\\n\", i+1, len(urls), urlStr)\n\n\t\tdownloadFn := func(url, output string, progressFn func(int64, int64)) (*downloader.TelegramDownloadResult, error) {\n\t\t\tresult, err := extractor.TelegramDownloadWithOptions(extractor.TelegramDownloadOptions{\n\t\t\t\tURL:        url,\n\t\t\t\tOutputPath: output,\n\t\t\t\tTakeout:    true,\n\t\t\t\tProgressFn: progressFn,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn &downloader.TelegramDownloadResult{\n\t\t\t\tTitle:    result.Title,\n\t\t\t\tFilename: result.Filename,\n\t\t\t\tSize:     result.Size,\n\t\t\t}, nil\n\t\t}\n\n\t\tif err := downloader.RunTelegramDownloadTUI(urlStr, \"\", lang, downloadFn); err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"  Error: %v\\n\", err)\n\t\t\tfailed++\n\t\t\tfailedURLs = append(failedURLs, urlStr)\n\t\t} else {\n\t\t\tsucceeded++\n\t\t}\n\t\tfmt.Println()\n\t}\n\n\treturn succeeded, failed, failedURLs\n}\n"
  },
  {
    "path": "internal/cli/search.go",
    "content": "package cli\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/downloader\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tpodcastFlag bool\n)\n\nvar searchCmd = &cobra.Command{\n\tUse:   \"search [query]\",\n\tShort: \"Search for podcasts and episodes\",\n\tArgs:  cobra.ExactArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tif !podcastFlag {\n\t\t\tfmt.Fprintln(os.Stderr, \"Please specify a search type: --podcast\")\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tquery := args[0]\n\n\t\t// Auto-detect: if query contains Chinese characters, use Xiaoyuzhou\n\t\t// Otherwise use iTunes\n\t\tif containsChinese(query) {\n\t\t\tif err := searchXiaoyuzhou(query); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t} else {\n\t\t\tif err := searchITunes(query); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t}\n\t},\n}\n\nfunc init() {\n\tsearchCmd.Flags().BoolVar(&podcastFlag, \"podcast\", false, \"search for podcasts\")\n\trootCmd.AddCommand(searchCmd)\n}\n\n// containsChinese checks if string contains Chinese characters\nfunc containsChinese(s string) bool {\n\tfor _, r := range s {\n\t\tif unicode.Is(unicode.Han, r) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// XiaoyuzhouSearchResponse represents the API response\ntype XiaoyuzhouSearchResponse struct {\n\tData struct {\n\t\tEpisodes []XiaoyuzhouEpisode `json:\"episodes\"`\n\t\tPodcasts []XiaoyuzhouPodcast `json:\"podcasts\"`\n\t} `json:\"data\"`\n}\n\ntype XiaoyuzhouPodcast struct {\n\tType              string `json:\"type\"`\n\tPid               string `json:\"pid\"`\n\tTitle             string `json:\"title\"`\n\tAuthor            string `json:\"author\"`\n\tBrief             string `json:\"brief\"`\n\tSubscriptionCount int    `json:\"subscriptionCount\"`\n\tEpisodeCount      int    `json:\"episodeCount\"`\n}\n\ntype XiaoyuzhouEpisode struct {\n\tType      string `json:\"type\"`\n\tEid       string `json:\"eid\"`\n\tPid       string `json:\"pid\"`\n\tTitle     string `json:\"title\"`\n\tDuration  int    `json:\"duration\"`\n\tPlayCount int    `json:\"playCount\"`\n\tPubDate   string `json:\"pubDate\"`\n\tEnclosure struct {\n\t\tURL string `json:\"url\"`\n\t} `json:\"enclosure\"`\n\tPodcast struct {\n\t\tTitle string `json:\"title\"`\n\t} `json:\"podcast\"`\n}\n\nfunc searchXiaoyuzhou(query string) error {\n\tcfg := config.LoadOrDefault()\n\tt := i18n.T(cfg.Language)\n\n\t// Show spinner while searching\n\tdone := make(chan bool)\n\tvar result XiaoyuzhouSearchResponse\n\tvar searchErr error\n\n\tgo func() {\n\t\t// Call Xiaoyuzhou search API\n\t\tapiURL := \"https://ask.xiaoyuzhoufm.com/api/keyword/search\"\n\t\tpayload := fmt.Sprintf(`{\"query\": \"%s\"}`, query)\n\n\t\treq, err := http.NewRequest(\"POST\", apiURL, strings.NewReader(payload))\n\t\tif err != nil {\n\t\t\tsearchErr = err\n\t\t\tdone <- true\n\t\t\treturn\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tclient := &http.Client{}\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tsearchErr = err\n\t\t\tdone <- true\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\t\tsearchErr = err\n\t\t}\n\t\tdone <- true\n\t}()\n\n\t// Run spinner\n\tif err := runSearchSpinner(query, cfg.Language, done); err != nil {\n\t\treturn err\n\t}\n\n\tif searchErr != nil {\n\t\treturn searchErr\n\t}\n\n\tif len(result.Data.Podcasts) == 0 && len(result.Data.Episodes) == 0 {\n\t\tfmt.Println(\"No results found.\")\n\t\treturn nil\n\t}\n\n\t// Build sections for TUI\n\t// All items are always selectable:\n\t// - Podcasts: select to browse episodes\n\t// - Episodes: select to download\n\n\tvar sections []SearchSection\n\n\t// Podcasts section\n\tif len(result.Data.Podcasts) > 0 {\n\t\tvar items []SearchItem\n\t\tfor _, p := range result.Data.Podcasts {\n\t\t\tsubtitle := fmt.Sprintf(\"%s | %d ep\", p.Author, p.EpisodeCount)\n\t\t\titems = append(items, SearchItem{\n\t\t\t\tTitle:      p.Title,\n\t\t\t\tSubtitle:   subtitle,\n\t\t\t\tSelectable: true,\n\t\t\t\tType:       ItemTypePodcast,\n\t\t\t\tPodcastID:  p.Pid,\n\t\t\t})\n\t\t}\n\t\tsections = append(sections, SearchSection{\n\t\t\tTitle: t.Search.Podcasts,\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\t// Episodes section\n\tif len(result.Data.Episodes) > 0 {\n\t\tvar items []SearchItem\n\t\tfor _, e := range result.Data.Episodes {\n\t\t\tduration := formatEpisodeDuration(e.Duration)\n\t\t\titems = append(items, SearchItem{\n\t\t\t\tTitle:       fmt.Sprintf(\"%s - %s\", e.Podcast.Title, e.Title),\n\t\t\t\tSubtitle:    duration,\n\t\t\t\tURL:         fmt.Sprintf(\"https://www.xiaoyuzhoufm.com/episode/%s\", e.Eid),\n\t\t\t\tDownloadURL: e.Enclosure.URL,\n\t\t\t\tSelectable:  true,\n\t\t\t\tType:        ItemTypeEpisode,\n\t\t\t})\n\t\t}\n\t\tsections = append(sections, SearchSection{\n\t\t\tTitle: t.Search.Episodes,\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\t// Run TUI loop (allows going back from episode view)\n\tfor {\n\t\tselected, err := RunSearchTUI(sections, query, cfg.Language)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(selected) == 0 {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Handle selection based on type\n\t\terr = handleSelectedItems(selected, \"xiaoyuzhou\", cfg.Language)\n\t\tif err == errGoBack {\n\t\t\tcontinue // Go back to podcast list\n\t\t}\n\t\treturn err\n\t}\n}\n\nfunc formatEpisodeDuration(seconds int) string {\n\tif seconds <= 0 {\n\t\treturn \"?\"\n\t}\n\th := seconds / 3600\n\tm := (seconds % 3600) / 60\n\ts := seconds % 60\n\tif h > 0 {\n\t\treturn fmt.Sprintf(\"%d:%02d:%02d\", h, m, s)\n\t}\n\treturn fmt.Sprintf(\"%d:%02d\", m, s)\n}\n\n// iTunes API response structures\ntype iTunesSearchResponse struct {\n\tResultCount int             `json:\"resultCount\"`\n\tResults     []iTunesResult  `json:\"results\"`\n}\n\ntype iTunesResult struct {\n\tWrapperType          string `json:\"wrapperType\"`\n\tKind                 string `json:\"kind\"`\n\tCollectionID         int    `json:\"collectionId\"`\n\tTrackID              int    `json:\"trackId\"`\n\tArtistName           string `json:\"artistName\"`\n\tCollectionName       string `json:\"collectionName\"`\n\tTrackName            string `json:\"trackName\"`\n\tFeedURL              string `json:\"feedUrl\"`\n\tTrackCount           int    `json:\"trackCount\"`\n\tPrimaryGenreName     string `json:\"primaryGenreName\"`\n\tReleaseDate          string `json:\"releaseDate\"`\n\tTrackTimeMillis      int    `json:\"trackTimeMillis\"`\n\tEpisodeURL           string `json:\"episodeUrl\"`\n\tEpisodeFileExtension string `json:\"episodeFileExtension\"`\n\tShortDescription     string `json:\"shortDescription\"`\n}\n\nfunc searchITunes(query string) error {\n\tcfg := config.LoadOrDefault()\n\tt := i18n.T(cfg.Language)\n\n\t// Show spinner while searching\n\tdone := make(chan bool)\n\tvar podcastResult, episodeResult iTunesSearchResponse\n\tvar searchErr error\n\n\tgo func() {\n\t\t// Search for both podcasts and episodes in parallel\n\t\tvar wg sync.WaitGroup\n\t\tvar podcastErr, episodeErr error\n\n\t\twg.Add(2)\n\n\t\t// Fetch podcasts\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tpodcastURL := fmt.Sprintf(\"https://itunes.apple.com/search?term=%s&media=podcast&entity=podcast&limit=50\",\n\t\t\t\turl.QueryEscape(query))\n\t\t\tresp, err := http.Get(podcastURL)\n\t\t\tif err != nil {\n\t\t\t\tpodcastErr = err\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\t\t\tif err := json.NewDecoder(resp.Body).Decode(&podcastResult); err != nil {\n\t\t\t\tpodcastErr = err\n\t\t\t}\n\t\t}()\n\n\t\t// Fetch episodes\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tepisodeURL := fmt.Sprintf(\"https://itunes.apple.com/search?term=%s&media=podcast&entity=podcastEpisode&limit=200\",\n\t\t\t\turl.QueryEscape(query))\n\t\t\tresp, err := http.Get(episodeURL)\n\t\t\tif err != nil {\n\t\t\t\tepisodeErr = err\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\t\t\tif err := json.NewDecoder(resp.Body).Decode(&episodeResult); err != nil {\n\t\t\t\tepisodeErr = err\n\t\t\t}\n\t\t}()\n\n\t\twg.Wait()\n\n\t\t// Report first error encountered\n\t\tif podcastErr != nil {\n\t\t\tsearchErr = podcastErr\n\t\t} else if episodeErr != nil {\n\t\t\tsearchErr = episodeErr\n\t\t}\n\t\tdone <- true\n\t}()\n\n\t// Run spinner\n\tif err := runSearchSpinner(query, cfg.Language, done); err != nil {\n\t\treturn err\n\t}\n\n\tif searchErr != nil {\n\t\treturn searchErr\n\t}\n\n\tif podcastResult.ResultCount == 0 && episodeResult.ResultCount == 0 {\n\t\tfmt.Println(\"No results found.\")\n\t\treturn nil\n\t}\n\n\t// Build sections for TUI - like Xiaoyuzhou, show both podcasts and episodes\n\tvar sections []SearchSection\n\n\t// Podcasts section\n\tif podcastResult.ResultCount > 0 {\n\t\tvar items []SearchItem\n\t\tfor _, p := range podcastResult.Results {\n\t\t\titems = append(items, SearchItem{\n\t\t\t\tTitle:      p.CollectionName,\n\t\t\t\tSubtitle:   fmt.Sprintf(\"%s | %d ep\", p.ArtistName, p.TrackCount),\n\t\t\t\tSelectable: true,\n\t\t\t\tType:       ItemTypePodcast,\n\t\t\t\tPodcastID:  fmt.Sprintf(\"%d\", p.CollectionID),\n\t\t\t\tFeedURL:    p.FeedURL,\n\t\t\t})\n\t\t}\n\t\tsections = append(sections, SearchSection{\n\t\t\tTitle: fmt.Sprintf(\"%s (%d)\", t.Search.Podcasts, podcastResult.ResultCount),\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\t// Episodes section\n\tif episodeResult.ResultCount > 0 {\n\t\tvar items []SearchItem\n\t\tfor _, p := range episodeResult.Results {\n\t\t\tduration := formatEpisodeDuration(p.TrackTimeMillis / 1000)\n\t\t\titems = append(items, SearchItem{\n\t\t\t\tTitle:       fmt.Sprintf(\"%s - %s\", p.CollectionName, p.TrackName),\n\t\t\t\tSubtitle:    duration,\n\t\t\t\tSelectable:  true,\n\t\t\t\tType:        ItemTypeEpisode,\n\t\t\t\tDownloadURL: p.EpisodeURL,\n\t\t\t})\n\t\t}\n\t\tsections = append(sections, SearchSection{\n\t\t\tTitle: fmt.Sprintf(\"%s (%d)\", t.Search.Episodes, episodeResult.ResultCount),\n\t\t\tItems: items,\n\t\t})\n\t}\n\n\t// Run TUI loop (allows going back from episode view)\n\tfor {\n\t\tselected, err := RunSearchTUI(sections, query, cfg.Language)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(selected) == 0 {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Handle selection\n\t\terr = handleSelectedItems(selected, \"itunes\", cfg.Language)\n\t\tif err == errGoBack {\n\t\t\tcontinue // Go back to podcast list\n\t\t}\n\t\treturn err\n\t}\n}\n\n// handleSelectedItems processes selected items based on their type\nfunc handleSelectedItems(items []SearchItem, source, lang string) error {\n\tif len(items) == 0 {\n\t\treturn nil\n\t}\n\n\t// Check if selected items are podcasts or episodes\n\tfirstItem := items[0]\n\n\tif firstItem.Type == ItemTypePodcast {\n\t\t// User selected podcasts - fetch episodes for each\n\t\t// For simplicity, only handle first selected podcast\n\t\tpodcast := items[0]\n\t\tif len(items) > 1 {\n\t\t\tfmt.Printf(\"\\nNote: Multiple podcasts selected, showing episodes for: %s\\n\", podcast.Title)\n\t\t}\n\n\t\treturn fetchAndShowEpisodes(podcast, source, lang)\n\t}\n\n\t// Episodes selected - download them\n\treturn downloadSelectedEpisodes(items)\n}\n\n// fetchAndShowEpisodes fetches episodes for a podcast and shows TUI\nfunc fetchAndShowEpisodes(podcast SearchItem, source, lang string) error {\n\tt := i18n.T(lang)\n\n\t// Show spinner while fetching episodes\n\tdone := make(chan bool)\n\tvar episodes []SearchItem\n\tvar fetchErr error\n\n\tgo func() {\n\t\tswitch source {\n\t\tcase \"itunes\":\n\t\t\tepisodes, fetchErr = fetchITunesEpisodes(podcast.PodcastID)\n\t\tcase \"xiaoyuzhou\":\n\t\t\tepisodes, fetchErr = fetchXiaoyuzhouEpisodes(podcast.PodcastID)\n\t\tdefault:\n\t\t\tfetchErr = fmt.Errorf(\"unknown source: %s\", source)\n\t\t}\n\t\tdone <- true\n\t}()\n\n\t// Run spinner with podcast title\n\tif err := runFetchEpisodesSpinner(podcast.Title, lang, done); err != nil {\n\t\treturn err\n\t}\n\n\tif fetchErr != nil {\n\t\treturn fetchErr\n\t}\n\n\tif len(episodes) == 0 {\n\t\tfmt.Println(\"No episodes found.\")\n\t\treturn nil\n\t}\n\n\t// Build sections for episode selection TUI\n\tsections := []SearchSection{\n\t\t{\n\t\t\tTitle: fmt.Sprintf(\"%s - %s\", t.Search.Episodes, podcast.Title),\n\t\t\tItems: episodes,\n\t\t},\n\t}\n\n\t// Run TUI for episode selection with back navigation enabled\n\tselected, err := RunSearchTUIWithBack(sections, podcast.Title, lang, true)\n\tif err == errGoBack {\n\t\treturn errGoBack // Propagate back to original search\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(selected) == 0 {\n\t\treturn nil\n\t}\n\n\t// Download selected episodes\n\treturn downloadSelectedEpisodes(selected)\n}\n\n// fetchITunesEpisodes fetches episodes for an iTunes podcast\nfunc fetchITunesEpisodes(podcastID string) ([]SearchItem, error) {\n\t// Use iTunes Lookup API to get episodes\n\tlookupURL := fmt.Sprintf(\"https://itunes.apple.com/lookup?id=%s&entity=podcastEpisode&limit=50\", podcastID)\n\n\tresp, err := http.Get(lookupURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result struct {\n\t\tResultCount int `json:\"resultCount\"`\n\t\tResults     []struct {\n\t\t\tWrapperType          string `json:\"wrapperType\"`\n\t\t\tTrackID              int    `json:\"trackId\"`\n\t\t\tTrackName            string `json:\"trackName\"`\n\t\t\tCollectionName       string `json:\"collectionName\"`\n\t\t\tArtistName           string `json:\"artistName\"`\n\t\t\tEpisodeURL           string `json:\"episodeUrl\"`\n\t\t\tEpisodeFileExtension string `json:\"episodeFileExtension\"`\n\t\t\tTrackTimeMillis      int    `json:\"trackTimeMillis\"`\n\t\t\tReleaseDate          string `json:\"releaseDate\"`\n\t\t} `json:\"results\"`\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar episodes []SearchItem\n\tfor _, r := range result.Results {\n\t\t// Skip the podcast itself (first result is usually the podcast info)\n\t\tif r.WrapperType != \"podcastEpisode\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tduration := formatEpisodeDuration(r.TrackTimeMillis / 1000)\n\t\tepisodes = append(episodes, SearchItem{\n\t\t\tTitle:       r.TrackName,\n\t\t\tSubtitle:    duration,\n\t\t\tURL:         fmt.Sprintf(\"https://podcasts.apple.com/podcast/id%s?i=%d\", \"\", r.TrackID),\n\t\t\tDownloadURL: r.EpisodeURL,\n\t\t\tSelectable:  true,\n\t\t\tType:        ItemTypeEpisode,\n\t\t})\n\t}\n\n\treturn episodes, nil\n}\n\n// fetchXiaoyuzhouEpisodes fetches episodes for a Xiaoyuzhou podcast\nfunc fetchXiaoyuzhouEpisodes(podcastID string) ([]SearchItem, error) {\n\t// Fetch podcast page which contains __NEXT_DATA__ with episodes\n\tpageURL := fmt.Sprintf(\"https://www.xiaoyuzhoufm.com/podcast/%s\", podcastID)\n\n\tresp, err := http.Get(pageURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Extract __NEXT_DATA__ JSON from the page\n\thtml := string(body)\n\tstartMarker := `<script id=\"__NEXT_DATA__\" type=\"application/json\">`\n\tendMarker := `</script>`\n\n\tstartIdx := strings.Index(html, startMarker)\n\tif startIdx == -1 {\n\t\treturn nil, fmt.Errorf(\"could not find episode data on page\")\n\t}\n\tstartIdx += len(startMarker)\n\n\tendIdx := strings.Index(html[startIdx:], endMarker)\n\tif endIdx == -1 {\n\t\treturn nil, fmt.Errorf(\"could not parse episode data\")\n\t}\n\n\tjsonData := html[startIdx : startIdx+endIdx]\n\n\t// Parse the JSON to extract episodes\n\tvar nextData struct {\n\t\tProps struct {\n\t\t\tPageProps struct {\n\t\t\t\tPodcast struct {\n\t\t\t\t\tTitle    string `json:\"title\"`\n\t\t\t\t\tEpisodes []struct {\n\t\t\t\t\t\tEid       string `json:\"eid\"`\n\t\t\t\t\t\tTitle     string `json:\"title\"`\n\t\t\t\t\t\tDuration  int    `json:\"duration\"`\n\t\t\t\t\t\tEnclosure struct {\n\t\t\t\t\t\t\tURL string `json:\"url\"`\n\t\t\t\t\t\t} `json:\"enclosure\"`\n\t\t\t\t\t} `json:\"episodes\"`\n\t\t\t\t} `json:\"podcast\"`\n\t\t\t} `json:\"pageProps\"`\n\t\t} `json:\"props\"`\n\t}\n\n\tif err := json.Unmarshal([]byte(jsonData), &nextData); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse episode data: %v\", err)\n\t}\n\n\tpodcast := nextData.Props.PageProps.Podcast\n\tif len(podcast.Episodes) == 0 {\n\t\treturn nil, fmt.Errorf(\"no episodes found\")\n\t}\n\n\tpodcastTitle := podcast.Title\n\tepisodes := podcast.Episodes\n\n\tvar items []SearchItem\n\tfor _, e := range episodes {\n\t\tduration := formatEpisodeDuration(e.Duration)\n\t\titems = append(items, SearchItem{\n\t\t\tTitle:       fmt.Sprintf(\"%s - %s\", podcastTitle, e.Title),\n\t\t\tSubtitle:    duration,\n\t\t\tURL:         fmt.Sprintf(\"https://www.xiaoyuzhoufm.com/episode/%s\", e.Eid),\n\t\t\tDownloadURL: e.Enclosure.URL,\n\t\t\tSelectable:  true,\n\t\t\tType:        ItemTypeEpisode,\n\t\t})\n\t}\n\n\treturn items, nil\n}\n\n// downloadSelectedEpisodes downloads the selected episodes sequentially\nfunc downloadSelectedEpisodes(items []SearchItem) error {\n\tif len(items) == 0 {\n\t\treturn nil\n\t}\n\n\tfmt.Printf(\"\\nDownloading %d episode(s)...\\n\\n\", len(items))\n\n\tfor i, item := range items {\n\t\tfmt.Printf(\"[%d/%d] %s\\n\", i+1, len(items), item.Title)\n\n\t\t// If we have a direct download URL, use it\n\t\tif item.DownloadURL != \"\" {\n\t\t\tif err := runDirectDownload(item.DownloadURL, item.Title); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"  Error: %v\\n\", err)\n\t\t\t}\n\t\t} else if item.URL != \"\" {\n\t\t\t// Use the URL to trigger normal download flow\n\t\t\tif err := runDownload(item.URL); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"  Error: %v\\n\", err)\n\t\t\t}\n\t\t}\n\t\tfmt.Println()\n\t}\n\n\treturn nil\n}\n\n// runDirectDownload downloads a file directly from URL\nfunc runDirectDownload(downloadURL, title string) error {\n\t// Use the downloader directly\n\tcfg := config.LoadOrDefault()\n\n\t// Determine extension from URL\n\text := \"mp3\"\n\tif strings.Contains(downloadURL, \".m4a\") {\n\t\text = \"m4a\"\n\t}\n\n\tfilename := sanitizeFilenameForDownload(title) + \".\" + ext\n\toutputDir := cfg.OutputDir\n\tif outputDir == \"\" {\n\t\toutputDir = \".\"\n\t}\n\n\t// Join directory and filename to create full path\n\toutputPath := filepath.Join(outputDir, filename)\n\n\td := downloader.New(cfg.Language)\n\treturn d.Download(downloadURL, outputPath, title)\n}\n\n// sanitizeFilenameForDownload removes invalid characters from filename\nfunc sanitizeFilenameForDownload(name string) string {\n\t// Replace invalid characters\n\treplacer := strings.NewReplacer(\n\t\t\"/\", \"-\",\n\t\t\"\\\\\", \"-\",\n\t\t\":\", \"-\",\n\t\t\"*\", \"\",\n\t\t\"?\", \"\",\n\t\t\"\\\"\", \"\",\n\t\t\"<\", \"\",\n\t\t\">\", \"\",\n\t\t\"|\", \"\",\n\t)\n\tresult := replacer.Replace(name)\n\t// Trim spaces and limit length\n\tresult = strings.TrimSpace(result)\n\tif len(result) > 200 {\n\t\tresult = result[:200]\n\t}\n\treturn result\n}\n\n// Search spinner model\ntype searchSpinnerModel struct {\n\tspinner spinner.Model\n\tmessage string // The action message (e.g., \"Searching\", \"Fetching episodes for\")\n\tquery   string\n\tlang    string\n\tdone    chan bool\n\tquit    bool\n}\n\ntype searchTickMsg time.Time\n\nfunc newSearchSpinnerModel(message, query, lang string, done chan bool) searchSpinnerModel {\n\ts := spinner.New()\n\ts.Spinner = spinner.Dot\n\ts.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(\"205\"))\n\treturn searchSpinnerModel{\n\t\tspinner: s,\n\t\tmessage: message,\n\t\tquery:   query,\n\t\tlang:    lang,\n\t\tdone:    done,\n\t}\n}\n\nfunc (m searchSpinnerModel) Init() tea.Cmd {\n\treturn tea.Batch(m.spinner.Tick, m.checkDone())\n}\n\nfunc (m searchSpinnerModel) checkDone() tea.Cmd {\n\treturn tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {\n\t\treturn searchTickMsg(t)\n\t})\n}\n\nfunc (m searchSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tif msg.String() == \"ctrl+c\" || msg.String() == \"q\" {\n\t\t\tm.quit = true\n\t\t\treturn m, tea.Quit\n\t\t}\n\n\tcase spinner.TickMsg:\n\t\tvar cmd tea.Cmd\n\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\treturn m, cmd\n\n\tcase searchTickMsg:\n\t\tselect {\n\t\tcase <-m.done:\n\t\t\treturn m, tea.Quit\n\t\tdefault:\n\t\t\treturn m, m.checkDone()\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (m searchSpinnerModel) View() string {\n\treturn fmt.Sprintf(\"\\n  %s %s... %s\\n\", m.spinner.View(), m.message, m.query)\n}\n\nfunc runSearchSpinner(query, lang string, done chan bool) error {\n\tt := i18n.T(lang)\n\tmodel := newSearchSpinnerModel(t.Search.Searching, query, lang, done)\n\tp := tea.NewProgram(model)\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif finalModel.(searchSpinnerModel).quit {\n\t\treturn fmt.Errorf(\"search cancelled\")\n\t}\n\treturn nil\n}\n\nfunc runFetchEpisodesSpinner(podcastTitle, lang string, done chan bool) error {\n\tt := i18n.T(lang)\n\tmodel := newSearchSpinnerModel(t.Search.FetchingEpisodes, podcastTitle, lang, done)\n\tp := tea.NewProgram(model)\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif finalModel.(searchSpinnerModel).quit {\n\t\treturn fmt.Errorf(\"cancelled\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cli/search_tui.go",
    "content": "package cli\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/key\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n)\n\nvar (\n\tsearchTitleStyle     = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"86\"))\n\tsearchSelectedStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color(\"205\"))\n\tsearchDimStyle       = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n\tsearchCheckStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color(\"42\"))\n\tsearchUncheckStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n\tsearchHelpStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n\tsearchDurationStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color(\"135\")) // purple\n\tsearchContainerStyle = lipgloss.NewStyle().Padding(1, 2)\n\n\t// Tab styles\n\tactiveTabStyle   = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"205\")).Underline(true)\n\tinactiveTabStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n)\n\n// ItemType distinguishes between podcasts and episodes\ntype ItemType int\n\nconst (\n\tItemTypePodcast ItemType = iota\n\tItemTypeEpisode\n)\n\n// SearchItem represents a selectable item (podcast or episode)\ntype SearchItem struct {\n\tTitle       string\n\tSubtitle    string // e.g., \"Duration: 45:30 | Plays: 1234\"\n\tURL         string\n\tDownloadURL string   // Direct download URL if available\n\tSelectable  bool     // Whether this item can be selected\n\tType        ItemType // Podcast or Episode\n\t// For podcasts (used to fetch episodes)\n\tPodcastID string // iTunes collection ID or Xiaoyuzhou pid\n\tFeedURL   string // RSS feed URL (for iTunes)\n}\n\n// SearchSection represents a section (Podcasts or Episodes)\ntype SearchSection struct {\n\tTitle string\n\tItems []SearchItem\n}\n\nconst maxSelections = 5\n\ntype searchModel struct {\n\tsections      []SearchSection\n\tactiveTab     int            // Which tab is active\n\tcursors       []int          // Cursor position for each tab\n\tscrollOffsets []int          // Scroll offset for each tab\n\tselected      map[int]bool   // Track selected items by global index within active tab\n\tselectedCount int\n\tconfirmed     bool\n\tgoBack        bool           // User wants to go back\n\tallowBack     bool           // Whether back navigation is allowed\n\tkeyBindings   searchKeyMap\n\twidth         int\n\theight        int\n\tquery         string\n\tlang          string\n}\n\ntype searchKeyMap struct {\n\tUp       key.Binding\n\tDown     key.Binding\n\tLeft     key.Binding\n\tRight    key.Binding\n\tTab      key.Binding\n\tShiftTab key.Binding\n\tToggle   key.Binding\n\tConfirm  key.Binding\n\tBack     key.Binding\n\tQuit     key.Binding\n}\n\nfunc defaultSearchKeyMap() searchKeyMap {\n\treturn searchKeyMap{\n\t\tUp: key.NewBinding(\n\t\t\tkey.WithKeys(\"up\", \"k\"),\n\t\t\tkey.WithHelp(\"↑/k\", \"up\"),\n\t\t),\n\t\tDown: key.NewBinding(\n\t\t\tkey.WithKeys(\"down\", \"j\"),\n\t\t\tkey.WithHelp(\"↓/j\", \"down\"),\n\t\t),\n\t\tLeft: key.NewBinding(\n\t\t\tkey.WithKeys(\"left\", \"h\"),\n\t\t\tkey.WithHelp(\"←/h\", \"prev tab\"),\n\t\t),\n\t\tRight: key.NewBinding(\n\t\t\tkey.WithKeys(\"right\", \"l\"),\n\t\t\tkey.WithHelp(\"→/l\", \"next tab\"),\n\t\t),\n\t\tTab: key.NewBinding(\n\t\t\tkey.WithKeys(\"tab\"),\n\t\t\tkey.WithHelp(\"tab\", \"next tab\"),\n\t\t),\n\t\tShiftTab: key.NewBinding(\n\t\t\tkey.WithKeys(\"shift+tab\"),\n\t\t\tkey.WithHelp(\"shift+tab\", \"prev tab\"),\n\t\t),\n\t\tToggle: key.NewBinding(\n\t\t\tkey.WithKeys(\" \", \"x\"),\n\t\t\tkey.WithHelp(\"space/x\", \"toggle\"),\n\t\t),\n\t\tConfirm: key.NewBinding(\n\t\t\tkey.WithKeys(\"enter\"),\n\t\t\tkey.WithHelp(\"enter\", \"confirm\"),\n\t\t),\n\t\tBack: key.NewBinding(\n\t\t\tkey.WithKeys(\"b\", \"backspace\"),\n\t\t\tkey.WithHelp(\"b\", \"back\"),\n\t\t),\n\t\tQuit: key.NewBinding(\n\t\t\tkey.WithKeys(\"q\", \"esc\", \"ctrl+c\"),\n\t\t\tkey.WithHelp(\"q/esc\", \"quit\"),\n\t\t),\n\t}\n}\n\nfunc newSearchModel(sections []SearchSection, query, lang string) searchModel {\n\t// Initialize cursors and scroll offsets for each tab\n\tcursors := make([]int, len(sections))\n\tscrollOffsets := make([]int, len(sections))\n\n\treturn searchModel{\n\t\tsections:      sections,\n\t\tactiveTab:     0,\n\t\tcursors:       cursors,\n\t\tscrollOffsets: scrollOffsets,\n\t\tselected:      make(map[int]bool),\n\t\tkeyBindings:   defaultSearchKeyMap(),\n\t\tquery:         query,\n\t\tlang:          lang,\n\t}\n}\n\nfunc (m searchModel) Init() tea.Cmd {\n\treturn nil\n}\n\nconst maxVisibleLines = 15 // Max items to show at once\n\n// visibleLines returns how many lines can be displayed\nfunc (m searchModel) visibleLines() int {\n\tif m.height <= 0 {\n\t\treturn maxVisibleLines\n\t}\n\t// Reserve: title (2 lines) + tabs (2 lines) + footer (3 lines) + padding\n\tavailable := m.height - 10\n\tif available > maxVisibleLines {\n\t\treturn maxVisibleLines\n\t}\n\tif available < 5 {\n\t\treturn 5 // minimum\n\t}\n\treturn available\n}\n\n// currentSection returns the active section\nfunc (m searchModel) currentSection() *SearchSection {\n\tif m.activeTab >= 0 && m.activeTab < len(m.sections) {\n\t\treturn &m.sections[m.activeTab]\n\t}\n\treturn nil\n}\n\n// currentItemType returns the type of items in the current tab\nfunc (m searchModel) currentItemType() ItemType {\n\tsection := m.currentSection()\n\tif section != nil && len(section.Items) > 0 {\n\t\treturn section.Items[0].Type\n\t}\n\treturn ItemTypeEpisode\n}\n\n// clearSelections clears all selected items\nfunc (m *searchModel) clearSelections() {\n\tm.selected = make(map[int]bool)\n\tm.selectedCount = 0\n}\n\n// adjustScroll ensures cursor is visible within current tab\nfunc (m *searchModel) adjustScroll() {\n\tvisible := m.visibleLines()\n\tcursor := m.cursors[m.activeTab]\n\toffset := m.scrollOffsets[m.activeTab]\n\n\tif cursor < offset {\n\t\tm.scrollOffsets[m.activeTab] = cursor\n\t} else if cursor >= offset+visible {\n\t\tm.scrollOffsets[m.activeTab] = cursor - visible + 1\n\t}\n}\n\nfunc (m searchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tm.width = msg.Width\n\t\tm.height = msg.Height\n\t\treturn m, nil\n\n\tcase tea.KeyMsg:\n\t\tsection := m.currentSection()\n\t\tif section == nil {\n\t\t\tif key.Matches(msg, m.keyBindings.Quit) {\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\t\t\treturn m, nil\n\t\t}\n\n\t\tswitch {\n\t\tcase key.Matches(msg, m.keyBindings.Quit):\n\t\t\treturn m, tea.Quit\n\n\t\tcase key.Matches(msg, m.keyBindings.Left), key.Matches(msg, m.keyBindings.ShiftTab):\n\t\t\tif m.activeTab > 0 {\n\t\t\t\tm.activeTab--\n\t\t\t\tm.clearSelections()\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Right), key.Matches(msg, m.keyBindings.Tab):\n\t\t\tif m.activeTab < len(m.sections)-1 {\n\t\t\t\tm.activeTab++\n\t\t\t\tm.clearSelections()\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Up):\n\t\t\tif m.cursors[m.activeTab] > 0 {\n\t\t\t\tm.cursors[m.activeTab]--\n\t\t\t\tm.adjustScroll()\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Down):\n\t\t\tif m.cursors[m.activeTab] < len(section.Items)-1 {\n\t\t\t\tm.cursors[m.activeTab]++\n\t\t\t\tm.adjustScroll()\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Toggle):\n\t\t\tcursor := m.cursors[m.activeTab]\n\t\t\tif cursor >= 0 && cursor < len(section.Items) && section.Items[cursor].Selectable {\n\t\t\t\t// Determine max selections based on item type\n\t\t\t\tcurrentType := m.currentItemType()\n\t\t\t\tmaxSel := maxSelections\n\t\t\t\tif currentType == ItemTypePodcast {\n\t\t\t\t\tmaxSel = 1\n\t\t\t\t}\n\n\t\t\t\tif m.selected[cursor] {\n\t\t\t\t\t// Deselect\n\t\t\t\t\tm.selected[cursor] = false\n\t\t\t\t\tm.selectedCount--\n\t\t\t\t} else if m.selectedCount < maxSel {\n\t\t\t\t\t// Select (only if under limit)\n\t\t\t\t\tm.selected[cursor] = true\n\t\t\t\t\tm.selectedCount++\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase key.Matches(msg, m.keyBindings.Confirm):\n\t\t\tm.confirmed = true\n\t\t\treturn m, tea.Quit\n\n\t\tcase key.Matches(msg, m.keyBindings.Back):\n\t\t\tif m.allowBack {\n\t\t\t\tm.goBack = true\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (m searchModel) View() string {\n\tt := i18n.T(m.lang)\n\n\tif len(m.sections) == 0 {\n\t\treturn \"\\n  No results found.\\n\\n\"\n\t}\n\n\tvar b strings.Builder\n\n\t// Title with query\n\tb.WriteString(fmt.Sprintf(\"  %s: %s\\n\\n\", searchTitleStyle.Render(t.Search.ResultsFor), m.query))\n\n\t// Render tabs\n\tvar tabs []string\n\tfor i, section := range m.sections {\n\t\ttabText := section.Title\n\t\tif i == m.activeTab {\n\t\t\ttabs = append(tabs, activeTabStyle.Render(tabText))\n\t\t} else {\n\t\t\ttabs = append(tabs, inactiveTabStyle.Render(tabText))\n\t\t}\n\t}\n\tb.WriteString(\"  \" + strings.Join(tabs, \"  │  \") + \"\\n\\n\")\n\n\t// Get current section\n\tsection := m.currentSection()\n\tif section == nil || len(section.Items) == 0 {\n\t\tb.WriteString(\"  No items in this section.\\n\")\n\t} else {\n\t\t// Build lines for current section\n\t\tvisible := m.visibleLines()\n\t\tcursor := m.cursors[m.activeTab]\n\t\toffset := m.scrollOffsets[m.activeTab]\n\n\t\t// Adjust offset if needed\n\t\tif cursor < offset {\n\t\t\toffset = cursor\n\t\t} else if cursor >= offset+visible {\n\t\t\toffset = cursor - visible + 1\n\t\t}\n\t\tif offset < 0 {\n\t\t\toffset = 0\n\t\t}\n\n\t\tendIdx := offset + visible\n\t\tif endIdx > len(section.Items) {\n\t\t\tendIdx = len(section.Items)\n\t\t}\n\n\t\tfor i := offset; i < endIdx; i++ {\n\t\t\titem := section.Items[i]\n\n\t\t\tcursorStr := \"  \"\n\t\t\tif i == cursor {\n\t\t\t\tcursorStr = searchSelectedStyle.Render(\"> \")\n\t\t\t}\n\n\t\t\t// Show checkbox for selectable items\n\t\t\tvar prefix string\n\t\t\tif item.Selectable {\n\t\t\t\tcheckbox := searchUncheckStyle.Render(\"[ ]\")\n\t\t\t\tif m.selected[i] {\n\t\t\t\t\tcheckbox = searchCheckStyle.Render(\"[x]\")\n\t\t\t\t}\n\t\t\t\tprefix = checkbox + \" \"\n\t\t\t} else {\n\t\t\t\tprefix = \"    \"\n\t\t\t}\n\n\t\t\t// Build the line based on item type\n\t\t\tvar line string\n\t\t\ttitle := item.Title\n\t\t\tif i == cursor {\n\t\t\t\ttitle = searchSelectedStyle.Render(title)\n\t\t\t}\n\n\t\t\tif item.Type == ItemTypeEpisode && item.Subtitle != \"\" {\n\t\t\t\t// Episode: [duration] title\n\t\t\t\tduration := searchDurationStyle.Render(fmt.Sprintf(\"[%s]\", item.Subtitle))\n\t\t\t\tline = fmt.Sprintf(\"%s %s\", duration, title)\n\t\t\t} else if item.Subtitle != \"\" {\n\t\t\t\t// Podcast: title (subtitle dimmed)\n\t\t\t\tline = fmt.Sprintf(\"%s %s\", title, searchDimStyle.Render(\"(\"+item.Subtitle+\")\"))\n\t\t\t} else {\n\t\t\t\tline = title\n\t\t\t}\n\n\t\t\tb.WriteString(fmt.Sprintf(\"%s%s%s\\n\", cursorStr, prefix, line))\n\t\t}\n\n\t\t// Show scroll indicator\n\t\tif len(section.Items) > visible {\n\t\t\tscrollInfo := fmt.Sprintf(\" (%d-%d of %d)\", offset+1, endIdx, len(section.Items))\n\t\t\tb.WriteString(searchDimStyle.Render(scrollInfo) + \"\\n\")\n\t\t}\n\t}\n\n\tb.WriteString(\"\\n\")\n\n\t// Determine current item type for dynamic hints\n\tisPodcast := m.currentItemType() == ItemTypePodcast\n\n\t// Selection count and help\n\tif m.selectedCount > 0 {\n\t\tmaxSel := maxSelections\n\t\tif isPodcast {\n\t\t\tmaxSel = 1\n\t\t}\n\t\tb.WriteString(fmt.Sprintf(\"  %s: %d/%d\\n\", t.Search.Selected, m.selectedCount, maxSel))\n\t} else {\n\t\tif isPodcast {\n\t\t\tb.WriteString(fmt.Sprintf(\"  %s\\n\", t.Search.SelectPodcastHint))\n\t\t} else {\n\t\t\tb.WriteString(fmt.Sprintf(\"  \"+t.Search.SelectHint+\"\\n\", maxSelections))\n\t\t}\n\t}\n\n\t// Help text with tab switching hint\n\tvar helpText string\n\tif len(m.sections) > 1 {\n\t\thelpText = \"←/→ switch tabs • \"\n\t}\n\tif m.allowBack {\n\t\thelpText += \"b back • \"\n\t}\n\tif isPodcast {\n\t\thelpText += t.Search.HelpPodcast\n\t} else {\n\t\thelpText += t.Search.Help\n\t}\n\tb.WriteString(searchHelpStyle.Render(\"  \" + helpText))\n\tb.WriteString(\"\\n\")\n\n\t// Apply container style\n\tcontent := searchContainerStyle.Render(b.String())\n\n\t// Make it fullscreen\n\tif m.width > 0 && m.height > 0 {\n\t\tcontent = lipgloss.Place(m.width, m.height, lipgloss.Left, lipgloss.Top, content)\n\t}\n\n\treturn content\n}\n\n// GetSelectedItems returns the selected items from the active tab\nfunc (m searchModel) GetSelectedItems() []SearchItem {\n\tvar items []SearchItem\n\tsection := m.currentSection()\n\tif section == nil {\n\t\treturn items\n\t}\n\n\tfor i, item := range section.Items {\n\t\tif m.selected[i] {\n\t\t\titems = append(items, item)\n\t\t}\n\t}\n\treturn items\n}\n\n// SearchTUIResult holds the result of RunSearchTUI\ntype SearchTUIResult struct {\n\tItems  []SearchItem\n\tGoBack bool\n}\n\n// RunSearchTUI runs the search TUI and returns selected items\nfunc RunSearchTUI(sections []SearchSection, query, lang string) ([]SearchItem, error) {\n\treturn RunSearchTUIWithBack(sections, query, lang, false)\n}\n\n// RunSearchTUIWithBack runs the search TUI with optional back navigation\nfunc RunSearchTUIWithBack(sections []SearchSection, query, lang string, allowBack bool) ([]SearchItem, error) {\n\tmodel := newSearchModel(sections, query, lang)\n\tmodel.allowBack = allowBack\n\tp := tea.NewProgram(model, tea.WithAltScreen())\n\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tm := finalModel.(searchModel)\n\tif m.goBack {\n\t\treturn nil, errGoBack\n\t}\n\tif !m.confirmed {\n\t\treturn nil, nil // User quit without confirming\n\t}\n\n\treturn m.GetSelectedItems(), nil\n}\n\n// ErrGoBack is returned when user wants to go back\nvar errGoBack = fmt.Errorf(\"go back\")\n"
  },
  {
    "path": "internal/cli/telegram.go",
    "content": "package cli\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/gotd/td/session\"\n\t\"github.com/gotd/td/session/tdesktop\"\n\t\"github.com/gotd/td/telegram\"\n\t\"github.com/gotd/td/tg\"\n\ttgpkg \"github.com/guiyumin/vget/internal/core/extractor/telegram\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar telegramCmd = &cobra.Command{\n\tUse:   \"telegram\",\n\tShort: \"Manage Telegram authentication\",\n\tLong:  \"Login, logout, and check status of Telegram session for downloading media\",\n}\n\nvar telegramLoginCmd = &cobra.Command{\n\tUse:   \"login\",\n\tShort: \"Login to Telegram\",\n\tLong: `Login to Telegram to enable media downloads.\n\nAvailable methods:\n  --import-desktop    Import session from Telegram Desktop app\n\nExample:\n  vget telegram login --import-desktop`,\n\tRunE: runTelegramLogin,\n}\n\nvar telegramLogoutCmd = &cobra.Command{\n\tUse:   \"logout\",\n\tShort: \"Clear Telegram session\",\n\tLong:  \"Remove the stored Telegram session. You'll need to login again to download Telegram media.\",\n\tRunE:  runTelegramLogout,\n}\n\nvar telegramStatusCmd = &cobra.Command{\n\tUse:   \"status\",\n\tShort: \"Check Telegram login status\",\n\tLong:  \"Check if you're currently logged in to Telegram and show account info.\",\n\tRunE:  runTelegramStatus,\n}\n\nfunc runTelegramLogin(cmd *cobra.Command, args []string) error {\n\timportDesktop, _ := cmd.Flags().GetBool(\"import-desktop\")\n\n\tif !importDesktop {\n\t\t// No method specified, show help\n\t\tfmt.Println(\"Please specify a login method:\")\n\t\tfmt.Println()\n\t\tfmt.Println(\"  vget telegram login --import-desktop\")\n\t\tfmt.Println(\"      Import session from Telegram Desktop app\")\n\t\tfmt.Println(\"      Requires: Telegram Desktop installed and logged in\")\n\t\tfmt.Println()\n\t\treturn nil\n\t}\n\n\t// Check config for custom Telegram directory\n\tcfg := config.LoadOrDefault()\n\ttdataPath := cfg.Telegram.TDataPath\n\t\n\t// If no custom path, use default locations\n\tif tdataPath == \"\" {\n\t\ttdataPath = getTelegramDesktopPath()\n\t\tif tdataPath == \"\" {\n\t\t\treturn fmt.Errorf(\"could not find Telegram Desktop data directory.\\n\"+\n\t\t\t\t\"Make sure Telegram Desktop is installed and you're logged in\")\n\t\t}\n\t}\n\n\tfmt.Printf(\"Found Telegram Desktop at: %s\\n\", tdataPath)\n\n\t// Check if Desktop is running (warn user)\n\tfmt.Println(\"Note: Close Telegram Desktop before importing for best results.\")\n\tfmt.Println()\n\n\t// Read accounts from tdata\n\taccounts, err := tdesktop.Read(tdataPath, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read Telegram Desktop data: %w\\n\"+\n\t\t\t\"Make sure Telegram Desktop is closed and you're logged in\", err)\n\t}\n\n\tif len(accounts) == 0 {\n\t\treturn fmt.Errorf(\"no accounts found in Telegram Desktop.\\n\" +\n\t\t\t\"Make sure you're logged in to Telegram Desktop\")\n\t}\n\n\t// Select account\n\tvar account tdesktop.Account\n\tif len(accounts) == 1 {\n\t\taccount = accounts[0]\n\t\tfmt.Printf(\"Found account: ID %d\\n\", account.Authorization.UserID)\n\t} else {\n\t\t// Multiple accounts - fetch user info for each\n\t\tfmt.Printf(\"Found %d accounts, fetching info...\\n\\n\", len(accounts))\n\n\t\ttype accountInfo struct {\n\t\t\taccount  tdesktop.Account\n\t\t\tname     string\n\t\t\tusername string\n\t\t}\n\t\tinfos := make([]accountInfo, len(accounts))\n\n\t\tfor i, acc := range accounts {\n\t\t\tinfos[i].account = acc\n\t\t\tname, username := getAccountInfo(acc)\n\t\t\tinfos[i].name = name\n\t\t\tinfos[i].username = username\n\t\t}\n\n\t\tfor i, info := range infos {\n\t\t\tif info.username != \"\" {\n\t\t\t\tfmt.Printf(\"  [%d] %s (@%s)\\n\", i+1, info.name, info.username)\n\t\t\t} else if info.name != \"\" {\n\t\t\t\tfmt.Printf(\"  [%d] %s\\n\", i+1, info.name)\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"  [%d] ID %d\\n\", i+1, info.account.Authorization.UserID)\n\t\t\t}\n\t\t}\n\t\tfmt.Println()\n\t\tfmt.Print(\"Select account: \")\n\n\t\tvar choice int\n\t\t_, err := fmt.Scanln(&choice)\n\t\tif err != nil || choice < 1 || choice > len(accounts) {\n\t\t\treturn fmt.Errorf(\"invalid selection\")\n\t\t}\n\t\taccount = accounts[choice-1]\n\t}\n\n\t// Convert tdesktop session to gotd session format\n\tsessionData, err := session.TDesktopSession(account)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to convert session: %w\", err)\n\t}\n\n\t// Create session directory\n\tif err := os.MkdirAll(tgpkg.SessionPath(), 0700); err != nil {\n\t\treturn fmt.Errorf(\"failed to create session directory: %w\", err)\n\t}\n\n\t// Save session to file first, then use FileStorage\n\tstorage := &session.FileStorage{Path: tgpkg.SessionFile()}\n\tloader := session.Loader{Storage: storage}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tif err := loader.Save(ctx, sessionData); err != nil {\n\t\treturn fmt.Errorf(\"failed to save session: %w\", err)\n\t}\n\n\t// Create client with file-based storage\n\tclient := telegram.NewClient(\n\t\ttgpkg.DesktopAppID,\n\t\ttgpkg.DesktopAppHash,\n\t\ttelegram.Options{\n\t\t\tSessionStorage: storage,\n\t\t},\n\t)\n\n\tvar userInfo string\n\terr = client.Run(ctx, func(ctx context.Context) error {\n\t\t// Verify the session works\n\t\tstatus, err := client.Auth().Status(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to check auth status: %w\", err)\n\t\t}\n\n\t\tif !status.Authorized {\n\t\t\treturn fmt.Errorf(\"session import failed - not authorized\")\n\t\t}\n\n\t\t// Get user info\n\t\tself, err := client.Self(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get user info: %w\", err)\n\t\t}\n\n\t\tuserInfo = formatUserInfo(self)\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\t// Clean up failed session file\n\t\tos.Remove(tgpkg.SessionFile())\n\t\treturn err\n\t}\n\n\tfmt.Println()\n\tfmt.Println(\"Successfully logged in!\")\n\tfmt.Println(userInfo)\n\tfmt.Println()\n\tfmt.Println(\"You can now download Telegram media:\")\n\tfmt.Println(\"  vget https://t.me/channel/123\")\n\n\treturn nil\n}\n\nfunc runTelegramLogout(cmd *cobra.Command, args []string) error {\n\tsessionFile := tgpkg.SessionFile()\n\n\tif _, err := os.Stat(sessionFile); os.IsNotExist(err) {\n\t\tfmt.Println(\"Not logged in to Telegram.\")\n\t\treturn nil\n\t}\n\n\tif err := os.Remove(sessionFile); err != nil {\n\t\treturn fmt.Errorf(\"failed to remove session: %w\", err)\n\t}\n\n\tfmt.Println(\"Logged out from Telegram.\")\n\treturn nil\n}\n\nfunc runTelegramStatus(cmd *cobra.Command, args []string) error {\n\tif !tgpkg.SessionExists() {\n\t\tfmt.Println(\"Not logged in to Telegram.\")\n\t\tfmt.Println(\"Run 'vget telegram login' to import your Telegram Desktop session.\")\n\t\treturn nil\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\n\tstorage := &session.FileStorage{Path: tgpkg.SessionFile()}\n\n\tclient := telegram.NewClient(\n\t\ttgpkg.DesktopAppID,\n\t\ttgpkg.DesktopAppHash,\n\t\ttelegram.Options{\n\t\t\tSessionStorage: storage,\n\t\t},\n\t)\n\n\tvar userInfo string\n\terr := client.Run(ctx, func(ctx context.Context) error {\n\t\tstatus, err := client.Auth().Status(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to check auth status: %w\", err)\n\t\t}\n\n\t\tif !status.Authorized {\n\t\t\treturn fmt.Errorf(\"session expired or invalid\")\n\t\t}\n\n\t\tself, err := client.Self(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get user info: %w\", err)\n\t\t}\n\n\t\tuserInfo = formatUserInfo(self)\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tfmt.Println(\"Session invalid or expired.\")\n\t\tfmt.Println(\"Run 'vget telegram login' to re-import your session.\")\n\t\treturn nil\n\t}\n\n\tfmt.Println(\"Logged in to Telegram\")\n\tfmt.Println(userInfo)\n\treturn nil\n}\n\nfunc formatUserInfo(self *tg.User) string {\n\tname := self.FirstName\n\tif self.LastName != \"\" {\n\t\tname += \" \" + self.LastName\n\t}\n\n\tinfo := fmt.Sprintf(\"  Name: %s\", name)\n\tif self.Username != \"\" {\n\t\tinfo += fmt.Sprintf(\"\\n  Username: @%s\", self.Username)\n\t}\n\tinfo += fmt.Sprintf(\"\\n  ID: %d\", self.ID)\n\n\treturn info\n}\n\nfunc getTelegramDesktopPath() string {\n\tvar paths []string\n\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\thome, _ := os.UserHomeDir()\n\t\tpaths = []string{\n\t\t\tfilepath.Join(home, \"Library\", \"Application Support\", \"Telegram Desktop\", \"tdata\"),\n\t\t}\n\tcase \"linux\":\n\t\thome, _ := os.UserHomeDir()\n\t\tpaths = []string{\n\t\t\tfilepath.Join(home, \".local\", \"share\", \"TelegramDesktop\", \"tdata\"),\n\t\t\t// Flatpak\n\t\t\tfilepath.Join(home, \".var\", \"app\", \"org.telegram.desktop\", \"data\", \"TelegramDesktop\", \"tdata\"),\n\t\t\t// Snap\n\t\t\tfilepath.Join(home, \"snap\", \"telegram-desktop\", \"current\", \".local\", \"share\", \"TelegramDesktop\", \"tdata\"),\n\t\t}\n\tcase \"windows\":\n\t\tappData := os.Getenv(\"APPDATA\")\n\t\tpaths = []string{\n\t\t\tfilepath.Join(appData, \"Telegram Desktop\", \"tdata\"),\n\t\t}\n\t}\n\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\treturn path\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// getAccountInfo fetches name and username for a tdesktop account\nfunc getAccountInfo(acc tdesktop.Account) (name, username string) {\n\tsessionData, err := session.TDesktopSession(acc)\n\tif err != nil {\n\t\treturn \"\", \"\"\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\n\tstorage := &session.StorageMemory{}\n\tloader := session.Loader{Storage: storage}\n\tif err := loader.Save(ctx, sessionData); err != nil {\n\t\treturn \"\", \"\"\n\t}\n\n\tclient := telegram.NewClient(\n\t\ttgpkg.DesktopAppID,\n\t\ttgpkg.DesktopAppHash,\n\t\ttelegram.Options{\n\t\t\tSessionStorage: storage,\n\t\t},\n\t)\n\n\t_ = client.Run(ctx, func(ctx context.Context) error {\n\t\tself, err := client.Self(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tname = self.FirstName\n\t\tif self.LastName != \"\" {\n\t\t\tname += \" \" + self.LastName\n\t\t}\n\t\tusername = self.Username\n\t\treturn nil\n\t})\n\n\treturn name, username\n}\n\nfunc init() {\n\ttelegramLoginCmd.Flags().Bool(\"import-desktop\", false, \"Import session from Telegram Desktop\")\n\ttelegramCmd.AddCommand(telegramLoginCmd)\n\ttelegramCmd.AddCommand(telegramLogoutCmd)\n\ttelegramCmd.AddCommand(telegramStatusCmd)\n\trootCmd.AddCommand(telegramCmd)\n}\n"
  },
  {
    "path": "internal/cli/update.go",
    "content": "package cli\n\nimport (\n\t\"github.com/guiyumin/vget/internal/updater\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar updateCmd = &cobra.Command{\n\tUse:   \"update\",\n\tShort: \"Update vget to the latest version\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\treturn updater.Update()\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(updateCmd)\n}\n"
  },
  {
    "path": "internal/cli/version.go",
    "content": "package cli\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n\n\t\"github.com/guiyumin/vget/internal/core/version\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar versionCmd = &cobra.Command{\n\tUse:   \"version\",\n\tShort: \"Print version information\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tfmt.Printf(\"vget v%s %s/%s\\n\", version.Version, runtime.GOOS, runtime.GOARCH)\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(versionCmd)\n}\n"
  },
  {
    "path": "internal/core/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tConfigFileName = \"config.yml\"\n\tAppDirName     = \"vget\"\n)\n\n// ConfigDir returns the standard config directory for vget.\n// Windows: %APPDATA%\\vget\\\n// macOS/Linux: ~/.config/vget/\nfunc ConfigDir() (string, error) {\n\tif runtime.GOOS == \"windows\" {\n\t\tappData := os.Getenv(\"APPDATA\")\n\t\tif appData != \"\" {\n\t\t\treturn filepath.Join(appData, AppDirName), nil\n\t\t}\n\t}\n\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(home, \".config\", AppDirName), nil\n}\n\n// ConfigPath returns the path to the config file.\n// e.g., ~/.config/vget/config.yml\nfunc ConfigPath() (string, error) {\n\tdir, err := ConfigDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(dir, ConfigFileName), nil\n}\n\ntype Config struct {\n\t// Language for metadata (e.g., \"en\", \"zh\", \"ja\")\n\tLanguage string `yaml:\"language,omitempty\"`\n\n\t// Default output directory\n\tOutputDir string `yaml:\"output_dir,omitempty\"`\n\n\t// Preferred format (e.g., \"mp4\", \"webm\", \"best\")\n\tFormat string `yaml:\"format,omitempty\"`\n\n\t// Default quality preference (e.g., \"1080p\", \"720p\", \"best\")\n\tQuality string `yaml:\"quality,omitempty\"`\n\n\t// WebDAV servers configuration\n\tWebDAVServers map[string]WebDAVServer `yaml:\"webdavServers,omitempty\"`\n\n\t// Twitter/X configuration\n\tTwitter TwitterConfig `yaml:\"twitter,omitempty\"`\n\n\t// Server configuration for `vget serve`\n\tServer ServerConfig `yaml:\"server,omitempty\"`\n\n\t// Express tracking providers configuration\n\t// Each provider has its own config structure stored as map[string]string\n\t// Example YAML:\n\t//   express:\n\t//     kuaidi100:\n\t//       key: \"xxx\"\n\t//       customer: \"yyy\"\n\t//     fedex:\n\t//       api_key: \"zzz\"\n\tExpress map[string]map[string]string `yaml:\"express,omitempty\"`\n\n\t// Torrent client configuration for dispatching magnet links\n\tTorrent TorrentConfig `yaml:\"torrent,omitempty\"`\n\n\t// Bilibili configuration\n\tBilibili BilibiliConfig `yaml:\"bilibili,omitempty\"`\n\n\t// Telegram configuration\n\tTelegram TelegramConfig `yaml:\"telegram,omitempty\"`\n}\n\n// BilibiliConfig holds Bilibili authentication settings\ntype BilibiliConfig struct {\n\t// Cookie is the full cookie string (SESSDATA, bili_jct, DedeUserID)\n\tCookie string `yaml:\"cookie,omitempty\"`\n}\n\n// TelegramConfig holds Telegram authentication settings\ntype TelegramConfig struct {\n\t// TDataPath is the custom path to Telegram Desktop tdata directory\n\tTDataPath string `yaml:\"tdata_path,omitempty\"`\n}\n\n// TorrentConfig holds configuration for remote torrent client integration\ntype TorrentConfig struct {\n\t// Enabled determines if torrent dispatch feature is active\n\tEnabled bool `yaml:\"enabled,omitempty\"`\n\n\t// Client type: \"transmission\", \"qbittorrent\", \"synology\"\n\tClient string `yaml:\"client,omitempty\"`\n\n\t// Host is the torrent client address (e.g., \"192.168.1.100:9091\")\n\tHost string `yaml:\"host,omitempty\"`\n\n\t// Username for authentication\n\tUsername string `yaml:\"username,omitempty\"`\n\n\t// Password for authentication\n\tPassword string `yaml:\"password,omitempty\"`\n\n\t// UseHTTPS enables HTTPS connection to torrent client\n\tUseHTTPS bool `yaml:\"use_https,omitempty\"`\n\n\t// DefaultSavePath overrides the client's default download directory\n\tDefaultSavePath string `yaml:\"default_save_path,omitempty\"`\n}\n\n// GetExpressConfig returns the config for a specific express provider\nfunc (c *Config) GetExpressConfig(provider string) map[string]string {\n\tif c.Express == nil {\n\t\treturn nil\n\t}\n\treturn c.Express[provider]\n}\n\n// SetExpressConfig sets a config value for an express provider\nfunc (c *Config) SetExpressConfig(provider, key, value string) {\n\tif c.Express == nil {\n\t\tc.Express = make(map[string]map[string]string)\n\t}\n\tif c.Express[provider] == nil {\n\t\tc.Express[provider] = make(map[string]string)\n\t}\n\tc.Express[provider][key] = value\n}\n\n// DeleteExpressConfig removes a config value for an express provider\nfunc (c *Config) DeleteExpressConfig(provider, key string) {\n\tif c.Express == nil || c.Express[provider] == nil {\n\t\treturn\n\t}\n\tdelete(c.Express[provider], key)\n\t// Clean up empty provider map\n\tif len(c.Express[provider]) == 0 {\n\t\tdelete(c.Express, provider)\n\t}\n}\n\n// TwitterConfig holds Twitter/X authentication settings\ntype TwitterConfig struct {\n\t// AuthToken is the auth_token cookie value from browser (for NSFW content)\n\tAuthToken string `yaml:\"auth_token,omitempty\"`\n}\n\n// ServerConfig holds HTTP server settings for `vget serve`\ntype ServerConfig struct {\n\t// Port is the HTTP listen port (default: 8080)\n\tPort int `yaml:\"port,omitempty\"`\n\n\t// MaxConcurrent is the max number of concurrent downloads (default: 10)\n\tMaxConcurrent int `yaml:\"max_concurrent,omitempty\"`\n\n\t// APIKey for authentication (optional, if set all requests must include X-API-Key header)\n\tAPIKey string `yaml:\"api_key,omitempty\"`\n}\n\n// WebDAVServer represents a WebDAV server configuration\ntype WebDAVServer struct {\n\t// URL is the WebDAV server URL (e.g., \"https://pikpak.com/dav\")\n\tURL string `yaml:\"url\"`\n\n\t// Username for authentication\n\tUsername string `yaml:\"username,omitempty\"`\n\n\t// Password for authentication\n\tPassword string `yaml:\"password,omitempty\"`\n}\n\n// GetWebDAVServer returns a WebDAV server by name, or nil if not found\nfunc (c *Config) GetWebDAVServer(name string) *WebDAVServer {\n\tif c.WebDAVServers == nil {\n\t\treturn nil\n\t}\n\tif s, ok := c.WebDAVServers[name]; ok {\n\t\treturn &s\n\t}\n\treturn nil\n}\n\n// SetWebDAVServer adds or updates a WebDAV server\nfunc (c *Config) SetWebDAVServer(name string, server WebDAVServer) {\n\tif c.WebDAVServers == nil {\n\t\tc.WebDAVServers = make(map[string]WebDAVServer)\n\t}\n\tc.WebDAVServers[name] = server\n}\n\n// DeleteWebDAVServer removes a WebDAV server by name\nfunc (c *Config) DeleteWebDAVServer(name string) {\n\tif c.WebDAVServers != nil {\n\t\tdelete(c.WebDAVServers, name)\n\t}\n}\n\n// DefaultDownloadDir returns the default download directory\n// Windows: ~/Downloads/vget\n// macOS: ~/Downloads/vget\n// Linux: ~/downloads\nfunc DefaultDownloadDir() string {\n\t// Docker: use the default container path (users mount their volume here)\n\tif IsRunningInDocker() {\n\t\treturn \"/home/vget/downloads\"\n\t}\n\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"./downloads\"\n\t}\n\n\tswitch runtime.GOOS {\n\tcase \"darwin\", \"windows\":\n\t\treturn filepath.Join(home, \"Downloads\", \"vget\")\n\tdefault:\n\t\t// Linux and others\n\t\treturn filepath.Join(home, \"downloads\")\n\t}\n}\n\n// IsRunningInDocker detects if we're running inside a Docker container\nfunc IsRunningInDocker() bool {\n\t// Check for .dockerenv file\n\tif _, err := os.Stat(\"/.dockerenv\"); err == nil {\n\t\treturn true\n\t}\n\t// Check cgroup\n\tif data, err := os.ReadFile(\"/proc/1/cgroup\"); err == nil {\n\t\tcontent := string(data)\n\t\tif strings.Contains(content, \"docker\") || strings.Contains(content, \"containerd\") {\n\t\t\treturn true\n\t\t}\n\t}\n\t// Check for kubernetes\n\tif os.Getenv(\"KUBERNETES_SERVICE_HOST\") != \"\" {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// DefaultConfig returns a config with sensible defaults\nfunc DefaultConfig() *Config {\n\treturn &Config{\n\t\tLanguage:  \"zh\",\n\t\tOutputDir: DefaultDownloadDir(),\n\t\tFormat:    \"mp4\",\n\t\tQuality:   \"best\",\n\t}\n}\n\n// Exists checks if config file exists\nfunc Exists() bool {\n\tpath, err := ConfigPath()\n\tif err != nil {\n\t\treturn false\n\t}\n\t_, err = os.Stat(path)\n\treturn err == nil\n}\n\n// Load reads the config from ~/.config/vget/config.yml\nfunc Load() (*Config, error) {\n\tpath, err := ConfigPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"config file not found: %w\", err)\n\t}\n\n\tcfg := &Config{}\n\tif err := yaml.Unmarshal(data, cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse %s: %w\", path, err)\n\t}\n\n\t// Expand tilde in OutputDir\n\tcfg.OutputDir = expandPath(cfg.OutputDir)\n\n\treturn cfg, nil\n}\n\n// expandPath expands the tilde (~) in the path to the user's home directory.\n// It handles both forward and backward slashes to ensure cross-platform compatibility\n// for configuration files.\nfunc expandPath(path string) string {\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif strings.HasPrefix(path, \"~\") {\n\t\t// Only expand if it's explicitly \"~\", \"~/\", or \"~\\\"\n\t\tif len(path) == 1 || path[1] == '/' || path[1] == '\\\\' {\n\t\t\thome, err := os.UserHomeDir()\n\t\t\tif err == nil {\n\t\t\t\tsubPath := path[1:]\n\t\t\t\t// Handle the separator manually to ensure clean join across platforms\n\t\t\t\t// This allows \"~\\Downloads\" to work correctly on macOS/Linux as well\n\t\t\t\tif len(subPath) > 0 && (subPath[0] == '/' || subPath[0] == '\\\\') {\n\t\t\t\t\tsubPath = subPath[1:]\n\t\t\t\t}\n\t\t\t\treturn filepath.Join(home, subPath)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn path\n}\n\n// Save writes the config to ~/.config/vget/config.yml\nfunc Save(cfg *Config) error {\n\tdata, err := yaml.Marshal(cfg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to serialize config: %w\", err)\n\t}\n\n\tconfigPath, err := ConfigPath()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get config path: %w\", err)\n\t}\n\n\t// Ensure config directory exists\n\tconfigDir := filepath.Dir(configPath)\n\tif err := os.MkdirAll(configDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create config directory: %w\", err)\n\t}\n\n\t// Add a header comment\n\theader := \"# vget configuration file\\n# Run 'vget init' to regenerate with defaults\\n\\n\"\n\tcontent := header + string(data)\n\n\treturn os.WriteFile(configPath, []byte(content), 0644)\n}\n\n// SavePath returns the path where config will be saved\nfunc SavePath() string {\n\tif path, err := ConfigPath(); err == nil {\n\t\treturn path\n\t}\n\treturn \"config.yml\"\n}\n\n// Init creates a new config.yml with default values\nfunc Init() error {\n\tif Exists() {\n\t\tpath, _ := ConfigPath()\n\t\treturn fmt.Errorf(\"%s already exists\", path)\n\t}\n\treturn Save(DefaultConfig())\n}\n\n// LoadOrDefault loads config if it exists, otherwise returns defaults.\n// It also applies defaults for any empty fields in the loaded config.\nfunc LoadOrDefault() *Config {\n\tcfg, err := Load()\n\tif err != nil {\n\t\treturn DefaultConfig()\n\t}\n\n\t// Apply defaults for empty fields (as documented in \"vget config unset\")\n\tdefaults := DefaultConfig()\n\tif cfg.Language == \"\" {\n\t\tcfg.Language = defaults.Language\n\t}\n\tif cfg.OutputDir == \"\" {\n\t\tcfg.OutputDir = defaults.OutputDir\n\t}\n\tif cfg.Format == \"\" {\n\t\tcfg.Format = defaults.Format\n\t}\n\tif cfg.Quality == \"\" {\n\t\tcfg.Quality = defaults.Quality\n\t}\n\n\treturn cfg\n}\n\n"
  },
  {
    "path": "internal/core/config/config_test.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestExpandPath(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"Empty path\",\n\t\t\tinput:    \"\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Absolute path\",\n\t\t\tinput:    \"/absolute/path\",\n\t\t\texpected: \"/absolute/path\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Relative path\",\n\t\t\tinput:    \"relative/path\",\n\t\t\texpected: \"relative/path\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Home directory only\",\n\t\t\tinput:    \"~\",\n\t\t\texpected: home,\n\t\t},\n\t\t{\n\t\t\tname:     \"Home directory with forward slash\",\n\t\t\tinput:    \"~/Downloads\",\n\t\t\texpected: filepath.Join(home, \"Downloads\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"Home directory with backslash (simulated)\",\n\t\t\tinput:    `~\\Downloads`,\n\t\t\texpected: filepath.Join(home, \"Downloads\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid tilde use (middle)\",\n\t\t\tinput:    \"/path/~/test\",\n\t\t\texpected: \"/path/~/test\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid tilde use (no separator)\",\n\t\t\tinput:    \"~user\",\n\t\t\texpected: \"~user\", // We don't support ~user expansion currently\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := expandPath(tt.input)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"expandPath(%q) = %q; want %q\", tt.input, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/core/config/sites.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst SitesFileName = \"sites.yml\"\n\n// Site represents a site configuration for browser-based extraction\ntype Site struct {\n\t// Match is a substring to match against the URL (e.g., \"kanav.ad\")\n\tMatch string `yaml:\"match\"`\n\n\t// Type is the media type to extract (e.g., \"m3u8\", \"mp4\")\n\tType string `yaml:\"type\"`\n}\n\n// SitesConfig holds the sites configuration\ntype SitesConfig struct {\n\tSites []Site `yaml:\"sites\"`\n}\n\n// LoadSites reads sites.yml from the current directory\nfunc LoadSites() (*SitesConfig, error) {\n\tdata, err := os.ReadFile(SitesFileName)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil // No sites.yml, that's fine\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to read %s: %w\", SitesFileName, err)\n\t}\n\n\tcfg := &SitesConfig{}\n\tif err := yaml.Unmarshal(data, cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse %s: %w\", SitesFileName, err)\n\t}\n\n\treturn cfg, nil\n}\n\n// SaveSites writes sites.yml to the current directory\nfunc SaveSites(cfg *SitesConfig) error {\n\tdata, err := yaml.Marshal(cfg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to serialize sites config: %w\", err)\n\t}\n\n\theader := \"# vget sites configuration\\n# Sites that require browser-based extraction\\n# Run 'vget config sites' to manage\\n\\n\"\n\tcontent := header + string(data)\n\n\treturn os.WriteFile(SitesFileName, []byte(content), 0644)\n}\n\n// MatchSite finds a matching site for the given URL\nfunc (c *SitesConfig) MatchSite(url string) *Site {\n\tif c == nil {\n\t\treturn nil\n\t}\n\tfor i := range c.Sites {\n\t\tif strings.Contains(url, c.Sites[i].Match) {\n\t\t\treturn &c.Sites[i]\n\t\t}\n\t}\n\treturn nil\n}\n\n// AddSite adds a new site configuration\nfunc (c *SitesConfig) AddSite(match, mediaType string) {\n\tc.Sites = append(c.Sites, Site{\n\t\tMatch: match,\n\t\tType:  mediaType,\n\t})\n}\n\n// RemoveSite removes a site by match string\nfunc (c *SitesConfig) RemoveSite(match string) bool {\n\tfor i := range c.Sites {\n\t\tif c.Sites[i].Match == match {\n\t\t\tc.Sites = append(c.Sites[:i], c.Sites[i+1:]...)\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SitesExist checks if sites.yml exists in current directory\nfunc SitesExist() bool {\n\t_, err := os.Stat(SitesFileName)\n\treturn err == nil\n}\n"
  },
  {
    "path": "internal/core/config/wizard.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n)\n\nconst asciiArt = `\n ██╗   ██╗ ██████╗ ███████╗████████╗\n ██║   ██║██╔════╝ ██╔════╝╚══██╔══╝\n ██║   ██║██║  ███╗█████╗     ██║\n ╚██╗ ██╔╝██║   ██║██╔══╝     ██║\n  ╚████╔╝ ╚██████╔╝███████╗   ██║\n   ╚═══╝   ╚═════╝ ╚══════╝   ╚═╝\n`\n\nvar (\n\tlogoStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color(\"86\")).Bold(true)\n\ttitleStyle       = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"86\"))\n\tstepStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color(\"248\"))\n\tselectedStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color(\"86\")).Bold(true)\n\tunselectedStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color(\"252\"))\n\tcursorStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color(\"86\"))\n\thelpStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color(\"245\"))\n\tinputStyle       = lipgloss.NewStyle().Foreground(lipgloss.Color(\"252\"))\n\tinputCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(\"86\")).Bold(true)\n\tlabelStyle       = lipgloss.NewStyle().Foreground(lipgloss.Color(\"248\")).Width(14)\n\tvalueStyle       = lipgloss.NewStyle().Foreground(lipgloss.Color(\"86\"))\n\tcontainerStyle   = lipgloss.NewStyle().Padding(2, 4)\n)\n\ntype model struct {\n\tcurrentStep int\n\tcursor      int\n\tconfig      *Config\n\tconfirmed   bool\n\tcancelled   bool\n\tinputBuffer string\n\tinputCursor int\n\twidth       int\n\theight      int\n}\n\nfunc initialModel(cfg *Config) model {\n\tm := model{\n\t\tcurrentStep: 0,\n\t\tcursor:      0,\n\t\tconfig:      cfg,\n\t}\n\n\t// Set initial cursor position for language\n\tm.setCursorFromConfig()\n\n\treturn m\n}\n\nfunc (m *model) t() *i18n.Translations {\n\treturn i18n.GetTranslations(m.config.Language)\n}\n\nfunc (m *model) getStepTitle() string {\n\tt := m.t()\n\tswitch m.currentStep {\n\tcase 0:\n\t\treturn t.Config.Language\n\tcase 1:\n\t\treturn t.Config.OutputDir\n\tcase 2:\n\t\treturn t.Config.Format\n\tcase 3:\n\t\treturn t.Config.Quality\n\tcase 4:\n\t\treturn t.Config.Confirm\n\t}\n\treturn \"\"\n}\n\nfunc (m *model) getStepDescription() string {\n\tt := m.t()\n\tswitch m.currentStep {\n\tcase 0:\n\t\treturn t.Config.LanguageDesc\n\tcase 1:\n\t\treturn t.Config.OutputDirDesc\n\tcase 2:\n\t\treturn t.Config.FormatDesc\n\tcase 3:\n\t\treturn t.Config.QualityDesc\n\tcase 4:\n\t\treturn t.Config.ConfirmDesc\n\t}\n\treturn \"\"\n}\n\nfunc (m *model) getOptions() []struct{ label, value string } {\n\tt := m.t()\n\tswitch m.currentStep {\n\tcase 0:\n\t\topts := make([]struct{ label, value string }, len(i18n.SupportedLanguages))\n\t\tfor i, lang := range i18n.SupportedLanguages {\n\t\t\topts[i] = struct{ label, value string }{lang.Name, lang.Code}\n\t\t}\n\t\treturn opts\n\tcase 2:\n\t\treturn []struct{ label, value string }{\n\t\t\t{\"MP4 \" + t.Config.Recommended, \"mp4\"},\n\t\t\t{\"WebM\", \"webm\"},\n\t\t\t{\"MKV\", \"mkv\"},\n\t\t\t{t.Config.BestAvailable, \"best\"},\n\t\t}\n\tcase 3:\n\t\treturn []struct{ label, value string }{\n\t\t\t{t.Config.BestAvailable, \"best\"},\n\t\t\t{\"4K (2160p)\", \"2160p\"},\n\t\t\t{\"1080p\", \"1080p\"},\n\t\t\t{\"720p\", \"720p\"},\n\t\t\t{\"480p\", \"480p\"},\n\t\t}\n\tcase 4:\n\t\treturn []struct{ label, value string }{\n\t\t\t{t.Config.YesSave, \"yes\"},\n\t\t\t{t.Config.NoCancel, \"no\"},\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (m *model) isInputStep() bool {\n\treturn m.currentStep == 1 // Only output dir is input step now\n}\n\nfunc (m *model) setCursorFromConfig() {\n\tif m.isInputStep() {\n\t\tswitch m.currentStep {\n\t\tcase 1:\n\t\t\tif m.config.OutputDir != \"\" {\n\t\t\t\tm.inputBuffer = m.config.OutputDir\n\t\t\t} else {\n\t\t\t\tm.inputBuffer = DefaultDownloadDir()\n\t\t\t}\n\t\t}\n\t\tm.inputCursor = len(m.inputBuffer)\n\t\treturn\n\t}\n\n\tvar currentValue string\n\tswitch m.currentStep {\n\tcase 0:\n\t\tcurrentValue = m.config.Language\n\tcase 2:\n\t\tcurrentValue = m.config.Format\n\tcase 3:\n\t\tcurrentValue = m.config.Quality\n\t}\n\n\toptions := m.getOptions()\n\tfor i, opt := range options {\n\t\tif opt.value == currentValue {\n\t\t\tm.cursor = i\n\t\t\tbreak\n\t\t}\n\t}\n}\n\nfunc (m model) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tm.width = msg.Width\n\t\tm.height = msg.Height\n\t\treturn m, nil\n\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\", \"esc\":\n\t\t\tm.cancelled = true\n\t\t\treturn m, tea.Quit\n\n\t\tcase \"left\":\n\t\t\tif m.currentStep > 0 {\n\t\t\t\tm.saveCurrentValue()\n\t\t\t\tm.currentStep--\n\t\t\t\tm.cursor = 0\n\t\t\t\tm.setCursorFromConfig()\n\t\t\t}\n\t\t\treturn m, nil\n\n\t\tcase \"right\", \"enter\":\n\t\t\tm.saveCurrentValue()\n\n\t\t\tif m.currentStep == 4 {\n\t\t\t\t// Confirmation step\n\t\t\t\tif m.cursor == 0 {\n\t\t\t\t\tm.confirmed = true\n\t\t\t\t} else {\n\t\t\t\t\tm.cancelled = true\n\t\t\t\t}\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\n\t\t\tm.currentStep++\n\t\t\tm.cursor = 0\n\t\t\tm.setCursorFromConfig()\n\t\t\treturn m, nil\n\n\t\tcase \"up\", \"k\":\n\t\t\tif !m.isInputStep() {\n\t\t\t\toptions := m.getOptions()\n\t\t\t\tif m.cursor > 0 {\n\t\t\t\t\tm.cursor--\n\t\t\t\t} else {\n\t\t\t\t\tm.cursor = len(options) - 1\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn m, nil\n\n\t\tcase \"down\", \"j\":\n\t\t\tif !m.isInputStep() {\n\t\t\t\toptions := m.getOptions()\n\t\t\t\tif m.cursor < len(options)-1 {\n\t\t\t\t\tm.cursor++\n\t\t\t\t} else {\n\t\t\t\t\tm.cursor = 0\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn m, nil\n\n\t\tcase \"backspace\":\n\t\t\tif m.isInputStep() && len(m.inputBuffer) > 0 {\n\t\t\t\tm.inputBuffer = m.inputBuffer[:len(m.inputBuffer)-1]\n\t\t\t}\n\t\t\treturn m, nil\n\n\t\tdefault:\n\t\t\tif m.isInputStep() && len(msg.String()) == 1 {\n\t\t\t\tm.inputBuffer += msg.String()\n\t\t\t}\n\t\t\treturn m, nil\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (m *model) saveCurrentValue() {\n\tif m.isInputStep() {\n\t\tswitch m.currentStep {\n\t\tcase 1:\n\t\t\tm.config.OutputDir = m.inputBuffer\n\t\t}\n\t\treturn\n\t}\n\n\toptions := m.getOptions()\n\tif m.cursor < len(options) {\n\t\tvalue := options[m.cursor].value\n\t\tswitch m.currentStep {\n\t\tcase 0:\n\t\t\tm.config.Language = value\n\t\tcase 2:\n\t\t\tm.config.Format = value\n\t\tcase 3:\n\t\t\tm.config.Quality = value\n\t\t}\n\t}\n}\n\nfunc (m model) View() string {\n\tvar b strings.Builder\n\tt := m.t()\n\n\t// Logo\n\tb.WriteString(logoStyle.Render(asciiArt))\n\tb.WriteString(\"\\n\\n\")\n\n\t// Progress indicator\n\tprogress := fmt.Sprintf(t.Config.StepOf, m.currentStep+1, 5)\n\tb.WriteString(stepStyle.Render(progress))\n\tb.WriteString(\"\\n\\n\")\n\n\t// Title\n\tb.WriteString(titleStyle.Render(m.getStepTitle()))\n\tb.WriteString(\"\\n\")\n\tb.WriteString(stepStyle.Render(m.getStepDescription()))\n\tb.WriteString(\"\\n\\n\")\n\n\t// Content\n\tif m.currentStep == 4 {\n\t\t// Review step\n\t\tb.WriteString(m.renderReview())\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\tif m.isInputStep() {\n\t\t// Input field\n\t\tb.WriteString(inputCursorStyle.Render(\"> \"))\n\t\tb.WriteString(inputStyle.Render(m.inputBuffer))\n\t\tb.WriteString(inputCursorStyle.Render(\"█\"))\n\t\tb.WriteString(\"\\n\")\n\t} else {\n\t\t// Options\n\t\toptions := m.getOptions()\n\t\tfor i, opt := range options {\n\t\t\tcursor := \"  \"\n\t\t\tstyle := unselectedStyle\n\t\t\tif i == m.cursor {\n\t\t\t\tcursor = cursorStyle.Render(\"> \")\n\t\t\t\tstyle = selectedStyle\n\t\t\t}\n\t\t\tb.WriteString(cursor)\n\t\t\tb.WriteString(style.Render(opt.label))\n\t\t\tb.WriteString(\"\\n\")\n\t\t}\n\t}\n\n\t// Help\n\tb.WriteString(\"\\n\")\n\thelp := fmt.Sprintf(\"← %s • → %s • ↑↓ %s • enter %s • esc %s\",\n\t\tt.Help.Back, t.Help.Next, t.Help.Select, t.Help.Confirm, t.Help.Quit)\n\tb.WriteString(helpStyle.Render(help))\n\n\t// Apply padding\n\tcontent := containerStyle.Render(b.String())\n\n\t// Make it fullscreen\n\tif m.width > 0 && m.height > 0 {\n\t\tcontent = lipgloss.Place(m.width, m.height, lipgloss.Left, lipgloss.Top, content)\n\t}\n\n\treturn content\n}\n\nfunc (m model) renderReview() string {\n\tvar b strings.Builder\n\tt := m.t()\n\n\toutputDir := m.config.OutputDir\n\tif outputDir == \"\" {\n\t\toutputDir = DefaultDownloadDir()\n\t}\n\n\tlines := []struct {\n\t\tlabel string\n\t\tvalue string\n\t}{\n\t\t{t.ConfigReview.Language, getLanguageName(m.config.Language)},\n\t\t{t.ConfigReview.OutputDir, outputDir},\n\t\t{t.ConfigReview.Format, m.config.Format},\n\t\t{t.ConfigReview.Quality, m.config.Quality},\n\t}\n\n\tfor _, line := range lines {\n\t\tb.WriteString(labelStyle.Render(line.label + \":\"))\n\t\tb.WriteString(valueStyle.Render(line.value))\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\treturn b.String()\n}\n\n// RunInitWizard runs an interactive TUI wizard to configure vget\nfunc RunInitWizard() (*Config, error) {\n\t// Load existing config or use defaults\n\tcfg := LoadOrDefault()\n\n\tm := initialModel(cfg)\n\tp := tea.NewProgram(m, tea.WithAltScreen())\n\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := finalModel.(model)\n\tif result.cancelled {\n\t\treturn nil, fmt.Errorf(\"configuration cancelled\")\n\t}\n\n\t// Set defaults for empty values\n\tif result.config.OutputDir == \"\" {\n\t\tresult.config.OutputDir = DefaultDownloadDir()\n\t}\n\n\treturn result.config, nil\n}\n\nfunc getLanguageName(code string) string {\n\tnames := map[string]string{\n\t\t\"en\": \"English\",\n\t\t\"zh\": \"中文\",\n\t\t\"ja\": \"日本語\",\n\t\t\"ko\": \"한국어\",\n\t\t\"es\": \"Español\",\n\t\t\"fr\": \"Français\",\n\t\t\"de\": \"Deutsch\",\n\t}\n\tif name, ok := names[code]; ok {\n\t\treturn name\n\t}\n\treturn code\n}\n"
  },
  {
    "path": "internal/core/downloader/downloader.go",
    "content": "package downloader\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n)\n\n// DefaultUserAgent is the default User-Agent header used for downloads\nconst DefaultUserAgent = \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n\n// Downloader handles file downloads with progress reporting\ntype Downloader struct {\n\tlang string\n}\n\n// New creates a new Downloader\nfunc New(lang string) *Downloader {\n\treturn &Downloader{\n\t\tlang: lang,\n\t}\n}\n\n// Download downloads a file from URL to the specified path using TUI\nfunc (d *Downloader) Download(url, output, videoID string) error {\n\treturn RunDownloadTUI(url, output, videoID, d.lang, nil)\n}\n\n// DownloadWithHeaders downloads a file from URL with custom headers\nfunc (d *Downloader) DownloadWithHeaders(url, output, videoID string, headers map[string]string) error {\n\treturn RunDownloadTUI(url, output, videoID, d.lang, headers)\n}\n\n// DownloadFromReader downloads from an io.ReadCloser to the specified path using TUI\n// This is useful for WebDAV and other sources that provide a reader instead of URL\nfunc (d *Downloader) DownloadFromReader(reader io.ReadCloser, size int64, output, displayID string) error {\n\treturn RunDownloadFromReaderTUI(reader, size, output, displayID, d.lang)\n}\n\nfunc formatBytes(b int64) string {\n\tconst unit = 1024\n\tif b < unit {\n\t\treturn fmt.Sprintf(\"%d B\", b)\n\t}\n\tdiv, exp := int64(unit), 0\n\tfor n := b / unit; n >= unit; n /= unit {\n\t\tdiv *= unit\n\t\texp++\n\t}\n\treturn fmt.Sprintf(\"%.1f %cB\", float64(b)/float64(div), \"KMGTPE\"[exp])\n}\n\nfunc formatDuration(d time.Duration) string {\n\tif d < 0 {\n\t\treturn \"??:??\"\n\t}\n\td = d.Round(time.Second)\n\tm := d / time.Minute\n\ts := (d % time.Minute) / time.Second\n\tif m > 60 {\n\t\th := m / 60\n\t\tm = m % 60\n\t\treturn fmt.Sprintf(\"%d:%02d:%02d\", h, m, s)\n\t}\n\treturn fmt.Sprintf(\"%d:%02d\", m, s)\n}\n"
  },
  {
    "path": "internal/core/downloader/ffmpeg.go",
    "content": "package downloader\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n)\n\n// FFmpegAvailable checks if ffmpeg is installed and available in PATH\nfunc FFmpegAvailable() bool {\n\t_, err := exec.LookPath(\"ffmpeg\")\n\treturn err == nil\n}\n\n// MergeVideoAudio merges separate video and audio files into a single output file using ffmpeg.\n// Uses stream copy (-c copy) for fast merging without re-encoding.\n// If deleteOriginals is true, removes the source files after successful merge.\n// Returns the path to the merged file.\nfunc MergeVideoAudio(videoPath, audioPath, outputPath string, deleteOriginals bool) error {\n\tif !FFmpegAvailable() {\n\t\treturn fmt.Errorf(\"ffmpeg not found in PATH\")\n\t}\n\n\t// Log ffmpeg version for debugging\n\tversionCmd := exec.Command(\"ffmpeg\", \"-version\")\n\tversionOut, _ := versionCmd.Output()\n\tversionLine := strings.Split(string(versionOut), \"\\n\")[0]\n\tlog.Printf(\"[ffmpeg] version: %s\", versionLine)\n\n\t// Check input files and log their sizes\n\tvideoInfo, err := os.Stat(videoPath)\n\tif err != nil {\n\t\tlog.Printf(\"[ffmpeg] ERROR: video file not found: %s\", videoPath)\n\t\treturn fmt.Errorf(\"video file not found: %w\", err)\n\t}\n\taudioInfo, err := os.Stat(audioPath)\n\tif err != nil {\n\t\tlog.Printf(\"[ffmpeg] ERROR: audio file not found: %s\", audioPath)\n\t\treturn fmt.Errorf(\"audio file not found: %w\", err)\n\t}\n\n\tlog.Printf(\"[ffmpeg] input video: %s (%d bytes)\", videoPath, videoInfo.Size())\n\tlog.Printf(\"[ffmpeg] input audio: %s (%d bytes)\", audioPath, audioInfo.Size())\n\tlog.Printf(\"[ffmpeg] output path: %s\", outputPath)\n\n\t// Run ffmpeg with stream copy (fast, no re-encoding)\n\t// -threads 1: single thread for stability in containers\n\t// -y: overwrite output file without asking\n\t// -f mp4: explicit output format\n\t// -map 0:v -map 1:a: take video from first input, audio from second\n\targs := []string{\n\t\t\"-threads\", \"1\",\n\t\t\"-i\", videoPath,\n\t\t\"-i\", audioPath,\n\t\t\"-map\", \"0:v\",\n\t\t\"-map\", \"1:a\",\n\t\t\"-c\", \"copy\",\n\t\t\"-f\", \"mp4\",\n\t\t\"-y\",\n\t\toutputPath,\n\t}\n\tlog.Printf(\"[ffmpeg] command: ffmpeg %s\", strings.Join(args, \" \"))\n\n\tcmd := exec.Command(\"ffmpeg\", args...)\n\toutput, err := cmd.CombinedOutput()\n\n\tif err != nil {\n\t\tlog.Printf(\"[ffmpeg] ERROR: merge failed: %v\", err)\n\t\tlog.Printf(\"[ffmpeg] output:\\n%s\", string(output))\n\t\treturn fmt.Errorf(\"ffmpeg merge failed: %w\\nOutput: %s\", err, string(output))\n\t}\n\n\t// Check output file\n\toutputInfo, err := os.Stat(outputPath)\n\tif err != nil {\n\t\tlog.Printf(\"[ffmpeg] ERROR: output file not created: %s\", outputPath)\n\t\treturn fmt.Errorf(\"output file not created: %w\", err)\n\t}\n\n\tlog.Printf(\"[ffmpeg] output file: %s (%d bytes)\", outputPath, outputInfo.Size())\n\n\t// Warn if output is suspiciously small\n\tinputTotal := videoInfo.Size() + audioInfo.Size()\n\tif outputInfo.Size() < 1024 || outputInfo.Size() < inputTotal/10 {\n\t\tlog.Printf(\"[ffmpeg] WARNING: output file is suspiciously small (%d bytes from %d bytes input)\", outputInfo.Size(), inputTotal)\n\t\tlog.Printf(\"[ffmpeg] ffmpeg output:\\n%s\", string(output))\n\t} else {\n\t\tlog.Printf(\"[ffmpeg] merge successful\")\n\t}\n\n\t// Delete original files if requested\n\tif deleteOriginals {\n\t\tif err := os.Remove(videoPath); err != nil {\n\t\t\tlog.Printf(\"[ffmpeg] warning: could not remove video file: %v\", err)\n\t\t}\n\t\tif err := os.Remove(audioPath); err != nil {\n\t\t\tlog.Printf(\"[ffmpeg] warning: could not remove audio file: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n"
  },
  {
    "path": "internal/core/downloader/hls.go",
    "content": "package downloader\n\nimport (\n\t\"context\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/tetratelabs/wazero\"\n\n\t\"codeberg.org/gruf/go-ffmpreg/ffmpreg\"\n\t\"codeberg.org/gruf/go-ffmpreg/wasm\"\n)\n\n// HLSConfig holds configuration for HLS downloads\ntype HLSConfig struct {\n\tWorkers    int // Number of parallel segment downloads\n\tBufferSize int // Buffer size for reading segments\n}\n\n// DefaultHLSConfig returns default HLS configuration\nfunc DefaultHLSConfig() HLSConfig {\n\treturn HLSConfig{\n\t\tWorkers:    8,\n\t\tBufferSize: 512 * 1024, // 512KB\n\t}\n}\n\n// hlsState tracks HLS download progress\ntype hlsState struct {\n\tdownloaded    int64 // Segments downloaded (atomic)\n\ttotalSegments int64 // Total segments\n\tbytesWritten  int64 // Total bytes written (atomic)\n}\n\nfunc (s *hlsState) getProgress() (downloaded, total int64) {\n\treturn atomic.LoadInt64(&s.downloaded), s.totalSegments\n}\n\nfunc (s *hlsState) getBytes() int64 {\n\treturn atomic.LoadInt64(&s.bytesWritten)\n}\n\nfunc (s *hlsState) addBytes(n int64) {\n\tatomic.AddInt64(&s.bytesWritten, n)\n}\n\nfunc (s *hlsState) incDownloaded() {\n\tatomic.AddInt64(&s.downloaded, 1)\n}\n\n// RunHLSDownloadTUI downloads an HLS stream with TUI progress\nfunc RunHLSDownloadTUI(m3u8URL, output, displayID, lang string) error {\n\treturn RunHLSDownloadWithHeadersTUI(m3u8URL, output, displayID, lang, nil)\n}\n\n// RunHLSDownloadWithHeadersTUI downloads an HLS stream with custom headers and TUI progress\nfunc RunHLSDownloadWithHeadersTUI(m3u8URL, output, displayID, lang string, headers map[string]string) error {\n\tstate := &downloadState{startTime: time.Now()}\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\t// Start download in background\n\tgo func() {\n\t\terr := downloadHLSWithHeaders(ctx, m3u8URL, output, state, DefaultHLSConfig(), headers)\n\t\tif err != nil {\n\t\t\tstate.setError(err)\n\t\t} else {\n\t\t\tstate.setDone()\n\t\t}\n\t}()\n\n\t// Run TUI\n\tmodel := newDownloadModel(output, displayID, lang, state)\n\tp := tea.NewProgram(model)\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tm := finalModel.(downloadModel)\n\t_, _, _, _, downloadErr := m.state.get()\n\tif downloadErr != nil {\n\t\treturn downloadErr\n\t}\n\n\t// Convert .ts to .mp4 in Docker environment\n\tmp4Path, err := convertTsToMp4(output)\n\tif err != nil {\n\t\t// Log warning but don't fail - the .ts file is still usable\n\t\tfmt.Printf(\"Warning: %v\\n\", err)\n\t} else if mp4Path != output {\n\t\tfmt.Printf(\"Converted to: %s\\n\", mp4Path)\n\t}\n\n\treturn nil\n}\n\n \n\n// downloadHLSWithHeaders downloads an HLS stream with custom headers\nfunc downloadHLSWithHeaders(ctx context.Context, m3u8URL, output string, state *downloadState, config HLSConfig, headers map[string]string) error {\n\t// Parse the m3u8 playlist\n\tplaylist, err := ParseM3U8WithHeaders(m3u8URL, headers)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse m3u8: %w\", err)\n\t}\n\n\t// If master playlist, get the best variant and parse it\n\tif playlist.IsMaster {\n\t\tvariant := playlist.SelectBestVariant()\n\t\tif variant == nil {\n\t\t\treturn fmt.Errorf(\"no variants found in master playlist\")\n\t\t}\n\t\tplaylist, err = ParseM3U8WithHeaders(variant.URL, headers)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse variant playlist: %w\", err)\n\t\t}\n\t}\n\n\tif len(playlist.Segments) == 0 {\n\t\treturn fmt.Errorf(\"no segments found in playlist\")\n\t}\n\n\t// Get encryption key if needed\n\tvar decryptKey []byte\n\tvar decryptIV []byte\n\tif playlist.IsEncrypted && playlist.KeyURL != \"\" {\n\t\tdecryptKey, err = fetchKeyWithHeaders(playlist.KeyURL, headers)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to fetch encryption key: %w\", err)\n\t\t}\n\t\tif playlist.KeyIV != \"\" {\n\t\t\tdecryptIV, _ = hex.DecodeString(playlist.KeyIV)\n\t\t}\n\t}\n\n\t// Create output file\n\tfile, err := os.Create(output)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\t// Set up progress tracking\n\t// For HLS we estimate total size (unknown until download complete)\n\t// We'll use segment count for progress\n\ttotalSegments := int64(len(playlist.Segments))\n\thlsState := &hlsState{totalSegments: totalSegments}\n\n\t// Progress updater\n\tprogressDone := make(chan struct{})\n\tgo func() {\n\t\tticker := time.NewTicker(100 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-progressDone:\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tdownloaded, total := hlsState.getProgress()\n\t\t\t\tbytes := hlsState.getBytes()\n\t\t\t\t// Estimate total bytes based on progress\n\t\t\t\tif downloaded > 0 {\n\t\t\t\t\testimatedTotal := bytes * total / downloaded\n\t\t\t\t\tstate.update(bytes, estimatedTotal)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\tdefer close(progressDone)\n\n\t// Download segments\n\t// We need to maintain order, so we download in parallel but write sequentially\n\terr = downloadSegmentsOrdered(ctx, playlist.Segments, file, decryptKey, decryptIV, hlsState, config, headers)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// downloadSegmentsOrdered downloads segments in parallel but writes them in order\nfunc downloadSegmentsOrdered(ctx context.Context, segments []Segment, file *os.File,\n\tdecryptKey, decryptIV []byte, hlsState *hlsState, config HLSConfig, headers map[string]string) error {\n\n\ttype segmentResult struct {\n\t\tindex int\n\t\tdata  []byte\n\t\terr   error\n\t}\n\n\t// Buffer to hold downloaded segments waiting to be written\n\tresults := make(map[int][]byte)\n\tresultsChan := make(chan segmentResult, config.Workers)\n\tvar resultsLock sync.Mutex\n\n\t// Segment queue\n\tsegmentChan := make(chan Segment, len(segments))\n\tfor _, seg := range segments {\n\t\tsegmentChan <- seg\n\t}\n\tclose(segmentChan)\n\n\t// Create HTTP client\n\tclient := &http.Client{\n\t\tTimeout: 60 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tProxy:               http.ProxyFromEnvironment,\n\t\t\tMaxIdleConnsPerHost: config.Workers * 2,\n\t\t\tDisableCompression:  true,\n\t\t},\n\t}\n\n\t// Start workers\n\tvar wg sync.WaitGroup\n\tfor i := 0; i < config.Workers; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor seg := range segmentChan {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn\n\t\t\t\tdefault:\n\t\t\t\t}\n\n\t\t\t\tdata, err := downloadSegment(client, seg.URL, decryptKey, decryptIV, seg.Index, headers)\n\t\t\t\tresultsChan <- segmentResult{\n\t\t\t\t\tindex: seg.Index,\n\t\t\t\t\tdata:  data,\n\t\t\t\t\terr:   err,\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\t// Close results channel when all workers done\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultsChan)\n\t}()\n\n\t// Collect results and write in order\n\tnextIndex := 0\n\tvar writeErr error\n\n\tfor result := range resultsChan {\n\t\tif result.err != nil {\n\t\t\twriteErr = result.err\n\t\t\tcontinue\n\t\t}\n\n\t\tresultsLock.Lock()\n\t\tresults[result.index] = result.data\n\t\thlsState.incDownloaded()\n\n\t\t// Write all consecutive segments we have\n\t\tfor {\n\t\t\tif data, ok := results[nextIndex]; ok {\n\t\t\t\t_, err := file.Write(data)\n\t\t\t\tif err != nil {\n\t\t\t\t\twriteErr = err\n\t\t\t\t\tresultsLock.Unlock()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\thlsState.addBytes(int64(len(data)))\n\t\t\t\tdelete(results, nextIndex)\n\t\t\t\tnextIndex++\n\t\t\t} else {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tresultsLock.Unlock()\n\t}\n\n\tif writeErr != nil {\n\t\treturn fmt.Errorf(\"failed to write segment: %w\", writeErr)\n\t}\n\n\treturn nil\n}\n\n// downloadSegment downloads a single segment\nfunc downloadSegment(client *http.Client, url string, decryptKey, decryptIV []byte, index int, headers map[string]string) ([]byte, error) {\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\n\t// Apply custom headers\n\tfor key, value := range headers {\n\t\treq.Header.Set(key, value)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"segment %d returned status %d\", index, resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Decrypt if needed\n\tif decryptKey != nil {\n\t\tdata, err = decryptAES128(data, decryptKey, decryptIV, index)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to decrypt segment %d: %w\", index, err)\n\t\t}\n\t}\n\n\treturn data, nil\n}\n\n// fetchKeyWithHeaders fetches the encryption key from the URL with custom headers\nfunc fetchKeyWithHeaders(url string, headers map[string]string) ([]byte, error) {\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\n\t// Apply custom headers\n\tfor key, value := range headers {\n\t\treq.Header.Set(key, value)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"key server returned status %d\", resp.StatusCode)\n\t}\n\n\treturn io.ReadAll(resp.Body)\n}\n\n// decryptAES128 decrypts AES-128-CBC encrypted data\nfunc decryptAES128(data, key, iv []byte, segmentIndex int) ([]byte, error) {\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// If no IV provided, use segment index as IV (per HLS spec)\n\tif iv == nil {\n\t\tiv = make([]byte, 16)\n\t\t// Segment sequence number as big-endian 128-bit integer\n\t\tiv[15] = byte(segmentIndex)\n\t\tiv[14] = byte(segmentIndex >> 8)\n\t\tiv[13] = byte(segmentIndex >> 16)\n\t\tiv[12] = byte(segmentIndex >> 24)\n\t}\n\n\tif len(data)%aes.BlockSize != 0 {\n\t\treturn nil, fmt.Errorf(\"ciphertext is not a multiple of block size\")\n\t}\n\n\tmode := cipher.NewCBCDecrypter(block, iv)\n\tmode.CryptBlocks(data, data)\n\n\t// Remove PKCS7 padding\n\tif len(data) > 0 {\n\t\tpadding := int(data[len(data)-1])\n\t\tif padding > 0 && padding <= aes.BlockSize {\n\t\t\tdata = data[:len(data)-padding]\n\t\t}\n\t}\n\n\treturn data, nil\n}\n\n// DownloadHLSWithProgress downloads an HLS stream with a progress callback (for server use)\n// Returns the final output path (may be .mp4 if converted in Docker) and error\nfunc DownloadHLSWithProgress(ctx context.Context, m3u8URL, output string, headers map[string]string, progressFn func(downloaded, total int64)) (string, error) {\n\thlsConfig := DefaultHLSConfig()\n\n\t// Parse the m3u8 playlist\n\tplaylist, err := ParseM3U8WithHeaders(m3u8URL, headers)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse m3u8: %w\", err)\n\t}\n\n\t// If master playlist, get the best variant and parse it\n\tif playlist.IsMaster {\n\t\tvariant := playlist.SelectBestVariant()\n\t\tif variant == nil {\n\t\t\treturn \"\", fmt.Errorf(\"no variants found in master playlist\")\n\t\t}\n\t\tplaylist, err = ParseM3U8WithHeaders(variant.URL, headers)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to parse variant playlist: %w\", err)\n\t\t}\n\t}\n\n\tif len(playlist.Segments) == 0 {\n\t\treturn \"\", fmt.Errorf(\"no segments found in playlist\")\n\t}\n\n\t// Get encryption key if needed\n\tvar decryptKey []byte\n\tvar decryptIV []byte\n\tif playlist.IsEncrypted && playlist.KeyURL != \"\" {\n\t\tdecryptKey, err = fetchKeyWithHeaders(playlist.KeyURL, headers)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to fetch encryption key: %w\", err)\n\t\t}\n\t\tif playlist.KeyIV != \"\" {\n\t\t\tdecryptIV, _ = hex.DecodeString(playlist.KeyIV)\n\t\t}\n\t}\n\n\t// Create output file\n\tfile, err := os.Create(output)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\n\t// Set up progress tracking using segment count\n\ttotalSegments := int64(len(playlist.Segments))\n\thlsState := &hlsState{totalSegments: totalSegments}\n\n\t// Progress updater goroutine\n\tprogressDone := make(chan struct{})\n\tgo func() {\n\t\tticker := time.NewTicker(200 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-progressDone:\n\t\t\t\treturn\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tif progressFn != nil {\n\t\t\t\t\tdownloaded, total := hlsState.getProgress()\n\t\t\t\t\tbytes := hlsState.getBytes()\n\t\t\t\t\t// Report actual bytes with estimated total based on segment progress\n\t\t\t\t\tif downloaded > 0 && bytes > 0 && total > 0 {\n\t\t\t\t\t\testimatedTotal := bytes * total / downloaded\n\t\t\t\t\t\tprogressFn(bytes, estimatedTotal)\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// No estimate yet, report bytes downloaded with unknown total\n\t\t\t\t\t\tprogressFn(bytes, -1)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\tdefer close(progressDone)\n\n\t// Download segments\n\terr = downloadSegmentsOrdered(ctx, playlist.Segments, file, decryptKey, decryptIV, hlsState, hlsConfig, headers)\n\tif err != nil {\n\t\tfile.Close()\n\t\treturn \"\", err\n\t}\n\n\t// Close file before conversion (ffmpeg needs exclusive access)\n\tfile.Close()\n\n\t// Final progress update - download complete\n\tif progressFn != nil {\n\t\tfinalBytes := hlsState.getBytes()\n\t\tprogressFn(finalBytes, finalBytes)\n\t}\n\n\t// Convert .ts to .mp4 in Docker environment\n\tfinalPath, convErr := convertTsToMp4(output)\n\tif convErr != nil {\n\t\t// Log warning but don't fail - the .ts file is still usable\n\t\tfmt.Printf(\"Warning: %v\\n\", convErr)\n\t\treturn output, nil\n\t}\n\n\treturn finalPath, nil\n}\n\n// convertTsToMp4 converts a .ts file to .mp4 using embedded ffmpeg (copy, no re-encoding)\n// Returns the new .mp4 path if conversion succeeded, otherwise returns original path\nfunc convertTsToMp4(tsPath string) (string, error) {\n\t// Only convert .ts files\n\tif !strings.HasSuffix(strings.ToLower(tsPath), \".ts\") {\n\t\treturn tsPath, nil\n\t}\n\n\t// Get absolute path for WASM filesystem mounting\n\tabsPath, err := filepath.Abs(tsPath)\n\tif err != nil {\n\t\treturn tsPath, fmt.Errorf(\"failed to get absolute path: %w\", err)\n\t}\n\n\t// Build mp4 output path\n\tmp4Path := strings.TrimSuffix(absPath, \".ts\") + \".mp4\"\n\tif strings.HasSuffix(absPath, \".TS\") {\n\t\tmp4Path = strings.TrimSuffix(absPath, \".TS\") + \".mp4\"\n\t}\n\n\t// Mount directory for WASM filesystem access\n\tdir := filepath.Dir(absPath)\n\n\t// Run embedded ffmpeg with stream copy (fast, no re-encoding)\n\tctx := context.Background()\n\targs := wasm.Args{\n\t\tStderr: io.Discard,\n\t\tStdout: io.Discard,\n\t\tArgs: []string{\n\t\t\t\"-err_detect\", \"ignore_err\",\n\t\t\t\"-i\", absPath,\n\t\t\t\"-c\", \"copy\",\n\t\t\t\"-y\",\n\t\t\tmp4Path,\n\t\t},\n\t\tConfig: func(cfg wazero.ModuleConfig) wazero.ModuleConfig {\n\t\t\treturn cfg.WithFSConfig(wazero.NewFSConfig().\n\t\t\t\tWithDirMount(dir, dir))\n\t\t},\n\t}\n\n\trc, err := ffmpreg.Ffmpeg(ctx, args)\n\tif err != nil {\n\t\treturn tsPath, fmt.Errorf(\"ffmpeg conversion failed: %w\", err)\n\t}\n\tif rc != 0 {\n\t\treturn tsPath, fmt.Errorf(\"ffmpeg exited with code %d\", rc)\n\t}\n\n\t// Conversion succeeded, delete the .ts file\n\tif err := os.Remove(tsPath); err != nil {\n\t\tfmt.Printf(\"Warning: could not remove original .ts file: %v\\n\", err)\n\t}\n\n\treturn mp4Path, nil\n}\n"
  },
  {
    "path": "internal/core/downloader/hls_parser.go",
    "content": "package downloader\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// M3U8Playlist represents a parsed m3u8 playlist\ntype M3U8Playlist struct {\n\tVariants      []Variant // For master playlists\n\tSegments      []Segment // For media playlists\n\tTotalDuration float64   // Total duration in seconds\n\tIsMaster      bool      // True if this is a master playlist\n\tIsEncrypted   bool      // True if segments are encrypted\n\tKeyURL        string    // URL of encryption key\n\tKeyIV         string    // Initialization vector for encryption\n}\n\n// Variant represents a stream variant in a master playlist\ntype Variant struct {\n\tURL        string\n\tBandwidth  int\n\tResolution string // e.g., \"1920x1080\"\n\tCodecs     string\n\tName       string // Name or description\n}\n\n// Segment represents a single media segment\ntype Segment struct {\n\tURL      string\n\tDuration float64\n\tIndex    int\n\tTitle    string\n}\n\nvar (\n\tbandwidthRegex   = regexp.MustCompile(`BANDWIDTH=(\\d+)`)\n\tresolutionRegex  = regexp.MustCompile(`RESOLUTION=(\\d+x\\d+)`)\n\tcodecsRegex      = regexp.MustCompile(`CODECS=\"([^\"]+)\"`)\n\tnameRegex        = regexp.MustCompile(`NAME=\"([^\"]+)\"`)\n\textinfoRegex     = regexp.MustCompile(`#EXTINF:([\\d.]+)(?:,(.*))?`)\n\tkeyMethodRegex   = regexp.MustCompile(`METHOD=([^,]+)`)\n\tkeyURIRegex      = regexp.MustCompile(`URI=\"([^\"]+)\"`)\n\tkeyIVRegex       = regexp.MustCompile(`IV=0x([0-9a-fA-F]+)`)\n)\n\n// ParseM3U8 parses an m3u8 playlist from a URL\nfunc ParseM3U8(m3u8URL string) (*M3U8Playlist, error) {\n\treturn ParseM3U8WithHeaders(m3u8URL, nil)\n}\n\n// ParseM3U8WithHeaders parses an m3u8 playlist from a URL with custom headers\nfunc ParseM3U8WithHeaders(m3u8URL string, headers map[string]string) (*M3U8Playlist, error) {\n\tclient := &http.Client{\n\t\tTimeout: 60 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tProxy:                  http.ProxyFromEnvironment,\n\t\t\tResponseHeaderTimeout:  30 * time.Second,\n\t\t\tIdleConnTimeout:        90 * time.Second,\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"GET\", m3u8URL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\n\t// Apply custom headers\n\tfor key, value := range headers {\n\t\treq.Header.Set(key, value)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch m3u8: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned status %d\", resp.StatusCode)\n\t}\n\n\treturn parseM3U8Content(resp.Body, m3u8URL)\n}\n\n// parseM3U8Content parses m3u8 content from a reader\nfunc parseM3U8Content(reader io.Reader, baseURL string) (*M3U8Playlist, error) {\n\tscanner := bufio.NewScanner(reader)\n\tplaylist := &M3U8Playlist{}\n\n\tvar currentSegmentDuration float64\n\tvar currentSegmentTitle string\n\tvar segmentIndex int\n\n\t// Parse base URL for resolving relative URLs\n\tbase, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid base URL: %w\", err)\n\t}\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Check for master playlist indicators\n\t\tif strings.HasPrefix(line, \"#EXT-X-STREAM-INF:\") {\n\t\t\tplaylist.IsMaster = true\n\t\t\tvariant := parseVariant(line)\n\n\t\t\t// Next non-comment line should be the URL\n\t\t\tfor scanner.Scan() {\n\t\t\t\tnextLine := strings.TrimSpace(scanner.Text())\n\t\t\t\tif nextLine == \"\" || strings.HasPrefix(nextLine, \"#\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tvariant.URL = resolveURL(base, nextLine)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tplaylist.Variants = append(playlist.Variants, variant)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse encryption key\n\t\tif strings.HasPrefix(line, \"#EXT-X-KEY:\") {\n\t\t\tmethod := extractRegex(keyMethodRegex, line)\n\t\t\tif method != \"NONE\" && method != \"\" {\n\t\t\t\tplaylist.IsEncrypted = true\n\t\t\t\tkeyURI := extractRegex(keyURIRegex, line)\n\t\t\t\tif keyURI != \"\" {\n\t\t\t\t\tplaylist.KeyURL = resolveURL(base, keyURI)\n\t\t\t\t}\n\t\t\t\tplaylist.KeyIV = extractRegex(keyIVRegex, line)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse segment info\n\t\tif strings.HasPrefix(line, \"#EXTINF:\") {\n\t\t\tmatches := extinfoRegex.FindStringSubmatch(line)\n\t\t\tif len(matches) >= 2 {\n\t\t\t\tcurrentSegmentDuration, _ = strconv.ParseFloat(matches[1], 64)\n\t\t\t\tif len(matches) >= 3 {\n\t\t\t\t\tcurrentSegmentTitle = matches[2]\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Skip other directives\n\t\tif strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\t// This is a segment URL\n\t\tif currentSegmentDuration > 0 || !playlist.IsMaster {\n\t\t\tsegment := Segment{\n\t\t\t\tURL:      resolveURL(base, line),\n\t\t\t\tDuration: currentSegmentDuration,\n\t\t\t\tIndex:    segmentIndex,\n\t\t\t\tTitle:    currentSegmentTitle,\n\t\t\t}\n\t\t\tplaylist.Segments = append(playlist.Segments, segment)\n\t\t\tplaylist.TotalDuration += currentSegmentDuration\n\t\t\tsegmentIndex++\n\t\t\tcurrentSegmentDuration = 0\n\t\t\tcurrentSegmentTitle = \"\"\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading m3u8: %w\", err)\n\t}\n\n\treturn playlist, nil\n}\n\n// parseVariant extracts variant information from EXT-X-STREAM-INF line\nfunc parseVariant(line string) Variant {\n\treturn Variant{\n\t\tBandwidth:  extractInt(bandwidthRegex, line),\n\t\tResolution: extractRegex(resolutionRegex, line),\n\t\tCodecs:     extractRegex(codecsRegex, line),\n\t\tName:       extractRegex(nameRegex, line),\n\t}\n}\n\n// resolveURL resolves a potentially relative URL against a base URL\nfunc resolveURL(base *url.URL, ref string) string {\n\trefURL, err := url.Parse(ref)\n\tif err != nil {\n\t\treturn ref\n\t}\n\treturn base.ResolveReference(refURL).String()\n}\n\n// extractRegex extracts the first capturing group from a regex match\nfunc extractRegex(re *regexp.Regexp, s string) string {\n\tmatches := re.FindStringSubmatch(s)\n\tif len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// extractInt extracts an integer from the first capturing group\nfunc extractInt(re *regexp.Regexp, s string) int {\n\tstr := extractRegex(re, s)\n\tif str == \"\" {\n\t\treturn 0\n\t}\n\tval, _ := strconv.Atoi(str)\n\treturn val\n}\n\n// SelectBestVariant returns the highest bandwidth variant\nfunc (p *M3U8Playlist) SelectBestVariant() *Variant {\n\tif len(p.Variants) == 0 {\n\t\treturn nil\n\t}\n\n\tbest := &p.Variants[0]\n\tfor i := range p.Variants {\n\t\tif p.Variants[i].Bandwidth > best.Bandwidth {\n\t\t\tbest = &p.Variants[i]\n\t\t}\n\t}\n\treturn best\n}\n\n// SelectVariantByResolution returns the variant matching the resolution\nfunc (p *M3U8Playlist) SelectVariantByResolution(resolution string) *Variant {\n\tfor i := range p.Variants {\n\t\tif p.Variants[i].Resolution == resolution {\n\t\t\treturn &p.Variants[i]\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/core/downloader/magic.go",
    "content": "package downloader\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// DetectFileType reads the first few bytes of the file to determine its type\n// Returns the suggested extension (without dot) if detected, or empty string if unknown\nfunc DetectFileType(path string) (string, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer f.Close()\n\n\t// Read first 12 bytes\n\t// WEBP needs at least 12 bytes: \"RIFF\" + 4 bytes size + \"WEBP\"\n\theader := make([]byte, 12)\n\tn, err := f.Read(header)\n\tif err != nil && err != io.EOF {\n\t\treturn \"\", err\n\t}\n\tif n < 4 {\n\t\treturn \"\", nil // Too short\n\t}\n\n\t// Check magic bytes\n\n\t// WebP: RIFF....WEBP\n\tif n >= 12 && string(header[0:4]) == \"RIFF\" && string(header[8:12]) == \"WEBP\" {\n\t\treturn \"webp\", nil\n\t}\n\n\t// PNG: 89 50 4E 47 0D 0A 1A 0A\n\tif n >= 8 && bytes.Equal(header[0:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {\n\t\treturn \"png\", nil\n\t}\n\n\t// GIF: GIF87a or GIF89a\n\tif n >= 6 && (string(header[0:6]) == \"GIF87a\" || string(header[0:6]) == \"GIF89a\") {\n\t\treturn \"gif\", nil\n\t}\n\n\t// JPEG: FF D8 FF\n\tif n >= 3 && bytes.Equal(header[0:3], []byte{0xFF, 0xD8, 0xFF}) {\n\t\treturn \"jpg\", nil\n\t}\n\n\treturn \"\", nil\n}\n\n// RenameByMagicBytes checks if the file's actual type differs from its extension\n// and renames it if necessary. Returns the final path (renamed or original).\nfunc RenameByMagicBytes(path string) string {\n\tdetectedExt, err := DetectFileType(path)\n\tif err != nil || detectedExt == \"\" {\n\t\treturn path\n\t}\n\n\text := filepath.Ext(path)\n\tcurrentExt := strings.TrimPrefix(ext, \".\")\n\tif currentExt == \"\" || strings.EqualFold(currentExt, detectedExt) {\n\t\treturn path\n\t}\n\n\tnewPath := path[:len(path)-len(ext)] + \".\" + detectedExt\n\tif err := os.Rename(path, newPath); err != nil {\n\t\treturn path\n\t}\n\treturn newPath\n}\n"
  },
  {
    "path": "internal/core/downloader/multistream.go",
    "content": "package downloader\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\n// MultiStreamConfig configures multi-stream downloads\ntype MultiStreamConfig struct {\n\tStreams    int   // Number of parallel streams (default 12)\n\tChunkSize  int64 // Size of each chunk in bytes (default 16MB)\n\tBufferSize int   // Buffer size per stream (default 1MB)\n\tUseHTTP2   bool  // Enable HTTP/2 (default true, better for HTTPS)\n}\n\n// DefaultMultiStreamConfig returns sensible defaults similar to rclone\nfunc DefaultMultiStreamConfig() MultiStreamConfig {\n\treturn MultiStreamConfig{\n\t\tStreams:    12,               // 12 parallel streams - balanced for stability\n\t\tChunkSize:  8 * 1024 * 1024,  // 8MB chunks - smaller for faster recovery on failure\n\t\tBufferSize: 1024 * 1024,      // 1MB buffer per stream\n\t\tUseHTTP2:   true,             // Enable HTTP/2 by default for better multiplexing\n\t}\n}\n\n// multiStreamState tracks progress across all streams\ntype multiStreamState struct {\n\tdownloaded int64 // atomic counter for total bytes downloaded\n\ttotal      int64\n\tstartTime  time.Time\n\tmu         sync.RWMutex\n\terrors     []error\n}\n\nfunc (s *multiStreamState) addBytes(n int64) {\n\tatomic.AddInt64(&s.downloaded, n)\n}\n\nfunc (s *multiStreamState) getDownloaded() int64 {\n\treturn atomic.LoadInt64(&s.downloaded)\n}\n\nfunc (s *multiStreamState) addError(err error) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.errors = append(s.errors, err)\n}\n\nfunc (s *multiStreamState) getErrors() []error {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\treturn s.errors\n}\n\n// chunk represents a portion of the file to download\ntype chunk struct {\n\tindex int\n\tstart int64\n\tend   int64 // inclusive\n}\n\n// probeRangeSupport checks if the server supports Range requests using a small ranged GET\n// This is more reliable than HEAD because many CDNs only advertise Accept-Ranges on GET\n// Returns: totalSize, supportsRange, error\nfunc probeRangeSupport(ctx context.Context, client *http.Client, url, authHeader string) (int64, bool, error) {\n\t// First try a ranged GET request for just 2 bytes\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn 0, false, err\n\t}\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Range\", \"bytes=0-1\")\n\tif authHeader != \"\" {\n\t\treq.Header.Set(\"Authorization\", authHeader)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn 0, false, err\n\t}\n\tdefer resp.Body.Close()\n\n\t// Drain the small response body\n\tio.Copy(io.Discard, resp.Body)\n\n\tswitch resp.StatusCode {\n\tcase http.StatusPartialContent:\n\t\t// Server supports ranges - parse Content-Range for total size\n\t\t// Format: bytes 0-1/total\n\t\tcontentRange := resp.Header.Get(\"Content-Range\")\n\t\tvar start, end, total int64\n\t\tif _, err := fmt.Sscanf(contentRange, \"bytes %d-%d/%d\", &start, &end, &total); err == nil {\n\t\t\treturn total, true, nil\n\t\t}\n\t\t// Couldn't parse Content-Range, fall back to HEAD\n\t\treturn probeWithHEAD(ctx, client, url, authHeader)\n\n\tcase http.StatusOK:\n\t\t// Server returned 200 instead of 206 - doesn't support ranges\n\t\t// But we can get the size from Content-Length\n\t\treturn resp.ContentLength, false, nil\n\n\tcase http.StatusRequestedRangeNotSatisfiable:\n\t\t// 416 means server supports ranges but our range was invalid\n\t\t// This shouldn't happen for bytes=0-1, but fall back to HEAD\n\t\treturn probeWithHEAD(ctx, client, url, authHeader)\n\n\tdefault:\n\t\treturn 0, false, fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t}\n}\n\n// probeWithHEAD is a fallback that uses HEAD request to get file size\nfunc probeWithHEAD(ctx context.Context, client *http.Client, url, authHeader string) (int64, bool, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"HEAD\", url, nil)\n\tif err != nil {\n\t\treturn 0, false, err\n\t}\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\tif authHeader != \"\" {\n\t\treq.Header.Set(\"Authorization\", authHeader)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn 0, false, err\n\t}\n\tresp.Body.Close()\n\n\tsupportsRange := resp.Header.Get(\"Accept-Ranges\") == \"bytes\"\n\treturn resp.ContentLength, supportsRange, nil\n}\n\n// MultiStreamDownload downloads a file using multiple parallel HTTP Range requests\nfunc MultiStreamDownload(ctx context.Context, url, output string, config MultiStreamConfig, state *downloadState) error {\n\t// Create HTTP client with optimized transport for high-speed downloads\n\tclient := &http.Client{\n\t\tTimeout: 0,\n\t\tTransport: &http.Transport{\n\t\t\tProxy:               http.ProxyFromEnvironment,\n\t\t\tMaxIdleConns:        0,                 // Unlimited idle connections\n\t\t\tMaxIdleConnsPerHost: config.Streams*2 + 10,\n\t\t\tMaxConnsPerHost:     0,                 // Unlimited connections per host (like rclone)\n\t\t\tIdleConnTimeout:     120 * time.Second,\n\t\t\tDisableCompression:  true,              // Avoid CPU overhead for already compressed media\n\t\t\tForceAttemptHTTP2:   config.UseHTTP2,   // Allow HTTP/2 for better multiplexing\n\t\t\tWriteBufferSize:     128 * 1024,        // 128KB write buffer\n\t\t\tReadBufferSize:      128 * 1024,        // 128KB read buffer\n\t\t},\n\t}\n\n\t// Probe for range support and get file size using a small ranged GET\n\t// Many CDNs only advertise Accept-Ranges on GET, not HEAD\n\ttotalSize, supportsRange, err := probeRangeSupport(ctx, client, url, \"\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to probe server: %w\", err)\n\t}\n\n\tif totalSize <= 0 {\n\t\treturn fmt.Errorf(\"server did not return Content-Length\")\n\t}\n\n\t// Fall back to single-stream if range not supported\n\tif !supportsRange {\n\t\treturn downloadWithProgress(client, url, output, state, nil)\n\t}\n\n\tstate.update(0, totalSize)\n\n\t// Create the output file\n\tfile, err := os.Create(output)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\t// Pre-allocate file size for efficiency\n\tif err := file.Truncate(totalSize); err != nil {\n\t\t// Non-fatal, continue anyway\n\t}\n\n\t// Calculate chunks\n\tchunks := calculateChunks(totalSize, config.ChunkSize)\n\n\t// Create multi-stream state\n\tmsState := &multiStreamState{\n\t\ttotal:     totalSize,\n\t\tstartTime: state.startTime,\n\t}\n\n\t// Start progress updater goroutine\n\tprogressDone := make(chan struct{})\n\tgo func() {\n\t\tticker := time.NewTicker(50 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-progressDone:\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tstate.update(msState.getDownloaded(), totalSize)\n\t\t\t}\n\t\t}\n\t}()\n\n\t// Download chunks in parallel using a worker pool\n\tvar wg sync.WaitGroup\n\tchunkChan := make(chan chunk, len(chunks))\n\n\t// Feed chunks to the channel\n\tfor _, c := range chunks {\n\t\tchunkChan <- c\n\t}\n\tclose(chunkChan)\n\n\t// Start worker goroutines\n\tfor i := 0; i < config.Streams; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor c := range chunkChan {\n\t\t\t\tif err := downloadChunk(ctx, client, url, file, c, config.BufferSize, msState); err != nil {\n\t\t\t\t\tmsState.addError(fmt.Errorf(\"chunk %d failed: %w\", c.index, err))\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\t// Wait for all downloads to complete\n\twg.Wait()\n\tclose(progressDone)\n\n\t// Final progress update\n\tstate.update(msState.getDownloaded(), totalSize)\n\n\t// Check for errors\n\tif errs := msState.getErrors(); len(errs) > 0 {\n\t\treturn fmt.Errorf(\"download failed with %d errors: %v\", len(errs), errs[0])\n\t}\n\n\t// Close file and rename by magic bytes if needed\n\tfile.Close()\n\tstate.setFinalPath(RenameByMagicBytes(output))\n\n\treturn nil\n}\n\n// calculateChunks divides the file into download chunks\n// Uses dynamic chunking - fixed chunk size regardless of file size\n// This keeps all workers busy throughout the download\nfunc calculateChunks(totalSize int64, chunkSize int64) []chunk {\n\tvar chunks []chunk\n\n\t// If file is small, just use one chunk\n\tif totalSize <= chunkSize {\n\t\treturn []chunk{{index: 0, start: 0, end: totalSize - 1}}\n\t}\n\n\t// Dynamic chunking: use fixed chunk size, create as many chunks as needed\n\t// For a 13.5GB file with 64MB chunks = ~210 chunks\n\t// With 12 workers, each processes ~17 chunks, staying busy throughout\n\tvar start int64\n\tindex := 0\n\tfor start < totalSize {\n\t\tend := start + chunkSize - 1\n\t\tif end >= totalSize {\n\t\t\tend = totalSize - 1\n\t\t}\n\t\tchunks = append(chunks, chunk{\n\t\t\tindex: index,\n\t\t\tstart: start,\n\t\t\tend:   end,\n\t\t})\n\t\tstart = end + 1\n\t\tindex++\n\t}\n\n\treturn chunks\n}\n\n// downloadChunk downloads a single chunk using HTTP Range request with resumable retry logic\n// Instead of restarting from byte 0 on failure, it resumes from the last successfully written byte\nfunc downloadChunk(ctx context.Context, client *http.Client, url string, file *os.File, c chunk, bufferSize int, state *multiStreamState) error {\n\tconst maxRetries = 10 // More retries since we resume, not restart\n\tvar lastErr error\n\tcurrentStart := c.start // Track where we are in the chunk\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tif attempt > 0 {\n\t\t\t// Shorter backoff since we're resuming: 500ms, 1s, 2s, 4s... capped at 8s\n\t\t\tbackoff := time.Duration(1<<uint(attempt-1)) * 500 * time.Millisecond\n\t\t\tif backoff > 8*time.Second {\n\t\t\t\tbackoff = 8 * time.Second\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn ctx.Err()\n\t\t\tcase <-time.After(backoff):\n\t\t\t}\n\t\t}\n\n\t\t// Create a sub-chunk from current position to end\n\t\tsubChunk := chunk{\n\t\t\tindex: c.index,\n\t\t\tstart: currentStart,\n\t\t\tend:   c.end,\n\t\t}\n\n\t\tbytesWritten, newOffset, err := downloadChunkOnce(ctx, client, url, file, subChunk, bufferSize, state)\n\t\tif err == nil {\n\t\t\treturn nil // Success!\n\t\t}\n\n\t\tlastErr = err\n\t\t// Update currentStart to resume from where we left off\n\t\t// bytesWritten already added to state, so we keep that progress\n\t\tif bytesWritten > 0 {\n\t\t\tcurrentStart = newOffset\n\t\t}\n\n\t\t// Check if context was cancelled\n\t\tif ctx.Err() != nil {\n\t\t\treturn ctx.Err()\n\t\t}\n\n\t\t// If we've made no progress at all in this attempt, count it as a real failure\n\t\t// Otherwise, reset attempt counter since we made progress\n\t\tif bytesWritten > 0 {\n\t\t\tattempt = 0 // Reset retries when we make progress\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"after %d retries: %w\", maxRetries, lastErr)\n}\n\n// downloadChunkOnce performs a single attempt to download a chunk\n// Returns bytes written, final offset position, and any error\nfunc downloadChunkOnce(ctx context.Context, client *http.Client, url string, file *os.File, c chunk, bufferSize int, state *multiStreamState) (int64, int64, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn 0, c.start, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Range\", fmt.Sprintf(\"bytes=%d-%d\", c.start, c.end))\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn 0, c.start, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {\n\t\treturn 0, c.start, fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t}\n\n\tbuf := make([]byte, bufferSize)\n\toffset := c.start\n\texpectedEnd := c.end + 1 // end is inclusive\n\tvar totalWritten int64\n\n\tfor {\n\t\tn, readErr := resp.Body.Read(buf)\n\t\tif n > 0 {\n\t\t\t// Write at specific offset (thread-safe with pwrite)\n\t\t\twritten, writeErr := file.WriteAt(buf[:n], offset)\n\t\t\tif writeErr != nil {\n\t\t\t\treturn totalWritten, offset, fmt.Errorf(\"write failed: %w\", writeErr)\n\t\t\t}\n\t\t\toffset += int64(written)\n\t\t\ttotalWritten += int64(written)\n\t\t\tstate.addBytes(int64(written))\n\t\t}\n\t\tif readErr == io.EOF {\n\t\t\t// Verify we got the full chunk\n\t\t\tif offset < expectedEnd {\n\t\t\t\treturn totalWritten, offset, fmt.Errorf(\"incomplete: got %d/%d bytes\", offset-c.start, expectedEnd-c.start)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tif readErr != nil {\n\t\t\treturn totalWritten, offset, fmt.Errorf(\"read failed: %w\", readErr)\n\t\t}\n\t}\n\n\treturn totalWritten, offset, nil\n}\n\n// RunMultiStreamDownloadTUI runs a multi-stream download with TUI progress\nfunc RunMultiStreamDownloadTUI(url, output, displayID, lang string, config MultiStreamConfig) error {\n\tstate := &downloadState{\n\t\tstartTime: time.Now(),\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\t// Start download in background\n\tgo func() {\n\t\terr := MultiStreamDownload(ctx, url, output, config, state)\n\t\tif err != nil {\n\t\t\tstate.setError(err)\n\t\t} else {\n\t\t\tstate.setDone()\n\t\t}\n\t}()\n\n\tmodel := newDownloadModel(output, displayID, lang, state)\n\n\tp := tea.NewProgram(model)\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\tcancel()\n\t\treturn err\n\t}\n\n\tm := finalModel.(downloadModel)\n\t_, _, _, _, downloadErr := m.state.get()\n\tif downloadErr != nil {\n\t\treturn downloadErr\n\t}\n\n\treturn nil\n}\n\n// MultiStreamDownloadWithAuth downloads a file using multiple parallel HTTP Range requests with auth\nfunc MultiStreamDownloadWithAuth(ctx context.Context, url, authHeader, output string, totalSize int64, config MultiStreamConfig, state *downloadState) error {\n\t// Create HTTP client with optimized transport for high-speed downloads\n\tclient := &http.Client{\n\t\tTimeout: 0,\n\t\tTransport: &http.Transport{\n\t\t\tProxy:               http.ProxyFromEnvironment,\n\t\t\tMaxIdleConns:        0,                 // Unlimited idle connections\n\t\t\tMaxIdleConnsPerHost: config.Streams*2 + 10,\n\t\t\tMaxConnsPerHost:     0,                 // Unlimited connections per host (like rclone)\n\t\t\tIdleConnTimeout:     120 * time.Second,\n\t\t\tDisableCompression:  true,              // Avoid CPU overhead for already compressed media\n\t\t\tForceAttemptHTTP2:   config.UseHTTP2,   // Allow HTTP/2 for better multiplexing\n\t\t\tWriteBufferSize:     128 * 1024,        // 128KB write buffer\n\t\t\tReadBufferSize:      128 * 1024,        // 128KB read buffer\n\t\t},\n\t}\n\n\t// Probe for range support using ranged GET (more reliable than HEAD)\n\t_, supportsRange, err := probeRangeSupport(ctx, client, url, authHeader)\n\tif err != nil {\n\t\t// If probe fails, assume range is supported (we have totalSize from caller)\n\t\tsupportsRange = true\n\t}\n\n\tstate.update(0, totalSize)\n\n\t// If no Range support, fall back to single-stream\n\tif !supportsRange {\n\t\treturn downloadWithAuthSingleStream(ctx, client, url, authHeader, output, totalSize, state)\n\t}\n\n\t// Create the output file\n\tfile, err := os.Create(output)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\t// Pre-allocate file size for efficiency\n\tif err := file.Truncate(totalSize); err != nil {\n\t\t// Non-fatal, continue anyway\n\t}\n\n\t// Calculate chunks\n\tchunks := calculateChunks(totalSize, config.ChunkSize)\n\n\t// Create multi-stream state\n\tmsState := &multiStreamState{\n\t\ttotal:     totalSize,\n\t\tstartTime: state.startTime,\n\t}\n\n\t// Start progress updater goroutine\n\tprogressDone := make(chan struct{})\n\tgo func() {\n\t\tticker := time.NewTicker(50 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-progressDone:\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tstate.update(msState.getDownloaded(), totalSize)\n\t\t\t}\n\t\t}\n\t}()\n\n\t// Download chunks in parallel using a worker pool\n\tvar wg sync.WaitGroup\n\tchunkChan := make(chan chunk, len(chunks))\n\n\t// Feed chunks to the channel\n\tfor _, c := range chunks {\n\t\tchunkChan <- c\n\t}\n\tclose(chunkChan)\n\n\t// Start worker goroutines\n\tfor i := 0; i < config.Streams; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor c := range chunkChan {\n\t\t\t\tif err := downloadChunkWithAuth(ctx, client, url, authHeader, file, c, config.BufferSize, msState); err != nil {\n\t\t\t\t\tmsState.addError(fmt.Errorf(\"chunk %d failed: %w\", c.index, err))\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\t// Wait for all downloads to complete\n\twg.Wait()\n\tclose(progressDone)\n\n\t// Final progress update\n\tstate.update(msState.getDownloaded(), totalSize)\n\n\t// Check for errors\n\tif errs := msState.getErrors(); len(errs) > 0 {\n\t\treturn fmt.Errorf(\"download failed with %d errors: %v\", len(errs), errs[0])\n\t}\n\n\t// Close file and rename by magic bytes if needed\n\tfile.Close()\n\tstate.setFinalPath(RenameByMagicBytes(output))\n\n\treturn nil\n}\n\n// downloadChunkWithAuth downloads a single chunk using HTTP Range request with auth\n// It includes resumable retry logic - on failure, it resumes from the last written byte\nfunc downloadChunkWithAuth(ctx context.Context, client *http.Client, url, authHeader string, file *os.File, c chunk, bufferSize int, state *multiStreamState) error {\n\tconst maxRetries = 10 // More retries since we resume, not restart\n\tvar lastErr error\n\tcurrentStart := c.start // Track where we are in the chunk\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tif attempt > 0 {\n\t\t\t// Shorter backoff since we're resuming: 500ms, 1s, 2s, 4s... capped at 8s\n\t\t\tbackoff := time.Duration(1<<uint(attempt-1)) * 500 * time.Millisecond\n\t\t\tif backoff > 8*time.Second {\n\t\t\t\tbackoff = 8 * time.Second\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn ctx.Err()\n\t\t\tcase <-time.After(backoff):\n\t\t\t}\n\t\t}\n\n\t\t// Create a sub-chunk from current position to end\n\t\tsubChunk := chunk{\n\t\t\tindex: c.index,\n\t\t\tstart: currentStart,\n\t\t\tend:   c.end,\n\t\t}\n\n\t\tbytesWritten, newOffset, err := downloadChunkWithAuthOnce(ctx, client, url, authHeader, file, subChunk, bufferSize, state)\n\t\tif err == nil {\n\t\t\treturn nil // Success!\n\t\t}\n\n\t\tlastErr = err\n\t\t// Update currentStart to resume from where we left off\n\t\tif bytesWritten > 0 {\n\t\t\tcurrentStart = newOffset\n\t\t}\n\n\t\t// Check if context was cancelled\n\t\tif ctx.Err() != nil {\n\t\t\treturn ctx.Err()\n\t\t}\n\n\t\t// Reset attempt counter when we make progress\n\t\tif bytesWritten > 0 {\n\t\t\tattempt = 0\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"after %d retries: %w\", maxRetries, lastErr)\n}\n\n// downloadChunkWithAuthOnce performs a single attempt to download a chunk\n// Returns bytes written, final offset, and any error\nfunc downloadChunkWithAuthOnce(ctx context.Context, client *http.Client, url, authHeader string, file *os.File, c chunk, bufferSize int, state *multiStreamState) (int64, int64, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn 0, c.start, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Range\", fmt.Sprintf(\"bytes=%d-%d\", c.start, c.end))\n\tif authHeader != \"\" {\n\t\treq.Header.Set(\"Authorization\", authHeader)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn 0, c.start, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {\n\t\treturn 0, c.start, fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t}\n\n\tbuf := make([]byte, bufferSize)\n\toffset := c.start\n\texpectedEnd := c.end + 1 // end is inclusive\n\tvar totalWritten int64\n\n\tfor {\n\t\tn, readErr := resp.Body.Read(buf)\n\t\tif n > 0 {\n\t\t\t// Write at specific offset (thread-safe with pwrite)\n\t\t\twritten, writeErr := file.WriteAt(buf[:n], offset)\n\t\t\tif writeErr != nil {\n\t\t\t\treturn totalWritten, offset, fmt.Errorf(\"write failed: %w\", writeErr)\n\t\t\t}\n\t\t\toffset += int64(written)\n\t\t\ttotalWritten += int64(written)\n\t\t\t// Update progress in real-time\n\t\t\tstate.addBytes(int64(written))\n\t\t}\n\t\tif readErr == io.EOF {\n\t\t\t// Verify we got the full chunk\n\t\t\tif offset < expectedEnd {\n\t\t\t\treturn totalWritten, offset, fmt.Errorf(\"incomplete: got %d/%d bytes\", offset-c.start, expectedEnd-c.start)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tif readErr != nil {\n\t\t\treturn totalWritten, offset, fmt.Errorf(\"read failed: %w\", readErr)\n\t\t}\n\t}\n\n\treturn totalWritten, offset, nil\n}\n\n// downloadWithAuthSingleStream falls back to single-stream download when Range not supported\nfunc downloadWithAuthSingleStream(ctx context.Context, client *http.Client, url, authHeader, output string, total int64, state *downloadState) error {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\tif authHeader != \"\" {\n\t\treq.Header.Set(\"Authorization\", authHeader)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"download request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"download failed with status %d\", resp.StatusCode)\n\t}\n\n\t// Create output file\n\tfile, err := os.Create(output)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\t// Download with progress tracking\n\tbuf := make([]byte, 128*1024) // 128KB buffer\n\tvar current int64\n\n\tfor {\n\t\tn, err := resp.Body.Read(buf)\n\t\tif n > 0 {\n\t\t\t_, writeErr := file.Write(buf[:n])\n\t\t\tif writeErr != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to write file: %w\", writeErr)\n\t\t\t}\n\t\t\tcurrent += int64(n)\n\t\t\tstate.update(current, total)\n\t\t}\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"download failed: %w\", err)\n\t\t}\n\t}\n\n\t// Close file and rename by magic bytes if needed\n\tfile.Close()\n\tstate.setFinalPath(RenameByMagicBytes(output))\n\n\treturn nil\n}\n\n// RunMultiStreamDownloadWithAuthTUI runs a multi-stream download with auth and TUI progress\nfunc RunMultiStreamDownloadWithAuthTUI(url, authHeader, output, displayID, lang string, totalSize int64, config MultiStreamConfig) error {\n\tstate := &downloadState{\n\t\tstartTime: time.Now(),\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\t// Start download in background\n\tgo func() {\n\t\terr := MultiStreamDownloadWithAuth(ctx, url, authHeader, output, totalSize, config, state)\n\t\tif err != nil {\n\t\t\tstate.setError(err)\n\t\t} else {\n\t\t\tstate.setDone()\n\t\t}\n\t}()\n\n\tmodel := newDownloadModel(output, displayID, lang, state)\n\n\tp := tea.NewProgram(model)\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\tcancel()\n\t\treturn err\n\t}\n\n\tm := finalModel.(downloadModel)\n\t_, _, _, _, downloadErr := m.state.get()\n\tif downloadErr != nil {\n\t\treturn downloadErr\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/core/downloader/progress.go",
    "content": "package downloader\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/progress\"\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n)\n\nvar (\n\thelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(\"241\"))\n\tinfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(\"86\"))\n\tdoneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(\"42\"))\n\terrStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color(\"196\"))\n)\n\n// downloadState holds the shared download state\ntype downloadState struct {\n\tmu          sync.RWMutex\n\tcurrent     int64\n\ttotal       int64\n\tspeed       float64\n\tdone        bool\n\terr         error\n\tstartTime   time.Time\n\tendTime     time.Time\n\tfinalSpeed  float64\n\tfinalPath   string\n}\n\nfunc (s *downloadState) update(current, total int64) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.current = current\n\ts.total = total\n\telapsed := time.Since(s.startTime).Seconds()\n\tif elapsed > 0 {\n\t\ts.speed = float64(current) / elapsed\n\t}\n}\n\nfunc (s *downloadState) setDone() {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.endTime = time.Now()\n\telapsed := s.endTime.Sub(s.startTime).Seconds()\n\tif elapsed > 0 {\n\t\ts.finalSpeed = float64(s.current) / elapsed\n\t}\n\ts.done = true\n}\n\nfunc (s *downloadState) setError(err error) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.err = err\n\ts.done = true\n}\n\nfunc (s *downloadState) setFinalPath(path string) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.finalPath = path\n}\n\nfunc (s *downloadState) getFinalPath() string {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\treturn s.finalPath\n}\n\nfunc (s *downloadState) get() (int64, int64, float64, bool, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\treturn s.current, s.total, s.speed, s.done, s.err\n}\n\nfunc (s *downloadState) getFinal() (elapsed time.Duration, avgSpeed float64) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\tif s.endTime.IsZero() {\n\t\treturn time.Since(s.startTime), s.speed\n\t}\n\treturn s.endTime.Sub(s.startTime), s.finalSpeed\n}\n\n// tickMsg triggers UI updates\ntype tickMsg time.Time\n\n// downloadDoneMsg signals download completion\ntype downloadDoneMsg struct{}\n\n// downloadModel is the Bubble Tea model for download progress\ntype downloadModel struct {\n\tprogress progress.Model\n\tspinner  spinner.Model\n\tt        *i18n.Translations\n\n\toutput  string\n\tvideoID string\n\n\tstate *downloadState\n}\n\nfunc newDownloadModel(output, videoID, lang string, state *downloadState) downloadModel {\n\t// Progress bar with gradient\n\tp := progress.New(\n\t\tprogress.WithDefaultGradient(),\n\t\tprogress.WithWidth(50),\n\t)\n\n\t// Spinner\n\ts := spinner.New()\n\ts.Spinner = spinner.Dot\n\ts.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(\"205\"))\n\n\treturn downloadModel{\n\t\tprogress: p,\n\t\tspinner:  s,\n\t\tt:        i18n.T(lang),\n\t\toutput:   output,\n\t\tvideoID:  videoID,\n\t\tstate:    state,\n\t}\n}\n\nfunc tickCmd() tea.Cmd {\n\treturn tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {\n\t\treturn tickMsg(t)\n\t})\n}\n\nfunc (m downloadModel) Init() tea.Cmd {\n\treturn tea.Batch(\n\t\tm.spinner.Tick,\n\t\ttickCmd(),\n\t)\n}\n\nfunc (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\", \"q\":\n\t\t\treturn m, tea.Quit\n\t\t}\n\n\tcase spinner.TickMsg:\n\t\tvar cmd tea.Cmd\n\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\treturn m, cmd\n\n\tcase progress.FrameMsg:\n\t\tprogressModel, cmd := m.progress.Update(msg)\n\t\tm.progress = progressModel.(progress.Model)\n\t\treturn m, cmd\n\n\tcase tickMsg:\n\t\tcurrent, total, _, done, err := m.state.get()\n\t\tif err != nil || done {\n\t\t\treturn m, tea.Quit\n\t\t}\n\n\t\tvar cmds []tea.Cmd\n\t\tcmds = append(cmds, tickCmd())\n\n\t\tif total > 0 {\n\t\t\tcmd := m.progress.SetPercent(float64(current) / float64(total))\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\n\t\treturn m, tea.Batch(cmds...)\n\n\tcase downloadDoneMsg:\n\t\treturn m, tea.Quit\n\t}\n\n\treturn m, nil\n}\n\nfunc (m downloadModel) View() string {\n\tcurrent, total, speed, done, err := m.state.get()\n\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"\\n  %s %s: %v\\n\\n\",\n\t\t\terrStyle.Render(\"✗\"),\n\t\t\tm.t.Download.Failed,\n\t\t\terr,\n\t\t)\n\t}\n\n\tif done {\n\t\telapsed, avgSpeed := m.state.getFinal()\n\t\t// Display full path\n\t\tdisplayPath := m.output\n\t\tif finalPath := m.state.getFinalPath(); finalPath != \"\" {\n\t\t\tdisplayPath = finalPath\n\t\t}\n\t\tif absPath, err := filepath.Abs(displayPath); err == nil {\n\t\t\tdisplayPath = absPath\n\t\t}\n\t\treturn fmt.Sprintf(\"\\n  %s %s\\n  %s: %s (%s)\\n  %s: %s  |  %s: %s/s\\n\\n\",\n\t\t\tdoneStyle.Render(\"✓\"),\n\t\t\tm.t.Download.Completed,\n\t\t\tm.t.Download.FileSaved,\n\t\t\tdisplayPath,\n\t\t\tformatBytes(current),\n\t\t\tm.t.Download.Elapsed,\n\t\t\tformatDuration(elapsed),\n\t\t\tm.t.Download.AvgSpeed,\n\t\t\tformatBytes(int64(avgSpeed)),\n\t\t)\n\t}\n\n\tvar s string\n\ts += \"\\n\"\n\n\t// Video ID with spinner\n\ts += fmt.Sprintf(\"  %s %s: %s\\n\\n\",\n\t\tm.spinner.View(),\n\t\tm.t.Download.Downloading,\n\t\tinfoStyle.Render(m.videoID),\n\t)\n\n\t// Progress bar\n\ts += fmt.Sprintf(\"  %s\\n\\n\", m.progress.View())\n\n\t// Stats\n\tif total > 0 {\n\t\tpercent := float64(current) / float64(total) * 100\n\t\teta := calculateETA(total-current, speed)\n\t\ts += fmt.Sprintf(\"  %s: %.1f%%  |  %s/%s  |  %s: %s/s  |  %s: %s\\n\",\n\t\t\tm.t.Download.Progress,\n\t\t\tpercent,\n\t\t\tformatBytes(current),\n\t\t\tformatBytes(total),\n\t\t\tm.t.Download.Speed,\n\t\t\tformatBytes(int64(speed)),\n\t\t\tm.t.Download.ETA,\n\t\t\teta,\n\t\t)\n\t} else {\n\t\ts += fmt.Sprintf(\"  %s  |  %s: %s/s\\n\",\n\t\t\tformatBytes(current),\n\t\t\tm.t.Download.Speed,\n\t\t\tformatBytes(int64(speed)),\n\t\t)\n\t}\n\n\ts += \"\\n\"\n\ts += helpStyle.Render(\"  Press q to cancel\")\n\ts += \"\\n\"\n\n\treturn s\n}\n\nfunc calculateETA(remaining int64, speed float64) string {\n\tif speed <= 0 {\n\t\treturn \"??:??\"\n\t}\n\teta := time.Duration(float64(remaining)/speed) * time.Second\n\treturn formatDuration(eta)\n}\n\n// RunDownloadTUI runs the download with a TUI progress display\nfunc RunDownloadTUI(url, output, videoID, lang string, headers map[string]string) error {\n\tclient := &http.Client{\n\t\tTimeout: 0,\n\t\tTransport: &http.Transport{\n\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t},\n\t}\n\n\tstate := &downloadState{\n\t\tstartTime: time.Now(),\n\t}\n\n\t// Start download in background\n\tgo func() {\n\t\terr := downloadWithProgress(client, url, output, state, headers)\n\t\tif err != nil {\n\t\t\tstate.setError(err)\n\t\t} else {\n\t\t\tstate.setDone()\n\t\t}\n\t}()\n\n\tmodel := newDownloadModel(output, videoID, lang, state)\n\n\tp := tea.NewProgram(model)\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tm := finalModel.(downloadModel)\n\t_, _, _, _, downloadErr := m.state.get()\n\tif downloadErr != nil {\n\t\treturn downloadErr\n\t}\n\n\treturn nil\n}\n\nfunc downloadWithProgress(client *http.Client, url, output string, state *downloadState, headers map[string]string) error {\n\t// Create HTTP request\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\t// Use custom headers if provided, otherwise use generic browser headers\n\tif len(headers) > 0 {\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t} else {\n\t\t// Generic browser headers as default\n\t\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\t}\n\n\t// Execute request\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"download request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"download failed with status %d\", resp.StatusCode)\n\t}\n\n\ttotal := resp.ContentLength\n\tstate.update(0, total)\n\n\t// Create output file\n\tfile, err := os.Create(output)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\t// Download with progress tracking\n\tbuf := make([]byte, 32*1024)\n\tvar current int64\n\n\tfor {\n\t\tn, err := resp.Body.Read(buf)\n\t\tif n > 0 {\n\t\t\t_, writeErr := file.Write(buf[:n])\n\t\t\tif writeErr != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to write file: %w\", writeErr)\n\t\t\t}\n\t\t\tcurrent += int64(n)\n\t\t\tstate.update(current, total)\n\t\t}\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"download failed: %w\", err)\n\t\t}\n\t}\n\n\t// Close file and rename by magic bytes if needed\n\tfile.Close()\n\tstate.setFinalPath(RenameByMagicBytes(output))\n\n\treturn nil\n}\n\n// RunDownloadFromReaderTUI runs the download from a reader with a TUI progress display\nfunc RunDownloadFromReaderTUI(reader io.ReadCloser, size int64, output, displayID, lang string) error {\n\tstate := &downloadState{\n\t\tstartTime: time.Now(),\n\t}\n\n\t// Start download in background\n\tgo func() {\n\t\terr := downloadFromReaderWithProgress(reader, size, output, state)\n\t\tif err != nil {\n\t\t\tstate.setError(err)\n\t\t} else {\n\t\t\tstate.setDone()\n\t\t}\n\t}()\n\n\tmodel := newDownloadModel(output, displayID, lang, state)\n\n\tp := tea.NewProgram(model)\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tm := finalModel.(downloadModel)\n\t_, _, _, _, downloadErr := m.state.get()\n\tif downloadErr != nil {\n\t\treturn downloadErr\n\t}\n\n\treturn nil\n}\n\nfunc downloadFromReaderWithProgress(reader io.ReadCloser, total int64, output string, state *downloadState) error {\n\tdefer reader.Close()\n\n\tstate.update(0, total)\n\n\t// Create output file\n\tfile, err := os.Create(output)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\t// Download with progress tracking\n\tbuf := make([]byte, 32*1024)\n\tvar current int64\n\n\tfor {\n\t\tn, err := reader.Read(buf)\n\t\tif n > 0 {\n\t\t\t_, writeErr := file.Write(buf[:n])\n\t\t\tif writeErr != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to write file: %w\", writeErr)\n\t\t\t}\n\t\t\tcurrent += int64(n)\n\t\t\tstate.update(current, total)\n\t\t}\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"download failed: %w\", err)\n\t\t}\n\t}\n\n\t// Close file and rename by magic bytes if needed\n\tfile.Close()\n\tstate.setFinalPath(RenameByMagicBytes(output))\n\n\treturn nil\n}\n\n// TelegramDownloadResult matches the extractor telegram result\ntype TelegramDownloadResult struct {\n\tTitle    string\n\tFilename string\n\tSize     int64\n}\n\n// TelegramDownloadFunc is the signature for the telegram download function\ntype TelegramDownloadFunc func(urlStr string, outputPath string, progressFn func(downloaded, total int64)) (*TelegramDownloadResult, error)\n\n// RunTelegramDownloadTUI runs Telegram download with TUI progress\nfunc RunTelegramDownloadTUI(urlStr, outputPath, lang string, downloadFn TelegramDownloadFunc) error {\n\tstate := &downloadState{\n\t\tstartTime: time.Now(),\n\t}\n\n\tvar result *TelegramDownloadResult\n\tvar downloadErr error\n\n\t// Start download in background\n\tgo func() {\n\t\tprogressFn := func(downloaded, total int64) {\n\t\t\tstate.update(downloaded, total)\n\t\t}\n\t\tresult, downloadErr = downloadFn(urlStr, outputPath, progressFn)\n\t\tif downloadErr != nil {\n\t\t\tstate.setError(downloadErr)\n\t\t} else {\n\t\t\tstate.setDone()\n\t\t}\n\t}()\n\n\t// Use the filename from URL as display ID initially\n\tdisplayID := \"Telegram media\"\n\n\tmodel := newDownloadModel(\"\", displayID, lang, state)\n\n\tp := tea.NewProgram(model)\n\t_, err := p.Run()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif downloadErr != nil {\n\t\treturn downloadErr\n\t}\n\n\tif result != nil {\n\t\tdisplayPath := result.Filename\n\t\tif absPath, err := filepath.Abs(result.Filename); err == nil {\n\t\t\tdisplayPath = absPath\n\t\t}\n\t\tfmt.Printf(\"  Saved: %s\\n\", displayPath)\n\t}\n\n\treturn nil\n}\n\n// RunMultiStreamDownloadWithAuthCallback runs a multi-stream download with auth and progress callback (for server use)\nfunc RunMultiStreamDownloadWithAuthCallback(ctx context.Context, url, authHeader, output string, totalSize int64, config MultiStreamConfig, progressFn func(downloaded, total int64)) error {\n\tstate := &downloadState{\n\t\tstartTime: time.Now(),\n\t}\n\n\t// Start a goroutine to forward progress updates to the callback\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tticker := time.NewTicker(100 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tcurrent, total, _, _, _ := state.get()\n\t\t\t\tif progressFn != nil {\n\t\t\t\t\tprogressFn(current, total)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\terr := MultiStreamDownloadWithAuth(ctx, url, authHeader, output, totalSize, config, state)\n\tclose(done)\n\n\t// Final progress update\n\tif progressFn != nil {\n\t\tcurrent, total, _, _, _ := state.get()\n\t\tprogressFn(current, total)\n\t}\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/core/extractor/bilibili.go",
    "content": "package extractor\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n)\n\n// BV/AV conversion constants (from https://github.com/Colerar/abv)\nconst (\n\txorCode  int64 = 23442827791579\n\tmaskCode int64 = (1 << 51) - 1\n\tmaxAID   int64 = maskCode + 1\n\tminAID   int64 = 1\n\tbase     int64 = 58\n\tbvLen    int   = 9\n)\n\nvar (\n\talphabet    = []byte(\"FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf\")\n\trevAlphabet = make(map[byte]int64)\n)\n\nfunc init() {\n\tfor i, c := range alphabet {\n\t\trevAlphabet[c] = int64(i)\n\t}\n}\n\n// BVToAV converts a BV ID to AV number\nfunc BVToAV(bvid string) (int64, error) {\n\t// Remove \"BV1\" prefix if present\n\tif strings.HasPrefix(strings.ToUpper(bvid), \"BV1\") {\n\t\tbvid = bvid[3:]\n\t} else if strings.HasPrefix(strings.ToUpper(bvid), \"BV\") {\n\t\tbvid = bvid[2:]\n\t}\n\n\tif len(bvid) != bvLen {\n\t\treturn 0, fmt.Errorf(\"invalid BV ID length: expected %d, got %d\", bvLen, len(bvid))\n\t}\n\n\tbv := []byte(bvid)\n\t// Swap positions\n\tbv[0], bv[6] = bv[6], bv[0]\n\tbv[1], bv[4] = bv[4], bv[1]\n\n\tvar avid int64\n\tfor _, b := range bv {\n\t\tval, ok := revAlphabet[b]\n\t\tif !ok {\n\t\t\treturn 0, fmt.Errorf(\"invalid character in BV ID: %c\", b)\n\t\t}\n\t\tavid = avid*base + val\n\t}\n\n\treturn (avid & maskCode) ^ xorCode, nil\n}\n\n// AVToBV converts an AV number to BV ID\nfunc AVToBV(avid int64) (string, error) {\n\tif avid < minAID {\n\t\treturn \"\", fmt.Errorf(\"AV %d is smaller than %d\", avid, minAID)\n\t}\n\tif avid >= maxAID {\n\t\treturn \"\", fmt.Errorf(\"AV %d is bigger than %d\", avid, maxAID)\n\t}\n\n\tbvid := make([]byte, bvLen)\n\ttmp := (maxAID | avid) ^ xorCode\n\n\tfor i := bvLen - 1; tmp != 0; i-- {\n\t\tbvid[i] = alphabet[tmp%base]\n\t\ttmp /= base\n\t}\n\n\t// Swap positions\n\tbvid[0], bvid[6] = bvid[6], bvid[0]\n\tbvid[1], bvid[4] = bvid[4], bvid[1]\n\n\treturn \"BV1\" + string(bvid), nil\n}\n\n// URL patterns for Bilibili\nvar (\n\tbilibiliVideoRegex   = regexp.MustCompile(`bilibili\\.com/video/(BV[\\w]+|av\\d+)`)\n\tbilibiliShortRegex   = regexp.MustCompile(`b23\\.tv/(BV[\\w]+|av\\d+|\\w+)`)\n\tbilibiliBangumiRegex = regexp.MustCompile(`bilibili\\.com/bangumi/play/(ep|ss)(\\d+)`)\n\tbvRegex              = regexp.MustCompile(`(?i)^BV1[\\w]{9}$`)\n\tavRegex              = regexp.MustCompile(`(?i)^av(\\d+)$`)\n)\n\n// Quality definitions\nvar qualityMap = map[int]string{\n\t127: \"8K\",\n\t126: \"Dolby Vision\",\n\t125: \"HDR\",\n\t120: \"4K\",\n\t116: \"1080P60\",\n\t112: \"1080P+\",\n\t80:  \"1080P\",\n\t74:  \"720P60\",\n\t64:  \"720P\",\n\t32:  \"480P\",\n\t16:  \"360P\",\n}\n\n// BilibiliExtractor handles Bilibili video extraction\ntype BilibiliExtractor struct {\n\tclient *http.Client\n\tcookie string\n\twbi    string // WBI signing key\n}\n\n// Name returns the extractor name\nfunc (b *BilibiliExtractor) Name() string {\n\treturn \"bilibili\"\n}\n\n// Match checks if URL is a Bilibili video URL\nfunc (b *BilibiliExtractor) Match(u *url.URL) bool {\n\turlStr := u.String()\n\treturn bilibiliVideoRegex.MatchString(urlStr) ||\n\t\tbilibiliShortRegex.MatchString(urlStr) ||\n\t\tbilibiliBangumiRegex.MatchString(urlStr)\n}\n\n// Extract retrieves video information from a Bilibili URL\nfunc (b *BilibiliExtractor) Extract(urlStr string) (Media, error) {\n\t// Initialize HTTP client\n\tif b.client == nil {\n\t\tb.client = &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\t\treturn http.ErrUseLastResponse // Don't follow redirects automatically\n\t\t\t},\n\t\t}\n\t}\n\n\t// Load cookie from config\n\tcfg := config.LoadOrDefault()\n\tif cfg.Bilibili.Cookie != \"\" {\n\t\tb.cookie = cfg.Bilibili.Cookie\n\t}\n\n\t// Resolve short URLs and extract video ID\n\taid, bvid, err := b.resolveVideoID(urlStr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to resolve video ID: %w\", err)\n\t}\n\n\t// Get WBI keys for signing\n\tif err := b.fetchWBIKeys(); err != nil {\n\t\t// Non-fatal: continue without WBI\n\t\tfmt.Printf(\"Warning: failed to get WBI keys: %v\\n\", err)\n\t}\n\n\t// Fetch video info\n\tvideoInfo, err := b.fetchVideoInfo(aid)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch video info: %w\", err)\n\t}\n\n\t// Get first page CID\n\tif len(videoInfo.Pages) == 0 {\n\t\treturn nil, fmt.Errorf(\"no video pages found\")\n\t}\n\tcid := videoInfo.Pages[0].CID\n\n\t// Fetch play URL to get stream info\n\tstreams, err := b.fetchPlayURL(aid, cid)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch play URL: %w\", err)\n\t}\n\n\t// Build formats from streams\n\tformats := b.buildFormats(streams)\n\tif len(formats) == 0 {\n\t\treturn nil, fmt.Errorf(\"no playable streams found\")\n\t}\n\n\treturn &VideoMedia{\n\t\tID:        bvid,\n\t\tTitle:     videoInfo.Title,\n\t\tUploader:  videoInfo.Owner.Name,\n\t\tDuration:  videoInfo.Duration,\n\t\tThumbnail: videoInfo.Pic,\n\t\tFormats:   formats,\n\t}, nil\n}\n\n// resolveVideoID extracts aid and bvid from URL\nfunc (b *BilibiliExtractor) resolveVideoID(urlStr string) (aid int64, bvid string, err error) {\n\t// Handle short URLs\n\tif strings.Contains(urlStr, \"b23.tv\") {\n\t\turlStr, err = b.resolveShortURL(urlStr)\n\t\tif err != nil {\n\t\t\treturn 0, \"\", err\n\t\t}\n\t}\n\n\t// Extract video ID from URL\n\tif matches := bilibiliVideoRegex.FindStringSubmatch(urlStr); len(matches) > 1 {\n\t\tid := matches[1]\n\t\tif bvRegex.MatchString(id) {\n\t\t\tbvid = id\n\t\t\taid, err = BVToAV(bvid)\n\t\t\tif err != nil {\n\t\t\t\treturn 0, \"\", err\n\t\t\t}\n\t\t} else if avMatches := avRegex.FindStringSubmatch(id); len(avMatches) > 1 {\n\t\t\taid, err = strconv.ParseInt(avMatches[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn 0, \"\", err\n\t\t\t}\n\t\t\tbvid, err = AVToBV(aid)\n\t\t\tif err != nil {\n\t\t\t\treturn 0, \"\", err\n\t\t\t}\n\t\t}\n\t} else {\n\t\treturn 0, \"\", fmt.Errorf(\"could not extract video ID from URL: %s\", urlStr)\n\t}\n\n\treturn aid, bvid, nil\n}\n\n// resolveShortURL follows redirects to get the full URL\nfunc (b *BilibiliExtractor) resolveShortURL(shortURL string) (string, error) {\n\treq, err := http.NewRequest(\"HEAD\", shortURL, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"User-Agent\", b.userAgent())\n\n\tresp, err := b.client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode >= 300 && resp.StatusCode < 400 {\n\t\tlocation := resp.Header.Get(\"Location\")\n\t\tif location != \"\" {\n\t\t\treturn location, nil\n\t\t}\n\t}\n\n\treturn shortURL, nil\n}\n\n// fetchWBIKeys obtains WBI signing keys from nav API\nfunc (b *BilibiliExtractor) fetchWBIKeys() error {\n\tapi := \"https://api.bilibili.com/x/web-interface/nav\"\n\n\treq, err := http.NewRequest(\"GET\", api, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tb.setHeaders(req)\n\n\tresp, err := b.client.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar result struct {\n\t\tData struct {\n\t\t\tWbiImg struct {\n\t\t\t\tImgURL string `json:\"img_url\"`\n\t\t\t\tSubURL string `json:\"sub_url\"`\n\t\t\t} `json:\"wbi_img\"`\n\t\t} `json:\"data\"`\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn err\n\t}\n\n\t// Extract keys from URLs\n\timgKey := extractKeyFromURL(result.Data.WbiImg.ImgURL)\n\tsubKey := extractKeyFromURL(result.Data.WbiImg.SubURL)\n\n\t// Generate mixin key\n\tb.wbi = getMixinKey(imgKey + subKey)\n\n\treturn nil\n}\n\n// extractKeyFromURL extracts the key part from a wbi URL\nfunc extractKeyFromURL(urlStr string) string {\n\t// URL like: https://i0.hdslb.com/bfs/wbi/xxx.png\n\t// Extract xxx (without extension)\n\tparts := strings.Split(urlStr, \"/\")\n\tif len(parts) == 0 {\n\t\treturn \"\"\n\t}\n\tfilename := parts[len(parts)-1]\n\tif idx := strings.LastIndex(filename, \".\"); idx > 0 {\n\t\treturn filename[:idx]\n\t}\n\treturn filename\n}\n\n// getMixinKey generates the mixin key for WBI signing\nfunc getMixinKey(orig string) string {\n\tmixinKeyEncTab := []int{\n\t\t46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,\n\t\t27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,\n\t}\n\n\tvar result strings.Builder\n\tfor _, idx := range mixinKeyEncTab {\n\t\tif idx < len(orig) {\n\t\t\tresult.WriteByte(orig[idx])\n\t\t}\n\t}\n\treturn result.String()\n}\n\n// wbiSign signs the query parameters with WBI\nfunc (b *BilibiliExtractor) wbiSign(params url.Values) string {\n\tif b.wbi == \"\" {\n\t\treturn params.Encode()\n\t}\n\n\t// Add timestamp\n\tparams.Set(\"wts\", strconv.FormatInt(time.Now().Unix(), 10))\n\n\t// Sort keys\n\tkeys := make([]string, 0, len(params))\n\tfor k := range params {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\n\t// Build query string\n\tvar query strings.Builder\n\tfor i, k := range keys {\n\t\tif i > 0 {\n\t\t\tquery.WriteByte('&')\n\t\t}\n\t\tquery.WriteString(k)\n\t\tquery.WriteByte('=')\n\t\t// Filter special characters\n\t\tv := filterWBIValue(params.Get(k))\n\t\tquery.WriteString(url.QueryEscape(v))\n\t}\n\n\t// Calculate signature\n\tqueryStr := query.String()\n\thash := md5.Sum([]byte(queryStr + b.wbi))\n\tsignature := hex.EncodeToString(hash[:])\n\n\treturn queryStr + \"&w_rid=\" + signature\n}\n\n// filterWBIValue removes special characters from WBI values\nfunc filterWBIValue(s string) string {\n\t// Remove !'()*\n\tvar result strings.Builder\n\tfor _, c := range s {\n\t\tif c != '!' && c != '\\'' && c != '(' && c != ')' && c != '*' {\n\t\t\tresult.WriteRune(c)\n\t\t}\n\t}\n\treturn result.String()\n}\n\n// BilibiliVideoInfo represents video metadata\ntype BilibiliVideoInfo struct {\n\tTitle    string `json:\"title\"`\n\tDesc     string `json:\"desc\"`\n\tPic      string `json:\"pic\"`\n\tDuration int    `json:\"duration\"`\n\tOwner    struct {\n\t\tMid  int64  `json:\"mid\"`\n\t\tName string `json:\"name\"`\n\t} `json:\"owner\"`\n\tPages []struct {\n\t\tCID      int64  `json:\"cid\"`\n\t\tPage     int    `json:\"page\"`\n\t\tPart     string `json:\"part\"`\n\t\tDuration int    `json:\"duration\"`\n\t} `json:\"pages\"`\n}\n\n// fetchVideoInfo retrieves video metadata\nfunc (b *BilibiliExtractor) fetchVideoInfo(aid int64) (*BilibiliVideoInfo, error) {\n\tapi := fmt.Sprintf(\"https://api.bilibili.com/x/web-interface/view?aid=%d\", aid)\n\n\treq, err := http.NewRequest(\"GET\", api, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tb.setHeaders(req)\n\n\tresp, err := b.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar result struct {\n\t\tCode    int               `json:\"code\"`\n\t\tMessage string            `json:\"message\"`\n\t\tData    BilibiliVideoInfo `json:\"data\"`\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif result.Code != 0 {\n\t\treturn nil, fmt.Errorf(\"API error: %s (code: %d)\", result.Message, result.Code)\n\t}\n\n\treturn &result.Data, nil\n}\n\n// BilibiliStreamInfo represents stream data\ntype BilibiliStreamInfo struct {\n\tVideos []struct {\n\t\tID        int    `json:\"id\"`\n\t\tBaseURL   string `json:\"baseUrl\"`\n\t\tBackupURL []string `json:\"backupUrl\"`\n\t\tBandwidth int64  `json:\"bandwidth\"`\n\t\tWidth     int    `json:\"width\"`\n\t\tHeight    int    `json:\"height\"`\n\t\tCodecs    string `json:\"codecs\"`\n\t\tCodecID   int    `json:\"codecid\"`\n\t} `json:\"video\"`\n\tAudios []struct {\n\t\tID        int    `json:\"id\"`\n\t\tBaseURL   string `json:\"baseUrl\"`\n\t\tBackupURL []string `json:\"backupUrl\"`\n\t\tBandwidth int64  `json:\"bandwidth\"`\n\t\tCodecs    string `json:\"codecs\"`\n\t} `json:\"audio\"`\n}\n\n// fetchPlayURL retrieves stream URLs\nfunc (b *BilibiliExtractor) fetchPlayURL(aid, cid int64) (*BilibiliStreamInfo, error) {\n\tparams := url.Values{}\n\tparams.Set(\"avid\", strconv.FormatInt(aid, 10))\n\tparams.Set(\"cid\", strconv.FormatInt(cid, 10))\n\tparams.Set(\"fnval\", \"4048\") // DASH + HDR + Dolby + 8K + AV1\n\tparams.Set(\"fnver\", \"0\")\n\tparams.Set(\"fourk\", \"1\")\n\tparams.Set(\"qn\", \"127\") // Request highest quality\n\n\t// Sign with WBI if available\n\tquery := b.wbiSign(params)\n\n\tapi := \"https://api.bilibili.com/x/player/wbi/playurl?\" + query\n\n\treq, err := http.NewRequest(\"GET\", api, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tb.setHeaders(req)\n\n\tresp, err := b.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar result struct {\n\t\tCode    int    `json:\"code\"`\n\t\tMessage string `json:\"message\"`\n\t\tData    struct {\n\t\t\tDash *BilibiliStreamInfo `json:\"dash\"`\n\t\t} `json:\"data\"`\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif result.Code != 0 {\n\t\treturn nil, fmt.Errorf(\"API error: %s (code: %d)\", result.Message, result.Code)\n\t}\n\n\tif result.Data.Dash == nil {\n\t\treturn nil, fmt.Errorf(\"no DASH streams available\")\n\t}\n\n\treturn result.Data.Dash, nil\n}\n\n// buildFormats converts stream info to VideoFormat slice\nfunc (b *BilibiliExtractor) buildFormats(streams *BilibiliStreamInfo) []VideoFormat {\n\tvar formats []VideoFormat\n\n\t// Find best audio stream\n\tvar bestAudioURL string\n\tvar bestAudioBandwidth int64\n\tfor _, audio := range streams.Audios {\n\t\tif audio.Bandwidth > bestAudioBandwidth {\n\t\t\tbestAudioBandwidth = audio.Bandwidth\n\t\t\tbestAudioURL = audio.BaseURL\n\t\t}\n\t}\n\n\t// Build video formats\n\tfor _, video := range streams.Videos {\n\t\tquality := qualityMap[video.ID]\n\t\tif quality == \"\" {\n\t\t\tquality = fmt.Sprintf(\"%dp\", video.Height)\n\t\t}\n\n\t\tcodec := getCodecName(video.CodecID)\n\n\t\tformat := VideoFormat{\n\t\t\tURL:      video.BaseURL,\n\t\t\tQuality:  fmt.Sprintf(\"%s [%s]\", quality, codec),\n\t\t\tExt:      \"mp4\",\n\t\t\tWidth:    video.Width,\n\t\t\tHeight:   video.Height,\n\t\t\tBitrate:  int(video.Bandwidth / 1000), // Convert to kbps\n\t\t\tAudioURL: bestAudioURL,\n\t\t\tHeaders: map[string]string{\n\t\t\t\t\"Referer\":    \"https://www.bilibili.com/\",\n\t\t\t\t\"User-Agent\": b.userAgent(),\n\t\t\t},\n\t\t}\n\n\t\tformats = append(formats, format)\n\t}\n\n\t// Sort by height (highest first), then by codec priority\n\tsort.Slice(formats, func(i, j int) bool {\n\t\tif formats[i].Height != formats[j].Height {\n\t\t\treturn formats[i].Height > formats[j].Height\n\t\t}\n\t\treturn formats[i].Bitrate > formats[j].Bitrate\n\t})\n\n\treturn formats\n}\n\n// getCodecName converts codec ID to name\nfunc getCodecName(codecID int) string {\n\tswitch codecID {\n\tcase 7:\n\t\treturn \"AVC\"\n\tcase 12:\n\t\treturn \"HEVC\"\n\tcase 13:\n\t\treturn \"AV1\"\n\tdefault:\n\t\treturn \"Unknown\"\n\t}\n}\n\n// setHeaders sets common request headers\nfunc (b *BilibiliExtractor) setHeaders(req *http.Request) {\n\treq.Header.Set(\"User-Agent\", b.userAgent())\n\treq.Header.Set(\"Referer\", \"https://www.bilibili.com/\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tif b.cookie != \"\" {\n\t\treq.Header.Set(\"Cookie\", b.cookie)\n\t}\n}\n\n// userAgent returns a random-ish user agent\nfunc (b *BilibiliExtractor) userAgent() string {\n\treturn \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n}\n\nfunc init() {\n\tRegister(&BilibiliExtractor{},\n\t\t\"bilibili.com\",\n\t\t\"www.bilibili.com\",\n\t\t\"b23.tv\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/browser.go",
    "content": "package extractor\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-rod/rod\"\n\t\"github.com/go-rod/rod/lib/launcher\"\n\t\"github.com/go-rod/rod/lib/proto\"\n\t\"github.com/go-rod/stealth\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n)\n\n// BrowserExtractor uses browser automation to intercept media URLs\ntype BrowserExtractor struct {\n\tsite    *config.Site\n\tvisible bool\n}\n\n// NewBrowserExtractor creates a new browser extractor for the given site\nfunc NewBrowserExtractor(site *config.Site, visible bool) *BrowserExtractor {\n\treturn &BrowserExtractor{site: site, visible: visible}\n}\n\n// NewGenericBrowserExtractor creates a browser extractor for unknown sites (defaults to m3u8)\nfunc NewGenericBrowserExtractor(visible bool) *BrowserExtractor {\n\treturn &BrowserExtractor{\n\t\tsite:    &config.Site{Type: \"m3u8\"},\n\t\tvisible: visible,\n\t}\n}\n\nfunc (e *BrowserExtractor) Name() string {\n\treturn \"browser\"\n}\n\nfunc (e *BrowserExtractor) Match(u *url.URL) bool {\n\treturn true // Called only when site matches\n}\n\n// extractionStrategy defines a method for finding media URLs\ntype extractionStrategy func(page *rod.Page, targetExt string) string\n\nfunc (e *BrowserExtractor) Extract(rawURL string) (Media, error) {\n\tif e.site == nil {\n\t\treturn nil, fmt.Errorf(\"no site configuration provided\")\n\t}\n\n\t// Parse the page URL to get origin\n\tpageURL, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid URL: %w\", err)\n\t}\n\tpageOrigin := fmt.Sprintf(\"%s://%s\", pageURL.Scheme, pageURL.Host)\n\n\t// Normalize extension to lowercase once for consistent matching\n\ttargetExt := strings.ToLower(\".\" + e.site.Type) // e.g., \".m3u8\", \".mp4\"\n\n\tfmt.Printf(\"  Trying to detecting %s stream...\\n\", e.site.Type)\n\n\t// Launch browser\n\tl := e.createLauncher(!e.visible) // headless unless --visible flag\n\tdefer l.Cleanup()\n\n\tu, err := l.Launch()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to launch browser: %w\", err)\n\t}\n\n\tbrowser := rod.New().ControlURL(u).MustConnect()\n\tdefer browser.MustClose()\n\n\tpage := stealth.MustPage(browser)\n\tdefer page.MustClose()\n\n\t// Try network interception first, then fallback strategies\n\tmediaURL := e.captureFromNetwork(page, rawURL, targetExt)\n\n\t// Fallback strategies if network capture didn't find anything\n\tif mediaURL == \"\" {\n\t\tstrategies := []extractionStrategy{\n\t\t\te.findInPerformanceAPI,\n\t\t\te.findInVideoPlayer,\n\t\t\te.findInPageSource,\n\t\t}\n\n\t\tfor _, strategy := range strategies {\n\t\t\tif found := strategy(page, targetExt); found != \"\" {\n\t\t\t\tmediaURL = found\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif mediaURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"website not supported (no %s stream found)\", e.site.Type)\n\t}\n\n\tfmt.Printf(\"Found: %s\\n\", mediaURL)\n\n\t// Extract page title\n\ttitle := page.MustEval(`() => document.title`).String()\n\ttitle = strings.TrimSpace(title)\n\tif title == \"\" {\n\t\tpageURL, _ := url.Parse(rawURL)\n\t\ttitle = filepath.Base(pageURL.Path)\n\t\tif title == \"\" || title == \"/\" {\n\t\t\ttitle = pageURL.Host\n\t\t}\n\t}\n\n\t// Generate ID from URL\n\tparsedURL, _ := url.Parse(mediaURL)\n\tid := filepath.Base(parsedURL.Path)\n\tif idx := strings.LastIndex(id, \".\"); idx > 0 {\n\t\tid = id[:idx]\n\t}\n\tif id == \"\" || id == \"/\" {\n\t\tid = \"video\"\n\t}\n\n\treturn &VideoMedia{\n\t\tID:    id,\n\t\tTitle: title,\n\t\tFormats: []VideoFormat{\n\t\t\t{\n\t\t\t\tURL:     mediaURL,\n\t\t\t\tQuality: \"best\",\n\t\t\t\tExt:     e.site.Type,\n\t\t\t\tHeaders: map[string]string{\"Referer\": rawURL, \"Origin\": pageOrigin},\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\n// captureFromNetwork intercepts network requests to find media URLs\nfunc (e *BrowserExtractor) captureFromNetwork(page *rod.Page, rawURL, targetExt string) string {\n\t// Enable Network domain to capture requests\n\t_ = proto.NetworkEnable{}.Call(page)\n\n\t// Also enable Fetch domain to intercept at lower level\n\t_ = proto.FetchEnable{\n\t\tPatterns: []*proto.FetchRequestPattern{\n\t\t\t{URLPattern: \"*\"},\n\t\t},\n\t}.Call(page)\n\n\t// Use channel for thread-safe communication\n\tfoundURL := make(chan string, 1)\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\n\t// Separate context for the listener so we can stop it independently\n\tlistenerCtx, stopListener := context.WithCancel(context.Background())\n\tlistenerDone := make(chan struct{})\n\n\t// Listen for network requests at CDP level\n\tgo func() {\n\t\tdefer close(listenerDone)\n\t\tpage.Context(listenerCtx).EachEvent(\n\t\t\tfunc(ev *proto.NetworkRequestWillBeSent) {\n\t\t\t\treqURL := ev.Request.URL\n\t\t\t\tif strings.Contains(strings.ToLower(reqURL), targetExt) {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase foundURL <- reqURL:\n\t\t\t\t\tdefault:\n\t\t\t\t\t\t// Already found one, ignore\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\tfunc(ev *proto.FetchRequestPaused) {\n\t\t\t\treqURL := ev.Request.URL\n\t\t\t\t// Continue the request regardless\n\t\t\t\t_ = proto.FetchContinueRequest{RequestID: ev.RequestID}.Call(page)\n\t\t\t\tif strings.Contains(strings.ToLower(reqURL), targetExt) {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase foundURL <- reqURL:\n\t\t\t\t\tdefault:\n\t\t\t\t\t\t// Already found one, ignore\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t)()\n\t}()\n\n\t// Navigate with timeout to prevent hanging on slow/broken pages\n\tnavCtx, navCancel := context.WithTimeout(ctx, 10*time.Second)\n\t_ = page.Context(navCtx).Navigate(rawURL)\n\t_ = page.Context(navCtx).WaitLoad()\n\tnavCancel()\n\n\t// Wait for capture or timeout\n\tvar result string\n\tselect {\n\tcase url := <-foundURL:\n\t\tresult = url\n\tcase <-ctx.Done():\n\t\t// Timeout: check one more time in case URL arrived just as we timed out\n\t\tselect {\n\t\tcase url := <-foundURL:\n\t\t\tresult = url\n\t\tdefault:\n\t\t\t// No URL found\n\t\t}\n\t}\n\n\t// Stop the listener and wait for it to finish\n\tstopListener()\n\t<-listenerDone\n\n\treturn result\n}\n\n// findInPerformanceAPI uses the browser's Performance API to find resource requests\nfunc (e *BrowserExtractor) findInPerformanceAPI(page *rod.Page, targetExt string) string {\n\t// Pass targetExt to JavaScript for filtering (already lowercase)\n\tresult, err := page.Eval(`(ext) => {\n\t\treturn performance.getEntriesByType('resource')\n\t\t\t.map(r => r.name)\n\t\t\t.filter(url => url.toLowerCase().includes(ext));\n\t}`, targetExt)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\tarr := result.Value.Arr()\n\tfor _, v := range arr {\n\t\turl := v.String()\n\t\tif strings.Contains(strings.ToLower(url), targetExt) {\n\t\t\treturn url\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// findInVideoPlayer queries the video player for its source URL\nfunc (e *BrowserExtractor) findInVideoPlayer(page *rod.Page, targetExt string) string {\n\t// targetExt is already lowercase\n\tresult, err := page.Eval(`(ext) => {\n\t\t// Check for video.js\n\t\tconst vjsPlayer = document.querySelector('.video-js');\n\t\tif (vjsPlayer && vjsPlayer.player) {\n\t\t\tconst src = vjsPlayer.player.currentSrc();\n\t\t\tif (src && src.toLowerCase().includes(ext)) return src;\n\t\t}\n\n\t\t// Check video element sources\n\t\tconst video = document.querySelector('video');\n\t\tif (video) {\n\t\t\tif (video.src && video.src.toLowerCase().includes(ext)) return video.src;\n\t\t\tconst sources = video.querySelectorAll('source');\n\t\t\tfor (const source of sources) {\n\t\t\t\tif (source.src && source.src.toLowerCase().includes(ext)) return source.src;\n\t\t\t}\n\t\t}\n\n\t\t// Check for any global player variable\n\t\tif (window.player && window.player.src) {\n\t\t\tconst src = typeof window.player.src === 'function' ? window.player.src() : window.player.src;\n\t\t\tif (src && src.toLowerCase().includes(ext)) return src;\n\t\t}\n\t\treturn '';\n\t}`, targetExt)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn result.Value.String()\n}\n\n// findInPageSource searches for media URLs in page HTML/JavaScript source\nfunc (e *BrowserExtractor) findInPageSource(page *rod.Page, targetExt string) string {\n\thtml, err := page.HTML()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\t// Escape special regex characters in targetExt (already lowercase)\n\tescapedExt := regexp.QuoteMeta(targetExt)\n\n\t// Case-insensitive patterns\n\tpatterns := []string{\n\t\t// Full URL with extension\n\t\t`(?i)https?://[^\"'\\s<>]+` + escapedExt + `[^\"'\\s<>]*`,\n\t\t// Quoted string containing extension\n\t\t`(?i)[\"']([^\"']*` + escapedExt + `[^\"']*)[\"']`,\n\t\t// src attribute with extension\n\t\t`(?i)src\\s*[=:]\\s*[\"']([^\"']*` + escapedExt + `[^\"']*)[\"']`,\n\t}\n\n\tfor _, pattern := range patterns {\n\t\tre, err := regexp.Compile(pattern)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tmatches := re.FindAllStringSubmatch(html, -1)\n\t\tfor _, match := range matches {\n\t\t\tvar foundURL string\n\t\t\tif len(match) > 1 {\n\t\t\t\tfoundURL = match[1]\n\t\t\t} else {\n\t\t\t\tfoundURL = match[0]\n\t\t\t}\n\n\t\t\tif !strings.Contains(strings.ToLower(foundURL), targetExt) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif strings.HasPrefix(foundURL, \"data:\") {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfoundURL = strings.TrimSpace(foundURL)\n\t\t\tif foundURL != \"\" {\n\t\t\t\treturn foundURL\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (e *BrowserExtractor) createLauncher(headless bool) *launcher.Launcher {\n\tuserDataDir := e.getUserDataDir()\n\n\t// Check for ROD_BROWSER env var (set in Docker)\n\tbrowserPath := os.Getenv(\"ROD_BROWSER\")\n\n\tl := launcher.New().\n\t\tHeadless(headless).\n\t\tUserDataDir(userDataDir).\n\t\tSet(\"no-sandbox\").\n\t\tSet(\"disable-gpu\").\n\t\tSet(\"disable-dev-shm-usage\").\n\t\tSet(\"disable-software-rasterizer\").\n\t\tSet(\"disable-extensions\").\n\t\tSet(\"disable-background-networking\").\n\t\tSet(\"disable-sync\").\n\t\tSet(\"disable-translate\").\n\t\tSet(\"no-first-run\").\n\t\tSet(\"safebrowsing-disable-auto-update\").\n\t\tSet(\"window-size\", \"1920,1080\").\n\t\tSet(\"user-agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\n\t// Explicitly set browser path if provided (required for Docker)\n\tif browserPath != \"\" {\n\t\tl = l.Bin(browserPath)\n\t}\n\n\treturn l\n}\n\nfunc (e *BrowserExtractor) getUserDataDir() string {\n\tconfigDir, err := config.ConfigDir()\n\tif err != nil {\n\t\treturn filepath.Join(os.TempDir(), \"vget-browser\")\n\t}\n\treturn filepath.Join(configDir, \"browser\")\n}\n"
  },
  {
    "path": "internal/core/extractor/direct.go",
    "content": "package extractor\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"strings\"\n\t\"time\"\n)\n\n// DirectExtractor handles direct file URLs (mp4, mp3, jpg, etc.)\n// This is a fallback extractor that matches any URL not handled by others\ntype DirectExtractor struct {\n\tclient *http.Client\n}\n\n// Name returns the extractor name\nfunc (d *DirectExtractor) Name() string {\n\treturn \"direct\"\n}\n\n// Match always returns true - this is the fallback extractor\nfunc (d *DirectExtractor) Match(u *url.URL) bool {\n\t// Only match http/https URLs\n\treturn u.Scheme == \"http\" || u.Scheme == \"https\"\n}\n\n// Extract retrieves media information from a direct URL\nfunc (d *DirectExtractor) Extract(urlStr string) (Media, error) {\n\tif d.client == nil {\n\t\td.client = &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t\tTransport: &http.Transport{\n\t\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t\t},\n\t\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\t\t// Follow redirects but limit to 10\n\t\t\t\tif len(via) >= 10 {\n\t\t\t\t\treturn fmt.Errorf(\"too many redirects\")\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}\n\t}\n\n\t// HEAD request to get Content-Type and filename\n\treq, err := http.NewRequest(\"HEAD\", urlStr, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\n\tresp, err := d.client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch URL: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned status %d\", resp.StatusCode)\n\t}\n\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\tfinalURL := resp.Request.URL.String() // URL after redirects\n\n\t// Determine media type and extension\n\tmediaType, ext := detectMediaType(contentType, finalURL)\n\n\t// Extract filename from URL path\n\tparsedURL, _ := url.Parse(finalURL)\n\tfilename := path.Base(parsedURL.Path)\n\tif filename == \"\" || filename == \"/\" || filename == \".\" {\n\t\tfilename = \"download\"\n\t}\n\n\t// Remove extension from filename for title\n\ttitle := strings.TrimSuffix(filename, \".\"+ext)\n\tif title == \"\" {\n\t\ttitle = filename\n\t}\n\n\t// Generate ID from URL\n\tid := generateID(finalURL)\n\n\tswitch mediaType {\n\tcase MediaTypeVideo:\n\t\treturn &VideoMedia{\n\t\t\tID:    id,\n\t\t\tTitle: title,\n\t\t\tFormats: []VideoFormat{\n\t\t\t\t{\n\t\t\t\t\tURL: finalURL,\n\t\t\t\t\tExt: ext,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\n\tcase MediaTypeAudio:\n\t\treturn &AudioMedia{\n\t\t\tID:    id,\n\t\t\tTitle: title,\n\t\t\tURL:   finalURL,\n\t\t\tExt:   ext,\n\t\t}, nil\n\n\tcase MediaTypeImage:\n\t\treturn &ImageMedia{\n\t\t\tID:    id,\n\t\t\tTitle: title,\n\t\t\tImages: []Image{\n\t\t\t\t{\n\t\t\t\t\tURL: finalURL,\n\t\t\t\t\tExt: ext,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\n\tdefault:\n\t\t// Treat unknown as video (generic file download)\n\t\treturn &VideoMedia{\n\t\t\tID:    id,\n\t\t\tTitle: title,\n\t\t\tFormats: []VideoFormat{\n\t\t\t\t{\n\t\t\t\t\tURL: finalURL,\n\t\t\t\t\tExt: ext,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n}\n\n// detectMediaType determines the media type from Content-Type header or URL extension\nfunc detectMediaType(contentType, urlStr string) (MediaType, string) {\n\t// First try Content-Type header\n\tcontentType = strings.ToLower(strings.Split(contentType, \";\")[0])\n\n\tswitch {\n\t// Video types\n\tcase strings.HasPrefix(contentType, \"video/\"):\n\t\text := strings.TrimPrefix(contentType, \"video/\")\n\t\tif ext == \"mp4\" || ext == \"webm\" || ext == \"quicktime\" {\n\t\t\tif ext == \"quicktime\" {\n\t\t\t\text = \"mov\"\n\t\t\t}\n\t\t\treturn MediaTypeVideo, ext\n\t\t}\n\t\treturn MediaTypeVideo, \"mp4\"\n\n\tcase contentType == \"application/vnd.apple.mpegurl\",\n\t\tcontentType == \"application/x-mpegurl\":\n\t\treturn MediaTypeVideo, \"m3u8\"\n\n\t// Audio types\n\tcase strings.HasPrefix(contentType, \"audio/\"):\n\t\text := strings.TrimPrefix(contentType, \"audio/\")\n\t\tswitch ext {\n\t\tcase \"mpeg\":\n\t\t\text = \"mp3\"\n\t\tcase \"mp4\", \"x-m4a\":\n\t\t\text = \"m4a\"\n\t\t}\n\t\treturn MediaTypeAudio, ext\n\n\t// Image types\n\tcase strings.HasPrefix(contentType, \"image/\"):\n\t\text := strings.TrimPrefix(contentType, \"image/\")\n\t\tif ext == \"jpeg\" {\n\t\t\text = \"jpg\"\n\t\t}\n\t\treturn MediaTypeImage, ext\n\t}\n\n\t// Fallback to URL extension\n\tparsedURL, err := url.Parse(urlStr)\n\tif err == nil {\n\t\text := strings.ToLower(strings.TrimPrefix(path.Ext(parsedURL.Path), \".\"))\n\t\tswitch ext {\n\t\tcase \"mp4\", \"webm\", \"mov\", \"avi\", \"mkv\", \"flv\", \"m3u8\", \"ts\":\n\t\t\treturn MediaTypeVideo, ext\n\t\tcase \"mp3\", \"m4a\", \"aac\", \"ogg\", \"wav\", \"flac\":\n\t\t\treturn MediaTypeAudio, ext\n\t\tcase \"jpg\", \"jpeg\", \"png\", \"gif\", \"webp\", \"bmp\":\n\t\t\tif ext == \"jpeg\" {\n\t\t\t\text = \"jpg\"\n\t\t\t}\n\t\t\treturn MediaTypeImage, ext\n\t\tcase \"\":\n\t\t\t// No extension, default to binary download\n\t\t\treturn MediaTypeVideo, \"bin\"\n\t\tdefault:\n\t\t\t// Unknown extension, use it as-is\n\t\t\treturn MediaTypeVideo, ext\n\t\t}\n\t}\n\n\treturn MediaTypeVideo, \"bin\"\n}\n\n// generateID creates a short ID from URL\nfunc generateID(urlStr string) string {\n\tparsedURL, err := url.Parse(urlStr)\n\tif err != nil {\n\t\treturn \"direct\"\n\t}\n\n\t// Use last path segment as ID\n\tbase := path.Base(parsedURL.Path)\n\tif base == \"\" || base == \"/\" || base == \".\" {\n\t\treturn parsedURL.Host\n\t}\n\n\t// Remove extension\n\tif idx := strings.LastIndex(base, \".\"); idx > 0 {\n\t\tbase = base[:idx]\n\t}\n\n\t// Limit length\n\tif len(base) > 32 {\n\t\tbase = base[:32]\n\t}\n\n\treturn base\n}\n\nfunc init() {\n\tRegisterFallback(&DirectExtractor{})\n}\n"
  },
  {
    "path": "internal/core/extractor/instagram.go",
    "content": "package extractor\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\n// InstagramExtractor handles Instagram video downloads\ntype InstagramExtractor struct{}\n\nfunc (e *InstagramExtractor) Name() string {\n\treturn \"instagram\"\n}\n\nfunc (e *InstagramExtractor) Match(u *url.URL) bool {\n\t// Host matching is done by registry\n\treturn true\n}\n\nfunc (e *InstagramExtractor) Extract(url string) (Media, error) {\n\treturn nil, fmt.Errorf(\"instagram support coming soon\")\n}\n\nfunc init() {\n\tRegister(&InstagramExtractor{},\n\t\t\"instagram.com\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/itunes.go",
    "content": "package extractor\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n)\n\n// iTunesExtractor handles Apple Podcasts downloads via iTunes API\ntype iTunesExtractor struct{}\n\nfunc (e *iTunesExtractor) Name() string {\n\treturn \"itunes\"\n}\n\n// Match URLs like:\n// https://podcasts.apple.com/podcast/id173001861\n// https://podcasts.apple.com/us/podcast/dan-carlins-hardcore-history/id173001861\n// https://podcasts.apple.com/us/podcast/dan-carlins-hardcore-history/id173001861?i=1000682587885\nvar applePodcastRegex = regexp.MustCompile(`/(?:podcast/)?(?:[^/]+/)?id(\\d+)`)\n\nfunc (e *iTunesExtractor) Match(u *url.URL) bool {\n\t// Host matching is done by registry\n\treturn true\n}\n\nfunc (e *iTunesExtractor) Extract(rawURL string) (Media, error) {\n\tu, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid URL: %w\", err)\n\t}\n\n\tmatches := applePodcastRegex.FindStringSubmatch(u.Path)\n\tif len(matches) < 2 {\n\t\treturn nil, fmt.Errorf(\"could not extract podcast ID from URL\")\n\t}\n\n\tpodcastID := matches[1]\n\tepisodeID := u.Query().Get(\"i\")\n\n\t// If episode ID provided, fetch that specific episode\n\tif episodeID != \"\" {\n\t\treturn e.extractEpisode(podcastID, episodeID)\n\t}\n\n\t// Otherwise list episodes from the podcast\n\treturn e.listEpisodes()\n}\n\nfunc (e *iTunesExtractor) extractEpisode(podcastID, episodeID string) (*AudioMedia, error) {\n\t// Lookup episode by ID\n\turl := fmt.Sprintf(\"https://itunes.apple.com/lookup?id=%s&entity=podcastEpisode\", podcastID)\n\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result iTunesLookupResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Find the specific episode\n\tfor _, item := range result.Results {\n\t\tif item.WrapperType == \"podcastEpisode\" && fmt.Sprintf(\"%d\", item.TrackID) == episodeID {\n\t\t\text := item.EpisodeFileExtension\n\t\t\tif ext == \"\" {\n\t\t\t\text = \"mp3\"\n\t\t\t}\n\n\t\t\t// Create filename: {podcast} - {episode}\n\t\t\tfilename := SanitizeFilename(fmt.Sprintf(\"%s - %s\", item.CollectionName, item.TrackName))\n\n\t\t\treturn &AudioMedia{\n\t\t\t\tID:       episodeID,\n\t\t\t\tTitle:    filename,\n\t\t\t\tUploader: item.ArtistName,\n\t\t\t\tDuration: item.TrackTimeMillis / 1000,\n\t\t\t\tURL:      item.EpisodeURL,\n\t\t\t\tExt:      ext,\n\t\t\t}, nil\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"episode not found\")\n}\n\nfunc (e *iTunesExtractor) listEpisodes() (*AudioMedia, error) {\n\t// Return error suggesting to use specific episode URL\n\t// We could list episodes here but for now keep it simple\n\treturn nil, fmt.Errorf(\"please provide a specific episode URL. Use 'vget search --podcast <name>' to find episodes, or visit the podcast page and select an episode\")\n}\n\n// iTunes API response structures\ntype iTunesLookupResponse struct {\n\tResultCount int                  `json:\"resultCount\"`\n\tResults     []iTunesLookupResult `json:\"results\"`\n}\n\ntype iTunesLookupResult struct {\n\tWrapperType          string `json:\"wrapperType\"`\n\tKind                 string `json:\"kind\"`\n\tTrackID              int    `json:\"trackId\"`\n\tArtistName           string `json:\"artistName\"`\n\tCollectionName       string `json:\"collectionName\"`\n\tTrackName            string `json:\"trackName\"`\n\tTrackTimeMillis      int    `json:\"trackTimeMillis\"`\n\tEpisodeURL           string `json:\"episodeUrl\"`\n\tEpisodeFileExtension string `json:\"episodeFileExtension\"`\n\tReleaseDate          string `json:\"releaseDate\"`\n}\n\nfunc init() {\n\tRegister(&iTunesExtractor{},\n\t\t\"podcasts.apple.com\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/m3u8.go",
    "content": "package extractor\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"strings\"\n\t\"time\"\n)\n\n// M3U8Extractor handles direct m3u8 playlist URLs\ntype M3U8Extractor struct {\n\tclient *http.Client\n}\n\n// Name returns the extractor name\nfunc (m *M3U8Extractor) Name() string {\n\treturn \"m3u8\"\n}\n\n// Match checks if the URL is an m3u8 playlist\nfunc (m *M3U8Extractor) Match(u *url.URL) bool {\n\t// Only match http/https URLs\n\tif u.Scheme != \"http\" && u.Scheme != \"https\" {\n\t\treturn false\n\t}\n\n\t// Check file extension\n\text := strings.ToLower(path.Ext(u.Path))\n\treturn ext == \".m3u8\" || ext == \".m3u\"\n}\n\n// Extract retrieves media information from an m3u8 URL\nfunc (m *M3U8Extractor) Extract(urlStr string) (Media, error) {\n\tif m.client == nil {\n\t\tm.client = &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t\tTransport: &http.Transport{\n\t\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t\t},\n\t\t}\n\t}\n\n\t// Parse URL to extract filename\n\tparsedURL, _ := url.Parse(urlStr)\n\tfilename := path.Base(parsedURL.Path)\n\ttitle := strings.TrimSuffix(filename, path.Ext(filename))\n\tif title == \"\" {\n\t\ttitle = \"stream\"\n\t}\n\n\t// Generate ID from URL\n\tid := generateM3U8ID(urlStr)\n\n\treturn &VideoMedia{\n\t\tID:    id,\n\t\tTitle: title,\n\t\tFormats: []VideoFormat{\n\t\t\t{\n\t\t\t\tURL: urlStr,\n\t\t\t\tExt: \"m3u8\",\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\n// generateM3U8ID creates a short ID from URL\nfunc generateM3U8ID(urlStr string) string {\n\tparsedURL, err := url.Parse(urlStr)\n\tif err != nil {\n\t\treturn \"m3u8\"\n\t}\n\n\tbase := path.Base(parsedURL.Path)\n\tif base == \"\" || base == \"/\" || base == \".\" {\n\t\treturn parsedURL.Host\n\t}\n\n\t// Remove extension\n\tif idx := strings.LastIndex(base, \".\"); idx > 0 {\n\t\tbase = base[:idx]\n\t}\n\n\tif len(base) > 32 {\n\t\tbase = base[:32]\n\t}\n\n\treturn base\n}\n\n"
  },
  {
    "path": "internal/core/extractor/registry.go",
    "content": "package extractor\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"path\"\n\t\"strings\"\n)\n\n// extractorsByHost maps hostnames to their extractors\nvar extractorsByHost = map[string]Extractor{}\n\n// fallbackExtractor handles direct file URLs and unknown hosts\nvar fallbackExtractor Extractor\n\n// m3u8Extractor handles m3u8 URLs specifically (no HEAD request validation)\nvar m3u8Extractor = &M3U8Extractor{}\n\n// directDownloadExtensions are file extensions that bypass host-based extractors\nvar directDownloadExtensions = map[string]bool{\n\t// Video\n\t\".mp4\": true, \".webm\": true, \".mov\": true, \".avi\": true, \".mkv\": true,\n\t\".flv\": true, \".m3u8\": true, \".ts\": true, \".m4v\": true, \".wmv\": true,\n\t// Audio\n\t\".mp3\": true, \".m4a\": true, \".aac\": true, \".ogg\": true, \".wav\": true,\n\t\".flac\": true, \".wma\": true,\n\t// Image\n\t\".jpg\": true, \".jpeg\": true, \".png\": true, \".gif\": true, \".webp\": true,\n\t\".bmp\": true, \".svg\": true, \".ico\": true, \".tiff\": true,\n\t// Documents\n\t\".pdf\": true, \".doc\": true, \".docx\": true, \".xls\": true, \".xlsx\": true,\n\t\".ppt\": true, \".pptx\": true, \".csv\": true, \".txt\": true, \".rtf\": true,\n\t// Ebooks\n\t\".epub\": true, \".mobi\": true, \".azw\": true, \".azw3\": true,\n\t// Archives\n\t\".zip\": true, \".tar\": true, \".gz\": true, \".bz2\": true, \".xz\": true,\n\t\".rar\": true, \".7z\": true, \".dmg\": true, \".iso\": true,\n}\n\n// Register adds an extractor for the given hostnames\nfunc Register(e Extractor, hosts ...string) {\n\tfor _, host := range hosts {\n\t\textractorsByHost[host] = e\n\t}\n}\n\n// NormalizeURL adds https:// scheme if missing and validates the result\nfunc NormalizeURL(rawURL string) (string, error) {\n\trawURL = strings.TrimSpace(rawURL)\n\tif rawURL == \"\" {\n\t\treturn \"\", fmt.Errorf(\"empty URL\")\n\t}\n\n\tvar candidate string\n\t// Already has scheme\n\tif strings.HasPrefix(rawURL, \"http://\") || strings.HasPrefix(rawURL, \"https://\") {\n\t\tcandidate = rawURL\n\t} else {\n\t\t// Try adding https://\n\t\tcandidate = \"https://\" + rawURL\n\t}\n\n\t// Parse and validate with strict parser (requires absolute URI)\n\tu, err := url.ParseRequestURI(candidate)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid URL: %s\", rawURL)\n\t}\n\n\t// Must have a valid hostname with at least one dot (e.g., example.com, not just \"asdasd\")\n\thost := u.Hostname()\n\tif host == \"\" || !strings.Contains(host, \".\") {\n\t\treturn \"\", fmt.Errorf(\"invalid URL (no valid domain): %s\", rawURL)\n\t}\n\n\treturn candidate, nil\n}\n\n// RegisterFallback sets the fallback extractor for direct files and unknown hosts\nfunc RegisterFallback(e Extractor) {\n\tfallbackExtractor = e\n}\n\n// Match finds the extractor for a URL using O(1) hostname lookup\n// Returns nil for unknown hosts (caller should check sites.yml)\nfunc Match(rawURL string) Extractor {\n\t// Normalize URL: add https:// if no scheme present\n\tnormalized, err := NormalizeURL(rawURL)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tu, err := url.Parse(normalized)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\t// Check if it's a direct file URL first (skip host-based extractors)\n\text := strings.ToLower(path.Ext(u.Path))\n\tif directDownloadExtensions[ext] {\n\t\t// Use specialized m3u8 extractor for HLS streams (no HEAD validation needed)\n\t\tif ext == \".m3u8\" || ext == \".m3u\" {\n\t\t\treturn m3u8Extractor\n\t\t}\n\t\treturn fallbackExtractor\n\t}\n\n\t// Lookup by hostname\n\thost := strings.ToLower(u.Hostname())\n\n\t// Try exact match\n\tif e, ok := extractorsByHost[host]; ok {\n\t\t// Also check path pattern via Match() (e.g., /status/ for Twitter)\n\t\tif e.Match(u) {\n\t\t\treturn e\n\t\t}\n\t}\n\n\t// Try without www. prefix\n\tif strings.HasPrefix(host, \"www.\") {\n\t\tif e, ok := extractorsByHost[host[4:]]; ok {\n\t\t\tif e.Match(u) {\n\t\t\t\treturn e\n\t\t\t}\n\t\t}\n\t}\n\n\t// Unknown host - return nil so caller can check sites.yml\n\treturn nil\n}\n\n// List returns all unique registered extractors\nfunc List() []Extractor {\n\tseen := make(map[string]bool)\n\tvar result []Extractor\n\tfor _, e := range extractorsByHost {\n\t\tif !seen[e.Name()] {\n\t\t\tseen[e.Name()] = true\n\t\t\tresult = append(result, e)\n\t\t}\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/core/extractor/telegram/constants.go",
    "content": "// Package telegram provides Telegram media extraction and download functionality.\npackage telegram\n\nconst (\n\t// Telegram Desktop's public API credentials\n\t// Safe to use - already public and used by many tools\n\tDesktopAppID   = 2040\n\tDesktopAppHash = \"b18441a1ff607e10a989891a5462e627\"\n)\n"
  },
  {
    "path": "internal/core/extractor/telegram/download.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gotd/td/session\"\n\t\"github.com/gotd/td/telegram\"\n\t\"github.com/gotd/td/telegram/downloader\"\n\t\"github.com/gotd/td/tg\"\n)\n\n// DownloadResult contains the result of a Telegram download\ntype DownloadResult struct {\n\tTitle    string\n\tFilename string\n\tSize     int64\n}\n\n// DownloadOptions configures the download behavior\ntype DownloadOptions struct {\n\tURL        string\n\tOutputPath string\n\tTakeout    bool // Use takeout session for lower rate limits\n\tProgressFn func(downloaded, total int64)\n}\n\n// Download downloads media from a Telegram URL directly.\n// This combines extraction and download because Telegram requires\n// the download to happen within the authenticated client context.\nfunc Download(urlStr string, outputPath string, progressFn func(downloaded, total int64)) (*DownloadResult, error) {\n\treturn DownloadWithOptions(DownloadOptions{\n\t\tURL:        urlStr,\n\t\tOutputPath: outputPath,\n\t\tProgressFn: progressFn,\n\t})\n}\n\n// DownloadWithOptions downloads media with configurable options including takeout mode\nfunc DownloadWithOptions(opts DownloadOptions) (*DownloadResult, error) {\n\tif !SessionExists() {\n\t\treturn nil, fmt.Errorf(\"not logged in to Telegram. Run 'vget telegram login' first\")\n\t}\n\n\tmsg, err := ParseURL(opts.URL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)\n\tdefer cancel()\n\n\tstorage := &session.FileStorage{Path: SessionFile()}\n\n\tclient := telegram.NewClient(DesktopAppID, DesktopAppHash, telegram.Options{\n\t\tSessionStorage: storage,\n\t})\n\n\tvar result *DownloadResult\n\n\terr = client.Run(ctx, func(ctx context.Context) error {\n\t\tstatus, err := client.Auth().Status(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to check auth status: %w\", err)\n\t\t}\n\t\tif !status.Authorized {\n\t\t\treturn fmt.Errorf(\"not authorized. Run 'vget telegram login' first\")\n\t\t}\n\n\t\tapi := client.API()\n\n\t\t// Initialize takeout session if requested\n\t\tvar takeout *TakeoutSession\n\t\tif opts.Takeout {\n\t\t\ttakeout = NewTakeoutSession(api)\n\t\t\tif err := takeout.Start(ctx); err != nil {\n\t\t\t\t// Log warning but continue without takeout\n\t\t\t\t// Some accounts may not have takeout enabled\n\t\t\t\tfmt.Printf(\"Warning: could not start takeout session: %v\\n\", err)\n\t\t\t} else {\n\t\t\t\tdefer func() {\n\t\t\t\t\tif err := takeout.Finish(ctx); err != nil {\n\t\t\t\t\t\tfmt.Printf(\"Warning: failed to finish takeout session: %v\\n\", err)\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}\n\t\t}\n\n\t\t// Resolve channel\n\t\tinputChannel, err := resolveChannel(ctx, api, msg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Get the message\n\t\tmsgResult, err := api.ChannelsGetMessages(ctx, &tg.ChannelsGetMessagesRequest{\n\t\t\tChannel: inputChannel,\n\t\t\tID:      []tg.InputMessageClass{&tg.InputMessageID{ID: msg.MessageID}},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get message: %w\", err)\n\t\t}\n\n\t\t// Extract message\n\t\ttgMsg, err := extractMessage(msgResult)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif tgMsg.Media == nil {\n\t\t\treturn fmt.Errorf(\"message has no media\")\n\t\t}\n\n\t\t// Download based on media type\n\t\tdl := downloader.NewDownloader()\n\n\t\tswitch media := tgMsg.Media.(type) {\n\t\tcase *tg.MessageMediaDocument:\n\t\t\tresult, err = downloadDocument(ctx, api, dl, media, tgMsg, opts.OutputPath, opts.ProgressFn)\n\t\t\treturn err\n\n\t\tcase *tg.MessageMediaPhoto:\n\t\t\tresult, err = downloadPhoto(ctx, api, dl, media, tgMsg, opts.OutputPath, opts.ProgressFn)\n\t\t\treturn err\n\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"unsupported media type: %T\", media)\n\t\t}\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn result, nil\n}\n\nfunc resolveChannel(ctx context.Context, api *tg.Client, msg *Message) (*tg.InputChannel, error) {\n\tif msg.IsPrivate {\n\t\tchannel, err := resolvePrivateChannel(ctx, api, msg.ChannelID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to resolve private channel: %w\", err)\n\t\t}\n\t\treturn &tg.InputChannel{\n\t\t\tChannelID:  channel.ID,\n\t\t\tAccessHash: channel.AccessHash,\n\t\t}, nil\n\t}\n\n\tresolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{\n\t\tUsername: msg.ChannelUsername,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to resolve username '%s': %w\", msg.ChannelUsername, err)\n\t}\n\n\tif len(resolved.Chats) > 0 {\n\t\tif channel, ok := resolved.Chats[0].(*tg.Channel); ok {\n\t\t\treturn &tg.InputChannel{\n\t\t\t\tChannelID:  channel.ID,\n\t\t\t\tAccessHash: channel.AccessHash,\n\t\t\t}, nil\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"could not resolve '%s' to a channel\", msg.ChannelUsername)\n}\n\n// ChannelInfo holds basic channel information for display\ntype ChannelInfo struct {\n\tID         int64\n\tAccessHash int64\n\tTitle      string\n\tUsername   string\n}\n\nfunc resolvePrivateChannel(ctx context.Context, api *tg.Client, channelID int64) (*ChannelInfo, error) {\n\tchannels, err := getAllChannels(ctx, api)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Look for the target channel by ID\n\tfor _, ch := range channels {\n\t\tif ch.ID == channelID {\n\t\t\treturn &ch, nil\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"channel not found. Make sure you've joined the channel\")\n}\n\nfunc getAllChannels(ctx context.Context, api *tg.Client) ([]ChannelInfo, error) {\n\tvar channels []ChannelInfo\n\tvar offsetDate int\n\tvar offsetID int\n\tvar offsetPeer tg.InputPeerClass = &tg.InputPeerEmpty{}\n\n\tfor {\n\t\tdialogs, err := api.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{\n\t\t\tOffsetPeer: offsetPeer,\n\t\t\tOffsetDate: offsetDate,\n\t\t\tOffsetID:   offsetID,\n\t\t\tLimit:      100,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn channels, err\n\t\t}\n\n\t\tvar chats []tg.ChatClass\n\t\tvar messages []tg.MessageClass\n\t\tvar done bool\n\n\t\tswitch d := dialogs.(type) {\n\t\tcase *tg.MessagesDialogs:\n\t\t\tchats = d.Chats\n\t\t\tmessages = d.Messages\n\t\t\tdone = true\n\t\tcase *tg.MessagesDialogsSlice:\n\t\t\tchats = d.Chats\n\t\t\tmessages = d.Messages\n\t\t\tdone = len(d.Dialogs) < 100\n\t\tdefault:\n\t\t\tdone = true\n\t\t}\n\n\t\tfor _, chat := range chats {\n\t\t\tif channel, ok := chat.(*tg.Channel); ok {\n\t\t\t\tchannels = append(channels, ChannelInfo{\n\t\t\t\t\tID:         channel.ID,\n\t\t\t\t\tAccessHash: channel.AccessHash,\n\t\t\t\t\tTitle:      channel.Title,\n\t\t\t\t\tUsername:   channel.Username,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tif done || len(messages) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tlastMsg := messages[len(messages)-1]\n\t\tif msg, ok := lastMsg.(*tg.Message); ok {\n\t\t\toffsetDate = msg.Date\n\t\t\toffsetID = msg.ID\n\t\t\tif len(channels) > 0 {\n\t\t\t\toffsetPeer = &tg.InputPeerChannel{\n\t\t\t\t\tChannelID:  channels[len(channels)-1].ID,\n\t\t\t\t\tAccessHash: channels[len(channels)-1].AccessHash,\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn channels, nil\n}\n\nfunc extractMessage(result tg.MessagesMessagesClass) (*tg.Message, error) {\n\tvar messages []tg.MessageClass\n\n\tswitch r := result.(type) {\n\tcase *tg.MessagesChannelMessages:\n\t\tmessages = r.Messages\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected response type: %T\", result)\n\t}\n\n\tif len(messages) == 0 {\n\t\treturn nil, fmt.Errorf(\"message not found\")\n\t}\n\n\tmsg, ok := messages[0].(*tg.Message)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unexpected message type: %T\", messages[0])\n\t}\n\n\treturn msg, nil\n}\n\nfunc downloadDocument(\n\tctx context.Context,\n\tapi *tg.Client,\n\tdl *downloader.Downloader,\n\tmedia *tg.MessageMediaDocument,\n\tmsg *tg.Message,\n\toutputPath string,\n\tprogressFn func(downloaded, total int64),\n) (*DownloadResult, error) {\n\tdoc, ok := media.Document.(*tg.Document)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"invalid document\")\n\t}\n\n\tinfo := ExtractDocumentInfo(doc, msg.Message, msg.ID)\n\n\t// Determine output filename\n\toutFile := outputPath\n\tif outFile == \"\" {\n\t\tif info.Filename != \"\" {\n\t\t\toutFile = info.Filename\n\t\t} else {\n\t\t\toutFile = fmt.Sprintf(\"%s.%s\", sanitizeFilename(info.Title), info.Ext)\n\t\t}\n\t}\n\n\t// Create output file\n\tf, err := os.Create(outFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer f.Close()\n\n\t// Download with progress\n\tvar writer io.Writer = f\n\tif progressFn != nil {\n\t\twriter = &progressWriter{w: f, fn: progressFn, total: doc.Size}\n\t}\n\n\t_, err = dl.Download(api, &tg.InputDocumentFileLocation{\n\t\tID:            doc.ID,\n\t\tAccessHash:    doc.AccessHash,\n\t\tFileReference: doc.FileReference,\n\t}).Stream(ctx, writer)\n\tif err != nil {\n\t\tos.Remove(outFile)\n\t\treturn nil, fmt.Errorf(\"download failed: %w\", err)\n\t}\n\n\treturn &DownloadResult{\n\t\tTitle:    info.Title,\n\t\tFilename: outFile,\n\t\tSize:     doc.Size,\n\t}, nil\n}\n\nfunc downloadPhoto(\n\tctx context.Context,\n\tapi *tg.Client,\n\tdl *downloader.Downloader,\n\tmedia *tg.MessageMediaPhoto,\n\tmsg *tg.Message,\n\toutputPath string,\n\tprogressFn func(downloaded, total int64),\n) (*DownloadResult, error) {\n\tphoto, ok := media.Photo.(*tg.Photo)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"invalid photo\")\n\t}\n\n\tlargest := FindLargestPhotoSize(photo.Sizes)\n\tif largest == nil {\n\t\treturn nil, fmt.Errorf(\"no photo sizes available\")\n\t}\n\n\ttitle := truncateText(msg.Message, 100)\n\tif title == \"\" {\n\t\ttitle = fmt.Sprintf(\"telegram_%d\", msg.ID)\n\t}\n\n\toutFile := outputPath\n\tif outFile == \"\" {\n\t\toutFile = fmt.Sprintf(\"%s.jpg\", sanitizeFilename(title))\n\t}\n\n\tf, err := os.Create(outFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer f.Close()\n\n\t_, err = dl.Download(api, &tg.InputPhotoFileLocation{\n\t\tID:            photo.ID,\n\t\tAccessHash:    photo.AccessHash,\n\t\tFileReference: photo.FileReference,\n\t\tThumbSize:     largest.Type,\n\t}).Stream(ctx, f)\n\tif err != nil {\n\t\tos.Remove(outFile)\n\t\treturn nil, fmt.Errorf(\"download failed: %w\", err)\n\t}\n\n\treturn &DownloadResult{\n\t\tTitle:    title,\n\t\tFilename: outFile,\n\t\tSize:     int64(largest.Size),\n\t}, nil\n}\n\n// progressWriter wraps an io.Writer to report progress\ntype progressWriter struct {\n\tw          io.Writer\n\tfn         func(downloaded, total int64)\n\ttotal      int64\n\tdownloaded int64\n}\n\nfunc (pw *progressWriter) Write(p []byte) (int, error) {\n\tn, err := pw.w.Write(p)\n\tpw.downloaded += int64(n)\n\tif pw.fn != nil {\n\t\tpw.fn(pw.downloaded, pw.total)\n\t}\n\treturn n, err\n}\n\nfunc sanitizeFilename(name string) string {\n\treplacer := strings.NewReplacer(\n\t\t\"/\", \"-\",\n\t\t\"\\\\\", \"-\",\n\t\t\":\", \"-\",\n\t\t\"*\", \"\",\n\t\t\"?\", \"\",\n\t\t\"\\\"\", \"\",\n\t\t\"<\", \"\",\n\t\t\">\", \"\",\n\t\t\"|\", \"\",\n\t\t\"\\n\", \" \",\n\t\t\"\\r\", \"\",\n\t)\n\tresult := replacer.Replace(name)\n\n\turlRegex := regexp.MustCompile(`https?://[^\\s]+`)\n\tresult = urlRegex.ReplaceAllString(result, \"\")\n\n\tresult = strings.TrimSpace(result)\n\tresult = strings.Trim(result, \".\")\n\n\tspaceRegex := regexp.MustCompile(`\\s+`)\n\tresult = spaceRegex.ReplaceAllString(result, \" \")\n\n\trunes := []rune(result)\n\tif len(runes) > 200 {\n\t\tresult = string(runes[:200])\n\t}\n\n\treturn strings.TrimSpace(result)\n}\n"
  },
  {
    "path": "internal/core/extractor/telegram/extractor.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gotd/td/session\"\n\t\"github.com/gotd/td/telegram\"\n\t\"github.com/gotd/td/tg\"\n)\n\n// Extractor handles Telegram media extraction\ntype Extractor struct{}\n\n// Name returns the extractor name\nfunc (e *Extractor) Name() string {\n\treturn \"telegram\"\n}\n\n// Match checks if URL is a Telegram message URL\nfunc (e *Extractor) Match(u *url.URL) bool {\n\thost := strings.ToLower(u.Host)\n\tif host != \"t.me\" && host != \"telegram.me\" {\n\t\treturn false\n\t}\n\treturn MatchURL(u.String())\n}\n\n// MediaInfo contains extracted media information for the extractor interface\ntype MediaInfo struct {\n\tID       string\n\tTitle    string\n\tUploader string\n\tURL      string\n\tExt      string\n\tWidth    int\n\tHeight   int\n\tSize     int64\n\tIsVideo  bool\n\tIsAudio  bool\n\tIsPhoto  bool\n}\n\n// Extract retrieves media info from a Telegram URL\nfunc (e *Extractor) Extract(urlStr string) (*MediaInfo, error) {\n\tif !SessionExists() {\n\t\treturn nil, fmt.Errorf(\"not logged in to Telegram. Run 'vget telegram login' first\")\n\t}\n\n\tmsg, err := ParseURL(urlStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\treturn e.extractMedia(ctx, msg)\n}\n\nfunc (e *Extractor) extractMedia(ctx context.Context, msg *Message) (*MediaInfo, error) {\n\tstorage := &session.FileStorage{Path: SessionFile()}\n\n\tclient := telegram.NewClient(DesktopAppID, DesktopAppHash, telegram.Options{\n\t\tSessionStorage: storage,\n\t})\n\n\tvar result *MediaInfo\n\n\terr := client.Run(ctx, func(ctx context.Context) error {\n\t\tstatus, err := client.Auth().Status(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to check auth status: %w\", err)\n\t\t}\n\t\tif !status.Authorized {\n\t\t\treturn fmt.Errorf(\"not authorized. Run 'vget telegram login' first\")\n\t\t}\n\n\t\tapi := client.API()\n\n\t\tinputChannel, err := resolveChannel(ctx, api, msg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tmsgResult, err := api.ChannelsGetMessages(ctx, &tg.ChannelsGetMessagesRequest{\n\t\t\tChannel: inputChannel,\n\t\t\tID:      []tg.InputMessageClass{&tg.InputMessageID{ID: msg.MessageID}},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get channel message: %w\", err)\n\t\t}\n\n\t\ttgMsg, err := extractMessage(msgResult)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif tgMsg.Media == nil {\n\t\t\treturn fmt.Errorf(\"message has no media\")\n\t\t}\n\n\t\tresult, err = e.extractMediaInfo(tgMsg)\n\t\treturn err\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn result, nil\n}\n\nfunc (e *Extractor) extractMediaInfo(msg *tg.Message) (*MediaInfo, error) {\n\tswitch media := msg.Media.(type) {\n\tcase *tg.MessageMediaDocument:\n\t\tdoc, ok := media.Document.(*tg.Document)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"invalid document\")\n\t\t}\n\n\t\tinfo := ExtractDocumentInfo(doc, msg.Message, msg.ID)\n\n\t\treturn &MediaInfo{\n\t\t\tID:       fmt.Sprintf(\"%d\", msg.ID),\n\t\t\tTitle:    info.Title,\n\t\t\tUploader: \"Telegram\",\n\t\t\tURL:      fmt.Sprintf(\"tg://document/%d_%d\", doc.ID, doc.AccessHash),\n\t\t\tExt:      info.Ext,\n\t\t\tWidth:    info.Width,\n\t\t\tHeight:   info.Height,\n\t\t\tSize:     info.Size,\n\t\t\tIsVideo:  info.IsVideo,\n\t\t\tIsAudio:  info.IsAudio,\n\t\t}, nil\n\n\tcase *tg.MessageMediaPhoto:\n\t\tphoto, ok := media.Photo.(*tg.Photo)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"invalid photo\")\n\t\t}\n\n\t\tlargest := FindLargestPhotoSize(photo.Sizes)\n\t\tif largest == nil {\n\t\t\treturn nil, fmt.Errorf(\"no photo sizes available\")\n\t\t}\n\n\t\ttitle := truncateText(msg.Message, 100)\n\t\tif title == \"\" {\n\t\t\ttitle = fmt.Sprintf(\"telegram_%d\", msg.ID)\n\t\t}\n\n\t\treturn &MediaInfo{\n\t\t\tID:       fmt.Sprintf(\"%d\", msg.ID),\n\t\t\tTitle:    title,\n\t\t\tUploader: \"Telegram\",\n\t\t\tURL:      fmt.Sprintf(\"tg://photo/%d_%d\", photo.ID, photo.AccessHash),\n\t\t\tExt:      \"jpg\",\n\t\t\tWidth:    largest.W,\n\t\t\tHeight:   largest.H,\n\t\t\tSize:     int64(largest.Size),\n\t\t\tIsPhoto:  true,\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported media type: %T\", media)\n\t}\n}\n"
  },
  {
    "path": "internal/core/extractor/telegram/media.go",
    "content": "package telegram\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/gotd/td/tg\"\n)\n\n// ExtractedMedia contains extracted media information\ntype ExtractedMedia struct {\n\tTitle    string\n\tFilename string\n\tExt      string\n\tSize     int64\n\tWidth    int\n\tHeight   int\n\tIsVideo  bool\n\tIsAudio  bool\n\tIsPhoto  bool\n}\n\n// ExtractDocumentInfo extracts metadata from a Telegram document\nfunc ExtractDocumentInfo(doc *tg.Document, messageText string, msgID int) *ExtractedMedia {\n\tvar filename string\n\tvar isVideo, isAudio bool\n\tvar width, height int\n\n\tfor _, attr := range doc.Attributes {\n\t\tswitch a := attr.(type) {\n\t\tcase *tg.DocumentAttributeFilename:\n\t\t\tfilename = a.FileName\n\t\tcase *tg.DocumentAttributeVideo:\n\t\t\tisVideo = true\n\t\t\twidth = a.W\n\t\t\theight = a.H\n\t\tcase *tg.DocumentAttributeAudio:\n\t\t\tisAudio = true\n\t\t}\n\t}\n\n\text := ExtFromMime(doc.MimeType)\n\tif filename != \"\" {\n\t\tif idx := strings.LastIndex(filename, \".\"); idx > 0 {\n\t\t\text = filename[idx+1:]\n\t\t}\n\t}\n\n\ttitle := truncateText(messageText, 100)\n\tif title == \"\" {\n\t\ttitle = fmt.Sprintf(\"telegram_%d\", msgID)\n\t}\n\n\treturn &ExtractedMedia{\n\t\tTitle:    title,\n\t\tFilename: filename,\n\t\tExt:      ext,\n\t\tSize:     doc.Size,\n\t\tWidth:    width,\n\t\tHeight:   height,\n\t\tIsVideo:  isVideo,\n\t\tIsAudio:  isAudio,\n\t}\n}\n\n// FindLargestPhotoSize finds the largest photo size from available sizes\nfunc FindLargestPhotoSize(sizes []tg.PhotoSizeClass) *tg.PhotoSize {\n\tvar largest *tg.PhotoSize\n\tvar largestArea int\n\n\tfor _, size := range sizes {\n\t\tif ps, ok := size.(*tg.PhotoSize); ok {\n\t\t\tarea := ps.W * ps.H\n\t\t\tif area > largestArea {\n\t\t\t\tlargest = ps\n\t\t\t\tlargestArea = area\n\t\t\t}\n\t\t}\n\t}\n\n\treturn largest\n}\n\n// ExtFromMime returns file extension from MIME type\nfunc ExtFromMime(mime string) string {\n\tswitch mime {\n\tcase \"video/mp4\":\n\t\treturn \"mp4\"\n\tcase \"video/webm\":\n\t\treturn \"webm\"\n\tcase \"video/quicktime\":\n\t\treturn \"mov\"\n\tcase \"audio/mpeg\":\n\t\treturn \"mp3\"\n\tcase \"audio/ogg\":\n\t\treturn \"ogg\"\n\tcase \"audio/mp4\":\n\t\treturn \"m4a\"\n\tcase \"image/jpeg\":\n\t\treturn \"jpg\"\n\tcase \"image/png\":\n\t\treturn \"png\"\n\tcase \"image/webp\":\n\t\treturn \"webp\"\n\tcase \"application/pdf\":\n\t\treturn \"pdf\"\n\tdefault:\n\t\treturn \"bin\"\n\t}\n}\n\nfunc truncateText(s string, maxLen int) string {\n\ts = strings.ReplaceAll(s, \"\\n\", \" \")\n\trunes := []rune(s)\n\tif len(runes) <= maxLen {\n\t\treturn s\n\t}\n\treturn string(runes[:maxLen-3]) + \"...\"\n}\n"
  },
  {
    "path": "internal/core/extractor/telegram/parser.go",
    "content": "package telegram\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n)\n\nvar (\n\t// t.me/channel/123 or t.me/username/123\n\tpublicURLRegex = regexp.MustCompile(`t\\.me/([^/]+)/(\\d+)`)\n\t// t.me/c/123456789/123 (private channel)\n\tprivateURLRegex = regexp.MustCompile(`t\\.me/c/(\\d+)/(\\d+)`)\n)\n\n// Message represents a parsed Telegram message URL\ntype Message struct {\n\tChannelUsername string // For public channels/users\n\tChannelID       int64  // For private channels (from /c/ URLs)\n\tMessageID       int\n\tIsPrivate       bool\n}\n\n// ParseURL parses a t.me URL into its components\nfunc ParseURL(urlStr string) (*Message, error) {\n\t// Try private channel format first: t.me/c/123456789/123\n\tif matches := privateURLRegex.FindStringSubmatch(urlStr); len(matches) >= 3 {\n\t\tchannelID, err := strconv.ParseInt(matches[1], 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid channel ID: %w\", err)\n\t\t}\n\t\tmsgID, err := strconv.Atoi(matches[2])\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid message ID: %w\", err)\n\t\t}\n\t\treturn &Message{\n\t\t\tChannelID: channelID,\n\t\t\tMessageID: msgID,\n\t\t\tIsPrivate: true,\n\t\t}, nil\n\t}\n\n\t// Try public format: t.me/channel/123\n\tif matches := publicURLRegex.FindStringSubmatch(urlStr); len(matches) >= 3 {\n\t\tmsgID, err := strconv.Atoi(matches[2])\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid message ID: %w\", err)\n\t\t}\n\t\treturn &Message{\n\t\t\tChannelUsername: matches[1],\n\t\t\tMessageID:       msgID,\n\t\t\tIsPrivate:       false,\n\t\t}, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"could not parse Telegram URL: %s\", urlStr)\n}\n\n// MatchURL checks if a URL string matches Telegram message patterns\nfunc MatchURL(urlStr string) bool {\n\treturn publicURLRegex.MatchString(urlStr) || privateURLRegex.MatchString(urlStr)\n}\n"
  },
  {
    "path": "internal/core/extractor/telegram/session.go",
    "content": "package telegram\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\n// SessionPath returns the path where Telegram session is stored\n// Uses ~/.config/vget/telegram/ to match vget's standard config directory\nfunc SessionPath() string {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\thome = os.Getenv(\"HOME\")\n\t}\n\treturn filepath.Join(home, \".config\", \"vget\", \"telegram\")\n}\n\n// SessionFile returns the full path to the desktop session file\nfunc SessionFile() string {\n\treturn filepath.Join(SessionPath(), \"desktop-session.json\")\n}\n\n// SessionExists checks if a Telegram session exists\nfunc SessionExists() bool {\n\t_, err := os.Stat(SessionFile())\n\treturn err == nil\n}\n"
  },
  {
    "path": "internal/core/extractor/telegram/takeout.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/gotd/td/bin\"\n\t\"github.com/gotd/td/telegram\"\n\t\"github.com/gotd/td/tg\"\n)\n\n// TakeoutSession manages a Telegram takeout session for bulk downloads\n// with lower rate limits. Takeout is Telegram's official data export feature.\ntype TakeoutSession struct {\n\tapi       *tg.Client\n\ttakeoutID int64\n}\n\n// NewTakeoutSession creates a new takeout session manager\nfunc NewTakeoutSession(api *tg.Client) *TakeoutSession {\n\treturn &TakeoutSession{api: api}\n}\n\n// Start initiates a takeout session with Telegram\n// This enables lower flood wait limits for bulk downloads\nfunc (t *TakeoutSession) Start(ctx context.Context) error {\n\treq := &tg.AccountInitTakeoutSessionRequest{\n\t\tFiles:       true,\n\t\tFileMaxSize: 4 * 1024 * 1024 * 1024, // 4GB\n\t}\n\treq.SetFlags()\n\n\tsession, err := t.api.AccountInitTakeoutSession(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"init takeout session: %w\", err)\n\t}\n\n\tt.takeoutID = session.ID\n\treturn nil\n}\n\n// Finish closes the takeout session\nfunc (t *TakeoutSession) Finish(ctx context.Context) error {\n\tif t.takeoutID == 0 {\n\t\treturn nil\n\t}\n\n\treq := &tg.AccountFinishTakeoutSessionRequest{Success: true}\n\treq.SetFlags()\n\n\t_, err := t.api.AccountFinishTakeoutSession(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finish takeout session: %w\", err)\n\t}\n\n\tt.takeoutID = 0\n\treturn nil\n}\n\n// ID returns the current takeout session ID\nfunc (t *TakeoutSession) ID() int64 {\n\treturn t.takeoutID\n}\n\n// Active returns true if a takeout session is active\nfunc (t *TakeoutSession) Active() bool {\n\treturn t.takeoutID != 0\n}\n\n// takeoutMiddleware wraps requests with the takeout session ID\ntype takeoutMiddleware struct {\n\tid int64\n}\n\n// nopDecoder is used to wrap the encoder for InvokeWithTakeoutRequest\ntype nopDecoder struct {\n\tbin.Encoder\n}\n\nfunc (n nopDecoder) Decode(_ *bin.Buffer) error {\n\treturn fmt.Errorf(\"decode not implemented\")\n}\n\n// Handle implements telegram.Middleware\nfunc (t takeoutMiddleware) Handle(next tg.Invoker) telegram.InvokeFunc {\n\treturn func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {\n\t\treturn next.Invoke(ctx, &tg.InvokeWithTakeoutRequest{\n\t\t\tTakeoutID: t.id,\n\t\t\tQuery:     nopDecoder{input},\n\t\t}, output)\n\t}\n}\n\n// Middleware returns a telegram.Middleware that wraps all requests with takeout\nfunc (t *TakeoutSession) Middleware() telegram.Middleware {\n\treturn takeoutMiddleware{id: t.takeoutID}\n}\n"
  },
  {
    "path": "internal/core/extractor/telegram.go",
    "content": "package extractor\n\nimport (\n\t\"net/url\"\n\n\t\"github.com/guiyumin/vget/internal/core/extractor/telegram\"\n)\n\n// Re-export telegram package functions for external use\nvar (\n\tTelegramDownload            = telegram.Download\n\tTelegramDownloadWithOptions = telegram.DownloadWithOptions\n\tTelegramSessionPath         = telegram.SessionPath\n\tTelegramSessionExists       = telegram.SessionExists\n)\n\n// Re-export constants\nconst (\n\tTelegramDesktopAppID   = telegram.DesktopAppID\n\tTelegramDesktopAppHash = telegram.DesktopAppHash\n)\n\n// Re-export types\ntype TelegramDownloadResult  = telegram.DownloadResult\ntype TelegramDownloadOptions = telegram.DownloadOptions\n\n// TelegramExtractor wraps the telegram.Extractor for registration\ntype TelegramExtractor struct {\n\text *telegram.Extractor\n}\n\nfunc (t *TelegramExtractor) Name() string {\n\treturn t.ext.Name()\n}\n\nfunc (t *TelegramExtractor) Match(u *url.URL) bool {\n\treturn t.ext.Match(u)\n}\n\nfunc (t *TelegramExtractor) Extract(urlStr string) (Media, error) {\n\tinfo, err := t.ext.Extract(urlStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Convert to extractor.Media interface\n\tif info.IsPhoto {\n\t\treturn &ImageMedia{\n\t\t\tID:       info.ID,\n\t\t\tTitle:    info.Title,\n\t\t\tUploader: info.Uploader,\n\t\t\tImages: []Image{\n\t\t\t\t{\n\t\t\t\t\tURL:    info.URL,\n\t\t\t\t\tExt:    info.Ext,\n\t\t\t\t\tWidth:  info.Width,\n\t\t\t\t\tHeight: info.Height,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\tif info.IsAudio {\n\t\treturn &AudioMedia{\n\t\t\tID:       info.ID,\n\t\t\tTitle:    info.Title,\n\t\t\tUploader: info.Uploader,\n\t\t\tURL:      info.URL,\n\t\t\tExt:      info.Ext,\n\t\t}, nil\n\t}\n\n\t// Default to video\n\treturn &VideoMedia{\n\t\tID:       info.ID,\n\t\tTitle:    info.Title,\n\t\tUploader: info.Uploader,\n\t\tFormats: []VideoFormat{\n\t\t\t{\n\t\t\t\tURL:    info.URL,\n\t\t\t\tExt:    info.Ext,\n\t\t\t\tWidth:  info.Width,\n\t\t\t\tHeight: info.Height,\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\nfunc init() {\n\tRegister(&TelegramExtractor{ext: &telegram.Extractor{}},\n\t\t\"t.me\",\n\t\t\"telegram.me\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/tiktok.go",
    "content": "package extractor\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\n// TikTokExtractor handles TikTok video downloads\ntype TikTokExtractor struct{}\n\nfunc (e *TikTokExtractor) Name() string {\n\treturn \"tiktok\"\n}\n\nfunc (e *TikTokExtractor) Match(u *url.URL) bool {\n\t// Host matching is done by registry\n\treturn true\n}\n\nfunc (e *TikTokExtractor) Extract(url string) (Media, error) {\n\treturn nil, fmt.Errorf(\"TikTok support coming soon\")\n}\n\nfunc init() {\n\tRegister(&TikTokExtractor{},\n\t\t\"tiktok.com\",\n\t\t\"vm.tiktok.com\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/twitter.go",
    "content": "package extractor\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\t// Public bearer token (same as used by web client)\n\ttwitterBearerToken = \"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA\"\n\n\ttwitterGuestTokenURL  = \"https://api.x.com/1.1/guest/activate.json\"\n\ttwitterGraphQLURL     = \"https://x.com/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId\"\n\ttwitterSyndicationURL = \"https://cdn.syndication.twimg.com/tweet-result\"\n)\n\nvar (\n\t// Matches twitter.com and x.com URLs with status\n\ttwitterURLRegex = regexp.MustCompile(`(?:twitter\\.com|x\\.com)/(?:[^/]+)/status/(\\d+)`)\n)\n\n// Twitter-specific error types for i18n support\ntype TwitterError struct {\n\tCode    string // \"nsfw\", \"protected\", \"unavailable\"\n\tMessage string // Original message for fallback\n}\n\nfunc (e *TwitterError) Error() string {\n\treturn e.Message\n}\n\n// Error code constants\nconst (\n\tTwitterErrorNSFW        = \"nsfw\"\n\tTwitterErrorProtected   = \"protected\"\n\tTwitterErrorUnavailable = \"unavailable\"\n)\n\n// TwitterExtractor handles Twitter/X media extraction\ntype TwitterExtractor struct {\n\tclient     *http.Client\n\tguestToken string\n\tauthToken  string // auth_token cookie for authenticated requests\n\tcsrfToken  string // ct0 cookie for CSRF protection\n}\n\n// Name returns the extractor name\nfunc (t *TwitterExtractor) Name() string {\n\treturn \"twitter\"\n}\n\n// Match checks if URL is a Twitter/X status URL\nfunc (t *TwitterExtractor) Match(u *url.URL) bool {\n\t// Host matching is done by registry, check path pattern\n\treturn twitterURLRegex.MatchString(u.String())\n}\n\n// SetAuth sets authentication credentials for accessing restricted content\nfunc (t *TwitterExtractor) SetAuth(authToken string) {\n\tt.authToken = authToken\n}\n\n// IsAuthenticated returns true if auth credentials are set\nfunc (t *TwitterExtractor) IsAuthenticated() bool {\n\treturn t.authToken != \"\"\n}\n\n// Extract retrieves media from a Twitter/X URL\nfunc (t *TwitterExtractor) Extract(urlStr string) (Media, error) {\n\t// Initialize HTTP client\n\tif t.client == nil {\n\t\tt.client = &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t\tTransport: &http.Transport{\n\t\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t\t},\n\t\t}\n\t}\n\n\t// Extract tweet ID from URL\n\tmatches := twitterURLRegex.FindStringSubmatch(urlStr)\n\tif len(matches) < 2 {\n\t\treturn nil, fmt.Errorf(\"could not extract tweet ID from URL\")\n\t}\n\ttweetID := matches[1]\n\n\t// If authenticated, use GraphQL API directly (supports NSFW content)\n\tif t.IsAuthenticated() {\n\t\tmedia, err := t.fetchFromGraphQLAuth(tweetID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to fetch tweet: %w\", err)\n\t\t}\n\t\treturn media, nil\n\t}\n\n\t// Try syndication API first (simpler, no auth needed for public tweets)\n\tmedia, err := t.fetchFromSyndication(tweetID)\n\tif err == nil {\n\t\treturn media, nil\n\t}\n\n\t// Fallback to GraphQL API with guest token\n\tif err := t.fetchGuestToken(); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get guest token: %w\", err)\n\t}\n\n\tmedia, err = t.fetchFromGraphQL(tweetID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to fetch tweet: %w\", err)\n\t}\n\n\treturn media, nil\n}\n\n// fetchFromSyndication tries the syndication endpoint (works for public tweets)\nfunc (t *TwitterExtractor) fetchFromSyndication(tweetID string) (Media, error) {\n\tparams := url.Values{}\n\tparams.Set(\"id\", tweetID)\n\tparams.Set(\"token\", \"x\") // Required but value doesn't matter\n\n\treqURL := twitterSyndicationURL + \"?\" + params.Encode()\n\n\treq, err := http.NewRequest(\"GET\", reqURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\tresp, err := t.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"syndication request failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar data syndicationResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&data); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse syndication response: %w\", err)\n\t}\n\n\treturn t.parseSyndicationResponse(&data, tweetID)\n}\n\n// fetchGuestToken obtains a guest token for API access\nfunc (t *TwitterExtractor) fetchGuestToken() error {\n\treq, err := http.NewRequest(\"POST\", twitterGuestTokenURL, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+twitterBearerToken)\n\n\tresp, err := t.client.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"guest token request failed with status %d\", resp.StatusCode)\n\t}\n\n\tvar result struct {\n\t\tGuestToken string `json:\"guest_token\"`\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn err\n\t}\n\n\tt.guestToken = result.GuestToken\n\treturn nil\n}\n\n// fetchFromGraphQL uses the GraphQL API\nfunc (t *TwitterExtractor) fetchFromGraphQL(tweetID string) (Media, error) {\n\tvariables := map[string]interface{}{\n\t\t\"tweetId\":                tweetID,\n\t\t\"withCommunity\":          false,\n\t\t\"includePromotedContent\": false,\n\t\t\"withVoice\":              false,\n\t}\n\n\t// Features from yt-dlp (actively maintained)\n\tfeatures := map[string]interface{}{\n\t\t\"creator_subscriptions_tweet_preview_api_enabled\":                         true,\n\t\t\"tweetypie_unmention_optimization_enabled\":                                true,\n\t\t\"responsive_web_edit_tweet_api_enabled\":                                   true,\n\t\t\"graphql_is_translatable_rweb_tweet_is_translatable_enabled\":              true,\n\t\t\"view_counts_everywhere_api_enabled\":                                      true,\n\t\t\"longform_notetweets_consumption_enabled\":                                 true,\n\t\t\"responsive_web_twitter_article_tweet_consumption_enabled\":                false,\n\t\t\"tweet_awards_web_tipping_enabled\":                                        false,\n\t\t\"freedom_of_speech_not_reach_fetch_enabled\":                               true,\n\t\t\"standardized_nudges_misinfo\":                                             true,\n\t\t\"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled\": true,\n\t\t\"longform_notetweets_rich_text_read_enabled\":                              true,\n\t\t\"longform_notetweets_inline_media_enabled\":                                true,\n\t\t\"responsive_web_graphql_exclude_directive_enabled\":                        true,\n\t\t\"verified_phone_label_enabled\":                                            false,\n\t\t\"responsive_web_media_download_video_enabled\":                             false,\n\t\t\"responsive_web_graphql_skip_user_profile_image_extensions_enabled\":       false,\n\t\t\"responsive_web_graphql_timeline_navigation_enabled\":                      true,\n\t\t\"responsive_web_enhance_cards_enabled\":                                    false,\n\t}\n\n\tvariablesJSON, _ := json.Marshal(variables)\n\tfeaturesJSON, _ := json.Marshal(features)\n\n\tparams := url.Values{}\n\tparams.Set(\"variables\", string(variablesJSON))\n\tparams.Set(\"features\", string(featuresJSON))\n\n\treqURL := twitterGraphQLURL + \"?\" + params.Encode()\n\n\treq, err := http.NewRequest(\"GET\", reqURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+twitterBearerToken)\n\treq.Header.Set(\"x-guest-token\", t.guestToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\n\tresp, err := t.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"GraphQL request failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn t.parseGraphQLResponse(body, tweetID)\n}\n\n// fetchCsrfToken fetches the ct0 CSRF token by making a request to Twitter\nfunc (t *TwitterExtractor) fetchCsrfToken() error {\n\treq, err := http.NewRequest(\"GET\", \"https://x.com\", nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\treq.AddCookie(&http.Cookie{Name: \"auth_token\", Value: t.authToken})\n\n\tresp, err := t.client.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tfor _, cookie := range resp.Cookies() {\n\t\tif cookie.Name == \"ct0\" {\n\t\t\tt.csrfToken = cookie.Value\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"could not obtain CSRF token\")\n}\n\n// fetchFromGraphQLAuth uses the GraphQL API with authentication (for NSFW content)\nfunc (t *TwitterExtractor) fetchFromGraphQLAuth(tweetID string) (Media, error) {\n\t// Fetch CSRF token if not already set\n\tif t.csrfToken == \"\" {\n\t\tif err := t.fetchCsrfToken(); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get CSRF token: %w\", err)\n\t\t}\n\t}\n\n\tvariables := map[string]interface{}{\n\t\t\"tweetId\":                tweetID,\n\t\t\"withCommunity\":          false,\n\t\t\"includePromotedContent\": false,\n\t\t\"withVoice\":              false,\n\t}\n\n\t// Features from yt-dlp (actively maintained)\n\tfeatures := map[string]interface{}{\n\t\t\"creator_subscriptions_tweet_preview_api_enabled\":                         true,\n\t\t\"tweetypie_unmention_optimization_enabled\":                                true,\n\t\t\"responsive_web_edit_tweet_api_enabled\":                                   true,\n\t\t\"graphql_is_translatable_rweb_tweet_is_translatable_enabled\":              true,\n\t\t\"view_counts_everywhere_api_enabled\":                                      true,\n\t\t\"longform_notetweets_consumption_enabled\":                                 true,\n\t\t\"responsive_web_twitter_article_tweet_consumption_enabled\":                false,\n\t\t\"tweet_awards_web_tipping_enabled\":                                        false,\n\t\t\"freedom_of_speech_not_reach_fetch_enabled\":                               true,\n\t\t\"standardized_nudges_misinfo\":                                             true,\n\t\t\"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled\": true,\n\t\t\"longform_notetweets_rich_text_read_enabled\":                              true,\n\t\t\"longform_notetweets_inline_media_enabled\":                                true,\n\t\t\"responsive_web_graphql_exclude_directive_enabled\":                        true,\n\t\t\"verified_phone_label_enabled\":                                            false,\n\t\t\"responsive_web_media_download_video_enabled\":                             false,\n\t\t\"responsive_web_graphql_skip_user_profile_image_extensions_enabled\":       false,\n\t\t\"responsive_web_graphql_timeline_navigation_enabled\":                      true,\n\t\t\"responsive_web_enhance_cards_enabled\":                                    false,\n\t}\n\n\tvariablesJSON, _ := json.Marshal(variables)\n\tfeaturesJSON, _ := json.Marshal(features)\n\n\tparams := url.Values{}\n\tparams.Set(\"variables\", string(variablesJSON))\n\tparams.Set(\"features\", string(featuresJSON))\n\n\treqURL := twitterGraphQLURL + \"?\" + params.Encode()\n\n\treq, err := http.NewRequest(\"GET\", reqURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Set authentication headers\n\treq.Header.Set(\"Authorization\", \"Bearer \"+twitterBearerToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\treq.Header.Set(\"x-twitter-auth-type\", \"OAuth2Session\")\n\treq.Header.Set(\"x-twitter-client-language\", \"en\")\n\treq.Header.Set(\"x-twitter-active-user\", \"yes\")\n\treq.Header.Set(\"x-csrf-token\", t.csrfToken)\n\n\t// Set cookies for authentication\n\treq.AddCookie(&http.Cookie{Name: \"auth_token\", Value: t.authToken})\n\treq.AddCookie(&http.Cookie{Name: \"ct0\", Value: t.csrfToken})\n\n\tresp, err := t.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"GraphQL request failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn t.parseGraphQLResponse(body, tweetID)\n}\n\n// parseSyndicationResponse extracts media from syndication API response\nfunc (t *TwitterExtractor) parseSyndicationResponse(data *syndicationResponse, tweetID string) (Media, error) {\n\tif len(data.MediaDetails) == 0 {\n\t\treturn nil, fmt.Errorf(\"no media found in tweet\")\n\t}\n\n\ttitle := truncateText(data.Text, 100)\n\tuploader := data.User.ScreenName\n\n\t// Collect videos separately for each media item\n\tvar videos []*VideoMedia\n\tvar images []Image\n\tvideoIndex := 0\n\n\tfor _, media := range data.MediaDetails {\n\t\tswitch media.Type {\n\t\tcase \"video\", \"animated_gif\":\n\t\t\tvar formats []VideoFormat\n\t\t\tfor _, variant := range media.VideoInfo.Variants {\n\t\t\t\tif variant.ContentType != \"video/mp4\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tformat := VideoFormat{\n\t\t\t\t\tURL:     variant.URL,\n\t\t\t\t\tExt:     \"mp4\",\n\t\t\t\t\tBitrate: variant.Bitrate,\n\t\t\t\t}\n\n\t\t\t\tif w, h := extractResolutionFromURL(variant.URL); w > 0 {\n\t\t\t\t\tformat.Width = w\n\t\t\t\t\tformat.Height = h\n\t\t\t\t\tformat.Quality = fmt.Sprintf(\"%dp\", h)\n\t\t\t\t} else if variant.Bitrate > 0 {\n\t\t\t\t\tformat.Quality = estimateQualityFromBitrate(variant.Bitrate)\n\t\t\t\t}\n\n\t\t\t\tformats = append(formats, format)\n\t\t\t}\n\n\t\t\tif len(formats) > 0 {\n\t\t\t\t// Sort by bitrate (highest first)\n\t\t\t\tsort.Slice(formats, func(i, j int) bool {\n\t\t\t\t\tif formats[i].Bitrate != formats[j].Bitrate {\n\t\t\t\t\t\treturn formats[i].Bitrate > formats[j].Bitrate\n\t\t\t\t\t}\n\t\t\t\t\treturn formats[i].Height > formats[j].Height\n\t\t\t\t})\n\n\t\t\t\tvideoIndex++\n\t\t\t\tvideos = append(videos, &VideoMedia{\n\t\t\t\t\tID:       fmt.Sprintf(\"%s_%d\", tweetID, videoIndex),\n\t\t\t\t\tTitle:    title,\n\t\t\t\t\tUploader: uploader,\n\t\t\t\t\tFormats:  formats,\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase \"photo\":\n\t\t\timageURL := getHighQualityImageURL(media.MediaURLHTTPS)\n\t\t\text := getImageExtension(media.MediaURLHTTPS)\n\n\t\t\timg := Image{\n\t\t\t\tURL: imageURL,\n\t\t\t\tExt: ext,\n\t\t\t}\n\n\t\t\tif media.OriginalWidth > 0 {\n\t\t\t\timg.Width = media.OriginalWidth\n\t\t\t\timg.Height = media.OriginalHeight\n\t\t\t}\n\n\t\t\timages = append(images, img)\n\t\t}\n\t}\n\n\t// Also check video field directly (for single video tweets)\n\tif len(videos) == 0 && data.Video.Variants != nil {\n\t\tvar formats []VideoFormat\n\t\tfor _, variant := range data.Video.Variants {\n\t\t\tif variant.Type != \"video/mp4\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tformat := VideoFormat{\n\t\t\t\tURL: variant.Src,\n\t\t\t\tExt: \"mp4\",\n\t\t\t}\n\n\t\t\tif w, h := extractResolutionFromURL(variant.Src); w > 0 {\n\t\t\t\tformat.Width = w\n\t\t\t\tformat.Height = h\n\t\t\t\tformat.Quality = fmt.Sprintf(\"%dp\", h)\n\t\t\t}\n\n\t\t\tformats = append(formats, format)\n\t\t}\n\n\t\tif len(formats) > 0 {\n\t\t\tvideos = append(videos, &VideoMedia{\n\t\t\t\tID:       tweetID,\n\t\t\t\tTitle:    title,\n\t\t\t\tUploader: uploader,\n\t\t\t\tFormats:  formats,\n\t\t\t})\n\t\t}\n\t}\n\n\t// Return appropriate media type\n\tif len(videos) > 1 {\n\t\t// Multiple videos - return MultiVideoMedia\n\t\treturn &MultiVideoMedia{\n\t\t\tID:       tweetID,\n\t\t\tTitle:    title,\n\t\t\tUploader: uploader,\n\t\t\tVideos:   videos,\n\t\t}, nil\n\t}\n\n\tif len(videos) == 1 {\n\t\t// Single video - return VideoMedia directly\n\t\tvideos[0].ID = tweetID // Use original tweet ID for single video\n\t\treturn videos[0], nil\n\t}\n\n\tif len(images) > 0 {\n\t\treturn &ImageMedia{\n\t\t\tID:       tweetID,\n\t\t\tTitle:    title,\n\t\t\tUploader: uploader,\n\t\t\tImages:   images,\n\t\t}, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"no media found in tweet\")\n}\n\n// parseGraphQLResponse extracts media from GraphQL API response\nfunc (t *TwitterExtractor) parseGraphQLResponse(body []byte, tweetID string) (Media, error) {\n\tvar resp graphQLResponse\n\tif err := json.Unmarshal(body, &resp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse GraphQL response: %w\", err)\n\t}\n\n\tresult := resp.Data.TweetResult.Result\n\tif result == nil {\n\t\treturn nil, fmt.Errorf(\"tweet not found or not accessible\")\n\t}\n\n\t// Handle different result types\n\tswitch result.TypeName {\n\tcase \"TweetTombstone\":\n\t\tmsg := \"tweet is unavailable\"\n\t\tif result.Tombstone != nil && result.Tombstone.Text.Text != \"\" {\n\t\t\tmsg = result.Tombstone.Text.Text\n\t\t}\n\t\treturn nil, &TwitterError{Code: TwitterErrorUnavailable, Message: msg}\n\tcase \"TweetUnavailable\":\n\t\tswitch result.Reason {\n\t\tcase \"NsfwLoggedOut\":\n\t\t\treturn nil, &TwitterError{Code: TwitterErrorNSFW, Message: \"age-restricted content requires login\"}\n\t\tcase \"Protected\":\n\t\t\treturn nil, &TwitterError{Code: TwitterErrorProtected, Message: \"protected tweet requires authorization\"}\n\t\tdefault:\n\t\t\tmsg := \"tweet is unavailable\"\n\t\t\tif result.Reason != \"\" {\n\t\t\t\tmsg = fmt.Sprintf(\"tweet unavailable: %s\", result.Reason)\n\t\t\t}\n\t\t\treturn nil, &TwitterError{Code: TwitterErrorUnavailable, Message: msg}\n\t\t}\n\t}\n\n\t// Handle tweet with visibility results\n\tlegacy := result.Legacy\n\tif legacy == nil && result.Tweet != nil {\n\t\tlegacy = result.Tweet.Legacy\n\t}\n\n\tif legacy == nil {\n\t\treturn nil, fmt.Errorf(\"could not find tweet data (type: %s)\", result.TypeName)\n\t}\n\n\ttitle := truncateText(legacy.FullText, 100)\n\tvar uploader string\n\tif result.Core != nil && result.Core.UserResults.Result != nil {\n\t\tuploader = result.Core.UserResults.Result.Legacy.ScreenName\n\t}\n\n\tif legacy.ExtendedEntities == nil || len(legacy.ExtendedEntities.Media) == 0 {\n\t\treturn nil, fmt.Errorf(\"no media found in tweet\")\n\t}\n\n\t// Collect videos separately for each media item\n\tvar videos []*VideoMedia\n\tvar images []Image\n\tvideoIndex := 0\n\n\tfor _, media := range legacy.ExtendedEntities.Media {\n\t\tswitch media.Type {\n\t\tcase \"video\", \"animated_gif\":\n\t\t\tvar formats []VideoFormat\n\t\t\tduration := media.VideoInfo.DurationMillis / 1000\n\n\t\t\tfor _, variant := range media.VideoInfo.Variants {\n\t\t\t\tif variant.ContentType != \"video/mp4\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tformat := VideoFormat{\n\t\t\t\t\tURL:     variant.URL,\n\t\t\t\t\tExt:     \"mp4\",\n\t\t\t\t\tBitrate: variant.Bitrate,\n\t\t\t\t}\n\n\t\t\t\tif w, h := extractResolutionFromURL(variant.URL); w > 0 {\n\t\t\t\t\tformat.Width = w\n\t\t\t\t\tformat.Height = h\n\t\t\t\t\tformat.Quality = fmt.Sprintf(\"%dp\", h)\n\t\t\t\t} else if variant.Bitrate > 0 {\n\t\t\t\t\tformat.Quality = estimateQualityFromBitrate(variant.Bitrate)\n\t\t\t\t}\n\n\t\t\t\tformats = append(formats, format)\n\t\t\t}\n\n\t\t\tif len(formats) > 0 {\n\t\t\t\t// Sort by bitrate (highest first)\n\t\t\t\tsort.Slice(formats, func(i, j int) bool {\n\t\t\t\t\treturn formats[i].Bitrate > formats[j].Bitrate\n\t\t\t\t})\n\n\t\t\t\tvideoIndex++\n\t\t\t\tvideos = append(videos, &VideoMedia{\n\t\t\t\t\tID:       fmt.Sprintf(\"%s_%d\", tweetID, videoIndex),\n\t\t\t\t\tTitle:    title,\n\t\t\t\t\tUploader: uploader,\n\t\t\t\t\tDuration: duration,\n\t\t\t\t\tFormats:  formats,\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase \"photo\":\n\t\t\timageURL := getHighQualityImageURL(media.MediaURLHTTPS)\n\t\t\text := getImageExtension(media.MediaURLHTTPS)\n\n\t\t\timg := Image{\n\t\t\t\tURL: imageURL,\n\t\t\t\tExt: ext,\n\t\t\t}\n\n\t\t\tif media.OriginalInfo.Width > 0 {\n\t\t\t\timg.Width = media.OriginalInfo.Width\n\t\t\t\timg.Height = media.OriginalInfo.Height\n\t\t\t}\n\n\t\t\timages = append(images, img)\n\t\t}\n\t}\n\n\t// Return appropriate media type\n\tif len(videos) > 1 {\n\t\t// Multiple videos - return MultiVideoMedia\n\t\treturn &MultiVideoMedia{\n\t\t\tID:       tweetID,\n\t\t\tTitle:    title,\n\t\t\tUploader: uploader,\n\t\t\tVideos:   videos,\n\t\t}, nil\n\t}\n\n\tif len(videos) == 1 {\n\t\t// Single video - return VideoMedia directly\n\t\tvideos[0].ID = tweetID // Use original tweet ID for single video\n\t\treturn videos[0], nil\n\t}\n\n\tif len(images) > 0 {\n\t\treturn &ImageMedia{\n\t\t\tID:       tweetID,\n\t\t\tTitle:    title,\n\t\t\tUploader: uploader,\n\t\t\tImages:   images,\n\t\t}, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"no media found in tweet\")\n}\n\n// Syndication API response structures\ntype syndicationResponse struct {\n\tText string `json:\"text\"`\n\tUser struct {\n\t\tScreenName string `json:\"screen_name\"`\n\t\tName       string `json:\"name\"`\n\t} `json:\"user\"`\n\tMediaDetails []struct {\n\t\tType           string `json:\"type\"`\n\t\tMediaURLHTTPS  string `json:\"media_url_https\"`\n\t\tOriginalWidth  int    `json:\"original_info_width\"`\n\t\tOriginalHeight int    `json:\"original_info_height\"`\n\t\tVideoInfo      struct {\n\t\t\tVariants []struct {\n\t\t\t\tBitrate     int    `json:\"bitrate\"`\n\t\t\t\tContentType string `json:\"content_type\"`\n\t\t\t\tURL         string `json:\"url\"`\n\t\t\t} `json:\"variants\"`\n\t\t} `json:\"video_info\"`\n\t} `json:\"mediaDetails\"`\n\tVideo struct {\n\t\tVariants []struct {\n\t\t\tType string `json:\"type\"`\n\t\t\tSrc  string `json:\"src\"`\n\t\t} `json:\"variants\"`\n\t} `json:\"video\"`\n}\n\n// GraphQL API response structures\ntype graphQLResponse struct {\n\tData struct {\n\t\tTweetResult struct {\n\t\t\tResult *graphQLTweetResult `json:\"result\"`\n\t\t} `json:\"tweetResult\"`\n\t} `json:\"data\"`\n}\n\ntype graphQLTweetResult struct {\n\tTypeName  string              `json:\"__typename\"`\n\tLegacy    *graphQLLegacy      `json:\"legacy\"`\n\tCore      *graphQLCore        `json:\"core\"`\n\tTweet     *graphQLTweetResult `json:\"tweet\"`     // For TweetWithVisibilityResults\n\tReason    string              `json:\"reason\"`    // For TweetUnavailable\n\tTombstone *struct {\n\t\tText struct {\n\t\t\tText string `json:\"text\"`\n\t\t} `json:\"text\"`\n\t} `json:\"tombstone\"` // For TweetTombstone\n}\n\ntype graphQLCore struct {\n\tUserResults struct {\n\t\tResult *struct {\n\t\t\tLegacy struct {\n\t\t\t\tScreenName string `json:\"screen_name\"`\n\t\t\t} `json:\"legacy\"`\n\t\t} `json:\"result\"`\n\t} `json:\"user_results\"`\n}\n\ntype graphQLLegacy struct {\n\tFullText         string `json:\"full_text\"`\n\tExtendedEntities *struct {\n\t\tMedia []struct {\n\t\t\tType          string `json:\"type\"`\n\t\t\tMediaURLHTTPS string `json:\"media_url_https\"`\n\t\t\tOriginalInfo  struct {\n\t\t\t\tWidth  int `json:\"width\"`\n\t\t\t\tHeight int `json:\"height\"`\n\t\t\t} `json:\"original_info\"`\n\t\t\tVideoInfo struct {\n\t\t\t\tDurationMillis int `json:\"duration_millis\"`\n\t\t\t\tVariants       []struct {\n\t\t\t\t\tBitrate     int    `json:\"bitrate\"`\n\t\t\t\t\tContentType string `json:\"content_type\"`\n\t\t\t\t\tURL         string `json:\"url\"`\n\t\t\t\t} `json:\"variants\"`\n\t\t\t} `json:\"video_info\"`\n\t\t} `json:\"media\"`\n\t} `json:\"extended_entities\"`\n}\n\n// Helper functions\n\nfunc truncateText(s string, maxLen int) string {\n\ts = strings.ReplaceAll(s, \"\\n\", \" \")\n\trunes := []rune(s)\n\tif len(runes) <= maxLen {\n\t\treturn s\n\t}\n\treturn string(runes[:maxLen-3]) + \"...\"\n}\n\nvar resolutionRegex = regexp.MustCompile(`/(\\d+)x(\\d+)/`)\n\nfunc extractResolutionFromURL(url string) (width, height int) {\n\tmatches := resolutionRegex.FindStringSubmatch(url)\n\tif len(matches) >= 3 {\n\t\tw, _ := strconv.Atoi(matches[1])\n\t\th, _ := strconv.Atoi(matches[2])\n\t\treturn w, h\n\t}\n\treturn 0, 0\n}\n\nfunc estimateQualityFromBitrate(bitrate int) string {\n\tswitch {\n\tcase bitrate >= 2000000:\n\t\treturn \"1080p\"\n\tcase bitrate >= 1000000:\n\t\treturn \"720p\"\n\tcase bitrate >= 500000:\n\t\treturn \"480p\"\n\tdefault:\n\t\treturn \"360p\"\n\t}\n}\n\n// getHighQualityImageURL converts a Twitter image URL to highest quality version\nfunc getHighQualityImageURL(imageURL string) string {\n\tbaseURL := strings.Split(imageURL, \"?\")[0]\n\n\tformat := \"jpg\"\n\tif strings.Contains(baseURL, \".png\") {\n\t\tformat = \"png\"\n\t} else if strings.Contains(baseURL, \".webp\") {\n\t\tformat = \"webp\"\n\t}\n\n\treturn baseURL + \"?format=\" + format + \"&name=orig\"\n}\n\n// getImageExtension extracts the image extension from URL\nfunc getImageExtension(imageURL string) string {\n\tbaseURL := strings.Split(imageURL, \"?\")[0]\n\tif strings.HasSuffix(baseURL, \".png\") {\n\t\treturn \"png\"\n\t} else if strings.HasSuffix(baseURL, \".webp\") {\n\t\treturn \"webp\"\n\t} else if strings.HasSuffix(baseURL, \".gif\") {\n\t\treturn \"gif\"\n\t}\n\treturn \"jpg\"\n}\n\nfunc init() {\n\tRegister(&TwitterExtractor{},\n\t\t\"twitter.com\",\n\t\t\"x.com\",\n\t\t\"mobile.twitter.com\",\n\t\t\"mobile.x.com\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/types.go",
    "content": "package extractor\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n)\n\n// MediaType represents the type of media being downloaded\ntype MediaType string\n\nconst (\n\tMediaTypeVideo MediaType = \"video\"\n\tMediaTypeAudio MediaType = \"audio\"\n\tMediaTypeImage MediaType = \"image\"\n)\n\n// Media is the interface for all extracted media types\ntype Media interface {\n\tGetID() string\n\tGetTitle() string\n\tGetUploader() string\n\tType() MediaType\n}\n\n// Extractor defines the interface for media extractors\ntype Extractor interface {\n\t// Name returns the extractor name (e.g., \"twitter\", \"direct\")\n\tName() string\n\n\t// Match returns true if this extractor can handle the URL\n\t// The URL is pre-parsed so extractors can reliably check the host/domain\n\tMatch(u *url.URL) bool\n\n\t// Extract retrieves media information from the URL\n\tExtract(url string) (Media, error)\n}\n\n// VideoMedia represents video content with multiple format options\ntype VideoMedia struct {\n\tID        string\n\tTitle     string\n\tUploader  string\n\tDuration  int // seconds\n\tThumbnail string\n\tFormats   []VideoFormat\n}\n\nfunc (v *VideoMedia) GetID() string       { return v.ID }\nfunc (v *VideoMedia) GetTitle() string    { return v.Title }\nfunc (v *VideoMedia) GetUploader() string { return v.Uploader }\nfunc (v *VideoMedia) Type() MediaType     { return MediaTypeVideo }\n\n// VideoFormat represents a single video quality option\ntype VideoFormat struct {\n\tURL      string\n\tQuality  string            // \"1080p\", \"720p\", etc.\n\tExt      string            // \"mp4\", \"m3u8\", \"ts\"\n\tWidth    int\n\tHeight   int\n\tBitrate  int\n\tHeaders  map[string]string // Custom headers for download (e.g., Referer)\n\tAudioURL string            // Separate audio stream URL (for adaptive formats that need merging)\n}\n\n// QualityLabel returns a human-readable quality label\nfunc (f *VideoFormat) QualityLabel() string {\n\tif f.Quality != \"\" {\n\t\treturn f.Quality\n\t}\n\tif f.Height > 0 {\n\t\treturn fmt.Sprintf(\"%dp\", f.Height)\n\t}\n\treturn \"unknown\"\n}\n\n// AudioMedia represents audio content (podcasts, music)\ntype AudioMedia struct {\n\tID       string\n\tTitle    string\n\tUploader string\n\tDuration int // seconds\n\tURL      string\n\tExt      string // \"mp3\", \"m4a\", etc.\n}\n\nfunc (a *AudioMedia) GetID() string       { return a.ID }\nfunc (a *AudioMedia) GetTitle() string    { return a.Title }\nfunc (a *AudioMedia) GetUploader() string { return a.Uploader }\nfunc (a *AudioMedia) Type() MediaType     { return MediaTypeAudio }\n\n// ImageMedia represents one or more images from a single source\ntype ImageMedia struct {\n\tID       string\n\tTitle    string\n\tUploader string\n\tImages   []Image\n}\n\nfunc (i *ImageMedia) GetID() string       { return i.ID }\nfunc (i *ImageMedia) GetTitle() string    { return i.Title }\nfunc (i *ImageMedia) GetUploader() string { return i.Uploader }\nfunc (i *ImageMedia) Type() MediaType     { return MediaTypeImage }\n\n// MultiVideoMedia represents multiple videos from a single source (e.g., Twitter multi-video tweets)\ntype MultiVideoMedia struct {\n\tID       string\n\tTitle    string\n\tUploader string\n\tVideos   []*VideoMedia\n}\n\nfunc (m *MultiVideoMedia) GetID() string       { return m.ID }\nfunc (m *MultiVideoMedia) GetTitle() string    { return m.Title }\nfunc (m *MultiVideoMedia) GetUploader() string { return m.Uploader }\nfunc (m *MultiVideoMedia) Type() MediaType     { return MediaTypeVideo }\n\n// Image represents a single image to download\ntype Image struct {\n\tURL    string\n\tExt    string // \"jpg\", \"png\", \"webp\"\n\tWidth  int\n\tHeight int\n}\n\n// SanitizeFilename removes or replaces characters that are invalid in filenames\nfunc SanitizeFilename(name string) string {\n\t// Remove URLs first (before character replacement mangles them)\n\turlRegex := regexp.MustCompile(`https?://[^\\s]+`)\n\tresult := urlRegex.ReplaceAllString(name, \"\")\n\n\t// Replace characters that are problematic in filenames\n\t// Includes both ASCII and full-width (CJK) versions of reserved characters\n\treplacer := strings.NewReplacer(\n\t\t// ASCII versions\n\t\t\"/\", \"-\",\n\t\t\"\\\\\", \"-\",\n\t\t\":\", \"-\",\n\t\t\"*\", \"\",\n\t\t\"?\", \"\",\n\t\t\"\\\"\", \"\",\n\t\t\"<\", \"\",\n\t\t\">\", \"\",\n\t\t\"|\", \"\",\n\t\t\"\\n\", \" \",\n\t\t\"\\r\", \"\",\n\t\t\"\\t\", \" \",\n\t\t// Full-width versions (common in Chinese/Japanese text)\n\t\t\"：\", \"-\", // U+FF1A Full-width colon\n\t\t\"／\", \"-\", // U+FF0F Full-width solidus\n\t\t\"＼\", \"-\", // U+FF3C Full-width reverse solidus\n\t\t\"。\", \"-\", // U+3002 CJK full stop\n\t\t\"＊\", \"\",  // U+FF0A Full-width asterisk\n\t\t\"？\", \"\",  // U+FF1F Full-width question mark\n\t\t\"＂\", \"\",  // U+FF02 Full-width quotation mark\n\t\t\"＜\", \"\",  // U+FF1C Full-width less-than\n\t\t\"＞\", \"\",  // U+FF1E Full-width greater-than\n\t\t\"｜\", \"\",  // U+FF5C Full-width vertical line\n\t\t// Additional problematic characters\n\t\t\"「\", \"\",  // CJK left corner bracket\n\t\t\"」\", \"\",  // CJK right corner bracket\n\t\t\"【\", \"\",  // CJK left black lenticular bracket\n\t\t\"】\", \"\",  // CJK right black lenticular bracket\n\t)\n\tresult = replacer.Replace(result)\n\n\t// Remove control characters (0x00-0x1F, 0x7F) which are invalid on Windows\n\tresult = strings.Map(func(r rune) rune {\n\t\tif r < 32 || r == 127 {\n\t\t\treturn -1 // -1 means delete the rune\n\t\t}\n\t\treturn r\n\t}, result)\n\n\t// Trim spaces and dots from ends\n\tresult = strings.TrimSpace(result)\n\tresult = strings.Trim(result, \".\")\n\n\t// Collapse multiple spaces\n\tspaceRegex := regexp.MustCompile(`\\s+`)\n\tresult = spaceRegex.ReplaceAllString(result, \" \")\n\n\t// Limit length to avoid \"file name too long\" errors\n\t// Most filesystems limit filenames to 255 bytes. For UTF-8 with CJK characters\n\t// (3-4 bytes each), 60 runes is safe (~180-240 bytes), leaving room for extension.\n\tconst maxRunes = 60\n\trunes := []rune(result)\n\tif len(runes) > maxRunes {\n\t\tresult = string(runes[:maxRunes])\n\t}\n\n\t// If result is empty after sanitization, return empty\n\tresult = strings.TrimSpace(result)\n\n\t// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)\n\t// These names (with or without extension) cannot be used as filenames on Windows\n\treservedNames := []string{\n\t\t\"CON\", \"PRN\", \"AUX\", \"NUL\",\n\t\t\"COM1\", \"COM2\", \"COM3\", \"COM4\", \"COM5\", \"COM6\", \"COM7\", \"COM8\", \"COM9\",\n\t\t\"LPT1\", \"LPT2\", \"LPT3\", \"LPT4\", \"LPT5\", \"LPT6\", \"LPT7\", \"LPT8\", \"LPT9\",\n\t}\n\tif slices.Contains(reservedNames, strings.ToUpper(result)) {\n\t\tresult = \"_\" + result\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "internal/core/extractor/types_test.go",
    "content": "package extractor\n\nimport \"testing\"\n\nfunc TestSanitizeFilename(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"Bilibili title with CJK brackets and special chars\",\n\t\t\tinput:    \"【这还不薅吗？】阿里云史低神价：35元/年，197.7元/5年！395.4元解锁10年\\u201c超值传家宝\\u201d，享200Mbps带宽+隐藏福利！哇！哇！哇！\",\n\t\t\texpected: \"这还不薅吗阿里云史低神价-35元-年，197.7元-5年！395.4元解锁10年\\u201c超值传家宝\\u201d，享200Mbps带宽+隐\", // truncated to 60 runes\n\t\t},\n\t\t{\n\t\t\tname:     \"ASCII reserved characters\",\n\t\t\tinput:    \"test:file*name?with<special>chars|here\",\n\t\t\texpected: \"test-filenamewithspecialcharshere\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Full-width reserved characters\",\n\t\t\tinput:    \"测试：文件＊名？含＜特殊＞字符｜这里\",\n\t\t\texpected: \"测试-文件名含特殊字符这里\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Path separators\",\n\t\t\tinput:    \"path/to\\\\file\",\n\t\t\texpected: \"path-to-file\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Full-width path separators\",\n\t\t\tinput:    \"路径／到＼文件\",\n\t\t\texpected: \"路径-到-文件\",\n\t\t},\n\t\t{\n\t\t\tname:     \"CJK brackets\",\n\t\t\tinput:    \"【标题】「内容」\",\n\t\t\texpected: \"标题内容\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Windows reserved name CON\",\n\t\t\tinput:    \"CON\",\n\t\t\texpected: \"_CON\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Windows reserved name lowercase\",\n\t\t\tinput:    \"aux\",\n\t\t\texpected: \"_aux\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Windows reserved name COM1\",\n\t\t\tinput:    \"COM1\",\n\t\t\texpected: \"_COM1\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Trailing dots and spaces\",\n\t\t\tinput:    \"filename...\",\n\t\t\texpected: \"filename\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Multiple spaces\",\n\t\t\tinput:    \"file   name   here\",\n\t\t\texpected: \"file name here\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Control characters\",\n\t\t\tinput:    \"file\\x00name\\x1fhere\",\n\t\t\texpected: \"filenamehere\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Newlines and tabs\",\n\t\t\tinput:    \"file\\nname\\there\",\n\t\t\texpected: \"file name here\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL in title\",\n\t\t\tinput:    \"Check out https://example.com/path for more\",\n\t\t\texpected: \"Check out for more\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Long filename truncation\",\n\t\t\tinput:    \"这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的标题\",\n\t\t\texpected: \"这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常\", // 60 runes\n\t\t},\n\t\t{\n\t\t\tname:     \"Empty after sanitization\",\n\t\t\tinput:    \"???***\",\n\t\t\texpected: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := SanitizeFilename(tt.input)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"SanitizeFilename(%q)\\n  got:  %q\\n  want: %q\", tt.input, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/core/extractor/xiaohongshu.go",
    "content": "package extractor\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-rod/rod\"\n\t\"github.com/go-rod/rod/lib/launcher\"\n\t\"github.com/go-rod/rod/lib/proto\"\n\t\"github.com/go-rod/stealth\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n)\n\n// XiaohongshuExtractor handles Xiaohongshu video/image downloads using browser automation\ntype XiaohongshuExtractor struct {\n\tvisible bool\n}\n\n// SetVisible configures whether to show the browser window\nfunc (e *XiaohongshuExtractor) SetVisible(visible bool) {\n\te.visible = visible\n}\n\nfunc (e *XiaohongshuExtractor) Name() string {\n\treturn \"xiaohongshu\"\n}\n\nfunc (e *XiaohongshuExtractor) Match(u *url.URL) bool {\n\treturn true\n}\n\n// xhsNoteDetail represents the note detail from __INITIAL_STATE__\ntype xhsNoteDetail struct {\n\tNote struct {\n\t\tNoteID    string `json:\"noteId\"`\n\t\tTitle     string `json:\"title\"`\n\t\tDesc      string `json:\"desc\"`\n\t\tType      string `json:\"type\"` // \"normal\" (image) or \"video\"\n\t\tUser      struct {\n\t\t\tNickname string `json:\"nickname\"`\n\t\t\tUserID   string `json:\"userId\"`\n\t\t} `json:\"user\"`\n\t\tImageList []struct {\n\t\t\tURLDefault string `json:\"urlDefault\"`\n\t\t\tWidth      int    `json:\"width\"`\n\t\t\tHeight     int    `json:\"height\"`\n\t\t} `json:\"imageList\"`\n\t\tVideo struct {\n\t\t\tMedia struct {\n\t\t\t\tStream struct {\n\t\t\t\t\tH264 []struct {\n\t\t\t\t\t\tMasterURL string `json:\"masterUrl\"`\n\t\t\t\t\t} `json:\"h264\"`\n\t\t\t\t} `json:\"stream\"`\n\t\t\t} `json:\"media\"`\n\t\t} `json:\"video\"`\n\t} `json:\"note\"`\n}\n\nfunc (e *XiaohongshuExtractor) Extract(rawURL string) (Media, error) {\n\t// Resolve short URL if needed\n\tfinalURL := rawURL\n\tif strings.Contains(rawURL, \"xhslink.com\") {\n\t\tresolved, err := e.resolveShortURL(rawURL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to resolve short URL: %w\", err)\n\t\t}\n\t\tfinalURL = resolved\n\t}\n\n\t// Extract note ID from URL\n\tnoteID := e.extractNoteID(finalURL)\n\tif noteID == \"\" {\n\t\treturn nil, fmt.Errorf(\"could not extract note ID from URL: %s\", finalURL)\n\t}\n\n\t// Launch browser and extract data\n\treturn e.extractWithBrowser(finalURL, noteID)\n}\n\nfunc (e *XiaohongshuExtractor) extractNoteID(rawURL string) string {\n\t// Pattern: /explore/{noteId} or /discovery/item/{noteId}\n\tpatterns := []string{\n\t\t`/explore/([a-zA-Z0-9]+)`,\n\t\t`/discovery/item/([a-zA-Z0-9]+)`,\n\t}\n\n\tfor _, pattern := range patterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindStringSubmatch(rawURL)\n\t\tif len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc (e *XiaohongshuExtractor) resolveShortURL(shortURL string) (string, error) {\n\t// Use browser to follow redirect\n\tl := e.createLauncher(true) // headless for redirect resolution\n\tdefer l.Cleanup()\n\n\tu := l.MustLaunch()\n\tbrowser := rod.New().ControlURL(u).MustConnect()\n\tdefer browser.MustClose()\n\n\tpage := stealth.MustPage(browser)\n\tdefer page.MustClose()\n\n\tpage.MustNavigate(shortURL)\n\tpage.MustWaitDOMStable()\n\n\tfinalURL := page.MustInfo().URL\n\n\t// Check if we got redirected to login page\n\t// If so, extract the actual target URL from redirectPath parameter\n\tif strings.Contains(finalURL, \"/login\") {\n\t\tparsedURL, err := url.Parse(finalURL)\n\t\tif err == nil {\n\t\t\tredirectPath := parsedURL.Query().Get(\"redirectPath\")\n\t\t\tif redirectPath != \"\" {\n\t\t\t\t// redirectPath is already URL-decoded by Query().Get()\n\t\t\t\treturn redirectPath, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn finalURL, nil\n}\n\nfunc (e *XiaohongshuExtractor) extractWithBrowser(targetURL, noteID string) (Media, error) {\n\t// Launch browser (headless by default, visible with --visible flag)\n\tl := e.createLauncher(!e.visible)\n\tdefer l.Cleanup()\n\n\tfmt.Println(\"Launching browser...\")\n\n\tu, err := l.Launch()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to launch browser: %w\", err)\n\t}\n\tfmt.Printf(\"Browser launched, connecting to: %s\\n\", u)\n\n\tbrowser := rod.New().ControlURL(u).MustConnect()\n\tdefer browser.MustClose()\n\tfmt.Println(\"Connected to browser\")\n\n\t// Load cookies if available\n\te.loadCookies(browser)\n\n\tpage := stealth.MustPage(browser)\n\tdefer page.MustClose()\n\tfmt.Println(\"Created stealth page\")\n\n\tfmt.Printf(\"Navigating to: %s\\n\", targetURL)\n\n\t// Navigate to the page\n\tpage.MustNavigate(targetURL)\n\tfmt.Println(\"Waiting for page to stabilize...\")\n\tpage.MustWaitDOMStable()\n\ttime.Sleep(2 * time.Second) // Extra wait for JS rendering\n\tfmt.Println(\"Page loaded, checking for data...\")\n\n\t// Wait for login if needed (up to 120 seconds)\n\tvar result string\n\tmaxWait := 120 * time.Second\n\tcheckInterval := 2 * time.Second\n\tstartTime := time.Now()\n\n\tfor {\n\t\t// Try to extract __INITIAL_STATE__\n\t\tresult = page.MustEval(`() => {\n\t\t\tif (window.__INITIAL_STATE__ &&\n\t\t\t    window.__INITIAL_STATE__.note &&\n\t\t\t    window.__INITIAL_STATE__.note.noteDetailMap) {\n\t\t\t\tconst noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap;\n\t\t\t\treturn JSON.stringify(noteDetailMap);\n\t\t\t}\n\t\t\treturn \"\";\n\t\t}`).String()\n\n\t\tif result != \"\" {\n\t\t\tfmt.Println(\"Data extracted successfully!\")\n\t\t\tbreak\n\t\t}\n\n\t\telapsed := time.Since(startTime)\n\t\tif elapsed >= maxWait {\n\t\t\treturn nil, fmt.Errorf(\"timeout waiting for note data (login may be required)\")\n\t\t}\n\n\t\t// Check if this is the first iteration - show login prompt\n\t\tif elapsed < checkInterval*2 {\n\t\t\tfmt.Println(\"\\n┌────────────────────────────────────────────────────┐\")\n\t\t\tfmt.Println(\"│  Login required! Please scan the QR code in the   │\")\n\t\t\tfmt.Println(\"│  browser window to log in to Xiaohongshu.         │\")\n\t\t\tfmt.Println(\"│                                                    │\")\n\t\t\tfmt.Println(\"│  Waiting up to 2 minutes for login...             │\")\n\t\t\tfmt.Println(\"└────────────────────────────────────────────────────┘\")\n\t\t}\n\n\t\tremaining := maxWait - elapsed\n\t\tfmt.Printf(\"\\rWaiting for login... %d seconds remaining\", int(remaining.Seconds()))\n\n\t\ttime.Sleep(checkInterval)\n\n\t\t// Refresh the page state after waiting\n\t\tpage.MustWaitDOMStable()\n\t}\n\tfmt.Println() // newline after progress\n\n\t// Save cookies for future sessions\n\te.saveCookies(browser)\n\n\t// Parse the response\n\tvar noteDetailMap map[string]xhsNoteDetail\n\tif err := json.Unmarshal([]byte(result), &noteDetailMap); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse note data: %w\", err)\n\t}\n\n\t// Debug: print available keys\n\tfmt.Printf(\"Looking for noteID: %s\\n\", noteID)\n\tfmt.Printf(\"Available keys in noteDetailMap:\\n\")\n\tfor key := range noteDetailMap {\n\t\tfmt.Printf(\"  - %s\\n\", key)\n\t}\n\n\tnoteDetail, exists := noteDetailMap[noteID]\n\tif !exists {\n\t\t// Try to find any key that contains the noteID\n\t\tfor key, detail := range noteDetailMap {\n\t\t\tif strings.Contains(key, noteID) {\n\t\t\t\tfmt.Printf(\"Found matching key: %s\\n\", key)\n\t\t\t\tnoteDetail = detail\n\t\t\t\texists = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t// If still not found, just use the first entry if there's only one\n\tif !exists && len(noteDetailMap) == 1 {\n\t\tfor key, detail := range noteDetailMap {\n\t\t\tfmt.Printf(\"Using single available key: %s\\n\", key)\n\t\t\tnoteDetail = detail\n\t\t\texists = true\n\t\t\t_ = key\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"note %s not found in response (available: %d keys)\", noteID, len(noteDetailMap))\n\t}\n\n\tnote := noteDetail.Note\n\ttitle := note.Title\n\tif title == \"\" {\n\t\ttitle = note.Desc\n\t}\n\tif title == \"\" {\n\t\ttitle = note.NoteID\n\t}\n\n\t// Check if it's a video or image post\n\tif note.Type == \"video\" {\n\t\treturn e.extractVideo(note.NoteID, title, note.User.Nickname, noteDetail)\n\t}\n\n\t// Image post\n\treturn e.extractImages(note.NoteID, title, note.User.Nickname, noteDetail)\n}\n\nfunc (e *XiaohongshuExtractor) extractVideo(id, title, uploader string, detail xhsNoteDetail) (Media, error) {\n\tvar videoURL string\n\n\t// Try to get video URL from the structure\n\th264Streams := detail.Note.Video.Media.Stream.H264\n\tif len(h264Streams) > 0 && h264Streams[0].MasterURL != \"\" {\n\t\tvideoURL = h264Streams[0].MasterURL\n\t}\n\n\tif videoURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"could not find video URL in note data\")\n\t}\n\n\t// Ensure HTTPS\n\tif strings.HasPrefix(videoURL, \"//\") {\n\t\tvideoURL = \"https:\" + videoURL\n\t}\n\n\treturn &VideoMedia{\n\t\tID:       id,\n\t\tTitle:    title,\n\t\tUploader: uploader,\n\t\tFormats: []VideoFormat{\n\t\t\t{\n\t\t\t\tURL:     videoURL,\n\t\t\t\tQuality: \"best\",\n\t\t\t\tExt:     \"mp4\",\n\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\"Referer\":    \"https://www.xiaohongshu.com/\",\n\t\t\t\t\t\"Origin\":     \"https://www.xiaohongshu.com\",\n\t\t\t\t\t\"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\nfunc (e *XiaohongshuExtractor) extractImages(id, title, uploader string, detail xhsNoteDetail) (Media, error) {\n\tif len(detail.Note.ImageList) == 0 {\n\t\treturn nil, fmt.Errorf(\"no images found in note\")\n\t}\n\n\tvar images []Image\n\tfor _, img := range detail.Note.ImageList {\n\t\timgURL := img.URLDefault\n\t\tif strings.HasPrefix(imgURL, \"//\") {\n\t\t\timgURL = \"https:\" + imgURL\n\t\t}\n\n\t\text := \"jpg\" // default\n\t\tif strings.Contains(imgURL, \".png\") {\n\t\t\text = \"png\"\n\t\t} else if strings.Contains(imgURL, \".webp\") {\n\t\t\text = \"webp\"\n\t\t}\n\n\t\timages = append(images, Image{\n\t\t\tURL:    imgURL,\n\t\t\tExt:    ext,\n\t\t\tWidth:  img.Width,\n\t\t\tHeight: img.Height,\n\t\t})\n\t}\n\n\treturn &ImageMedia{\n\t\tID:       id,\n\t\tTitle:    title,\n\t\tUploader: uploader,\n\t\tImages:   images,\n\t}, nil\n}\n\nfunc (e *XiaohongshuExtractor) createLauncher(headless bool) *launcher.Launcher {\n\t// Use Rod's auto-downloaded Chromium with persistent user data\n\t// This keeps login state between runs\n\tuserDataDir := e.getUserDataDir()\n\n\t// Check for ROD_BROWSER env var (set in Docker)\n\tbrowserPath := os.Getenv(\"ROD_BROWSER\")\n\n\tl := launcher.New().\n\t\tHeadless(headless).\n\t\tUserDataDir(userDataDir).\n\t\tSet(\"no-sandbox\").\n\t\tSet(\"disable-gpu\").\n\t\tSet(\"disable-dev-shm-usage\").\n\t\tSet(\"disable-software-rasterizer\").\n\t\tSet(\"disable-extensions\").\n\t\tSet(\"disable-background-networking\").\n\t\tSet(\"disable-sync\").\n\t\tSet(\"disable-translate\").\n\t\tSet(\"no-first-run\").\n\t\tSet(\"safebrowsing-disable-auto-update\").\n\t\tSet(\"window-size\", \"1920,1080\").\n\t\tSet(\"user-agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\n\t// Explicitly set browser path if provided (required for Docker)\n\tif browserPath != \"\" {\n\t\tl = l.Bin(browserPath)\n\t}\n\n\treturn l\n}\n\n// getUserDataDir returns the persistent browser data directory\n// Located at ~/.config/vget/browser/\n// This is shared across all extractors that need browser automation\nfunc (e *XiaohongshuExtractor) getUserDataDir() string {\n\tconfigDir, err := config.ConfigDir()\n\tif err != nil {\n\t\t// Fallback to temp dir if config dir unavailable\n\t\treturn filepath.Join(os.TempDir(), \"vget-browser\")\n\t}\n\treturn filepath.Join(configDir, \"browser\")\n}\n\nfunc (e *XiaohongshuExtractor) loadCookies(browser *rod.Browser) {\n\t// Try to load cookies from ~/.config/vget/xhs_cookies.json\n\tconfigDir, err := config.ConfigDir()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tcookiePath := filepath.Join(configDir, \"xhs_cookies.json\")\n\tdata, err := os.ReadFile(cookiePath)\n\tif err != nil {\n\t\treturn // No cookies file, that's fine\n\t}\n\n\tvar cookies []*proto.NetworkCookie\n\tif err := json.Unmarshal(data, &cookies); err != nil {\n\t\treturn\n\t}\n\n\tfmt.Println(\"Loaded saved cookies from previous session\")\n\tbrowser.MustSetCookies(cookies...)\n}\n\nfunc (e *XiaohongshuExtractor) saveCookies(browser *rod.Browser) {\n\t// Save cookies to ~/.config/vget/xhs_cookies.json\n\tconfigDir, err := config.ConfigDir()\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// Ensure directory exists\n\tif err := os.MkdirAll(configDir, 0755); err != nil {\n\t\treturn\n\t}\n\n\tcookies, err := browser.GetCookies()\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// Filter to only XHS-related cookies\n\tvar xhsCookies []*proto.NetworkCookie\n\tfor _, c := range cookies {\n\t\tif strings.Contains(c.Domain, \"xiaohongshu\") || strings.Contains(c.Domain, \"xhscdn\") {\n\t\t\txhsCookies = append(xhsCookies, c)\n\t\t}\n\t}\n\n\tif len(xhsCookies) == 0 {\n\t\treturn\n\t}\n\n\tdata, err := json.MarshalIndent(xhsCookies, \"\", \"  \")\n\tif err != nil {\n\t\treturn\n\t}\n\n\tcookiePath := filepath.Join(configDir, \"xhs_cookies.json\")\n\tif err := os.WriteFile(cookiePath, data, 0600); err != nil {\n\t\tfmt.Printf(\"Warning: failed to save cookies: %v\\n\", err)\n\t\treturn\n\t}\n\tfmt.Printf(\"Saved %d cookies for future sessions\\n\", len(xhsCookies))\n}\n\nfunc init() {\n\tRegister(&XiaohongshuExtractor{},\n\t\t\"xiaohongshu.com\",\n\t\t\"xhslink.com\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/xiaoyuzhou.go",
    "content": "package extractor\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// XiaoyuzhouExtractor handles xiaoyuzhoufm.com podcast downloads\ntype XiaoyuzhouExtractor struct{}\n\nfunc (e *XiaoyuzhouExtractor) Name() string {\n\treturn \"xiaoyuzhou\"\n}\n\nfunc (e *XiaoyuzhouExtractor) Match(u *url.URL) bool {\n\t// Host matching is done by registry, check path pattern\n\treturn strings.HasPrefix(u.Path, \"/episode/\") || strings.HasPrefix(u.Path, \"/podcast/\")\n}\n\nfunc (e *XiaoyuzhouExtractor) Extract(url string) (Media, error) {\n\tif strings.Contains(url, \"/episode/\") {\n\t\treturn e.extractEpisode(url)\n\t}\n\tif strings.Contains(url, \"/podcast/\") {\n\t\treturn e.extractPodcast(url)\n\t}\n\treturn nil, fmt.Errorf(\"unsupported URL format\")\n}\n\n// extractEpisode extracts a single episode\nfunc (e *XiaoyuzhouExtractor) extractEpisode(url string) (*AudioMedia, error) {\n\t// Extract episode ID from URL\n\tre := regexp.MustCompile(`/episode/([a-zA-Z0-9]+)`)\n\tmatches := re.FindStringSubmatch(url)\n\tif len(matches) < 2 {\n\t\treturn nil, fmt.Errorf(\"could not extract episode ID from URL\")\n\t}\n\tepisodeID := matches[1]\n\n\t// Fetch the episode page to get JSON data\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse the embedded JSON data from the page\n\t// Look for the script tag with __NEXT_DATA__\n\tcontent := string(body)\n\tjsonStart := strings.Index(content, `<script id=\"__NEXT_DATA__\" type=\"application/json\">`)\n\tif jsonStart == -1 {\n\t\treturn nil, fmt.Errorf(\"could not find episode data in page\")\n\t}\n\n\tjsonStart = strings.Index(content[jsonStart:], \">\") + jsonStart + 1\n\tjsonEnd := strings.Index(content[jsonStart:], \"</script>\") + jsonStart\n\n\tif jsonEnd <= jsonStart {\n\t\treturn nil, fmt.Errorf(\"could not parse episode data\")\n\t}\n\n\tjsonData := content[jsonStart:jsonEnd]\n\n\t// Parse the JSON\n\tvar pageData struct {\n\t\tProps struct {\n\t\t\tPageProps struct {\n\t\t\t\tEpisode struct {\n\t\t\t\t\tEid       string `json:\"eid\"`\n\t\t\t\t\tTitle     string `json:\"title\"`\n\t\t\t\t\tDuration  int    `json:\"duration\"`\n\t\t\t\t\tEnclosure struct {\n\t\t\t\t\t\tURL string `json:\"url\"`\n\t\t\t\t\t} `json:\"enclosure\"`\n\t\t\t\t\tPodcast struct {\n\t\t\t\t\t\tTitle string `json:\"title\"`\n\t\t\t\t\t} `json:\"podcast\"`\n\t\t\t\t} `json:\"episode\"`\n\t\t\t} `json:\"pageProps\"`\n\t\t} `json:\"props\"`\n\t}\n\n\tif err := json.Unmarshal([]byte(jsonData), &pageData); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse episode JSON: %v\", err)\n\t}\n\n\tepisode := pageData.Props.PageProps.Episode\n\tif episode.Enclosure.URL == \"\" {\n\t\treturn nil, fmt.Errorf(\"no audio URL found for episode\")\n\t}\n\n\t// Determine file extension\n\text := \"m4a\"\n\tif strings.Contains(episode.Enclosure.URL, \".mp3\") {\n\t\text = \"mp3\"\n\t}\n\n\t// Create filename: {podcast} - {title}\n\tfilename := SanitizeFilename(fmt.Sprintf(\"%s - %s\", episode.Podcast.Title, episode.Title))\n\n\treturn &AudioMedia{\n\t\tID:       episodeID,\n\t\tTitle:    filename,\n\t\tUploader: episode.Podcast.Title,\n\t\tDuration: episode.Duration,\n\t\tURL:      episode.Enclosure.URL,\n\t\tExt:      ext,\n\t}, nil\n}\n\n// extractPodcast lists all episodes from a podcast\nfunc (e *XiaoyuzhouExtractor) extractPodcast(_ string) (*AudioMedia, error) {\n\t// For now, return an error suggesting to use search\n\t// Full podcast download can be implemented later\n\treturn nil, fmt.Errorf(\"podcast download not yet implemented. Use 'vget search --podcast <name>' to find specific episodes\")\n}\n\n\nfunc init() {\n\tRegister(&XiaoyuzhouExtractor{},\n\t\t\"xiaoyuzhoufm.com\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/extractor/youtube.go",
    "content": "package extractor\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n)\n\n// YouTubeDockerRequiredError indicates YouTube extraction needs Docker\ntype YouTubeDockerRequiredError struct {\n\tURL string\n}\n\nfunc (e *YouTubeDockerRequiredError) Error() string {\n\treturn \"YouTube extraction requires Docker\"\n}\n\n// YouTubeDirectDownload indicates yt-dlp should handle the download directly\ntype YouTubeDirectDownload struct {\n\tURL       string\n\tOutputDir string\n}\n\n// Implement Media interface for YouTubeDirectDownload\nfunc (y *YouTubeDirectDownload) GetID() string       { return y.URL }\nfunc (y *YouTubeDirectDownload) GetTitle() string    { return \"YouTube Video\" }\nfunc (y *YouTubeDirectDownload) GetUploader() string { return \"\" }\nfunc (y *YouTubeDirectDownload) Type() MediaType     { return MediaTypeVideo }\n\n// ytdlpExtractor uses yt-dlp/youtube-dl for YouTube extraction (Docker only)\ntype ytdlpExtractor struct{}\n\nfunc (e *ytdlpExtractor) Name() string {\n\treturn \"YouTube (yt-dlp)\"\n}\n\nfunc (e *ytdlpExtractor) Match(u *url.URL) bool {\n\thost := strings.ToLower(u.Host)\n\treturn host == \"youtube.com\" ||\n\t\thost == \"www.youtube.com\" ||\n\t\thost == \"youtu.be\" ||\n\t\thost == \"m.youtube.com\" ||\n\t\thost == \"music.youtube.com\"\n}\n\nfunc (e *ytdlpExtractor) Extract(urlStr string) (Media, error) {\n\tif !config.IsRunningInDocker() {\n\t\treturn nil, &YouTubeDockerRequiredError{URL: urlStr}\n\t}\n\n\t// For YouTube, we return a special marker that tells the CLI\n\t// to use yt-dlp for direct download instead of vget's downloader\n\t// OutputDir will be set by CLI from config\n\treturn &YouTubeDirectDownload{\n\t\tURL: urlStr,\n\t}, nil\n}\n\n// DownloadWithYtdlp downloads a YouTube video using yt-dlp directly\nfunc DownloadWithYtdlp(url, outputDir string) error {\n\treturn DownloadWithYtdlpProgress(context.Background(), url, outputDir, nil)\n}\n\n// DownloadWithYtdlpProgress downloads a YouTube video using yt-dlp with progress callback\nfunc DownloadWithYtdlpProgress(ctx context.Context, url, outputDir string, progressFn func(downloaded, total int64)) error {\n\toutputTemplate := filepath.Join(outputDir, \"%(title)s.%(ext)s\")\n\n\tcmd := exec.CommandContext(ctx, \"yt-dlp\",\n\t\t\"-f\", \"bv*+ba/b\", // best video + best audio, or best combined\n\t\t\"--merge-output-format\", \"mp4\",\n\t\t\"--no-playlist\",\n\t\t\"--newline\",                         // Output progress on new lines for parsing\n\t\t\"--remote-components\", \"ejs:github\", // download JS challenge solver\n\t\t\"-o\", outputTemplate,\n\t\turl,\n\t)\n\n\t// If no progress callback, just run normally\n\tif progressFn == nil {\n\t\tcmd.Stdout = os.Stdout\n\t\tcmd.Stderr = os.Stderr\n\t\terr := cmd.Run()\n\t\tif err == nil {\n\t\t\treturn nil\n\t\t}\n\t\t// Fallback to youtube-dl\n\t\treturn downloadWithYoutubeDL(ctx, url, outputDir)\n\t}\n\n\t// Parse progress from stderr\n\tstderr, err := cmd.StderrPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn downloadWithYoutubeDL(ctx, url, outputDir)\n\t}\n\n\t// Parse yt-dlp progress output\n\t// Format: [download]  45.2% of  150.00MiB at  5.00MiB/s ETA 00:15\n\tprogressRe := regexp.MustCompile(`\\[download\\]\\s+(\\d+\\.?\\d*)%\\s+of\\s+~?(\\d+\\.?\\d*)(Ki|Mi|Gi)?B`)\n\n\tscanner := bufio.NewScanner(stderr)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tmatches := progressRe.FindStringSubmatch(line)\n\t\tif len(matches) >= 3 {\n\t\t\tpercent, _ := strconv.ParseFloat(matches[1], 64)\n\t\t\tsize, _ := strconv.ParseFloat(matches[2], 64)\n\n\t\t\t// Convert size to bytes\n\t\t\tmultiplier := int64(1)\n\t\t\tif len(matches) >= 4 {\n\t\t\t\tswitch matches[3] {\n\t\t\t\tcase \"Ki\":\n\t\t\t\t\tmultiplier = 1024\n\t\t\t\tcase \"Mi\":\n\t\t\t\t\tmultiplier = 1024 * 1024\n\t\t\t\tcase \"Gi\":\n\t\t\t\t\tmultiplier = 1024 * 1024 * 1024\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttotalBytes := int64(size * float64(multiplier))\n\t\t\tdownloadedBytes := int64(float64(totalBytes) * percent / 100)\n\t\t\tprogressFn(downloadedBytes, totalBytes)\n\t\t}\n\t}\n\n\terr = cmd.Wait()\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\t// Fallback to youtube-dl\n\treturn downloadWithYoutubeDL(ctx, url, outputDir)\n}\n\nfunc downloadWithYoutubeDL(ctx context.Context, url, outputDir string) error {\n\toutputTemplate := filepath.Join(outputDir, \"%(title)s.%(ext)s\")\n\tcmd := exec.CommandContext(ctx, \"youtube-dl\",\n\t\t\"-f\", \"bestvideo+bestaudio/best\",\n\t\t\"--merge-output-format\", \"mp4\",\n\t\t\"--no-playlist\",\n\t\t\"-o\", outputTemplate,\n\t\turl,\n\t)\n\tcmd.Stdout = os.Stdout\n\tcmd.Stderr = os.Stderr\n\treturn cmd.Run()\n}\n\nfunc init() {\n\tRegister(&ytdlpExtractor{},\n\t\t\"youtube.com\",\n\t\t\"www.youtube.com\",\n\t\t\"youtu.be\",\n\t\t\"m.youtube.com\",\n\t\t\"music.youtube.com\",\n\t)\n}\n"
  },
  {
    "path": "internal/core/i18n/i18n.go",
    "content": "package i18n\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\n//go:embed locales/*.yml\nvar localesFS embed.FS\n\n// Translations holds all translation strings organized by section\ntype Translations struct {\n\tConfig       ConfigTranslations       `yaml:\"config\"`\n\tConfigReview ConfigReviewTranslations `yaml:\"config_review\"`\n\tHelp         HelpTranslations         `yaml:\"help\"`\n\tDownload     DownloadTranslations     `yaml:\"download\"`\n\tErrors       ErrorTranslations        `yaml:\"errors\"`\n\tSearch       SearchTranslations       `yaml:\"search\"`\n\tTwitter      TwitterTranslations      `yaml:\"twitter\"`\n\tSites        SitesTranslations        `yaml:\"sites\"`\n\tUI           UITranslations           `yaml:\"ui\"`\n\tServer       ServerTranslations       `yaml:\"server\"`\n\tYouTube      YouTubeTranslations      `yaml:\"youtube\"`\n}\n\ntype ConfigTranslations struct {\n\tStepOf        string `yaml:\"step_of\"`\n\tLanguage      string `yaml:\"language\"`\n\tLanguageDesc  string `yaml:\"language_desc\"`\n\tOutputDir     string `yaml:\"output_dir\"`\n\tOutputDirDesc string `yaml:\"output_dir_desc\"`\n\tFormat        string `yaml:\"format\"`\n\tFormatDesc    string `yaml:\"format_desc\"`\n\tQuality       string `yaml:\"quality\"`\n\tQualityDesc   string `yaml:\"quality_desc\"`\n\tConfirm       string `yaml:\"confirm\"`\n\tConfirmDesc   string `yaml:\"confirm_desc\"`\n\tYesSave       string `yaml:\"yes_save\"`\n\tNoCancel      string `yaml:\"no_cancel\"`\n\tBestAvailable string `yaml:\"best_available\"`\n\tRecommended   string `yaml:\"recommended\"`\n}\n\ntype ConfigReviewTranslations struct {\n\tLanguage  string `yaml:\"language\"`\n\tOutputDir string `yaml:\"output_dir\"`\n\tFormat    string `yaml:\"format\"`\n\tQuality   string `yaml:\"quality\"`\n}\n\ntype HelpTranslations struct {\n\tBack    string `yaml:\"back\"`\n\tNext    string `yaml:\"next\"`\n\tSelect  string `yaml:\"select\"`\n\tConfirm string `yaml:\"confirm\"`\n\tQuit    string `yaml:\"quit\"`\n}\n\ntype DownloadTranslations struct {\n\tDownloading      string `yaml:\"downloading\"`\n\tExtracting       string `yaml:\"extracting\"`\n\tCompleted        string `yaml:\"completed\"`\n\tFailed           string `yaml:\"failed\"`\n\tProgress         string `yaml:\"progress\"`\n\tSpeed            string `yaml:\"speed\"`\n\tETA              string `yaml:\"eta\"`\n\tElapsed          string `yaml:\"elapsed\"`\n\tAvgSpeed         string `yaml:\"avg_speed\"`\n\tFileSaved        string `yaml:\"file_saved\"`\n\tNoFormats        string `yaml:\"no_formats\"`\n\tSelectFormat     string `yaml:\"select_format\"`\n\tFormatsAvailable string `yaml:\"formats_available\"`\n\tSelectedFormat   string `yaml:\"selected_format\"`\n\tQualityHint      string `yaml:\"quality_hint\"`\n}\n\ntype ErrorTranslations struct {\n\tConfigNotFound   string `yaml:\"config_not_found\"`\n\tInvalidURL       string `yaml:\"invalid_url\"`\n\tNetworkError     string `yaml:\"network_error\"`\n\tExtractionFailed string `yaml:\"extraction_failed\"`\n\tDownloadFailed   string `yaml:\"download_failed\"`\n\tNoExtractor      string `yaml:\"no_extractor\"`\n}\n\ntype SearchTranslations struct {\n\tResultsFor        string `yaml:\"results_for\"`\n\tSearching         string `yaml:\"searching\"`\n\tFetchingEpisodes  string `yaml:\"fetching_episodes\"`\n\tPodcasts          string `yaml:\"podcasts\"`\n\tEpisodes          string `yaml:\"episodes\"`\n\tSelectHint        string `yaml:\"select_hint\"`\n\tSelectPodcastHint string `yaml:\"select_podcast_hint\"`\n\tSelected          string `yaml:\"selected\"`\n\tHelp              string `yaml:\"help\"`\n\tHelpPodcast       string `yaml:\"help_podcast\"`\n}\n\ntype TwitterTranslations struct {\n\tEnterAuthToken    string `yaml:\"enter_auth_token\"`\n\tAuthSaved         string `yaml:\"auth_saved\"`\n\tAuthCanDownload   string `yaml:\"auth_can_download\"`\n\tAuthCleared       string `yaml:\"auth_cleared\"`\n\tAuthRequired      string `yaml:\"auth_required\"`\n\tNsfwLoginRequired string `yaml:\"nsfw_login_required\"`\n\tProtectedTweet    string `yaml:\"protected_tweet\"`\n\tTweetUnavailable  string `yaml:\"tweet_unavailable\"`\n\tAuthHint             string `yaml:\"auth_hint\"`\n\tDeprecatedSet        string `yaml:\"deprecated_set\"`\n\tDeprecatedClear      string `yaml:\"deprecated_clear\"`\n\tDeprecatedUseNew     string `yaml:\"deprecated_use_new\"`\n\tDeprecatedUseNewUnset string `yaml:\"deprecated_use_new_unset\"`\n}\n\ntype SitesTranslations struct {\n\tConfigureSite   string `yaml:\"configure_site\"`\n\tDomainMatch     string `yaml:\"domain_match\"`\n\tSelectType      string `yaml:\"select_type\"`\n\tOnlyM3u8ForNow  string `yaml:\"only_m3u8_for_now\"`\n\tExistingSites   string `yaml:\"existing_sites\"`\n\tSiteAdded       string `yaml:\"site_added\"`\n\tSavedTo         string `yaml:\"saved_to\"`\n\tCancelled       string `yaml:\"cancelled\"`\n\tEnterConfirm    string `yaml:\"enter_confirm\"`\n\tEscCancel       string `yaml:\"esc_cancel\"`\n}\n\n// UITranslations holds translations for the web UI\ntype UITranslations struct {\n\tDownloadTo       string `yaml:\"download_to\" json:\"download_to\"`\n\tEdit             string `yaml:\"edit\" json:\"edit\"`\n\tSave             string `yaml:\"save\" json:\"save\"`\n\tCancel           string `yaml:\"cancel\" json:\"cancel\"`\n\tPasteURL         string `yaml:\"paste_url\" json:\"paste_url\"`\n\tDownload         string `yaml:\"download\" json:\"download\"`\n\tBulkDownload     string `yaml:\"bulk_download\" json:\"bulk_download\"`\n\tComingSoon       string `yaml:\"coming_soon\" json:\"coming_soon\"`\n\tBulkPasteURLs    string `yaml:\"bulk_paste_urls\" json:\"bulk_paste_urls\"`\n\tBulkSelectFile   string `yaml:\"bulk_select_file\" json:\"bulk_select_file\"`\n\tBulkDragDrop     string `yaml:\"bulk_drag_drop\" json:\"bulk_drag_drop\"`\n\tBulkURLCount     string `yaml:\"bulk_url_count\" json:\"bulk_url_count\"`\n\tBulkSubmitAll    string `yaml:\"bulk_submit_all\" json:\"bulk_submit_all\"`\n\tBulkSubmitting   string `yaml:\"bulk_submitting\" json:\"bulk_submitting\"`\n\tBulkClear        string `yaml:\"bulk_clear\" json:\"bulk_clear\"`\n\tBulkInvalidHint  string `yaml:\"bulk_invalid_hint\" json:\"bulk_invalid_hint\"`\n\tAdding           string `yaml:\"adding\" json:\"adding\"`\n\tJobs             string `yaml:\"jobs\" json:\"jobs\"`\n\tTotal            string `yaml:\"total\" json:\"total\"`\n\tNoDownloads      string `yaml:\"no_downloads\" json:\"no_downloads\"`\n\tPasteHint        string `yaml:\"paste_hint\" json:\"paste_hint\"`\n\tQueued           string `yaml:\"queued\" json:\"queued\"`\n\tDownloading      string `yaml:\"downloading\" json:\"downloading\"`\n\tCompleted        string `yaml:\"completed\" json:\"completed\"`\n\tFailed           string `yaml:\"failed\" json:\"failed\"`\n\tCancelled        string `yaml:\"cancelled\" json:\"cancelled\"`\n\tSettings         string `yaml:\"settings\" json:\"settings\"`\n\tLanguage         string `yaml:\"language\" json:\"language\"`\n\tFormat           string `yaml:\"format\" json:\"format\"`\n\tQuality          string `yaml:\"quality\" json:\"quality\"`\n\tTwitterAuth      string `yaml:\"twitter_auth\" json:\"twitter_auth\"`\n\tServerPort       string `yaml:\"server_port\" json:\"server_port\"`\n\tMaxConcurrent    string `yaml:\"max_concurrent\" json:\"max_concurrent\"`\n\tAPIKey           string `yaml:\"api_key\" json:\"api_key\"`\n\tWebDAVServers    string `yaml:\"webdav_servers\" json:\"webdav_servers\"`\n\tAdd              string `yaml:\"add\" json:\"add\"`\n\tDelete           string `yaml:\"delete\" json:\"delete\"`\n\tName             string `yaml:\"name\" json:\"name\"`\n\tURL              string `yaml:\"url\" json:\"url\"`\n\tUsername         string `yaml:\"username\" json:\"username\"`\n\tPassword         string `yaml:\"password\" json:\"password\"`\n\tNoWebDAVServers  string `yaml:\"no_webdav_servers\" json:\"no_webdav_servers\"`\n\tConfigured       string `yaml:\"configured\" json:\"configured\"`\n\tNotConfigured    string `yaml:\"not_configured\" json:\"not_configured\"`\n\tClearHistory     string `yaml:\"clear_history\" json:\"clear_history\"`\n\tClearAll         string `yaml:\"clear_all\" json:\"clear_all\"`\n\t// WebDAV\n\tWebDAVBrowser    string `yaml:\"webdav_browser\" json:\"webdav_browser\"`\n\tSelectRemote     string `yaml:\"select_remote\" json:\"select_remote\"`\n\tEmptyDirectory   string `yaml:\"empty_directory\" json:\"empty_directory\"`\n\tDownloadSelected string `yaml:\"download_selected\" json:\"download_selected\"`\n\tSelectedFiles    string `yaml:\"selected_files\" json:\"selected_files\"`\n\tLoading          string `yaml:\"loading\" json:\"loading\"`\n\tGoToSettings     string `yaml:\"go_to_settings\" json:\"go_to_settings\"`\n\t// Torrent\n\tTorrent              string `yaml:\"torrent\" json:\"torrent\"`\n\tTorrentHint          string `yaml:\"torrent_hint\" json:\"torrent_hint\"`\n\tTorrentSubmit        string `yaml:\"torrent_submit\" json:\"torrent_submit\"`\n\tTorrentSubmitting    string `yaml:\"torrent_submitting\" json:\"torrent_submitting\"`\n\tTorrentSuccess       string `yaml:\"torrent_success\" json:\"torrent_success\"`\n\tTorrentNotConfigured string `yaml:\"torrent_not_configured\" json:\"torrent_not_configured\"`\n\tTorrentSettings      string `yaml:\"torrent_settings\" json:\"torrent_settings\"`\n\tTorrentClient        string `yaml:\"torrent_client\" json:\"torrent_client\"`\n\tTorrentHost          string `yaml:\"torrent_host\" json:\"torrent_host\"`\n\tTorrentTest          string `yaml:\"torrent_test\" json:\"torrent_test\"`\n\tTorrentTesting       string `yaml:\"torrent_testing\" json:\"torrent_testing\"`\n\tTorrentTestSuccess   string `yaml:\"torrent_test_success\" json:\"torrent_test_success\"`\n\tTorrentEnabled       string `yaml:\"torrent_enabled\" json:\"torrent_enabled\"`\n\t// Toast\n\tDownloadQueued  string `yaml:\"download_queued\" json:\"download_queued\"`\n\tDownloadsQueued string `yaml:\"downloads_queued\" json:\"downloads_queued\"`\n\t// Podcast\n\tPodcast                string `yaml:\"podcast\" json:\"podcast\"`\n\tPodcastSearch          string `yaml:\"podcast_search\" json:\"podcast_search\"`\n\tPodcastSearchHint      string `yaml:\"podcast_search_hint\" json:\"podcast_search_hint\"`\n\tPodcastSearching       string `yaml:\"podcast_searching\" json:\"podcast_searching\"`\n\tPodcastChannels        string `yaml:\"podcast_channels\" json:\"podcast_channels\"`\n\tPodcastEpisodes        string `yaml:\"podcast_episodes\" json:\"podcast_episodes\"`\n\tPodcastNoResults       string `yaml:\"podcast_no_results\" json:\"podcast_no_results\"`\n\tPodcastEpisodesCount   string `yaml:\"podcast_episodes_count\" json:\"podcast_episodes_count\"`\n\tPodcastBack            string `yaml:\"podcast_back\" json:\"podcast_back\"`\n\tPodcastDownloadStarted string `yaml:\"podcast_download_started\" json:\"podcast_download_started\"`\n\t// API Token\n\tTokenTitle             string `yaml:\"token_title\" json:\"token_title\"`\n\tTokenDescription       string `yaml:\"token_description\" json:\"token_description\"`\n\tTokenCustomPayload     string `yaml:\"token_custom_payload\" json:\"token_custom_payload\"`\n\tTokenCustomPayloadHint string `yaml:\"token_custom_payload_hint\" json:\"token_custom_payload_hint\"`\n\tTokenGenerate          string `yaml:\"token_generate\" json:\"token_generate\"`\n\tTokenGenerating        string `yaml:\"token_generating\" json:\"token_generating\"`\n\tTokenGenerated         string `yaml:\"token_generated\" json:\"token_generated\"`\n\tTokenCopy              string `yaml:\"token_copy\" json:\"token_copy\"`\n\tTokenCopied            string `yaml:\"token_copied\" json:\"token_copied\"`\n\tTokenUsage             string `yaml:\"token_usage\" json:\"token_usage\"`\n\tTokenInvalidJSON       string `yaml:\"token_invalid_json\" json:\"token_invalid_json\"`\n\t// History\n\tHistory              string `yaml:\"history\" json:\"history\"`\n\tHistoryTitle         string `yaml:\"history_title\" json:\"history_title\"`\n\tHistoryEmpty         string `yaml:\"history_empty\" json:\"history_empty\"`\n\tHistoryEmptyHint     string `yaml:\"history_empty_hint\" json:\"history_empty_hint\"`\n\tHistoryClearAll      string `yaml:\"history_clear_all\" json:\"history_clear_all\"`\n\tHistoryStats         string `yaml:\"history_stats\" json:\"history_stats\"`\n\tHistoryTotalDownloaded string `yaml:\"history_total_downloaded\" json:\"history_total_downloaded\"`\n}\n\n// ServerTranslations holds translations for server messages\ntype ServerTranslations struct {\n\tNoConfigWarning string `yaml:\"no_config_warning\" json:\"no_config_warning\"`\n\tRunInitHint     string `yaml:\"run_init_hint\" json:\"run_init_hint\"`\n}\n\n// YouTubeTranslations holds translations for YouTube messages\ntype YouTubeTranslations struct {\n\tDockerRequired   string `yaml:\"docker_required\"`\n\tDockerHintServer string `yaml:\"docker_hint_server\"`\n\tDockerHintCLI    string `yaml:\"docker_hint_cli\"`\n}\n\nvar (\n\ttranslationsCache = make(map[string]*Translations)\n\tcacheMutex        sync.RWMutex\n\tdefaultLang       = \"zh\"\n)\n\n// SupportedLanguages returns all available language codes\nvar SupportedLanguages = []struct {\n\tCode string\n\tName string\n}{\n\t{\"zh\", \"中文\"},\n\t{\"en\", \"English\"},\n\t{\"jp\", \"日本語\"},\n\t{\"kr\", \"한국어\"},\n\t{\"es\", \"Español\"},\n\t{\"fr\", \"Français\"},\n\t{\"de\", \"Deutsch\"},\n}\n\n// GetTranslations returns translations for the specified language\nfunc GetTranslations(lang string) *Translations {\n\tcacheMutex.RLock()\n\tif t, ok := translationsCache[lang]; ok {\n\t\tcacheMutex.RUnlock()\n\t\treturn t\n\t}\n\tcacheMutex.RUnlock()\n\n\t// Load from file\n\tt, err := loadTranslations(lang)\n\tif err != nil {\n\t\t// Fall back to English\n\t\tif lang != defaultLang {\n\t\t\treturn GetTranslations(defaultLang)\n\t\t}\n\t\t// Return empty translations if even English fails\n\t\treturn &Translations{}\n\t}\n\n\tcacheMutex.Lock()\n\ttranslationsCache[lang] = t\n\tcacheMutex.Unlock()\n\n\treturn t\n}\n\nfunc loadTranslations(lang string) (*Translations, error) {\n\tfilename := fmt.Sprintf(\"locales/%s.yml\", lang)\n\tdata, err := localesFS.ReadFile(filename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar t Translations\n\tif err := yaml.Unmarshal(data, &t); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &t, nil\n}\n\n// T is a convenience function for getting translations\nfunc T(lang string) *Translations {\n\treturn GetTranslations(lang)\n}\n"
  },
  {
    "path": "internal/core/i18n/locales/de.yml",
    "content": "# Deutsche Übersetzungen\n\nconfig:\n  step_of: \"Schritt %d von %d\"\n  language: \"Sprache\"\n  language_desc: \"Bevorzugte Sprache für Metadaten\"\n  output_dir: \"Ausgabeverzeichnis\"\n  output_dir_desc: \"Wo Videos gespeichert werden\"\n  format: \"Format\"\n  format_desc: \"Bevorzugtes Videoformat\"\n  quality: \"Qualität\"\n  quality_desc: \"Bevorzugte Videoqualität\"\n  confirm: \"Bestätigen\"\n  confirm_desc: \"Konfiguration prüfen und speichern\"\n  yes_save: \"Ja, speichern\"\n  no_cancel: \"Nein, abbrechen\"\n  best_available: \"Beste verfügbar\"\n  recommended: \"(empfohlen)\"\n\nconfig_review:\n  language: \"Sprache\"\n  output_dir: \"Verzeichnis\"\n  format: \"Format\"\n  quality: \"Qualität\"\n\nhelp:\n  back: \"zurück\"\n  next: \"weiter\"\n  select: \"auswählen\"\n  confirm: \"bestätigen\"\n  quit: \"beenden\"\n\ndownload:\n  downloading: \"Herunterladen\"\n  extracting: \"Extrahieren\"\n  completed: \"Abgeschlossen\"\n  failed: \"Fehlgeschlagen\"\n  progress: \"Fortschritt\"\n  speed: \"Geschwindigkeit\"\n  eta: \"Verbleibende Zeit\"\n  elapsed: \"Verstrichene Zeit\"\n  avg_speed: \"Durchschnitt\"\n  file_saved: \"Datei gespeichert unter\"\n  no_formats: \"Keine Formate verfügbar\"\n  select_format: \"Format auswählen\"\n  formats_available: \"Verfügbare Formate\"\n  selected_format: \"Format\"\n  quality_hint: \"Mit -q Qualität wählen, z.B.: vget -q 720p <url>\"\n\nerrors:\n  config_not_found: \"Konfigurationsdatei nicht gefunden\"\n  invalid_url: \"Ungültige URL\"\n  network_error: \"Netzwerkfehler\"\n  extraction_failed: \"Extraktion fehlgeschlagen\"\n  download_failed: \"Download fehlgeschlagen\"\n  no_extractor: \"Kein Extraktor für diese URL gefunden\"\n\nsearch:\n  results_for: \"Suchergebnisse\"\n  searching: \"Suche\"\n  fetching_episodes: \"Lade Episoden\"\n  podcasts: \"Podcasts\"\n  episodes: \"Episoden\"\n  select_hint: \"Wähle bis zu %d Episoden\"\n  select_podcast_hint: \"Wähle einen Podcast um Episoden anzuzeigen\"\n  selected: \"Ausgewählt\"\n  help: \"↑/↓ navigieren • Leertaste auswählen • Enter herunterladen • q beenden\"\n  help_podcast: \"↑/↓ navigieren • Leertaste auswählen • Enter durchsuchen • q beenden\"\n\ntwitter:\n  enter_auth_token: \"auth_token eingeben\"\n  auth_saved: \"Twitter auth_token gespeichert.\"\n  auth_can_download: \"Sie können jetzt altersbeschränkte Inhalte herunterladen.\"\n  auth_cleared: \"Twitter-Authentifizierung gelöscht.\"\n  auth_required: \"auth_token ist erforderlich\"\n  nsfw_login_required: \"Altersbeschränkte Inhalte erfordern eine Anmeldung.\"\n  protected_tweet: \"Geschützter Tweet erfordert Autorisierung.\"\n  tweet_unavailable: \"Tweet nicht verfügbar.\"\n  auth_hint: \"Führen Sie 'vget config set twitter.auth_token <value>' aus, um Ihr auth token zu konfigurieren.\"\n  deprecated_set: \"'vget config twitter set' ist veraltet.\"\n  deprecated_clear: \"'vget config twitter clear' ist veraltet.\"\n  deprecated_use_new: \"Bitte verwenden Sie: vget config set twitter.auth_token <value>\"\n  deprecated_use_new_unset: \"Bitte verwenden Sie: vget config unset twitter.auth_token\"\n\n# Web-Oberfläche Übersetzungen\nui:\n  download_to: \"Speichern unter:\"\n  edit: \"Bearbeiten\"\n  save: \"Speichern\"\n  cancel: \"Abbrechen\"\n  paste_url: \"URL zum Herunterladen einfügen...\"\n  download: \"Herunterladen\"\n  bulk_download: \"Massen-Download\"\n  coming_soon: \"Demnächst verfügbar\"\n  bulk_paste_urls: \"URLs hier einfügen (eine pro Zeile)...\"\n  bulk_select_file: \"Datei auswählen\"\n  bulk_drag_drop: \"oder .txt-Datei hierher ziehen\"\n  bulk_url_count: \"URLs\"\n  bulk_submit_all: \"Alle herunterladen\"\n  bulk_submitting: \"Wird gesendet...\"\n  bulk_clear: \"Leeren\"\n  bulk_invalid_hint: \"Leerzeilen und Zeilen mit # am Anfang werden ignoriert\"\n  adding: \"Hinzufügen...\"\n  jobs: \"Aufgaben\"\n  total: \"gesamt\"\n  no_downloads: \"Keine Downloads\"\n  paste_hint: \"Fügen Sie oben eine URL ein, um zu beginnen\"\n  queued: \"wartend\"\n  downloading: \"lädt\"\n  completed: \"abgeschlossen\"\n  failed: \"fehlgeschlagen\"\n  cancelled: \"abgebrochen\"\n  settings: \"Einstellungen\"\n  language: \"Sprache\"\n  format: \"Format\"\n  quality: \"Qualität\"\n  twitter_auth: \"Twitter Auth\"\n  server_port: \"Server-Port\"\n  max_concurrent: \"Max. gleichzeitig\"\n  api_key: \"API-Schlüssel\"\n  webdav_servers: \"WebDAV-Server\"\n  add: \"Hinzufügen\"\n  delete: \"Löschen\"\n  name: \"Name\"\n  url: \"URL\"\n  username: \"Benutzername\"\n  password: \"Passwort\"\n  no_webdav_servers: \"Keine WebDAV-Server konfiguriert\"\n  clear_history: \"Löschen\"\n  clear_all: \"Alle löschen\"\n  # WebDAV\n  webdav_browser: \"WebDAV\"\n  select_remote: \"Remote auswählen\"\n  empty_directory: \"Leeres Verzeichnis\"\n  download_selected: \"Ausgewählte herunterladen\"\n  selected_files: \"ausgewählt\"\n  loading: \"Laden...\"\n  go_to_settings: \"Zu Einstellungen\"\n  # Torrent\n  torrent: \"BT/Magnet\"\n  torrent_hint: \"Magnet-Link oder Torrent-URL einfügen...\"\n  torrent_submit: \"Senden\"\n  torrent_submitting: \"Wird gesendet...\"\n  torrent_success: \"Torrent erfolgreich hinzugefügt\"\n  torrent_not_configured: \"Torrent-Client nicht konfiguriert. In den Einstellungen einrichten.\"\n  torrent_settings: \"Torrent-Client\"\n  torrent_client: \"Client-Typ\"\n  torrent_host: \"Host\"\n  torrent_test: \"Verbindung testen\"\n  torrent_testing: \"Teste...\"\n  torrent_test_success: \"Verbindung erfolgreich\"\n  torrent_enabled: \"Torrent aktivieren\"\n  # Toast\n  download_queued: \"Download gestartet. Fortschritt auf der Download-Seite überprüfen.\"\n  downloads_queued: \"Downloads gestartet. Fortschritt auf der Download-Seite überprüfen.\"\n  # Podcast\n  podcast: \"Podcast\"\n  podcast_search: \"Suchen\"\n  podcast_search_hint: \"Podcasts oder Episoden suchen...\"\n  podcast_searching: \"Suche...\"\n  podcast_channels: \"Podcasts\"\n  podcast_episodes: \"Episoden\"\n  podcast_no_results: \"Keine Ergebnisse gefunden\"\n  podcast_episodes_count: \"Episoden\"\n  podcast_back: \"Zurück\"\n  podcast_download_started: \"Download gestartet\"\n  # AI\n  ai: \"KI\"\n  # AI-Schrittnamen\n  # AI-Schrittdetails\n  # Lokale Spracherkennung\n  # Modell-Download\n  # API Token\n  token_title: \"API-Token-Generator\"\n  token_description: \"Generieren Sie JWT-Tokens für externen API-Zugriff (Chrome-Erweiterung, Skripte, etc.). Tokens werden mit Ihrem konfigurierten api_key signiert.\"\n  token_custom_payload: \"Benutzerdefinierte Nutzlast (optional)\"\n  token_custom_payload_hint: \"Fügen Sie benutzerdefinierte Claims hinzu, die im JWT-Token enthalten sein sollen. Muss gültiges JSON sein.\"\n  token_generate: \"Token generieren\"\n  token_generating: \"Generiere...\"\n  token_generated: \"Generierter Token\"\n  token_copy: \"Kopieren\"\n  token_copied: \"Kopiert!\"\n  token_usage: \"Verwendung\"\n  token_invalid_json: \"Ungültiges JSON\"\n  # Verlauf\n  history: \"Verlauf\"\n  history_title: \"Download-Verlauf\"\n  history_empty: \"Kein Download-Verlauf\"\n  history_empty_hint: \"Abgeschlossene Downloads werden hier angezeigt\"\n  history_clear_all: \"Gesamten Verlauf löschen\"\n  history_stats: \"Statistiken\"\n  history_total_downloaded: \"Gesamt heruntergeladen\"\n\n# Server-Nachrichten\nserver:\n  no_config_warning: \"Keine Konfigurationsdatei gefunden. Standardeinstellungen werden verwendet.\"\n  run_init_hint: \"Führen Sie 'vget init' aus, um vget interaktiv zu konfigurieren.\"\n\n# YouTube-Nachrichten\nyoutube:\n  docker_required: \"YouTube-Download erfordert Docker.\"\n  docker_hint_server: \"Führen Sie den vget-Server in Docker aus (empfohlen für NAS):\"\n  docker_hint_cli: \"Oder laden Sie direkt über CLI herunter:\"\n"
  },
  {
    "path": "internal/core/i18n/locales/en.yml",
    "content": "# English translations\n\nconfig:\n  step_of: \"Step %d of %d\"\n  language: \"Language\"\n  language_desc: \"Preferred language for metadata\"\n  output_dir: \"Output Directory\"\n  output_dir_desc: \"Where to save downloaded videos\"\n  format: \"Format\"\n  format_desc: \"Preferred video format\"\n  quality: \"Quality\"\n  quality_desc: \"Preferred video quality\"\n  confirm: \"Confirm\"\n  confirm_desc: \"Review and save configuration\"\n  yes_save: \"Yes, save\"\n  no_cancel: \"No, cancel\"\n  best_available: \"Best available\"\n  recommended: \"(recommended)\"\n\nconfig_review:\n  language: \"Language\"\n  output_dir: \"Output Dir\"\n  format: \"Format\"\n  quality: \"Quality\"\n\nhelp:\n  back: \"back\"\n  next: \"next\"\n  select: \"select\"\n  confirm: \"confirm\"\n  quit: \"quit\"\n\ndownload:\n  downloading: \"Downloading\"\n  extracting: \"Extracting\"\n  completed: \"Completed\"\n  failed: \"Failed\"\n  progress: \"Progress\"\n  speed: \"Speed\"\n  eta: \"ETA\"\n  elapsed: \"Elapsed\"\n  avg_speed: \"Avg speed\"\n  file_saved: \"File saved to\"\n  no_formats: \"No formats available\"\n  select_format: \"Select format\"\n  formats_available: \"Formats available\"\n  selected_format: \"Format\"\n  quality_hint: \"Use -q to select a specific quality, e.g., vget -q 720p <url>\"\n\nerrors:\n  config_not_found: \"Config file not found\"\n  invalid_url: \"Invalid URL\"\n  network_error: \"Network error\"\n  extraction_failed: \"Extraction failed\"\n  download_failed: \"Download failed\"\n  no_extractor: \"No extractor found for this URL\"\n\nsearch:\n  results_for: \"Search results for\"\n  searching: \"Searching\"\n  fetching_episodes: \"Fetching episodes for\"\n  podcasts: \"Podcasts\"\n  episodes: \"Episodes\"\n  select_hint: \"Select up to %d episodes\"\n  select_podcast_hint: \"Select a podcast to browse episodes\"\n  selected: \"Selected\"\n  help: \"↑/↓ navigate • space toggle • enter download • q quit\"\n  help_podcast: \"↑/↓ navigate • space toggle • enter browse • q quit\"\n\ntwitter:\n  enter_auth_token: \"Enter auth_token\"\n  auth_saved: \"Twitter auth_token saved.\"\n  auth_can_download: \"You can now download age-restricted content.\"\n  auth_cleared: \"Twitter authentication cleared.\"\n  auth_required: \"auth_token is required\"\n  nsfw_login_required: \"Age-restricted content requires login.\"\n  protected_tweet: \"Protected tweet requires authorization.\"\n  tweet_unavailable: \"Tweet is unavailable.\"\n  auth_hint: \"Run 'vget config set twitter.auth_token <value>' to set your auth token.\"\n  deprecated_set: \"'vget config twitter set' is deprecated.\"\n  deprecated_clear: \"'vget config twitter clear' is deprecated.\"\n  deprecated_use_new: \"Please use: vget config set twitter.auth_token <value>\"\n  deprecated_use_new_unset: \"Please use: vget config unset twitter.auth_token\"\n\nsites:\n  configure_site: \"Configure Site\"\n  domain_match: \"Domain match (e.g., kanav.ad):\"\n  select_type: \"Select type:\"\n  only_m3u8_for_now: \"(only m3u8 for now)\"\n  existing_sites: \"Existing sites in ./sites.yml:\"\n  site_added: \"Site added\"\n  saved_to: \"Saved to ./sites.yml\"\n  cancelled: \"Cancelled\"\n  enter_confirm: \"enter confirm\"\n  esc_cancel: \"esc cancel\"\n\n# Web UI translations\nui:\n  download_to: \"Download to:\"\n  edit: \"Edit\"\n  save: \"Save\"\n  cancel: \"Cancel\"\n  paste_url: \"Paste URL to download...\"\n  download: \"Download\"\n  bulk_download: \"Bulk Download\"\n  coming_soon: \"Coming Soon\"\n  bulk_paste_urls: \"Paste URLs here (one per line)...\"\n  bulk_select_file: \"Select File\"\n  bulk_drag_drop: \"or drag and drop a .txt file here\"\n  bulk_url_count: \"URLs\"\n  bulk_submit_all: \"Download All\"\n  bulk_submitting: \"Submitting...\"\n  bulk_clear: \"Clear\"\n  bulk_invalid_hint: \"Empty lines and lines starting with # are ignored\"\n  adding: \"Adding...\"\n  jobs: \"Jobs\"\n  total: \"total\"\n  no_downloads: \"No downloads yet\"\n  paste_hint: \"Paste a URL above to get started\"\n  queued: \"queued\"\n  downloading: \"downloading\"\n  completed: \"completed\"\n  failed: \"failed\"\n  cancelled: \"cancelled\"\n  settings: \"Settings\"\n  language: \"Language\"\n  format: \"Format\"\n  quality: \"Quality\"\n  twitter_auth: \"Twitter Auth\"\n  server_port: \"Server Port\"\n  max_concurrent: \"Max Concurrent\"\n  api_key: \"API Key\"\n  webdav_servers: \"WebDAV Servers\"\n  add: \"Add\"\n  delete: \"Delete\"\n  name: \"Name\"\n  url: \"URL\"\n  username: \"Username\"\n  password: \"Password\"\n  no_webdav_servers: \"No WebDAV servers configured\"\n  clear_history: \"Clear\"\n  clear_all: \"Clear All\"\n  # WebDAV\n  webdav_browser: \"WebDAV\"\n  select_remote: \"Select Remote\"\n  empty_directory: \"Empty directory\"\n  download_selected: \"Download Selected\"\n  selected_files: \"selected\"\n  loading: \"Loading...\"\n  go_to_settings: \"Go to Settings\"\n  # Torrent\n  torrent: \"BT/Magnet\"\n  torrent_hint: \"Paste magnet link or torrent URL...\"\n  torrent_submit: \"Send\"\n  torrent_submitting: \"Sending...\"\n  torrent_success: \"Torrent added successfully\"\n  torrent_not_configured: \"Torrent client not configured. Go to Settings to set up.\"\n  torrent_settings: \"Torrent Client\"\n  torrent_client: \"Client Type\"\n  torrent_host: \"Host\"\n  torrent_test: \"Test Connection\"\n  torrent_testing: \"Testing...\"\n  torrent_test_success: \"Connection successful\"\n  torrent_enabled: \"Enable Torrent\"\n  # Toast\n  download_queued: \"Download started. Check progress on Download page.\"\n  downloads_queued: \"downloads started. Check progress on Download page.\"\n  # Podcast\n  podcast: \"Podcast\"\n  podcast_search: \"Search\"\n  podcast_search_hint: \"Search podcasts or episodes...\"\n  podcast_searching: \"Searching...\"\n  podcast_channels: \"Podcasts\"\n  podcast_episodes: \"Episodes\"\n  podcast_no_results: \"No results found\"\n  podcast_episodes_count: \"episodes\"\n  podcast_back: \"Back\"\n  podcast_download_started: \"Download started\"\n  # AI\n  ai: \"AI\"\n  # AI step names\n  # AI step details\n  # Local Speech to Text\n  # Model Download\n  # API Token\n  token_title: \"API Token Generator\"\n  token_description: \"Generate JWT tokens for external API access (Chrome extension, scripts, etc.). Tokens are signed with your configured api_key.\"\n  token_custom_payload: \"Custom Payload (optional)\"\n  token_custom_payload_hint: \"Add custom claims to include in the JWT token. Must be valid JSON.\"\n  token_generate: \"Generate Token\"\n  token_generating: \"Generating...\"\n  token_generated: \"Generated Token\"\n  token_copy: \"Copy\"\n  token_copied: \"Copied!\"\n  token_usage: \"Usage\"\n  token_invalid_json: \"Invalid JSON\"\n  # History\n  history: \"History\"\n  history_title: \"Download History\"\n  history_empty: \"No download history\"\n  history_empty_hint: \"Completed downloads will appear here\"\n  history_clear_all: \"Clear All History\"\n  history_stats: \"Statistics\"\n  history_total_downloaded: \"Total Downloaded\"\n\n# Server messages\nserver:\n  no_config_warning: \"No config file found. Using default settings.\"\n  run_init_hint: \"Click Settings to configure, or run 'vget init' in terminal.\"\n\n# YouTube messages\nyoutube:\n  docker_required: \"YouTube extraction requires Docker.\"\n  docker_hint_server: \"Run vget server in Docker (recommended for NAS):\"\n  docker_hint_cli: \"Or download directly via CLI:\"\n"
  },
  {
    "path": "internal/core/i18n/locales/es.yml",
    "content": "# Traducciones en español\n\nconfig:\n  step_of: \"Paso %d de %d\"\n  language: \"Idioma\"\n  language_desc: \"Idioma preferido para metadatos\"\n  output_dir: \"Directorio de salida\"\n  output_dir_desc: \"Dónde guardar los videos\"\n  format: \"Formato\"\n  format_desc: \"Formato de video preferido\"\n  quality: \"Calidad\"\n  quality_desc: \"Calidad de video preferida\"\n  confirm: \"Confirmar\"\n  confirm_desc: \"Revisar y guardar configuración\"\n  yes_save: \"Sí, guardar\"\n  no_cancel: \"No, cancelar\"\n  best_available: \"Mejor disponible\"\n  recommended: \"(recomendado)\"\n\nconfig_review:\n  language: \"Idioma\"\n  output_dir: \"Directorio\"\n  format: \"Formato\"\n  quality: \"Calidad\"\n\nhelp:\n  back: \"atrás\"\n  next: \"siguiente\"\n  select: \"seleccionar\"\n  confirm: \"confirmar\"\n  quit: \"salir\"\n\ndownload:\n  downloading: \"Descargando\"\n  extracting: \"Extrayendo\"\n  completed: \"Completado\"\n  failed: \"Fallido\"\n  progress: \"Progreso\"\n  speed: \"Velocidad\"\n  eta: \"Tiempo restante\"\n  elapsed: \"Tiempo transcurrido\"\n  avg_speed: \"Velocidad media\"\n  file_saved: \"Archivo guardado en\"\n  no_formats: \"No hay formatos disponibles\"\n  select_format: \"Seleccionar formato\"\n  formats_available: \"Formatos disponibles\"\n  selected_format: \"Formato\"\n  quality_hint: \"Usa -q para elegir calidad, ej: vget -q 720p <url>\"\n\nerrors:\n  config_not_found: \"Archivo de configuración no encontrado\"\n  invalid_url: \"URL inválida\"\n  network_error: \"Error de red\"\n  extraction_failed: \"Extracción fallida\"\n  download_failed: \"Descarga fallida\"\n  no_extractor: \"No se encontró extractor para esta URL\"\n\nsearch:\n  results_for: \"Resultados de búsqueda\"\n  searching: \"Buscando\"\n  fetching_episodes: \"Obteniendo episodios\"\n  podcasts: \"Podcasts\"\n  episodes: \"Episodios\"\n  select_hint: \"Selecciona hasta %d episodios\"\n  select_podcast_hint: \"Selecciona un podcast para ver episodios\"\n  selected: \"Seleccionados\"\n  help: \"↑/↓ navegar • espacio seleccionar • enter descargar • q salir\"\n  help_podcast: \"↑/↓ navegar • espacio seleccionar • enter explorar • q salir\"\n\ntwitter:\n  enter_auth_token: \"Ingresa auth_token\"\n  auth_saved: \"auth_token de Twitter guardado.\"\n  auth_can_download: \"Ahora puedes descargar contenido con restricción de edad.\"\n  auth_cleared: \"Autenticación de Twitter eliminada.\"\n  auth_required: \"auth_token es requerido\"\n  nsfw_login_required: \"El contenido con restricción de edad requiere iniciar sesión.\"\n  protected_tweet: \"El tweet protegido requiere autorización.\"\n  tweet_unavailable: \"Tweet no disponible.\"\n  auth_hint: \"Ejecuta 'vget config set twitter.auth_token <value>' para configurar tu auth token.\"\n  deprecated_set: \"'vget config twitter set' está obsoleto.\"\n  deprecated_clear: \"'vget config twitter clear' está obsoleto.\"\n  deprecated_use_new: \"Por favor usa: vget config set twitter.auth_token <value>\"\n  deprecated_use_new_unset: \"Por favor usa: vget config unset twitter.auth_token\"\n\n# Traducciones de la interfaz web\nui:\n  download_to: \"Descargar en:\"\n  edit: \"Editar\"\n  save: \"Guardar\"\n  cancel: \"Cancelar\"\n  paste_url: \"Pegar URL para descargar...\"\n  download: \"Descargar\"\n  bulk_download: \"Descarga masiva\"\n  coming_soon: \"Próximamente\"\n  bulk_paste_urls: \"Pegar URLs aquí (una por línea)...\"\n  bulk_select_file: \"Seleccionar archivo\"\n  bulk_drag_drop: \"o arrastra un archivo .txt aquí\"\n  bulk_url_count: \"URLs\"\n  bulk_submit_all: \"Descargar todo\"\n  bulk_submitting: \"Enviando...\"\n  bulk_clear: \"Limpiar\"\n  bulk_invalid_hint: \"Las líneas vacías y las que empiezan con # se ignoran\"\n  adding: \"Añadiendo...\"\n  jobs: \"Trabajos\"\n  total: \"total\"\n  no_downloads: \"Sin descargas\"\n  paste_hint: \"Pega una URL arriba para comenzar\"\n  queued: \"en cola\"\n  downloading: \"descargando\"\n  completed: \"completado\"\n  failed: \"fallido\"\n  cancelled: \"cancelado\"\n  settings: \"Configuración\"\n  language: \"Idioma\"\n  format: \"Formato\"\n  quality: \"Calidad\"\n  twitter_auth: \"Auth de Twitter\"\n  server_port: \"Puerto del servidor\"\n  max_concurrent: \"Máx. simultáneos\"\n  api_key: \"Clave API\"\n  webdav_servers: \"Servidores WebDAV\"\n  add: \"Añadir\"\n  delete: \"Eliminar\"\n  name: \"Nombre\"\n  url: \"URL\"\n  username: \"Usuario\"\n  password: \"Contraseña\"\n  no_webdav_servers: \"No hay servidores WebDAV configurados\"\n  clear_history: \"Limpiar\"\n  clear_all: \"Limpiar todo\"\n  # WebDAV\n  webdav_browser: \"WebDAV\"\n  select_remote: \"Seleccionar remoto\"\n  empty_directory: \"Directorio vacío\"\n  download_selected: \"Descargar seleccionados\"\n  selected_files: \"seleccionados\"\n  loading: \"Cargando...\"\n  go_to_settings: \"Ir a configuración\"\n  # Torrent\n  torrent: \"BT/Magnet\"\n  torrent_hint: \"Pega un enlace magnet o URL de torrent...\"\n  torrent_submit: \"Enviar\"\n  torrent_submitting: \"Enviando...\"\n  torrent_success: \"Torrent añadido exitosamente\"\n  torrent_not_configured: \"Cliente torrent no configurado. Configúrelo en ajustes.\"\n  torrent_settings: \"Cliente Torrent\"\n  torrent_client: \"Tipo de cliente\"\n  torrent_host: \"Host\"\n  torrent_test: \"Probar conexión\"\n  torrent_testing: \"Probando...\"\n  torrent_test_success: \"Conexión exitosa\"\n  torrent_enabled: \"Activar Torrent\"\n  # Toast\n  download_queued: \"Descarga iniciada. Verifica el progreso en la página de Descargas.\"\n  downloads_queued: \"descargas iniciadas. Verifica el progreso en la página de Descargas.\"\n  # Podcast\n  podcast: \"Podcast\"\n  podcast_search: \"Buscar\"\n  podcast_search_hint: \"Buscar podcasts o episodios...\"\n  podcast_searching: \"Buscando...\"\n  podcast_channels: \"Podcasts\"\n  podcast_episodes: \"Episodios\"\n  podcast_no_results: \"No se encontraron resultados\"\n  podcast_episodes_count: \"episodios\"\n  podcast_back: \"Volver\"\n  podcast_download_started: \"Descarga iniciada\"\n  # AI\n  ai: \"IA\"\n  # Nombres de pasos AI\n  # Detalles de pasos AI\n  # Voz a texto local\n  # Descarga de modelos\n  # API Token\n  token_title: \"Generador de API Token\"\n  token_description: \"Genera tokens JWT para acceso API externo (extensión de Chrome, scripts, etc.). Los tokens se firman con tu api_key configurada.\"\n  token_custom_payload: \"Carga personalizada (opcional)\"\n  token_custom_payload_hint: \"Añade claims personalizados para incluir en el token JWT. Debe ser JSON válido.\"\n  token_generate: \"Generar Token\"\n  token_generating: \"Generando...\"\n  token_generated: \"Token Generado\"\n  token_copy: \"Copiar\"\n  token_copied: \"¡Copiado!\"\n  token_usage: \"Uso\"\n  token_invalid_json: \"JSON inválido\"\n  # Historial\n  history: \"Historial\"\n  history_title: \"Historial de Descargas\"\n  history_empty: \"Sin historial de descargas\"\n  history_empty_hint: \"Las descargas completadas aparecerán aquí\"\n  history_clear_all: \"Borrar Todo el Historial\"\n  history_stats: \"Estadísticas\"\n  history_total_downloaded: \"Total Descargado\"\n\n# Mensajes del servidor\nserver:\n  no_config_warning: \"No se encontró archivo de configuración. Usando configuración predeterminada.\"\n  run_init_hint: \"Ejecute 'vget init' para configurar vget de forma interactiva.\"\n\n# Mensajes de YouTube\nyoutube:\n  docker_required: \"La descarga de YouTube requiere Docker.\"\n  docker_hint_server: \"Ejecute el servidor vget en Docker (recomendado para NAS):\"\n  docker_hint_cli: \"O descargue directamente via CLI:\"\n"
  },
  {
    "path": "internal/core/i18n/locales/fr.yml",
    "content": "# Traductions françaises\n\nconfig:\n  step_of: \"Étape %d sur %d\"\n  language: \"Langue\"\n  language_desc: \"Langue préférée pour les métadonnées\"\n  output_dir: \"Répertoire de sortie\"\n  output_dir_desc: \"Où enregistrer les vidéos\"\n  format: \"Format\"\n  format_desc: \"Format vidéo préféré\"\n  quality: \"Qualité\"\n  quality_desc: \"Qualité vidéo préférée\"\n  confirm: \"Confirmer\"\n  confirm_desc: \"Vérifier et sauvegarder\"\n  yes_save: \"Oui, sauvegarder\"\n  no_cancel: \"Non, annuler\"\n  best_available: \"Meilleure qualité\"\n  recommended: \"(recommandé)\"\n\nconfig_review:\n  language: \"Langue\"\n  output_dir: \"Répertoire\"\n  format: \"Format\"\n  quality: \"Qualité\"\n\nhelp:\n  back: \"retour\"\n  next: \"suivant\"\n  select: \"sélectionner\"\n  confirm: \"confirmer\"\n  quit: \"quitter\"\n\ndownload:\n  downloading: \"Téléchargement\"\n  extracting: \"Extraction\"\n  completed: \"Terminé\"\n  failed: \"Échoué\"\n  progress: \"Progression\"\n  speed: \"Vitesse\"\n  eta: \"Temps restant\"\n  elapsed: \"Temps écoulé\"\n  avg_speed: \"Vitesse moyenne\"\n  file_saved: \"Fichier enregistré dans\"\n  no_formats: \"Aucun format disponible\"\n  select_format: \"Sélectionner le format\"\n  formats_available: \"Formats disponibles\"\n  selected_format: \"Format\"\n  quality_hint: \"Utilisez -q pour choisir la qualité, ex: vget -q 720p <url>\"\n\nerrors:\n  config_not_found: \"Fichier de configuration introuvable\"\n  invalid_url: \"URL invalide\"\n  network_error: \"Erreur réseau\"\n  extraction_failed: \"Échec de l'extraction\"\n  download_failed: \"Échec du téléchargement\"\n  no_extractor: \"Aucun extracteur trouvé pour cette URL\"\n\nsearch:\n  results_for: \"Résultats de recherche\"\n  searching: \"Recherche\"\n  fetching_episodes: \"Chargement des épisodes\"\n  podcasts: \"Podcasts\"\n  episodes: \"Épisodes\"\n  select_hint: \"Sélectionnez jusqu'à %d épisodes\"\n  select_podcast_hint: \"Sélectionnez un podcast pour voir les épisodes\"\n  selected: \"Sélectionnés\"\n  help: \"↑/↓ naviguer • espace sélectionner • entrée télécharger • q quitter\"\n  help_podcast: \"↑/↓ naviguer • espace sélectionner • entrée parcourir • q quitter\"\n\ntwitter:\n  enter_auth_token: \"Entrez auth_token\"\n  auth_saved: \"auth_token Twitter enregistré.\"\n  auth_can_download: \"Vous pouvez maintenant télécharger du contenu réservé aux adultes.\"\n  auth_cleared: \"Authentification Twitter supprimée.\"\n  auth_required: \"auth_token est requis\"\n  nsfw_login_required: \"Le contenu réservé aux adultes nécessite une connexion.\"\n  protected_tweet: \"Le tweet protégé nécessite une autorisation.\"\n  tweet_unavailable: \"Tweet non disponible.\"\n  auth_hint: \"Exécutez 'vget config set twitter.auth_token <value>' pour configurer votre auth token.\"\n  deprecated_set: \"'vget config twitter set' est obsolète.\"\n  deprecated_clear: \"'vget config twitter clear' est obsolète.\"\n  deprecated_use_new: \"Veuillez utiliser : vget config set twitter.auth_token <value>\"\n  deprecated_use_new_unset: \"Veuillez utiliser : vget config unset twitter.auth_token\"\n\n# Traductions de l'interface web\nui:\n  download_to: \"Télécharger dans :\"\n  edit: \"Modifier\"\n  save: \"Enregistrer\"\n  cancel: \"Annuler\"\n  paste_url: \"Coller l'URL à télécharger...\"\n  download: \"Télécharger\"\n  bulk_download: \"Téléchargement en masse\"\n  coming_soon: \"Bientôt disponible\"\n  bulk_paste_urls: \"Coller les URLs ici (une par ligne)...\"\n  bulk_select_file: \"Sélectionner un fichier\"\n  bulk_drag_drop: \"ou glisser-déposer un fichier .txt ici\"\n  bulk_url_count: \"URLs\"\n  bulk_submit_all: \"Tout télécharger\"\n  bulk_submitting: \"Envoi en cours...\"\n  bulk_clear: \"Effacer\"\n  bulk_invalid_hint: \"Les lignes vides et celles commençant par # sont ignorées\"\n  adding: \"Ajout...\"\n  jobs: \"Tâches\"\n  total: \"total\"\n  no_downloads: \"Aucun téléchargement\"\n  paste_hint: \"Collez une URL ci-dessus pour commencer\"\n  queued: \"en attente\"\n  downloading: \"téléchargement\"\n  completed: \"terminé\"\n  failed: \"échoué\"\n  cancelled: \"annulé\"\n  settings: \"Paramètres\"\n  language: \"Langue\"\n  format: \"Format\"\n  quality: \"Qualité\"\n  twitter_auth: \"Auth Twitter\"\n  server_port: \"Port du serveur\"\n  max_concurrent: \"Max. simultanés\"\n  api_key: \"Clé API\"\n  webdav_servers: \"Serveurs WebDAV\"\n  add: \"Ajouter\"\n  delete: \"Supprimer\"\n  name: \"Nom\"\n  url: \"URL\"\n  username: \"Utilisateur\"\n  password: \"Mot de passe\"\n  no_webdav_servers: \"Aucun serveur WebDAV configuré\"\n  clear_history: \"Effacer\"\n  clear_all: \"Tout effacer\"\n  # WebDAV\n  webdav_browser: \"WebDAV\"\n  select_remote: \"Sélectionner distant\"\n  empty_directory: \"Répertoire vide\"\n  download_selected: \"Télécharger la sélection\"\n  selected_files: \"sélectionnés\"\n  loading: \"Chargement...\"\n  go_to_settings: \"Aller aux paramètres\"\n  # Torrent\n  torrent: \"BT/Magnet\"\n  torrent_hint: \"Collez un lien magnet ou une URL torrent...\"\n  torrent_submit: \"Envoyer\"\n  torrent_submitting: \"Envoi...\"\n  torrent_success: \"Torrent ajouté avec succès\"\n  torrent_not_configured: \"Client torrent non configuré. Configurez-le dans les paramètres.\"\n  torrent_settings: \"Client Torrent\"\n  torrent_client: \"Type de client\"\n  torrent_host: \"Hôte\"\n  torrent_test: \"Tester la connexion\"\n  torrent_testing: \"Test...\"\n  torrent_test_success: \"Connexion réussie\"\n  torrent_enabled: \"Activer Torrent\"\n  # Toast\n  download_queued: \"Téléchargement démarré. Vérifiez la progression sur la page Téléchargement.\"\n  downloads_queued: \"téléchargements démarrés. Vérifiez la progression sur la page Téléchargement.\"\n  # Podcast\n  podcast: \"Podcast\"\n  podcast_search: \"Rechercher\"\n  podcast_search_hint: \"Rechercher des podcasts ou épisodes...\"\n  podcast_searching: \"Recherche...\"\n  podcast_channels: \"Podcasts\"\n  podcast_episodes: \"Épisodes\"\n  podcast_no_results: \"Aucun résultat trouvé\"\n  podcast_episodes_count: \"épisodes\"\n  podcast_back: \"Retour\"\n  podcast_download_started: \"Téléchargement démarré\"\n  # AI\n  ai: \"IA\"\n  # Noms des étapes AI\n  # Détails des étapes AI\n  # Reconnaissance vocale locale\n  # Téléchargement de modèles\n  # API Token\n  token_title: \"Générateur de Token API\"\n  token_description: \"Générez des tokens JWT pour l'accès API externe (extension Chrome, scripts, etc.). Les tokens sont signés avec votre api_key configurée.\"\n  token_custom_payload: \"Charge personnalisée (optionnel)\"\n  token_custom_payload_hint: \"Ajoutez des claims personnalisés à inclure dans le token JWT. Doit être du JSON valide.\"\n  token_generate: \"Générer Token\"\n  token_generating: \"Génération...\"\n  token_generated: \"Token Généré\"\n  token_copy: \"Copier\"\n  token_copied: \"Copié !\"\n  token_usage: \"Utilisation\"\n  token_invalid_json: \"JSON invalide\"\n  # Historique\n  history: \"Historique\"\n  history_title: \"Historique des Téléchargements\"\n  history_empty: \"Aucun historique de téléchargement\"\n  history_empty_hint: \"Les téléchargements terminés apparaîtront ici\"\n  history_clear_all: \"Effacer Tout l'Historique\"\n  history_stats: \"Statistiques\"\n  history_total_downloaded: \"Total Téléchargé\"\n\n# Messages du serveur\nserver:\n  no_config_warning: \"Fichier de configuration introuvable. Utilisation des paramètres par défaut.\"\n  run_init_hint: \"Exécutez 'vget init' pour configurer vget de manière interactive.\"\n\n# Messages YouTube\nyoutube:\n  docker_required: \"Le téléchargement YouTube nécessite Docker.\"\n  docker_hint_server: \"Exécutez le serveur vget dans Docker (recommandé pour NAS) :\"\n  docker_hint_cli: \"Ou téléchargez directement via CLI :\"\n"
  },
  {
    "path": "internal/core/i18n/locales/jp.yml",
    "content": "# 日本語翻訳\n\nconfig:\n  step_of: \"ステップ %d / %d\"\n  language: \"言語\"\n  language_desc: \"メタデータの言語設定\"\n  output_dir: \"出力ディレクトリ\"\n  output_dir_desc: \"動画の保存先\"\n  format: \"フォーマット\"\n  format_desc: \"動画フォーマットの設定\"\n  quality: \"画質\"\n  quality_desc: \"動画の画質設定\"\n  confirm: \"確認\"\n  confirm_desc: \"設定を確認して保存\"\n  yes_save: \"はい、保存する\"\n  no_cancel: \"いいえ、キャンセル\"\n  best_available: \"最高画質\"\n  recommended: \"（推奨）\"\n\nconfig_review:\n  language: \"言語\"\n  output_dir: \"出力先\"\n  format: \"フォーマット\"\n  quality: \"画質\"\n\nhelp:\n  back: \"戻る\"\n  next: \"次へ\"\n  select: \"選択\"\n  confirm: \"確定\"\n  quit: \"終了\"\n\ndownload:\n  downloading: \"ダウンロード中\"\n  extracting: \"解析中\"\n  completed: \"完了\"\n  failed: \"失敗\"\n  progress: \"進捗\"\n  speed: \"速度\"\n  eta: \"残り時間\"\n  elapsed: \"経過時間\"\n  avg_speed: \"平均速度\"\n  file_saved: \"保存先\"\n  no_formats: \"利用可能なフォーマットがありません\"\n  select_format: \"フォーマットを選択\"\n  formats_available: \"利用可能なフォーマット\"\n  selected_format: \"フォーマット\"\n  quality_hint: \"-q で画質を選択できます。例：vget -q 720p <url>\"\n\nerrors:\n  config_not_found: \"設定ファイルが見つかりません\"\n  invalid_url: \"無効なURL\"\n  network_error: \"ネットワークエラー\"\n  extraction_failed: \"解析に失敗しました\"\n  download_failed: \"ダウンロードに失敗しました\"\n  no_extractor: \"このURLに対応する解析器がありません\"\n\nsearch:\n  results_for: \"検索結果\"\n  searching: \"検索中\"\n  fetching_episodes: \"エピソードを取得中\"\n  podcasts: \"ポッドキャスト\"\n  episodes: \"エピソード\"\n  select_hint: \"最大 %d 件まで選択できます\"\n  select_podcast_hint: \"ポッドキャストを選択してエピソードを表示\"\n  selected: \"選択中\"\n  help: \"↑/↓ 移動 • スペース 選択 • Enter ダウンロード • q 終了\"\n  help_podcast: \"↑/↓ 移動 • スペース 選択 • Enter 表示 • q 終了\"\n\ntwitter:\n  enter_auth_token: \"auth_tokenを入力してください\"\n  auth_saved: \"Twitter auth_tokenを保存しました。\"\n  auth_can_download: \"年齢制限のあるコンテンツをダウンロードできるようになりました。\"\n  auth_cleared: \"Twitter認証をクリアしました。\"\n  auth_required: \"auth_tokenは必須です\"\n  nsfw_login_required: \"年齢制限コンテンツにはログインが必要です。\"\n  protected_tweet: \"保護されたツイートには認証が必要です。\"\n  tweet_unavailable: \"ツイートは利用できません。\"\n  auth_hint: \"'vget config set twitter.auth_token <value>' を実行して auth token を設定してください。\"\n  deprecated_set: \"'vget config twitter set' は非推奨です。\"\n  deprecated_clear: \"'vget config twitter clear' は非推奨です。\"\n  deprecated_use_new: \"代わりに使用してください：vget config set twitter.auth_token <value>\"\n  deprecated_use_new_unset: \"代わりに使用してください：vget config unset twitter.auth_token\"\n\n# Web UI 翻訳\nui:\n  download_to: \"保存先：\"\n  edit: \"編集\"\n  save: \"保存\"\n  cancel: \"キャンセル\"\n  paste_url: \"URLを貼り付け...\"\n  download: \"ダウンロード\"\n  bulk_download: \"一括ダウンロード\"\n  coming_soon: \"近日公開\"\n  bulk_paste_urls: \"URLを貼り付け（1行に1つ）...\"\n  bulk_select_file: \"ファイル選択\"\n  bulk_drag_drop: \"または .txt ファイルをここにドロップ\"\n  bulk_url_count: \"件のURL\"\n  bulk_submit_all: \"全てダウンロード\"\n  bulk_submitting: \"送信中...\"\n  bulk_clear: \"クリア\"\n  bulk_invalid_hint: \"空行と # で始まる行は無視されます\"\n  adding: \"追加中...\"\n  jobs: \"ジョブ\"\n  total: \"件\"\n  no_downloads: \"ダウンロードはありません\"\n  paste_hint: \"上にURLを貼り付けて開始\"\n  queued: \"待機中\"\n  downloading: \"ダウンロード中\"\n  completed: \"完了\"\n  failed: \"失敗\"\n  cancelled: \"キャンセル済\"\n  settings: \"設定\"\n  language: \"言語\"\n  format: \"フォーマット\"\n  quality: \"画質\"\n  twitter_auth: \"Twitter認証\"\n  server_port: \"サーバーポート\"\n  max_concurrent: \"最大同時実行数\"\n  api_key: \"APIキー\"\n  webdav_servers: \"WebDAVサーバー\"\n  add: \"追加\"\n  delete: \"削除\"\n  name: \"名前\"\n  url: \"URL\"\n  username: \"ユーザー名\"\n  password: \"パスワード\"\n  no_webdav_servers: \"WebDAVサーバーは設定されていません\"\n  clear_history: \"クリア\"\n  clear_all: \"すべてクリア\"\n  # WebDAV\n  webdav_browser: \"WebDAV\"\n  select_remote: \"リモートを選択\"\n  empty_directory: \"空のディレクトリ\"\n  download_selected: \"選択をダウンロード\"\n  selected_files: \"選択済み\"\n  loading: \"読み込み中...\"\n  go_to_settings: \"設定へ\"\n  # トレント\n  torrent: \"BT/マグネット\"\n  torrent_hint: \"マグネットリンクまたはトレントURLを貼り付け...\"\n  torrent_submit: \"送信\"\n  torrent_submitting: \"送信中...\"\n  torrent_success: \"トレントが正常に追加されました\"\n  torrent_not_configured: \"トレントクライアントが設定されていません。設定で構成してください。\"\n  torrent_settings: \"トレントクライアント\"\n  torrent_client: \"クライアントタイプ\"\n  torrent_host: \"ホスト\"\n  torrent_test: \"接続テスト\"\n  torrent_testing: \"テスト中...\"\n  torrent_test_success: \"接続成功\"\n  torrent_enabled: \"トレントを有効にする\"\n  # Toast\n  download_queued: \"ダウンロードを開始しました。ダウンロードページで進捗を確認してください。\"\n  downloads_queued: \"件のダウンロードを開始しました。ダウンロードページで進捗を確認してください。\"\n  # Podcast\n  podcast: \"ポッドキャスト\"\n  podcast_search: \"検索\"\n  podcast_search_hint: \"ポッドキャストやエピソードを検索...\"\n  podcast_searching: \"検索中...\"\n  podcast_channels: \"ポッドキャスト\"\n  podcast_episodes: \"エピソード\"\n  podcast_no_results: \"結果が見つかりません\"\n  podcast_episodes_count: \"エピソード\"\n  podcast_back: \"戻る\"\n  podcast_download_started: \"ダウンロードを開始しました\"\n  # AI\n  ai: \"AI\"\n  # AI ステップ名\n  # AI ステップ詳細\n  # ローカル音声認識\n  # モデルダウンロード\n  # API Token\n  token_title: \"API Token 生成\"\n  token_description: \"外部 API アクセス用の JWT Token を生成します（Chrome 拡張機能、スクリプトなど）。Token は設定した api_key で署名されます。\"\n  token_custom_payload: \"カスタムペイロード（オプション）\"\n  token_custom_payload_hint: \"JWT Token に含めるカスタムクレームを追加します。有効な JSON である必要があります。\"\n  token_generate: \"Token を生成\"\n  token_generating: \"生成中...\"\n  token_generated: \"生成された Token\"\n  token_copy: \"コピー\"\n  token_copied: \"コピーしました！\"\n  token_usage: \"使用方法\"\n  token_invalid_json: \"無効な JSON\"\n  # 履歴\n  history: \"履歴\"\n  history_title: \"ダウンロード履歴\"\n  history_empty: \"ダウンロード履歴がありません\"\n  history_empty_hint: \"完了したダウンロードがここに表示されます\"\n  history_clear_all: \"すべての履歴を削除\"\n  history_stats: \"統計\"\n  history_total_downloaded: \"合計ダウンロード数\"\n\n# サーバーメッセージ\nserver:\n  no_config_warning: \"設定ファイルが見つかりません。デフォルト設定を使用します。\"\n  run_init_hint: \"'vget init' を実行して対話的に設定してください。\"\n\n# YouTube メッセージ\nyoutube:\n  docker_required: \"YouTubeのダウンロードにはDockerが必要です。\"\n  docker_hint_server: \"vget サーバーを Docker で実行（NAS ユーザー向け）：\"\n  docker_hint_cli: \"またはコマンドラインで直接ダウンロード：\"\n"
  },
  {
    "path": "internal/core/i18n/locales/kr.yml",
    "content": "# 한국어 번역\n\nconfig:\n  step_of: \"%d단계 / %d단계\"\n  language: \"언어\"\n  language_desc: \"메타데이터 언어 설정\"\n  output_dir: \"출력 디렉토리\"\n  output_dir_desc: \"동영상 저장 위치\"\n  format: \"형식\"\n  format_desc: \"선호하는 동영상 형식\"\n  quality: \"화질\"\n  quality_desc: \"선호하는 동영상 화질\"\n  confirm: \"확인\"\n  confirm_desc: \"설정을 검토하고 저장\"\n  yes_save: \"예, 저장\"\n  no_cancel: \"아니오, 취소\"\n  best_available: \"최고 화질\"\n  recommended: \"(권장)\"\n\nconfig_review:\n  language: \"언어\"\n  output_dir: \"출력 경로\"\n  format: \"형식\"\n  quality: \"화질\"\n\nhelp:\n  back: \"뒤로\"\n  next: \"다음\"\n  select: \"선택\"\n  confirm: \"확인\"\n  quit: \"종료\"\n\ndownload:\n  downloading: \"다운로드 중\"\n  extracting: \"추출 중\"\n  completed: \"완료\"\n  failed: \"실패\"\n  progress: \"진행률\"\n  speed: \"속도\"\n  eta: \"남은 시간\"\n  elapsed: \"경과 시간\"\n  avg_speed: \"평균 속도\"\n  file_saved: \"파일 저장됨\"\n  no_formats: \"사용 가능한 형식 없음\"\n  select_format: \"형식 선택\"\n  formats_available: \"사용 가능한 형식\"\n  selected_format: \"형식\"\n  quality_hint: \"-q로 화질을 선택하세요. 예: vget -q 720p <url>\"\n\nerrors:\n  config_not_found: \"설정 파일을 찾을 수 없습니다\"\n  invalid_url: \"잘못된 URL\"\n  network_error: \"네트워크 오류\"\n  extraction_failed: \"추출 실패\"\n  download_failed: \"다운로드 실패\"\n  no_extractor: \"이 URL에 대한 추출기를 찾을 수 없습니다\"\n\nsearch:\n  results_for: \"검색 결과\"\n  searching: \"검색 중\"\n  fetching_episodes: \"에피소드 불러오는 중\"\n  podcasts: \"팟캐스트\"\n  episodes: \"에피소드\"\n  select_hint: \"최대 %d개 선택 가능\"\n  select_podcast_hint: \"팟캐스트를 선택하여 에피소드 보기\"\n  selected: \"선택됨\"\n  help: \"↑/↓ 이동 • 스페이스 선택 • Enter 다운로드 • q 종료\"\n  help_podcast: \"↑/↓ 이동 • 스페이스 선택 • Enter 보기 • q 종료\"\n\ntwitter:\n  enter_auth_token: \"auth_token을 입력하세요\"\n  auth_saved: \"Twitter auth_token이 저장되었습니다.\"\n  auth_can_download: \"이제 연령 제한 콘텐츠를 다운로드할 수 있습니다.\"\n  auth_cleared: \"Twitter 인증이 삭제되었습니다.\"\n  auth_required: \"auth_token은 필수입니다\"\n  nsfw_login_required: \"연령 제한 콘텐츠는 로그인이 필요합니다.\"\n  protected_tweet: \"보호된 트윗은 인증이 필요합니다.\"\n  tweet_unavailable: \"트윗을 사용할 수 없습니다.\"\n  auth_hint: \"'vget config set twitter.auth_token <value>'을 실행하여 auth token을 설정하세요.\"\n  deprecated_set: \"'vget config twitter set'은 더 이상 사용되지 않습니다.\"\n  deprecated_clear: \"'vget config twitter clear'는 더 이상 사용되지 않습니다.\"\n  deprecated_use_new: \"대신 사용하세요: vget config set twitter.auth_token <value>\"\n  deprecated_use_new_unset: \"대신 사용하세요: vget config unset twitter.auth_token\"\n\n# Web UI 번역\nui:\n  download_to: \"저장 위치:\"\n  edit: \"편집\"\n  save: \"저장\"\n  cancel: \"취소\"\n  paste_url: \"URL을 붙여넣기...\"\n  download: \"다운로드\"\n  bulk_download: \"대량 다운로드\"\n  coming_soon: \"곧 출시 예정\"\n  bulk_paste_urls: \"URL을 붙여넣기 (한 줄에 하나)...\"\n  bulk_select_file: \"파일 선택\"\n  bulk_drag_drop: \"또는 .txt 파일을 여기에 드래그\"\n  bulk_url_count: \"개의 URL\"\n  bulk_submit_all: \"모두 다운로드\"\n  bulk_submitting: \"제출 중...\"\n  bulk_clear: \"지우기\"\n  bulk_invalid_hint: \"빈 줄과 #로 시작하는 줄은 무시됩니다\"\n  adding: \"추가 중...\"\n  jobs: \"작업\"\n  total: \"개\"\n  no_downloads: \"다운로드 없음\"\n  paste_hint: \"위에 URL을 붙여넣어 시작하세요\"\n  queued: \"대기 중\"\n  downloading: \"다운로드 중\"\n  completed: \"완료\"\n  failed: \"실패\"\n  cancelled: \"취소됨\"\n  settings: \"설정\"\n  language: \"언어\"\n  format: \"형식\"\n  quality: \"품질\"\n  twitter_auth: \"Twitter 인증\"\n  server_port: \"서버 포트\"\n  max_concurrent: \"최대 동시 실행\"\n  api_key: \"API 키\"\n  webdav_servers: \"WebDAV 서버\"\n  add: \"추가\"\n  delete: \"삭제\"\n  name: \"이름\"\n  url: \"URL\"\n  username: \"사용자 이름\"\n  password: \"비밀번호\"\n  no_webdav_servers: \"WebDAV 서버가 구성되지 않았습니다\"\n  clear_history: \"삭제\"\n  clear_all: \"모두 삭제\"\n  # WebDAV\n  webdav_browser: \"WebDAV\"\n  select_remote: \"원격 선택\"\n  empty_directory: \"빈 디렉토리\"\n  download_selected: \"선택 다운로드\"\n  selected_files: \"선택됨\"\n  loading: \"로딩 중...\"\n  go_to_settings: \"설정으로 이동\"\n  # 토렌트\n  torrent: \"BT/마그넷\"\n  torrent_hint: \"마그넷 링크 또는 토렌트 URL 붙여넣기...\"\n  torrent_submit: \"전송\"\n  torrent_submitting: \"전송 중...\"\n  torrent_success: \"토렌트가 성공적으로 추가되었습니다\"\n  torrent_not_configured: \"토렌트 클라이언트가 구성되지 않았습니다. 설정에서 구성하세요.\"\n  torrent_settings: \"토렌트 클라이언트\"\n  torrent_client: \"클라이언트 유형\"\n  torrent_host: \"호스트\"\n  torrent_test: \"연결 테스트\"\n  torrent_testing: \"테스트 중...\"\n  torrent_test_success: \"연결 성공\"\n  torrent_enabled: \"토렌트 활성화\"\n  # Toast\n  download_queued: \"다운로드가 시작되었습니다. 다운로드 페이지에서 진행 상황을 확인하세요.\"\n  downloads_queued: \"개의 다운로드가 시작되었습니다. 다운로드 페이지에서 진행 상황을 확인하세요.\"\n  # Podcast\n  podcast: \"팟캐스트\"\n  podcast_search: \"검색\"\n  podcast_search_hint: \"팟캐스트 또는 에피소드 검색...\"\n  podcast_searching: \"검색 중...\"\n  podcast_channels: \"팟캐스트\"\n  podcast_episodes: \"에피소드\"\n  podcast_no_results: \"결과를 찾을 수 없습니다\"\n  podcast_episodes_count: \"에피소드\"\n  podcast_back: \"뒤로\"\n  podcast_download_started: \"다운로드 시작됨\"\n  # AI\n  ai: \"AI\"\n  # AI 단계 이름\n  # AI 단계 상세\n  # 로컬 음성 인식\n  # 모델 다운로드\n  # API Token\n  token_title: \"API Token 생성기\"\n  token_description: \"외부 API 접근을 위한 JWT Token을 생성합니다 (Chrome 확장 프로그램, 스크립트 등). Token은 설정된 api_key로 서명됩니다.\"\n  token_custom_payload: \"사용자 정의 페이로드 (선택사항)\"\n  token_custom_payload_hint: \"JWT Token에 포함할 사용자 정의 클레임을 추가합니다. 유효한 JSON이어야 합니다.\"\n  token_generate: \"Token 생성\"\n  token_generating: \"생성 중...\"\n  token_generated: \"생성된 Token\"\n  token_copy: \"복사\"\n  token_copied: \"복사됨!\"\n  token_usage: \"사용법\"\n  token_invalid_json: \"잘못된 JSON\"\n  # 기록\n  history: \"기록\"\n  history_title: \"다운로드 기록\"\n  history_empty: \"다운로드 기록이 없습니다\"\n  history_empty_hint: \"완료된 다운로드가 여기에 표시됩니다\"\n  history_clear_all: \"모든 기록 삭제\"\n  history_stats: \"통계\"\n  history_total_downloaded: \"총 다운로드\"\n\n# 서버 메시지\nserver:\n  no_config_warning: \"설정 파일을 찾을 수 없습니다. 기본 설정을 사용합니다.\"\n  run_init_hint: \"'vget init'을 실행하여 대화형으로 설정하세요.\"\n\n# YouTube 메시지\nyoutube:\n  docker_required: \"YouTube 다운로드에는 Docker가 필요합니다.\"\n  docker_hint_server: \"Docker에서 vget 서버 실행 (NAS 사용자 권장):\"\n  docker_hint_cli: \"또는 CLI로 직접 다운로드:\"\n"
  },
  {
    "path": "internal/core/i18n/locales/zh.yml",
    "content": "# 中文翻译\n\nconfig:\n  step_of: \"第 %d 步，共 %d 步\"\n  language: \"语言\"\n  language_desc: \"元数据的首选语言\"\n  output_dir: \"输出目录\"\n  output_dir_desc: \"视频保存位置\"\n  format: \"格式\"\n  format_desc: \"首选视频格式\"\n  quality: \"画质\"\n  quality_desc: \"首选视频画质\"\n  confirm: \"确认\"\n  confirm_desc: \"检查并保存配置\"\n  yes_save: \"是，保存\"\n  no_cancel: \"否，取消\"\n  best_available: \"最佳可用\"\n  recommended: \"（推荐）\"\n\nconfig_review:\n  language: \"语言\"\n  output_dir: \"输出目录\"\n  format: \"格式\"\n  quality: \"画质\"\n\nhelp:\n  back: \"返回\"\n  next: \"下一步\"\n  select: \"选择\"\n  confirm: \"确认\"\n  quit: \"退出\"\n\ndownload:\n  downloading: \"下载中\"\n  extracting: \"解析中\"\n  completed: \"完成\"\n  failed: \"失败\"\n  progress: \"进度\"\n  speed: \"速度\"\n  eta: \"剩余时间\"\n  elapsed: \"耗时\"\n  avg_speed: \"平均速度\"\n  file_saved: \"文件已保存至\"\n  no_formats: \"没有可用格式\"\n  select_format: \"选择格式\"\n  formats_available: \"可用格式\"\n  selected_format: \"格式\"\n  quality_hint: \"使用 -q 选择画质，例如：vget -q 720p <url>\"\n\nerrors:\n  config_not_found: \"未找到配置文件\"\n  invalid_url: \"无效的URL\"\n  network_error: \"网络错误\"\n  extraction_failed: \"解析失败\"\n  download_failed: \"下载失败\"\n  no_extractor: \"没有找到适用于此URL的解析器\"\n\nsearch:\n  results_for: \"搜索结果\"\n  searching: \"搜索中\"\n  fetching_episodes: \"获取剧集\"\n  podcasts: \"播客节目\"\n  episodes: \"单集\"\n  select_hint: \"最多选择 %d 集\"\n  select_podcast_hint: \"选择节目查看剧集\"\n  selected: \"已选\"\n  help: \"↑/↓ 移动 • 空格 选择 • 回车 下载 • q 退出\"\n  help_podcast: \"↑/↓ 移动 • 空格 选择 • 回车 查看剧集 • q 退出\"\n\ntwitter:\n  enter_auth_token: \"请输入 auth_token\"\n  auth_saved: \"Twitter auth_token 已保存。\"\n  auth_can_download: \"现在可以下载年龄限制内容了。\"\n  auth_cleared: \"Twitter 认证已清除。\"\n  auth_required: \"auth_token 不能为空\"\n  nsfw_login_required: \"年龄限制内容需要登录。\"\n  protected_tweet: \"受保护的推文需要授权。\"\n  tweet_unavailable: \"推文不可用。\"\n  auth_hint: \"运行 'vget config set twitter.auth_token <value>' 设置 auth token。\"\n  deprecated_set: \"'vget config twitter set' 已弃用。\"\n  deprecated_clear: \"'vget config twitter clear' 已弃用。\"\n  deprecated_use_new: \"请使用：vget config set twitter.auth_token <value>\"\n  deprecated_use_new_unset: \"请使用：vget config unset twitter.auth_token\"\n\nsites:\n  configure_site: \"配置网站\"\n  domain_match: \"域名匹配 (例如 kanav.ad):\"\n  select_type: \"选择类型:\"\n  only_m3u8_for_now: \"(目前仅支持 m3u8)\"\n  existing_sites: \"已配置的网站 (./sites.yml):\"\n  site_added: \"网站已添加\"\n  saved_to: \"已保存到 ./sites.yml\"\n  cancelled: \"已取消\"\n  enter_confirm: \"回车确认\"\n  esc_cancel: \"esc 取消\"\n\n# Web UI 翻译\nui:\n  download_to: \"下载到：\"\n  edit: \"编辑\"\n  save: \"保存\"\n  cancel: \"取消\"\n  paste_url: \"粘贴下载链接...\"\n  download: \"下载\"\n  bulk_download: \"批量下载\"\n  coming_soon: \"即将推出\"\n  bulk_paste_urls: \"在此粘贴链接（每行一个）...\"\n  bulk_select_file: \"选择文件\"\n  bulk_drag_drop: \"或拖放 .txt 文件到此处\"\n  bulk_url_count: \"个链接\"\n  bulk_submit_all: \"全部下载\"\n  bulk_submitting: \"提交中...\"\n  bulk_clear: \"清空\"\n  bulk_invalid_hint: \"空行和以 # 开头的行会被忽略\"\n  adding: \"添加中...\"\n  jobs: \"任务\"\n  total: \"个\"\n  no_downloads: \"暂无下载任务\"\n  paste_hint: \"在上方粘贴链接开始下载\"\n  queued: \"排队中\"\n  downloading: \"下载中\"\n  completed: \"已完成\"\n  failed: \"失败\"\n  cancelled: \"已取消\"\n  settings: \"设置\"\n  language: \"语言\"\n  format: \"格式\"\n  quality: \"质量\"\n  twitter_auth: \"Twitter 认证\"\n  server_port: \"服务端口\"\n  max_concurrent: \"最大并发\"\n  api_key: \"API 密钥\"\n  webdav_servers: \"WebDAV 服务器\"\n  add: \"添加\"\n  delete: \"删除\"\n  name: \"名称\"\n  url: \"地址\"\n  username: \"用户名\"\n  password: \"密码\"\n  no_webdav_servers: \"未配置 WebDAV 服务器\"\n  clear_history: \"清除\"\n  clear_all: \"全部清除\"\n  # WebDAV\n  webdav_browser: \"WebDAV\"\n  select_remote: \"选择远程\"\n  empty_directory: \"空目录\"\n  download_selected: \"下载选中\"\n  selected_files: \"已选中\"\n  loading: \"加载中...\"\n  go_to_settings: \"前往设置\"\n  # 磁力下载\n  torrent: \"磁力下载\"\n  torrent_hint: \"粘贴磁力链接或种子URL...\"\n  torrent_submit: \"发送\"\n  torrent_submitting: \"发送中...\"\n  torrent_success: \"种子添加成功\"\n  torrent_not_configured: \"未配置下载客户端，请先在设置中配置。\"\n  torrent_settings: \"下载客户端\"\n  torrent_client: \"客户端类型\"\n  torrent_host: \"地址\"\n  torrent_test: \"测试连接\"\n  torrent_testing: \"测试中...\"\n  torrent_test_success: \"连接成功\"\n  torrent_enabled: \"启用磁力下载\"\n  # Toast\n  download_queued: \"下载任务已创建，请在下载页查看进度\"\n  downloads_queued: \"个下载任务已创建，请在下载页查看进度\"\n  # Podcast\n  podcast: \"播客\"\n  podcast_search: \"搜索\"\n  podcast_search_hint: \"搜索播客节目或单集...\"\n  podcast_searching: \"搜索中...\"\n  podcast_channels: \"播客节目\"\n  podcast_episodes: \"单集\"\n  podcast_no_results: \"未找到结果\"\n  podcast_episodes_count: \"集\"\n  podcast_back: \"返回\"\n  podcast_download_started: \"下载已开始\"\n  # AI\n  ai: \"AI\"\n  # AI 步骤名称\n  # AI 步骤详情\n  # 本地语音转文字\n  # 模型下载\n  # API Token\n  token_title: \"API Token 生成器\"\n  token_description: \"生成用于外部 API 访问的 JWT Token（Chrome 扩展、脚本等）。Token 使用您配置的 api_key 签名。\"\n  token_custom_payload: \"自定义字段（可选）\"\n  token_custom_payload_hint: \"添加自定义声明到 JWT Token 中。必须是有效的 JSON。\"\n  token_generate: \"生成 Token\"\n  token_generating: \"生成中...\"\n  token_generated: \"生成的 Token\"\n  token_copy: \"复制\"\n  token_copied: \"已复制！\"\n  token_usage: \"使用方法\"\n  token_invalid_json: \"无效的 JSON\"\n  # 历史记录\n  history: \"历史记录\"\n  history_title: \"下载历史\"\n  history_empty: \"暂无下载历史\"\n  history_empty_hint: \"完成的下载将显示在这里\"\n  history_clear_all: \"清空所有历史\"\n  history_stats: \"统计\"\n  history_total_downloaded: \"总下载量\"\n\n# 服务器消息\nserver:\n  no_config_warning: \"未找到配置文件，使用默认设置。\"\n  run_init_hint: \"点击「设置」进行配置，或在终端运行 'vget init'。\"\n\n# YouTube 消息\nyoutube:\n  docker_required: \"YouTube 下载需要在 Docker 中运行。\"\n  docker_hint_server: \"部署 vget 服务器（推荐 NAS 用户）：\"\n  docker_hint_cli: \"或直接通过命令行下载：\"\n"
  },
  {
    "path": "internal/core/site/bilibili/auth.go",
    "content": "package bilibili\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n)\n\n// Auth handles Bilibili authentication via QR code or cookie\ntype Auth struct {\n\tclient *http.Client\n}\n\n// QRSession holds the QR code login session data\ntype QRSession struct {\n\tURL       string // QR code content URL (to be encoded as QR)\n\tQRCodeKey string // Key for polling status\n}\n\n// QRStatus represents the status of QR code login\ntype QRStatus int\n\nconst (\n\tQRWaiting   QRStatus = 86101 // Not scanned yet\n\tQRScanned   QRStatus = 86090 // Scanned, waiting for confirmation\n\tQRExpired   QRStatus = 86038 // QR code expired\n\tQRConfirmed QRStatus = 0     // Login successful\n)\n\n// Credentials stores the login credentials\ntype Credentials struct {\n\tSESSDATA   string\n\tBiliJCT    string\n\tDedeUserID string\n}\n\n// NewAuth creates a new Auth instance\nfunc NewAuth() *Auth {\n\treturn &Auth{\n\t\tclient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// GenerateQRCode requests a new QR code for login\nfunc (a *Auth) GenerateQRCode() (*QRSession, error) {\n\tapi := \"https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header\"\n\n\treq, err := http.NewRequest(\"GET\", api, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.setHeaders(req)\n\n\tresp, err := a.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar result struct {\n\t\tCode    int    `json:\"code\"`\n\t\tMessage string `json:\"message\"`\n\t\tData    struct {\n\t\t\tURL       string `json:\"url\"`\n\t\t\tQRCodeKey string `json:\"qrcode_key\"`\n\t\t} `json:\"data\"`\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif result.Code != 0 {\n\t\treturn nil, fmt.Errorf(\"API error: %s (code: %d)\", result.Message, result.Code)\n\t}\n\n\treturn &QRSession{\n\t\tURL:       result.Data.URL,\n\t\tQRCodeKey: result.Data.QRCodeKey,\n\t}, nil\n}\n\n// PollQRStatus checks the status of QR code login\n// Returns the status code, credentials (on success), and any error\nfunc (a *Auth) PollQRStatus(qrcodeKey string) (QRStatus, *Credentials, error) {\n\tapi := fmt.Sprintf(\"https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=%s&source=main-fe-header\",\n\t\turl.QueryEscape(qrcodeKey))\n\n\treq, err := http.NewRequest(\"GET\", api, nil)\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\ta.setHeaders(req)\n\n\tresp, err := a.client.Do(req)\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\n\tvar result struct {\n\t\tCode    int    `json:\"code\"`\n\t\tMessage string `json:\"message\"`\n\t\tData    struct {\n\t\t\tURL          string `json:\"url\"`\n\t\t\tRefreshToken string `json:\"refresh_token\"`\n\t\t\tTimestamp    int64  `json:\"timestamp\"`\n\t\t\tCode         int    `json:\"code\"`\n\t\t\tMessage      string `json:\"message\"`\n\t\t} `json:\"data\"`\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn 0, nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tstatus := QRStatus(result.Data.Code)\n\n\t// If login confirmed, extract credentials from the URL\n\tif status == QRConfirmed && result.Data.URL != \"\" {\n\t\tcreds, err := a.parseCredentialsFromURL(result.Data.URL)\n\t\tif err != nil {\n\t\t\treturn status, nil, fmt.Errorf(\"failed to parse credentials: %w\", err)\n\t\t}\n\t\treturn status, creds, nil\n\t}\n\n\treturn status, nil, nil\n}\n\n// parseCredentialsFromURL extracts SESSDATA, bili_jct, DedeUserID from the callback URL\nfunc (a *Auth) parseCredentialsFromURL(urlStr string) (*Credentials, error) {\n\tparsed, err := url.Parse(urlStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tquery := parsed.Query()\n\tcreds := &Credentials{\n\t\tSESSDATA:   query.Get(\"SESSDATA\"),\n\t\tBiliJCT:    query.Get(\"bili_jct\"),\n\t\tDedeUserID: query.Get(\"DedeUserID\"),\n\t}\n\n\tif creds.SESSDATA == \"\" {\n\t\treturn nil, fmt.Errorf(\"SESSDATA not found in response\")\n\t}\n\n\treturn creds, nil\n}\n\n// SaveCredentials saves credentials to config file\nfunc (a *Auth) SaveCredentials(creds *Credentials) error {\n\tcfg := config.LoadOrDefault()\n\tcfg.Bilibili.Cookie = creds.ToCookieString()\n\treturn config.Save(cfg)\n}\n\n// ToCookieString converts credentials to cookie format\nfunc (c *Credentials) ToCookieString() string {\n\treturn fmt.Sprintf(\"SESSDATA=%s; bili_jct=%s; DedeUserID=%s\",\n\t\tc.SESSDATA, c.BiliJCT, c.DedeUserID)\n}\n\n// LoadCredentials loads saved credentials from config\nfunc (a *Auth) LoadCredentials() *Credentials {\n\tcfg := config.LoadOrDefault()\n\tif cfg.Bilibili.Cookie == \"\" {\n\t\treturn nil\n\t}\n\n\treturn ParseCookieString(cfg.Bilibili.Cookie)\n}\n\n// ParseCookieString parses a cookie string into credentials\nfunc ParseCookieString(cookie string) *Credentials {\n\tcreds := &Credentials{}\n\n\tfor part := range strings.SplitSeq(cookie, \";\") {\n\t\tpart = strings.TrimSpace(part)\n\t\tif val, ok := strings.CutPrefix(part, \"SESSDATA=\"); ok {\n\t\t\tcreds.SESSDATA = val\n\t\t} else if val, ok := strings.CutPrefix(part, \"bili_jct=\"); ok {\n\t\t\tcreds.BiliJCT = val\n\t\t} else if val, ok := strings.CutPrefix(part, \"DedeUserID=\"); ok {\n\t\t\tcreds.DedeUserID = val\n\t\t}\n\t}\n\n\treturn creds\n}\n\n// ValidateCredentials checks if credentials are valid by calling user info API\nfunc (a *Auth) ValidateCredentials(creds *Credentials) (string, error) {\n\tapi := \"https://api.bilibili.com/x/web-interface/nav\"\n\n\treq, err := http.NewRequest(\"GET\", api, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\ta.setHeaders(req)\n\treq.Header.Set(\"Cookie\", creds.ToCookieString())\n\n\tresp, err := a.client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar result struct {\n\t\tCode    int    `json:\"code\"`\n\t\tMessage string `json:\"message\"`\n\t\tData    struct {\n\t\t\tIsLogin bool   `json:\"isLogin\"`\n\t\t\tUName   string `json:\"uname\"`\n\t\t\tMid     int64  `json:\"mid\"`\n\t\t} `json:\"data\"`\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif result.Code != 0 {\n\t\treturn \"\", fmt.Errorf(\"API error: %s (code: %d)\", result.Message, result.Code)\n\t}\n\n\tif !result.Data.IsLogin {\n\t\treturn \"\", fmt.Errorf(\"credentials are invalid or expired\")\n\t}\n\n\treturn result.Data.UName, nil\n}\n\n// String returns a human-readable status string\nfunc (s QRStatus) String() string {\n\tswitch s {\n\tcase QRWaiting:\n\t\treturn \"等待扫码\"\n\tcase QRScanned:\n\t\treturn \"扫码成功，请在手机上确认\"\n\tcase QRExpired:\n\t\treturn \"二维码已过期\"\n\tcase QRConfirmed:\n\t\treturn \"登录成功\"\n\tdefault:\n\t\treturn fmt.Sprintf(\"未知状态: %d\", s)\n\t}\n}\n\n// setHeaders sets common request headers\nfunc (a *Auth) setHeaders(req *http.Request) {\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Referer\", \"https://www.bilibili.com/\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n}\n"
  },
  {
    "path": "internal/core/tracker/kuaidi100.go",
    "content": "package tracker\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\tKuaidi100APIURL         = \"https://poll.kuaidi100.com/poll/query.do\"\n\tKuaidi100AutoNumberURL  = \"http://www.kuaidi100.com/autonumber/auto\"\n\tKuaidi100DeliveryTimeURL = \"https://api.kuaidi100.com/label/order?method=time\"\n)\n\n// Kuaidi100Config holds the API credentials\ntype Kuaidi100Config struct {\n\tKey      string // Authorization key (授权key)\n\tCustomer string // Customer ID (查询公司编号)\n\tSecret   string // Secret for delivery time API (授权secret)\n}\n\n// Kuaidi100Tracker implements package tracking via kuaidi100.com API\ntype Kuaidi100Tracker struct {\n\tconfig Kuaidi100Config\n\tclient *http.Client\n}\n\n// NewKuaidi100Tracker creates a new tracker instance\nfunc NewKuaidi100Tracker(key, customer string) *Kuaidi100Tracker {\n\treturn &Kuaidi100Tracker{\n\t\tconfig: Kuaidi100Config{\n\t\t\tKey:      key,\n\t\t\tCustomer: customer,\n\t\t},\n\t\tclient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// NewKuaidi100TrackerWithSecret creates a new tracker instance with secret for delivery time API\nfunc NewKuaidi100TrackerWithSecret(key, customer, secret string) *Kuaidi100Tracker {\n\treturn &Kuaidi100Tracker{\n\t\tconfig: Kuaidi100Config{\n\t\t\tKey:      key,\n\t\t\tCustomer: customer,\n\t\t\tSecret:   secret,\n\t\t},\n\t\tclient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// SetSecret sets the secret for delivery time API\nfunc (t *Kuaidi100Tracker) SetSecret(secret string) {\n\tt.config.Secret = secret\n}\n\n// QueryParam represents the query parameters for kuaidi100 API\ntype QueryParam struct {\n\tCom      string `json:\"com\"`               // Courier company code (e.g., \"yuantong\", \"shunfeng\")\n\tNum      string `json:\"num\"`               // Tracking number\n\tPhone    string `json:\"phone,omitempty\"`   // Phone number (required for some couriers like SF)\n\tFrom     string `json:\"from,omitempty\"`    // Origin city\n\tTo       string `json:\"to,omitempty\"`      // Destination city\n\tResultv2 string `json:\"resultv2\"`          // Enable district parsing (1=enabled)\n\tShow     string `json:\"show\"`              // Response format: 0=json, 1=xml, 2=html, 3=text\n\tOrder    string `json:\"order\"`             // Sort order: desc (newest first), asc (oldest first)\n}\n\n// TrackingResponse represents the API response\ntype TrackingResponse struct {\n\tMessage   string         `json:\"message\"`   // Error message if any\n\tState     string         `json:\"state\"`     // Tracking state code\n\tStatus    string         `json:\"status\"`    // Status code (200=success)\n\tCondition string         `json:\"condition\"` // Current condition\n\tIsCheck   string         `json:\"ischeck\"`   // Whether delivered (1=yes)\n\tCom       string         `json:\"com\"`       // Courier company code\n\tNu        string         `json:\"nu\"`        // Tracking number\n\tData      []TrackingData `json:\"data\"`      // Tracking events\n}\n\n// TrackingData represents a single tracking event\ntype TrackingData struct {\n\tContext    string `json:\"context\"`    // Event description\n\tTime       string `json:\"time\"`       // Event time (formatted)\n\tFtime      string `json:\"ftime\"`      // Event time (formatted, alternative)\n\tStatus     string `json:\"status\"`     // Status at this point\n\tAreaCode   string `json:\"areaCode\"`   // Area code\n\tAreaName   string `json:\"areaName\"`   // Area name\n\tAreaCenter string `json:\"areaCenter\"` // Area center coordinates\n\tLocation   string `json:\"location\"`   // Location description\n}\n\n// StateDescription returns human-readable state description\nfunc (r *TrackingResponse) StateDescription() string {\n\tstates := map[string]string{\n\t\t\"0\":  \"在途\",       // In transit\n\t\t\"1\":  \"揽收\",       // Picked up\n\t\t\"2\":  \"疑难\",       // Problem\n\t\t\"3\":  \"已签收\",      // Delivered\n\t\t\"4\":  \"退签\",       // Rejected\n\t\t\"5\":  \"派件中\",      // Out for delivery\n\t\t\"6\":  \"退回\",       // Returned\n\t\t\"7\":  \"转投\",       // Redirected\n\t\t\"10\": \"待清关\",      // Pending customs\n\t\t\"11\": \"清关中\",      // Customs processing\n\t\t\"12\": \"已清关\",      // Customs cleared\n\t\t\"13\": \"清关异常\",     // Customs exception\n\t\t\"14\": \"收件人拒签\",    // Recipient refused\n\t}\n\tif desc, ok := states[r.State]; ok {\n\t\treturn desc\n\t}\n\treturn \"未知状态\"\n}\n\n// IsDelivered returns true if package has been delivered\nfunc (r *TrackingResponse) IsDelivered() bool {\n\treturn r.IsCheck == \"1\" || r.State == \"3\"\n}\n\n// Track queries the tracking info for a package\nfunc (t *Kuaidi100Tracker) Track(courierCode, trackingNumber string) (*TrackingResponse, error) {\n\treturn t.TrackWithPhone(courierCode, trackingNumber, \"\")\n}\n\n// TrackWithPhone queries tracking info with phone number (required for some couriers like SF Express)\nfunc (t *Kuaidi100Tracker) TrackWithPhone(courierCode, trackingNumber, phone string) (*TrackingResponse, error) {\n\t// Build query parameters\n\tparam := QueryParam{\n\t\tCom:      courierCode,\n\t\tNum:      trackingNumber,\n\t\tPhone:    phone,\n\t\tResultv2: \"1\",\n\t\tShow:     \"0\", // JSON format\n\t\tOrder:    \"desc\",\n\t}\n\n\tparamJSON, err := json.Marshal(param)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal params: %w\", err)\n\t}\n\n\t// Calculate sign: MD5(param + key + customer) -> uppercase\n\tsignStr := string(paramJSON) + t.config.Key + t.config.Customer\n\thash := md5.Sum([]byte(signStr))\n\tsign := strings.ToUpper(hex.EncodeToString(hash[:]))\n\n\t// Build POST data\n\tformData := url.Values{}\n\tformData.Set(\"customer\", t.config.Customer)\n\tformData.Set(\"param\", string(paramJSON))\n\tformData.Set(\"sign\", sign)\n\n\t// Send request\n\tresp, err := t.client.PostForm(Kuaidi100APIURL, formData)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse response\n\tvar result TrackingResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w (body: %s)\", err, string(body))\n\t}\n\n\t// Check for API errors\n\tif result.Status != \"200\" && result.Status != \"\" {\n\t\treturn nil, fmt.Errorf(\"API error: %s\", result.Message)\n\t}\n\n\treturn &result, nil\n}\n\n// AutoNumberResponse represents the auto number detection API response\ntype AutoNumberResponse struct {\n\tComCode  string `json:\"comCode\"`  // Courier company code\n\tNoCount  int    `json:\"noCount\"`  // Match count\n\tNoPre    string `json:\"noPre\"`    // Number prefix\n\tStartTime string `json:\"startTime\"` // Start time\n}\n\n// AutoNumber detects the courier company from a tracking number\n// Returns a list of possible courier codes\nfunc (t *Kuaidi100Tracker) AutoNumber(trackingNumber string) ([]AutoNumberResponse, error) {\n\tformData := url.Values{}\n\tformData.Set(\"key\", t.config.Key)\n\tformData.Set(\"num\", trackingNumber)\n\n\tresp, err := t.client.PostForm(Kuaidi100AutoNumberURL, formData)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tvar result []AutoNumberResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w (body: %s)\", err, string(body))\n\t}\n\n\treturn result, nil\n}\n\n// DeliveryTimeParam represents the parameters for delivery time estimation\ntype DeliveryTimeParam struct {\n\tKuaidicom string `json:\"kuaidicom\"`         // Courier company code\n\tFrom      string `json:\"from\"`              // Origin address (must include 3+ levels, e.g., 广东深圳市南山区)\n\tTo        string `json:\"to\"`                // Destination address (must include 3+ levels)\n\tOrderTime string `json:\"orderTime\"`         // Order time, format: yyyy-MM-dd HH:mm:ss\n\tExpType   string `json:\"expType,omitempty\"` // Product type (e.g., 特惠送, 标快)\n}\n\n// DeliveryTimeResponse represents the delivery time estimation API response\ntype DeliveryTimeResponse struct {\n\tResult     bool   `json:\"result\"`     // Whether successful\n\tReturnCode string `json:\"returnCode\"` // Return code\n\tMessage    string `json:\"message\"`    // Error message if any\n\tData       *DeliveryTimeData `json:\"data,omitempty\"`\n}\n\n// DeliveryTimeData contains the estimated delivery time info\ntype DeliveryTimeData struct {\n\tArriveTime     string `json:\"arriveTime\"`     // Estimated arrival time\n\tSortingName    string `json:\"sortingName\"`    // Sorting center name\n\tPickupTime     string `json:\"pickupTime\"`     // Estimated pickup time\n\tHour           string `json:\"hour\"`           // Estimated hours\n\tDay            string `json:\"day\"`            // Estimated days\n\tExpectTime     string `json:\"expectTime\"`     // Expected delivery time range\n\tSecondDayArrive bool   `json:\"secondDayArrive\"` // Whether arrives next day\n}\n\n// EstimateDeliveryTime estimates the delivery time for a shipment\n// Requires secret to be configured\nfunc (t *Kuaidi100Tracker) EstimateDeliveryTime(param DeliveryTimeParam) (*DeliveryTimeResponse, error) {\n\tif t.config.Secret == \"\" {\n\t\treturn nil, fmt.Errorf(\"secret is required for delivery time estimation, set express.kuaidi100.secret in config\")\n\t}\n\n\t// Set default order time if not provided\n\tif param.OrderTime == \"\" {\n\t\tparam.OrderTime = time.Now().Format(\"2006-01-02 15:04:05\")\n\t}\n\n\tparamJSON, err := json.Marshal(param)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal params: %w\", err)\n\t}\n\n\t// Get current timestamp in milliseconds\n\ttimestamp := fmt.Sprintf(\"%d\", time.Now().UnixMilli())\n\n\t// Calculate sign: MD5(param + t + key + secret) -> uppercase\n\tsignStr := string(paramJSON) + timestamp + t.config.Key + t.config.Secret\n\thash := md5.Sum([]byte(signStr))\n\tsign := strings.ToUpper(hex.EncodeToString(hash[:]))\n\n\t// Build POST data\n\tformData := url.Values{}\n\tformData.Set(\"param\", string(paramJSON))\n\tformData.Set(\"key\", t.config.Key)\n\tformData.Set(\"t\", timestamp)\n\tformData.Set(\"sign\", sign)\n\n\tresp, err := t.client.PostForm(Kuaidi100DeliveryTimeURL, formData)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tvar result DeliveryTimeResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w (body: %s)\", err, string(body))\n\t}\n\n\tif !result.Result {\n\t\treturn nil, fmt.Errorf(\"API error: %s (code: %s)\", result.Message, result.ReturnCode)\n\t}\n\n\treturn &result, nil\n}\n\n// CourierInfo contains courier code and name\ntype CourierInfo struct {\n\tCode string // kuaidi100 API code\n\tName string // Chinese name\n}\n\n// CourierCodes maps aliases/codes to kuaidi100 courier info\n// Source: https://github.com/simman/Kuaidi100 and https://www.kuaidi100.com/all/\nvar CourierCodes = map[string]CourierInfo{\n\t// 顺丰\n\t\"sf\":       {Code: \"shunfeng\", Name: \"顺丰速运\"},\n\t\"shunfeng\": {Code: \"shunfeng\", Name: \"顺丰速运\"},\n\t\"顺丰\":      {Code: \"shunfeng\", Name: \"顺丰速运\"},\n\n\t// 圆通\n\t\"yt\":       {Code: \"yuantong\", Name: \"圆通速递\"},\n\t\"yuantong\": {Code: \"yuantong\", Name: \"圆通速递\"},\n\t\"圆通\":      {Code: \"yuantong\", Name: \"圆通速递\"},\n\n\t// 申通\n\t\"sto\":      {Code: \"shentong\", Name: \"申通快递\"},\n\t\"shentong\": {Code: \"shentong\", Name: \"申通快递\"},\n\t\"申通\":      {Code: \"shentong\", Name: \"申通快递\"},\n\n\t// 中通\n\t\"zto\":       {Code: \"zhongtong\", Name: \"中通快递\"},\n\t\"zhongtong\": {Code: \"zhongtong\", Name: \"中通快递\"},\n\t\"中通\":       {Code: \"zhongtong\", Name: \"中通快递\"},\n\n\t// 韵达\n\t\"yd\":    {Code: \"yunda\", Name: \"韵达快递\"},\n\t\"yunda\": {Code: \"yunda\", Name: \"韵达快递\"},\n\t\"韵达\":   {Code: \"yunda\", Name: \"韵达快递\"},\n\n\t// 极兔\n\t\"jt\":        {Code: \"jtexpress\", Name: \"极兔速递\"},\n\t\"jitu\":      {Code: \"jtexpress\", Name: \"极兔速递\"},\n\t\"jtexpress\": {Code: \"jtexpress\", Name: \"极兔速递\"},\n\t\"极兔\":       {Code: \"jtexpress\", Name: \"极兔速递\"},\n\n\t// 京东\n\t\"jd\":   {Code: \"jd\", Name: \"京东物流\"},\n\t\"京东\":  {Code: \"jd\", Name: \"京东物流\"},\n\n\t// EMS\n\t\"ems\": {Code: \"ems\", Name: \"EMS\"},\n\n\t// 邮政\n\t\"yzgn\":   {Code: \"youzhengguonei\", Name: \"邮政国内\"},\n\t\"youzheng\": {Code: \"youzhengguonei\", Name: \"邮政国内\"},\n\t\"邮政\":    {Code: \"youzhengguonei\", Name: \"邮政国内\"},\n\n\t// 德邦\n\t\"dbwl\":       {Code: \"debangwuliu\", Name: \"德邦物流\"},\n\t\"debang\":     {Code: \"debangwuliu\", Name: \"德邦物流\"},\n\t\"debangwuliu\": {Code: \"debangwuliu\", Name: \"德邦物流\"},\n\t\"德邦\":        {Code: \"debangwuliu\", Name: \"德邦物流\"},\n\n\t// 安能\n\t\"anneng\":     {Code: \"annengwuliu\", Name: \"安能物流\"},\n\t\"annengwuliu\": {Code: \"annengwuliu\", Name: \"安能物流\"},\n\t\"安能\":        {Code: \"annengwuliu\", Name: \"安能物流\"},\n\n\t// 百世/汇通\n\t\"best\":         {Code: \"huitongkuaidi\", Name: \"百世快递\"},\n\t\"huitong\":      {Code: \"huitongkuaidi\", Name: \"百世快递\"},\n\t\"huitongkuaidi\": {Code: \"huitongkuaidi\", Name: \"百世快递\"},\n\t\"百世\":          {Code: \"huitongkuaidi\", Name: \"百世快递\"},\n\n\t// 跨越\n\t\"kuayue\": {Code: \"kuayue\", Name: \"跨越速运\"},\n\t\"跨越\":    {Code: \"kuayue\", Name: \"跨越速运\"},\n\n\t// 国际快递\n\t\"ups\":   {Code: \"ups\", Name: \"UPS\"},\n\t\"fedex\": {Code: \"fedex\", Name: \"FedEx\"},\n\t\"dhl\":   {Code: \"dhl\", Name: \"DHL\"},\n\t\"tnt\":   {Code: \"tnt\", Name: \"TNT\"},\n\t\"usps\":  {Code: \"usps\", Name: \"USPS\"},\n\n\t// 菜鸟\n\t\"cainiao\": {Code: \"cainiao\", Name: \"菜鸟\"},\n\t\"菜鸟\":     {Code: \"cainiao\", Name: \"菜鸟\"},\n}\n\n// GetCourierCode returns the kuaidi100 courier code for an alias\nfunc GetCourierCode(alias string) string {\n\talias = strings.ToLower(alias)\n\tif info, ok := CourierCodes[alias]; ok {\n\t\treturn info.Code\n\t}\n\t// Return as-is if no alias found (might be direct kuaidi100 code)\n\treturn alias\n}\n\n// GetCourierInfo returns the courier info for an alias, or nil if not found\nfunc GetCourierInfo(alias string) *CourierInfo {\n\talias = strings.ToLower(alias)\n\tif info, ok := CourierCodes[alias]; ok {\n\t\treturn &info\n\t}\n\treturn nil\n}\n\n// ListCouriers returns a list of common courier codes for display\nfunc ListCouriers() []CourierInfo {\n\t// Return deduplicated list of common couriers\n\treturn []CourierInfo{\n\t\t{Code: \"shunfeng\", Name: \"顺丰速运 (sf)\"},\n\t\t{Code: \"yuantong\", Name: \"圆通速递 (yt)\"},\n\t\t{Code: \"shentong\", Name: \"申通快递 (sto)\"},\n\t\t{Code: \"zhongtong\", Name: \"中通快递 (zto)\"},\n\t\t{Code: \"yunda\", Name: \"韵达快递 (yd)\"},\n\t\t{Code: \"jtexpress\", Name: \"极兔速递 (jt)\"},\n\t\t{Code: \"jd\", Name: \"京东物流 (jd)\"},\n\t\t{Code: \"ems\", Name: \"EMS (ems)\"},\n\t\t{Code: \"youzhengguonei\", Name: \"邮政国内 (yzgn)\"},\n\t\t{Code: \"debangwuliu\", Name: \"德邦物流 (dbwl)\"},\n\t\t{Code: \"annengwuliu\", Name: \"安能物流 (anneng)\"},\n\t\t{Code: \"huitongkuaidi\", Name: \"百世快递 (best)\"},\n\t\t{Code: \"kuayue\", Name: \"跨越速运 (kuayue)\"},\n\t\t{Code: \"ups\", Name: \"UPS (ups)\"},\n\t\t{Code: \"fedex\", Name: \"FedEx (fedex)\"},\n\t\t{Code: \"dhl\", Name: \"DHL (dhl)\"},\n\t}\n}\n"
  },
  {
    "path": "internal/core/version/version.go",
    "content": "package version\n\nvar (\n\tVersion = \"0.13.5\"\n\tCommit  = \"unknown\"\n\tDate    = \"2026-03-14\"\n)\n"
  },
  {
    "path": "internal/core/webdav/client.go",
    "content": "package webdav\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/emersion/go-webdav\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n)\n\n// Client wraps go-webdav client with convenience methods\ntype Client struct {\n\tclient   *webdav.Client\n\tbaseURL  string\n\tusername string\n\tpassword string\n}\n\n// FileInfo contains information about a remote file\ntype FileInfo struct {\n\tName string\n\tPath string\n\tSize int64\n\tIsDir bool\n}\n\n// NewClient creates a new WebDAV client\n// URL format: webdav://user:pass@host/path or https://user:pass@host/path\nfunc NewClient(rawURL string) (*Client, error) {\n\tparsed, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid URL: %w\", err)\n\t}\n\n\t// Convert webdav:// to https://\n\tscheme := parsed.Scheme\n\tif scheme == \"webdav\" {\n\t\tscheme = \"https\"\n\t} else if scheme == \"webdav+http\" {\n\t\tscheme = \"http\"\n\t}\n\n\t// Build base URL without credentials and path\n\tbaseURL := fmt.Sprintf(\"%s://%s\", scheme, parsed.Host)\n\n\t// Extract credentials and create HTTP client\n\tvar httpClient webdav.HTTPClient\n\tif parsed.User != nil {\n\t\tusername := parsed.User.Username()\n\t\tpassword, _ := parsed.User.Password()\n\t\thttpClient = webdav.HTTPClientWithBasicAuth(nil, username, password)\n\t}\n\n\tclient, err := webdav.NewClient(httpClient, baseURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t}\n\n\tvar username, password string\n\tif parsed.User != nil {\n\t\tusername = parsed.User.Username()\n\t\tpassword, _ = parsed.User.Password()\n\t}\n\n\treturn &Client{\n\t\tclient:   client,\n\t\tbaseURL:  baseURL,\n\t\tusername: username,\n\t\tpassword: password,\n\t}, nil\n}\n\n// ParseURL extracts the file path from a WebDAV URL\nfunc ParseURL(rawURL string) (string, error) {\n\tparsed, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn parsed.Path, nil\n}\n\n// Stat returns information about a file\nfunc (c *Client) Stat(ctx context.Context, filePath string) (*FileInfo, error) {\n\tinfo, err := c.client.Stat(ctx, filePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to stat %s: %w\", filePath, err)\n\t}\n\n\treturn &FileInfo{\n\t\tName:  path.Base(info.Path),\n\t\tPath:  info.Path,\n\t\tSize:  info.Size,\n\t\tIsDir: info.IsDir,\n\t}, nil\n}\n\n// List returns the contents of a directory\nfunc (c *Client) List(ctx context.Context, dirPath string) ([]FileInfo, error) {\n\tinfos, err := c.client.ReadDir(ctx, dirPath, false)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list %s: %w\", dirPath, err)\n\t}\n\n\t// Normalize dirPath for comparison\n\tnormalizedDir := strings.TrimSuffix(dirPath, \"/\")\n\tif normalizedDir == \"\" {\n\t\tnormalizedDir = \"/\"\n\t}\n\n\tresult := make([]FileInfo, 0, len(infos))\n\tfor _, info := range infos {\n\t\t// Skip the directory itself (some WebDAV servers include it)\n\t\tinfoPath := strings.TrimSuffix(info.Path, \"/\")\n\t\tif infoPath == normalizedDir || infoPath == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := path.Base(info.Path)\n\t\t// Skip entries with empty names or just \".\"\n\t\tif name == \"\" || name == \".\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tresult = append(result, FileInfo{\n\t\t\tName:  name,\n\t\t\tPath:  info.Path,\n\t\t\tSize:  info.Size,\n\t\t\tIsDir: info.IsDir,\n\t\t})\n\t}\n\treturn result, nil\n}\n\n// Open opens a file for reading and returns the reader and file size\nfunc (c *Client) Open(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {\n\t// First get the file size\n\tinfo, err := c.client.Stat(ctx, filePath)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"failed to stat %s: %w\", filePath, err)\n\t}\n\n\tif info.IsDir {\n\t\treturn nil, 0, fmt.Errorf(\"%s is a directory\", filePath)\n\t}\n\n\treader, err := c.client.Open(ctx, filePath)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"failed to open %s: %w\", filePath, err)\n\t}\n\n\treturn reader, info.Size, nil\n}\n\n// IsWebDAVURL checks if a URL is a WebDAV URL or a remote path (remote:path)\nfunc IsWebDAVURL(rawURL string) bool {\n\treturn strings.HasPrefix(rawURL, \"webdav://\") ||\n\t\tstrings.HasPrefix(rawURL, \"webdav+http://\") ||\n\t\tIsRemotePath(rawURL)\n}\n\n// IsRemotePath checks if the URL is a remote path format (e.g., \"pikpak:/path/to/file\")\nfunc IsRemotePath(rawURL string) bool {\n\t// Check for remote:path format (not a URL scheme like http://)\n\tif idx := strings.Index(rawURL, \":\"); idx > 0 {\n\t\tprefix := rawURL[:idx]\n\t\t// Make sure it's not a URL scheme (no slashes after colon at position idx+1)\n\t\tif idx+1 < len(rawURL) && rawURL[idx+1] != '/' {\n\t\t\treturn true\n\t\t}\n\t\t// Also match remote:/path (single slash for absolute path)\n\t\tif idx+2 < len(rawURL) && rawURL[idx+1] == '/' && rawURL[idx+2] != '/' {\n\t\t\treturn true\n\t\t}\n\t\t// Check if prefix looks like a remote name (no dots, not a known scheme)\n\t\tif !strings.Contains(prefix, \".\") &&\n\t\t\tprefix != \"http\" && prefix != \"https\" &&\n\t\t\tprefix != \"webdav\" && prefix != \"webdav+http\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ParseRemotePath parses a remote path like \"pikpak:/path/to/file\" into remote name and path\nfunc ParseRemotePath(remotePath string) (remoteName, filePath string, err error) {\n\tidx := strings.Index(remotePath, \":\")\n\tif idx <= 0 {\n\t\treturn \"\", \"\", fmt.Errorf(\"invalid remote path format: %s\", remotePath)\n\t}\n\tremoteName = remotePath[:idx]\n\tfilePath = remotePath[idx+1:]\n\n\t// Ensure path starts with /\n\tif !strings.HasPrefix(filePath, \"/\") {\n\t\tfilePath = \"/\" + filePath\n\t}\n\n\treturn remoteName, filePath, nil\n}\n\n// NewClientFromConfig creates a WebDAV client from a configured server\nfunc NewClientFromConfig(server *config.WebDAVServer) (*Client, error) {\n\tvar httpClient webdav.HTTPClient\n\tif server.Username != \"\" {\n\t\thttpClient = webdav.HTTPClientWithBasicAuth(nil, server.Username, server.Password)\n\t}\n\n\tclient, err := webdav.NewClient(httpClient, server.URL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t}\n\n\treturn &Client{\n\t\tclient:   client,\n\t\tbaseURL:  server.URL,\n\t\tusername: server.Username,\n\t\tpassword: server.Password,\n\t}, nil\n}\n\n// ExtractFilename extracts the filename from a WebDAV path\nfunc ExtractFilename(filePath string) string {\n\treturn path.Base(filePath)\n}\n\n// GetFileURL returns the full HTTP URL for a file path\nfunc (c *Client) GetFileURL(filePath string) string {\n\t// Ensure path starts with /\n\tif !strings.HasPrefix(filePath, \"/\") {\n\t\tfilePath = \"/\" + filePath\n\t}\n\treturn c.baseURL + filePath\n}\n\n// GetAuthHeader returns the Basic Auth header value if credentials are set\nfunc (c *Client) GetAuthHeader() string {\n\tif c.username == \"\" {\n\t\treturn \"\"\n\t}\n\tauth := c.username + \":\" + c.password\n\treturn \"Basic \" + base64.StdEncoding.EncodeToString([]byte(auth))\n}\n\n// SupportsRangeRequests checks if the server supports HTTP Range requests for a file\nfunc (c *Client) SupportsRangeRequests(ctx context.Context, filePath string) (bool, error) {\n\tfileURL := c.GetFileURL(filePath)\n\n\treq, err := http.NewRequestWithContext(ctx, \"HEAD\", fileURL, nil)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\tif auth := c.GetAuthHeader(); auth != \"\" {\n\t\treq.Header.Set(\"Authorization\", auth)\n\t}\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tresp.Body.Close()\n\n\treturn resp.Header.Get(\"Accept-Ranges\") == \"bytes\", nil\n}\n"
  },
  {
    "path": "internal/server/auth.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\nconst (\n\t// SessionCookieName is the name of the session cookie\n\tSessionCookieName = \"vget_session\"\n\t// SessionDuration is the duration for session tokens (24 hours)\n\tSessionDuration = 24 * time.Hour\n\t// APITokenDuration is the duration for API tokens (1 year)\n\tAPITokenDuration = 365 * 24 * time.Hour\n)\n\n// JWTClaims represents the claims in a JWT token\ntype JWTClaims struct {\n\tTokenType string         `json:\"type\"` // \"session\" or \"api\"\n\tCustom    map[string]any `json:\"custom,omitempty\"`\n\tjwt.RegisteredClaims\n}\n\n// GenerateTokenRequest is the request body for POST /api/auth/token\ntype GenerateTokenRequest struct {\n\tPayload map[string]any `json:\"payload,omitempty\"`\n}\n\n// generateJWT creates a new JWT token signed with the api_key\nfunc (s *Server) generateJWT(tokenType string, duration time.Duration, customPayload map[string]any) (string, error) {\n\tnow := time.Now()\n\tclaims := JWTClaims{\n\t\tTokenType: tokenType,\n\t\tCustom:    customPayload,\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tExpiresAt: jwt.NewNumericDate(now.Add(duration)),\n\t\t\tIssuedAt:  jwt.NewNumericDate(now),\n\t\t\tNotBefore: jwt.NewNumericDate(now),\n\t\t\tIssuer:    \"vget\",\n\t\t},\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\treturn token.SignedString([]byte(s.apiKey))\n}\n\n// validateJWT validates a JWT token and returns the claims\nfunc (s *Server) validateJWT(tokenString string) (*JWTClaims, error) {\n\ttoken, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) {\n\t\treturn []byte(s.apiKey), nil\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {\n\t\treturn claims, nil\n\t}\n\n\treturn nil, jwt.ErrSignatureInvalid\n}\n\n// jwtAuthMiddleware handles authentication via session cookie or Bearer token\nfunc (s *Server) jwtAuthMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpath := c.Request.URL.Path\n\n\t\t// Only API routes require auth - skip static files and SPA routes\n\t\tif !strings.HasPrefix(path, \"/api/\") {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Health endpoint doesn't require auth\n\t\tif path == \"/api/health\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Auth endpoints don't require auth\n\t\tif strings.HasPrefix(path, \"/api/auth/\") {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// If no api_key configured, allow all requests\n\t\tif s.apiKey == \"\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Check for session cookie first\n\t\tif cookie, err := c.Cookie(SessionCookieName); err == nil {\n\t\t\tif _, err := s.validateJWT(cookie); err == nil {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// Check for Bearer token in Authorization header\n\t\tauthHeader := c.GetHeader(\"Authorization\")\n\t\tif token, found := strings.CutPrefix(authHeader, \"Bearer \"); found {\n\t\t\tif _, err := s.validateJWT(token); err == nil {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// No valid authentication\n\t\tc.JSON(http.StatusUnauthorized, Response{\n\t\t\tCode:    401,\n\t\t\tData:    nil,\n\t\t\tMessage: \"unauthorized: valid session or API token required\",\n\t\t})\n\t\tc.Abort()\n\t}\n}\n\n// setSessionCookie sets a session cookie for web UI access\nfunc (s *Server) setSessionCookie(c *gin.Context) {\n\t// Only set cookie if api_key is configured\n\tif s.apiKey == \"\" {\n\t\treturn\n\t}\n\n\t// Check if valid session cookie already exists\n\tif cookie, err := c.Cookie(SessionCookieName); err == nil {\n\t\tif _, err := s.validateJWT(cookie); err == nil {\n\t\t\treturn // Valid cookie exists, no need to set new one\n\t\t}\n\t}\n\n\t// Generate new session token\n\ttoken, err := s.generateJWT(\"session\", SessionDuration, nil)\n\tif err != nil {\n\t\treturn // Silently fail, user can still use API token\n\t}\n\n\t// Set cookie\n\tc.SetCookie(\n\t\tSessionCookieName,\n\t\ttoken,\n\t\tint(SessionDuration.Seconds()),\n\t\t\"/\",\n\t\t\"\",    // domain - empty means current domain\n\t\tfalse, // secure - false to allow HTTP\n\t\ttrue,  // httpOnly - prevent JS access\n\t)\n}\n\n// handleAuthStatus returns whether api_key is configured\nfunc (s *Server) handleAuthStatus(c *gin.Context) {\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"api_key_configured\": s.apiKey != \"\",\n\t\t},\n\t\tMessage: \"auth status retrieved\",\n\t})\n}\n\n// handleGenerateToken generates a new API token for external use\n// Always returns HTTP 200, with status indicated in response body\nfunc (s *Server) handleGenerateToken(c *gin.Context) {\n\tif s.apiKey == \"\" {\n\t\tc.JSON(http.StatusOK, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: \"API KEY is not configured\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Parse optional custom payload from request body\n\tvar req GenerateTokenRequest\n\t// Ignore binding errors - payload is optional\n\t_ = c.ShouldBindJSON(&req)\n\n\ttoken, err := s.generateJWT(\"api\", APITokenDuration, req.Payload)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: \"failed to generate token\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 201,\n\t\tData: gin.H{\n\t\t\t\"jwt\": token,\n\t\t},\n\t\tMessage: \"JWT Token generated\",\n\t})\n}\n"
  },
  {
    "path": "internal/server/bilibili.go",
    "content": "package server\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/site/bilibili\"\n)\n\n// handleBilibiliQRGenerate generates a new QR code for Bilibili login\nfunc (s *Server) handleBilibiliQRGenerate(c *gin.Context) {\n\tauth := bilibili.NewAuth()\n\n\tsession, err := auth.GenerateQRCode()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"url\":        session.URL,\n\t\t\t\"qrcode_key\": session.QRCodeKey,\n\t\t},\n\t\tMessage: \"QR code generated\",\n\t})\n}\n\n// handleBilibiliQRPoll polls the status of QR code login\nfunc (s *Server) handleBilibiliQRPoll(c *gin.Context) {\n\tqrcodeKey := c.Query(\"qrcode_key\")\n\tif qrcodeKey == \"\" {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"qrcode_key is required\",\n\t\t})\n\t\treturn\n\t}\n\n\tauth := bilibili.NewAuth()\n\tstatus, creds, err := auth.PollQRStatus(qrcodeKey)\n\tlog.Printf(\"[Bilibili] Poll status: %d (%s), creds: %v, err: %v\", status, status.String(), creds != nil, err)\n\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Build response\n\tdata := gin.H{\n\t\t\"status\":      int(status),\n\t\t\"status_text\": status.String(),\n\t}\n\n\t// If login confirmed, save credentials and return success\n\tif status == bilibili.QRConfirmed && creds != nil {\n\t\tlog.Printf(\"[Bilibili] Login confirmed! Saving credentials...\")\n\t\tif err := auth.SaveCredentials(creds); err != nil {\n\t\t\tlog.Printf(\"[Bilibili] Failed to save credentials: %v\", err)\n\t\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\t\tCode:    500,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: \"failed to save credentials: \" + err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// Try to get username\n\t\tusername, validateErr := auth.ValidateCredentials(creds)\n\t\tlog.Printf(\"[Bilibili] Validate result: username=%s, err=%v\", username, validateErr)\n\t\tif username == \"\" {\n\t\t\tusername = creds.DedeUserID\n\t\t}\n\n\t\tdata[\"logged_in\"] = true\n\t\tdata[\"username\"] = username\n\n\t\t// Update server's cached config\n\t\ts.cfg = config.LoadOrDefault()\n\t\tlog.Printf(\"[Bilibili] Login successful for user: %s\", username)\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    data,\n\t\tMessage: status.String(),\n\t})\n}\n\n// handleBilibiliStatus returns the current Bilibili login status\nfunc (s *Server) handleBilibiliStatus(c *gin.Context) {\n\tcfg := config.LoadOrDefault()\n\n\tif cfg.Bilibili.Cookie == \"\" {\n\t\tc.JSON(http.StatusOK, Response{\n\t\t\tCode: 200,\n\t\t\tData: gin.H{\n\t\t\t\t\"logged_in\": false,\n\t\t\t},\n\t\t\tMessage: \"not logged in\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Parse and validate credentials\n\tcreds := bilibili.ParseCookieString(cfg.Bilibili.Cookie)\n\tif creds.SESSDATA == \"\" {\n\t\tc.JSON(http.StatusOK, Response{\n\t\t\tCode: 200,\n\t\t\tData: gin.H{\n\t\t\t\t\"logged_in\": false,\n\t\t\t},\n\t\t\tMessage: \"invalid cookie\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Try to validate and get username\n\tauth := bilibili.NewAuth()\n\tusername, err := auth.ValidateCredentials(creds)\n\tif err != nil {\n\t\t// Cookie exists but validation failed (might be expired)\n\t\tc.JSON(http.StatusOK, Response{\n\t\t\tCode: 200,\n\t\t\tData: gin.H{\n\t\t\t\t\"logged_in\": false,\n\t\t\t\t\"error\":     err.Error(),\n\t\t\t},\n\t\t\tMessage: \"cookie expired or invalid\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"logged_in\": true,\n\t\t\t\"username\":  username,\n\t\t},\n\t\tMessage: \"logged in\",\n\t})\n}\n"
  },
  {
    "path": "internal/server/embed.go",
    "content": "package server\n\nimport (\n\t\"embed\"\n\t\"io/fs\"\n)\n\n//go:embed all:dist\nvar distFS embed.FS\n\n// GetDistFS returns the embedded dist filesystem\n// Returns nil if dist directory doesn't exist (dev mode)\nfunc GetDistFS() fs.FS {\n\tsubFS, err := fs.Sub(distFS, \"dist\")\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn subFS\n}\n"
  },
  {
    "path": "internal/server/history.go",
    "content": "package server\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t_ \"modernc.org/sqlite\"\n)\n\nconst historyDBFile = \"history.db\"\n\n// HistoryRecord represents a completed download in history\ntype HistoryRecord struct {\n\tID          string `json:\"id\"`\n\tURL         string `json:\"url\"`\n\tFilename    string `json:\"filename\"`\n\tStatus      string `json:\"status\"` // \"completed\" or \"failed\"\n\tSizeBytes   int64  `json:\"size_bytes\"`\n\tStartedAt   int64  `json:\"started_at\"`   // Unix timestamp\n\tCompletedAt int64  `json:\"completed_at\"` // Unix timestamp\n\tDuration    int64  `json:\"duration_seconds\"`\n\tError       string `json:\"error,omitempty\"`\n}\n\n// HistoryDB manages SQLite database for download history\ntype HistoryDB struct {\n\tdb *sql.DB\n\tmu sync.RWMutex\n}\n\n// NewHistoryDB creates and initializes the history database\nfunc NewHistoryDB() (*HistoryDB, error) {\n\t// Get config directory\n\tconfigDir, err := config.ConfigDir()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get config dir: %w\", err)\n\t}\n\n\t// Ensure config directory exists\n\tif err := os.MkdirAll(configDir, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create config dir: %w\", err)\n\t}\n\n\tdbPath := filepath.Join(configDir, historyDBFile)\n\n\t// Open database\n\tdb, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to open history database: %w\", err)\n\t}\n\n\t// Create table if not exists (using INTEGER for timestamps)\n\t_, err = db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS download_history (\n\t\t\tid TEXT PRIMARY KEY,\n\t\t\turl TEXT NOT NULL,\n\t\t\tfilename TEXT,\n\t\t\tstatus TEXT NOT NULL,\n\t\t\tsize_bytes INTEGER DEFAULT 0,\n\t\t\tstarted_at INTEGER NOT NULL,\n\t\t\tcompleted_at INTEGER NOT NULL,\n\t\t\tduration_seconds INTEGER DEFAULT 0,\n\t\t\terror_message TEXT\n\t\t);\n\t\tCREATE INDEX IF NOT EXISTS idx_completed_at ON download_history(completed_at DESC);\n\t\tCREATE INDEX IF NOT EXISTS idx_status ON download_history(status);\n\t`)\n\tif err != nil {\n\t\tdb.Close()\n\t\treturn nil, fmt.Errorf(\"failed to create history table: %w\", err)\n\t}\n\n\treturn &HistoryDB{db: db}, nil\n}\n\n// Close closes the database connection\nfunc (h *HistoryDB) Close() error {\n\tif h.db != nil {\n\t\treturn h.db.Close()\n\t}\n\treturn nil\n}\n\n// RecordJob saves a completed or failed job to history\nfunc (h *HistoryDB) RecordJob(job *Job) error {\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\n\tduration := int64(job.UpdatedAt.Sub(job.CreatedAt).Seconds())\n\n\t_, err := h.db.Exec(`\n\t\tINSERT OR REPLACE INTO download_history\n\t\t(id, url, filename, status, size_bytes, started_at, completed_at, duration_seconds, error_message)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`,\n\t\tjob.ID,\n\t\tjob.URL,\n\t\tjob.Filename,\n\t\tstring(job.Status),\n\t\tjob.Total,\n\t\tjob.CreatedAt.Unix(),\n\t\tjob.UpdatedAt.Unix(),\n\t\tduration,\n\t\tjob.Error,\n\t)\n\n\treturn err\n}\n\n// GetHistory returns download history with pagination\nfunc (h *HistoryDB) GetHistory(limit, offset int) ([]HistoryRecord, int, error) {\n\th.mu.RLock()\n\tdefer h.mu.RUnlock()\n\n\t// Get total count\n\tvar total int\n\terr := h.db.QueryRow(\"SELECT COUNT(*) FROM download_history\").Scan(&total)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"failed to count history: %w\", err)\n\t}\n\n\t// Get records\n\trows, err := h.db.Query(`\n\t\tSELECT id, url, filename, status, size_bytes, started_at, completed_at, duration_seconds, error_message\n\t\tFROM download_history\n\t\tORDER BY completed_at DESC\n\t\tLIMIT ? OFFSET ?\n\t`, limit, offset)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"failed to query history: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\trecords := make([]HistoryRecord, 0)\n\tfor rows.Next() {\n\t\tvar r HistoryRecord\n\t\tvar errorMsg sql.NullString\n\t\tvar startedAt, completedAt int64\n\n\t\terr := rows.Scan(\n\t\t\t&r.ID,\n\t\t\t&r.URL,\n\t\t\t&r.Filename,\n\t\t\t&r.Status,\n\t\t\t&r.SizeBytes,\n\t\t\t&startedAt,\n\t\t\t&completedAt,\n\t\t\t&r.Duration,\n\t\t\t&errorMsg,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, 0, fmt.Errorf(\"failed to scan history row: %w\", err)\n\t\t}\n\n\t\tr.StartedAt = startedAt\n\t\tr.CompletedAt = completedAt\n\t\tif errorMsg.Valid {\n\t\t\tr.Error = errorMsg.String\n\t\t}\n\t\trecords = append(records, r)\n\t}\n\n\treturn records, total, nil\n}\n\n// GetStats returns download statistics\nfunc (h *HistoryDB) GetStats() (completed int, failed int, totalBytes int64, err error) {\n\th.mu.RLock()\n\tdefer h.mu.RUnlock()\n\n\terr = h.db.QueryRow(`\n\t\tSELECT\n\t\t\tCOUNT(CASE WHEN status = 'completed' THEN 1 END),\n\t\t\tCOUNT(CASE WHEN status = 'failed' THEN 1 END),\n\t\t\tCOALESCE(SUM(CASE WHEN status = 'completed' THEN size_bytes ELSE 0 END), 0)\n\t\tFROM download_history\n\t`).Scan(&completed, &failed, &totalBytes)\n\n\treturn\n}\n\n// DeleteRecord deletes a single history record\nfunc (h *HistoryDB) DeleteRecord(id string) error {\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\n\tresult, err := h.db.Exec(\"DELETE FROM download_history WHERE id = ?\", id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trows, _ := result.RowsAffected()\n\tif rows == 0 {\n\t\treturn fmt.Errorf(\"record not found\")\n\t}\n\n\treturn nil\n}\n\n// ClearHistory deletes all history records\nfunc (h *HistoryDB) ClearHistory() (int64, error) {\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\n\tresult, err := h.db.Exec(\"DELETE FROM download_history\")\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn result.RowsAffected()\n}\n"
  },
  {
    "path": "internal/server/job.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"log\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/guiyumin/vget/internal/core/extractor\"\n)\n\n// JobStatus represents the current state of a download job\ntype JobStatus string\n\nconst (\n\tJobStatusQueued      JobStatus = \"queued\"\n\tJobStatusDownloading JobStatus = \"downloading\"\n\tJobStatusCompleted   JobStatus = \"completed\"\n\tJobStatusFailed      JobStatus = \"failed\"\n\tJobStatusCancelled   JobStatus = \"cancelled\"\n)\n\n// Job represents a download job\ntype Job struct {\n\tID         string    `json:\"id\"`\n\tURL        string    `json:\"url\"`\n\tFilename   string    `json:\"filename,omitempty\"`\n\tStatus     JobStatus `json:\"status\"`\n\tProgress   float64   `json:\"progress\"`\n\tDownloaded int64     `json:\"downloaded\"` // bytes downloaded\n\tTotal      int64     `json:\"total\"`      // total bytes (-1 if unknown)\n\tError      string    `json:\"error,omitempty\"`\n\tCreatedAt  time.Time `json:\"created_at\"`\n\tUpdatedAt  time.Time `json:\"updated_at\"`\n\n\t// Internal fields (not serialized)\n\tcancel context.CancelFunc `json:\"-\"`\n\tctx    context.Context    `json:\"-\"`\n}\n\n// JobQueue manages download jobs with a worker pool\ntype JobQueue struct {\n\tjobs          map[string]*Job\n\tmu            sync.RWMutex\n\tqueue         chan *Job\n\tmaxConcurrent int\n\toutputDir     string\n\tdownloadFn    DownloadFunc\n\twg            sync.WaitGroup\n\tcleanupTicker *time.Ticker\n\tstopCleanup   chan struct{}\n\thistoryDB     *HistoryDB // Optional: for persisting download history\n}\n\n// DownloadFunc is the function signature for downloading a URL\n// It receives the job context, URL, output path, and a progress callback\ntype DownloadFunc func(ctx context.Context, url, outputPath string, progressFn func(downloaded, total int64)) error\n\n// NewJobQueue creates a new job queue with the specified concurrency\nfunc NewJobQueue(maxConcurrent int, outputDir string, downloadFn DownloadFunc) *JobQueue {\n\tif maxConcurrent <= 0 {\n\t\tmaxConcurrent = 10\n\t}\n\n\tjq := &JobQueue{\n\t\tjobs:          make(map[string]*Job),\n\t\tqueue:         make(chan *Job, 100),\n\t\tmaxConcurrent: maxConcurrent,\n\t\toutputDir:     outputDir,\n\t\tdownloadFn:    downloadFn,\n\t\tstopCleanup:   make(chan struct{}),\n\t\thistoryDB:     nil,\n\t}\n\n\treturn jq\n}\n\n// SetHistoryDB sets the history database for persisting completed downloads\nfunc (jq *JobQueue) SetHistoryDB(db *HistoryDB) {\n\tjq.historyDB = db\n}\n\n// Start begins the worker pool and cleanup routine\nfunc (jq *JobQueue) Start() {\n\t// Start workers\n\tfor i := 0; i < jq.maxConcurrent; i++ {\n\t\tjq.wg.Add(1)\n\t\tgo jq.worker()\n\t}\n\n\t// Start cleanup routine (every 10 minutes, remove jobs older than 1 hour)\n\tjq.cleanupTicker = time.NewTicker(10 * time.Minute)\n\tgo jq.cleanupLoop()\n}\n\n// Stop gracefully shuts down the job queue\nfunc (jq *JobQueue) Stop() {\n\tclose(jq.queue)\n\tclose(jq.stopCleanup)\n\tif jq.cleanupTicker != nil {\n\t\tjq.cleanupTicker.Stop()\n\t}\n\tjq.wg.Wait()\n}\n\nfunc (jq *JobQueue) worker() {\n\tdefer jq.wg.Done()\n\n\tfor job := range jq.queue {\n\t\tjq.processJob(job)\n\t}\n}\n\nfunc (jq *JobQueue) processJob(job *Job) {\n\tjq.updateJobStatus(job.ID, JobStatusDownloading, 0, \"\")\n\n\t// Create progress callback\n\tprogressFn := func(downloaded, total int64) {\n\t\tjq.updateJobProgressBytes(job.ID, downloaded, total)\n\t}\n\n\t// Execute download\n\terr := jq.downloadFn(job.ctx, job.URL, job.Filename, progressFn)\n\n\tif err != nil {\n\t\tif job.ctx.Err() == context.Canceled {\n\t\t\tjq.updateJobStatus(job.ID, JobStatusCancelled, 0, \"cancelled by user\")\n\t\t} else {\n\t\t\tjq.updateJobStatus(job.ID, JobStatusFailed, 0, err.Error())\n\t\t}\n\t\tjq.recordJobToHistory(job.ID)\n\t\treturn\n\t}\n\n\tjq.updateJobStatus(job.ID, JobStatusCompleted, 100, \"\")\n\tjq.recordJobToHistory(job.ID)\n}\n\n// recordJobToHistory saves a completed/failed job to the history database\nfunc (jq *JobQueue) recordJobToHistory(id string) {\n\tif jq.historyDB == nil {\n\t\treturn\n\t}\n\n\tjq.mu.RLock()\n\tjob, ok := jq.jobs[id]\n\tif !ok {\n\t\tjq.mu.RUnlock()\n\t\treturn\n\t}\n\t// Make a copy to avoid holding the lock during DB write\n\tjobCopy := *job\n\tjq.mu.RUnlock()\n\n\t// Only record completed or failed jobs\n\tif jobCopy.Status == JobStatusCompleted || jobCopy.Status == JobStatusFailed {\n\t\tif err := jq.historyDB.RecordJob(&jobCopy); err != nil {\n\t\t\t// Log error but don't fail the job\n\t\t\tlog.Printf(\"Warning: failed to record job to history: %v\", err)\n\t\t}\n\t}\n}\n\nfunc (jq *JobQueue) cleanupLoop() {\n\tfor {\n\t\tselect {\n\t\tcase <-jq.cleanupTicker.C:\n\t\t\tjq.cleanupOldJobs()\n\t\tcase <-jq.stopCleanup:\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (jq *JobQueue) cleanupOldJobs() {\n\tjq.mu.Lock()\n\tdefer jq.mu.Unlock()\n\n\tcutoff := time.Now().Add(-1 * time.Hour)\n\tfor id, job := range jq.jobs {\n\t\t// Only cleanup completed, failed, or cancelled jobs older than 1 hour\n\t\tif (job.Status == JobStatusCompleted || job.Status == JobStatusFailed || job.Status == JobStatusCancelled) &&\n\t\t\tjob.UpdatedAt.Before(cutoff) {\n\t\t\tdelete(jq.jobs, id)\n\t\t}\n\t}\n}\n\n// ClearHistory removes all completed, failed, and cancelled jobs\nfunc (jq *JobQueue) ClearHistory() int {\n\tjq.mu.Lock()\n\tdefer jq.mu.Unlock()\n\n\tcount := 0\n\tfor id, job := range jq.jobs {\n\t\tif job.Status == JobStatusCompleted || job.Status == JobStatusFailed || job.Status == JobStatusCancelled {\n\t\t\tdelete(jq.jobs, id)\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\n// RemoveJob removes a single completed, failed, or cancelled job by ID\nfunc (jq *JobQueue) RemoveJob(id string) bool {\n\tjq.mu.Lock()\n\tdefer jq.mu.Unlock()\n\n\tjob, ok := jq.jobs[id]\n\tif !ok {\n\t\treturn false\n\t}\n\n\t// Can only remove completed, failed, or cancelled jobs\n\tif job.Status != JobStatusCompleted && job.Status != JobStatusFailed && job.Status != JobStatusCancelled {\n\t\treturn false\n\t}\n\n\tdelete(jq.jobs, id)\n\treturn true\n}\n\n// AddFailedJob creates a job that immediately fails with the given error\nfunc (jq *JobQueue) AddFailedJob(rawURL, errorMsg string) *Job {\n\tid, _ := generateJobID()\n\n\tjob := &Job{\n\t\tID:        id,\n\t\tURL:       rawURL,\n\t\tStatus:    JobStatusFailed,\n\t\tError:     errorMsg,\n\t\tProgress:  0,\n\t\tCreatedAt: time.Now(),\n\t\tUpdatedAt: time.Now(),\n\t}\n\n\tjq.mu.Lock()\n\tjq.jobs[id] = job\n\tjq.mu.Unlock()\n\n\treturn job\n}\n\n// AddJob creates and queues a new download job\nfunc (jq *JobQueue) AddJob(rawURL, filename string) (*Job, error) {\n\t// Normalize URL: add https:// if missing\n\turl, err := extractor.NormalizeURL(rawURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tid, err := generateJobID()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate job ID: %w\", err)\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tjob := &Job{\n\t\tID:        id,\n\t\tURL:       url,\n\t\tFilename:  filename,\n\t\tStatus:    JobStatusQueued,\n\t\tProgress:  0,\n\t\tCreatedAt: time.Now(),\n\t\tUpdatedAt: time.Now(),\n\t\tctx:       ctx,\n\t\tcancel:    cancel,\n\t}\n\n\tjq.mu.Lock()\n\tjq.jobs[id] = job\n\tjq.mu.Unlock()\n\n\t// Queue the job (non-blocking with buffered channel)\n\tselect {\n\tcase jq.queue <- job:\n\t\treturn job, nil\n\tdefault:\n\t\t// Queue is full\n\t\tjq.mu.Lock()\n\t\tdelete(jq.jobs, id)\n\t\tjq.mu.Unlock()\n\t\tcancel()\n\t\treturn nil, fmt.Errorf(\"job queue is full\")\n\t}\n}\n\n// GetJob returns a job by ID\nfunc (jq *JobQueue) GetJob(id string) *Job {\n\tjq.mu.RLock()\n\tdefer jq.mu.RUnlock()\n\n\tif job, ok := jq.jobs[id]; ok {\n\t\t// Return a copy to avoid race conditions\n\t\tjobCopy := *job\n\t\treturn &jobCopy\n\t}\n\treturn nil\n}\n\n// GetAllJobs returns all jobs\nfunc (jq *JobQueue) GetAllJobs() []*Job {\n\tjq.mu.RLock()\n\tdefer jq.mu.RUnlock()\n\n\tjobs := make([]*Job, 0, len(jq.jobs))\n\tfor _, job := range jq.jobs {\n\t\tjobCopy := *job\n\t\tjobs = append(jobs, &jobCopy)\n\t}\n\treturn jobs\n}\n\n// CancelJob cancels a job by ID\nfunc (jq *JobQueue) CancelJob(id string) bool {\n\tjq.mu.Lock()\n\tdefer jq.mu.Unlock()\n\n\tjob, ok := jq.jobs[id]\n\tif !ok {\n\t\treturn false\n\t}\n\n\t// Can only cancel queued or downloading jobs\n\tif job.Status != JobStatusQueued && job.Status != JobStatusDownloading {\n\t\treturn false\n\t}\n\n\tjob.cancel()\n\tjob.Status = JobStatusCancelled\n\tjob.UpdatedAt = time.Now()\n\treturn true\n}\n\nfunc (jq *JobQueue) updateJobStatus(id string, status JobStatus, progress float64, errMsg string) {\n\tjq.mu.Lock()\n\tdefer jq.mu.Unlock()\n\n\tif job, ok := jq.jobs[id]; ok {\n\t\tjob.Status = status\n\t\tif progress > 0 {\n\t\t\tjob.Progress = progress\n\t\t}\n\t\tif errMsg != \"\" {\n\t\t\tjob.Error = errMsg\n\t\t}\n\t\tjob.UpdatedAt = time.Now()\n\t}\n}\n\nfunc (jq *JobQueue) updateJobProgressBytes(id string, downloaded, total int64) {\n\tjq.mu.Lock()\n\tdefer jq.mu.Unlock()\n\n\tif job, ok := jq.jobs[id]; ok {\n\t\tjob.Downloaded = downloaded\n\t\tjob.Total = total\n\t\tif total > 0 {\n\t\t\tjob.Progress = float64(downloaded) / float64(total) * 100\n\t\t}\n\t\tjob.UpdatedAt = time.Now()\n\t}\n}\n\nfunc generateJobID() (string, error) {\n\tbytes := make([]byte, 8)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn hex.EncodeToString(bytes), nil\n}\n"
  },
  {
    "path": "internal/server/podcast.go",
    "content": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n\t\"unicode\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Podcast search types\n\ntype PodcastSearchRequest struct {\n\tQuery string `json:\"query\" binding:\"required\"`\n\tLang  string `json:\"lang\"` // language code, defaults to config language\n}\n\ntype PodcastSearchResult struct {\n\tSource   string           `json:\"source\"`   // \"xiaoyuzhou\" or \"itunes\"\n\tPodcasts []PodcastChannel `json:\"podcasts\"` // podcast channels\n\tEpisodes []PodcastEpisode `json:\"episodes\"` // individual episodes\n}\n\ntype PodcastChannel struct {\n\tID           string `json:\"id\"`\n\tTitle        string `json:\"title\"`\n\tAuthor       string `json:\"author\"`\n\tDescription  string `json:\"description\"`\n\tEpisodeCount int    `json:\"episode_count\"`\n\tFeedURL      string `json:\"feed_url,omitempty\"` // iTunes only\n\tSource       string `json:\"source\"`             // \"xiaoyuzhou\" or \"itunes\"\n}\n\ntype PodcastEpisode struct {\n\tID          string `json:\"id\"`\n\tTitle       string `json:\"title\"`\n\tPodcastName string `json:\"podcast_name\"`\n\tDuration    int    `json:\"duration\"` // seconds\n\tPubDate     string `json:\"pub_date,omitempty\"`\n\tDownloadURL string `json:\"download_url\"`\n\tSource      string `json:\"source\"` // \"xiaoyuzhou\" or \"itunes\"\n}\n\ntype PodcastEpisodesRequest struct {\n\tPodcastID string `json:\"podcast_id\" binding:\"required\"`\n\tSource    string `json:\"source\" binding:\"required\"` // \"xiaoyuzhou\" or \"itunes\"\n}\n\n// containsChinese checks if string contains Chinese characters\nfunc containsChinese(s string) bool {\n\tfor _, r := range s {\n\t\tif unicode.Is(unicode.Han, r) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// handlePodcastSearch handles POST /api/podcast/search\nfunc (s *Server) handlePodcastSearch(c *gin.Context) {\n\tvar req PodcastSearchRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"query is required\",\n\t\t})\n\t\treturn\n\t}\n\n\tlang := req.Lang\n\tif lang == \"\" {\n\t\tlang = s.cfg.Language\n\t}\n\tif lang == \"\" {\n\t\tlang = \"zh\"\n\t}\n\n\tvar results []PodcastSearchResult\n\n\t// Determine which sources to search based on language and query\n\tif lang == \"zh\" {\n\t\tif containsChinese(req.Query) {\n\t\t\t// Chinese query: search xiaoyuzhou only\n\t\t\tresult, err := searchXiaoyuzhouAPI(req.Query)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\t\t\tCode:    500,\n\t\t\t\t\tData:    nil,\n\t\t\t\t\tMessage: fmt.Sprintf(\"xiaoyuzhou search failed: %v\", err),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresults = append(results, *result)\n\t\t} else {\n\t\t\t// English query with zh lang: search both\n\t\t\tvar wg sync.WaitGroup\n\t\t\tvar mu sync.Mutex\n\t\t\tvar errors []string\n\n\t\t\twg.Add(2)\n\n\t\t\tgo func() {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tresult, err := searchXiaoyuzhouAPI(req.Query)\n\t\t\t\tif err != nil {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\terrors = append(errors, fmt.Sprintf(\"xiaoyuzhou: %v\", err))\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, *result)\n\t\t\t\tmu.Unlock()\n\t\t\t}()\n\n\t\t\tgo func() {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tresult, err := searchITunesAPI(req.Query)\n\t\t\t\tif err != nil {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\terrors = append(errors, fmt.Sprintf(\"itunes: %v\", err))\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, *result)\n\t\t\t\tmu.Unlock()\n\t\t\t}()\n\n\t\t\twg.Wait()\n\n\t\t\tif len(results) == 0 && len(errors) > 0 {\n\t\t\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\t\t\tCode:    500,\n\t\t\t\t\tData:    nil,\n\t\t\t\t\tMessage: strings.Join(errors, \"; \"),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Non-zh language: search iTunes only\n\t\tresult, err := searchITunesAPI(req.Query)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\t\tCode:    500,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: fmt.Sprintf(\"itunes search failed: %v\", err),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tresults = append(results, *result)\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    gin.H{\"results\": results},\n\t\tMessage: \"search completed\",\n\t})\n}\n\n// handlePodcastEpisodes handles POST /api/podcast/episodes\nfunc (s *Server) handlePodcastEpisodes(c *gin.Context) {\n\tvar req PodcastEpisodesRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"podcast_id and source are required\",\n\t\t})\n\t\treturn\n\t}\n\n\tvar episodes []PodcastEpisode\n\tvar podcastTitle string\n\tvar err error\n\n\tswitch req.Source {\n\tcase \"xiaoyuzhou\":\n\t\tepisodes, podcastTitle, err = fetchXiaoyuzhouEpisodesAPI(req.PodcastID)\n\tcase \"itunes\":\n\t\tepisodes, podcastTitle, err = fetchITunesEpisodesAPI(req.PodcastID)\n\tdefault:\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid source: must be xiaoyuzhou or itunes\",\n\t\t})\n\t\treturn\n\t}\n\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to fetch episodes: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"podcast_title\": podcastTitle,\n\t\t\t\"episodes\":      episodes,\n\t\t},\n\t\tMessage: fmt.Sprintf(\"%d episodes found\", len(episodes)),\n\t})\n}\n\n// Xiaoyuzhou API functions\n\ntype xiaoyuzhouSearchResponse struct {\n\tData struct {\n\t\tEpisodes []struct {\n\t\t\tEid       string `json:\"eid\"`\n\t\t\tPid       string `json:\"pid\"`\n\t\t\tTitle     string `json:\"title\"`\n\t\t\tDuration  int    `json:\"duration\"`\n\t\t\tPlayCount int    `json:\"playCount\"`\n\t\t\tPubDate   string `json:\"pubDate\"`\n\t\t\tEnclosure struct {\n\t\t\t\tURL string `json:\"url\"`\n\t\t\t} `json:\"enclosure\"`\n\t\t\tPodcast struct {\n\t\t\t\tTitle string `json:\"title\"`\n\t\t\t} `json:\"podcast\"`\n\t\t} `json:\"episodes\"`\n\t\tPodcasts []struct {\n\t\t\tPid               string `json:\"pid\"`\n\t\t\tTitle             string `json:\"title\"`\n\t\t\tAuthor            string `json:\"author\"`\n\t\t\tBrief             string `json:\"brief\"`\n\t\t\tSubscriptionCount int    `json:\"subscriptionCount\"`\n\t\t\tEpisodeCount      int    `json:\"episodeCount\"`\n\t\t} `json:\"podcasts\"`\n\t} `json:\"data\"`\n}\n\nfunc searchXiaoyuzhouAPI(query string) (*PodcastSearchResult, error) {\n\tapiURL := \"https://ask.xiaoyuzhoufm.com/api/keyword/search\"\n\tpayload := fmt.Sprintf(`{\"query\": \"%s\"}`, query)\n\n\treq, err := http.NewRequest(\"POST\", apiURL, strings.NewReader(payload))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result xiaoyuzhouSearchResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tsearchResult := &PodcastSearchResult{\n\t\tSource:   \"xiaoyuzhou\",\n\t\tPodcasts: make([]PodcastChannel, 0),\n\t\tEpisodes: make([]PodcastEpisode, 0),\n\t}\n\n\tfor _, p := range result.Data.Podcasts {\n\t\tsearchResult.Podcasts = append(searchResult.Podcasts, PodcastChannel{\n\t\t\tID:           p.Pid,\n\t\t\tTitle:        p.Title,\n\t\t\tAuthor:       p.Author,\n\t\t\tDescription:  p.Brief,\n\t\t\tEpisodeCount: p.EpisodeCount,\n\t\t\tSource:       \"xiaoyuzhou\",\n\t\t})\n\t}\n\n\tfor _, e := range result.Data.Episodes {\n\t\tsearchResult.Episodes = append(searchResult.Episodes, PodcastEpisode{\n\t\t\tID:          e.Eid,\n\t\t\tTitle:       e.Title,\n\t\t\tPodcastName: e.Podcast.Title,\n\t\t\tDuration:    e.Duration,\n\t\t\tPubDate:     e.PubDate,\n\t\t\tDownloadURL: e.Enclosure.URL,\n\t\t\tSource:      \"xiaoyuzhou\",\n\t\t})\n\t}\n\n\treturn searchResult, nil\n}\n\nfunc fetchXiaoyuzhouEpisodesAPI(podcastID string) ([]PodcastEpisode, string, error) {\n\tpageURL := fmt.Sprintf(\"https://www.xiaoyuzhoufm.com/podcast/%s\", podcastID)\n\n\tresp, err := http.Get(pageURL)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\thtml := string(body)\n\tstartMarker := `<script id=\"__NEXT_DATA__\" type=\"application/json\">`\n\tendMarker := `</script>`\n\n\tstartIdx := strings.Index(html, startMarker)\n\tif startIdx == -1 {\n\t\treturn nil, \"\", fmt.Errorf(\"could not find episode data on page\")\n\t}\n\tstartIdx += len(startMarker)\n\n\tendIdx := strings.Index(html[startIdx:], endMarker)\n\tif endIdx == -1 {\n\t\treturn nil, \"\", fmt.Errorf(\"could not parse episode data\")\n\t}\n\n\tjsonData := html[startIdx : startIdx+endIdx]\n\n\tvar nextData struct {\n\t\tProps struct {\n\t\t\tPageProps struct {\n\t\t\t\tPodcast struct {\n\t\t\t\t\tTitle    string `json:\"title\"`\n\t\t\t\t\tEpisodes []struct {\n\t\t\t\t\t\tEid       string `json:\"eid\"`\n\t\t\t\t\t\tTitle     string `json:\"title\"`\n\t\t\t\t\t\tDuration  int    `json:\"duration\"`\n\t\t\t\t\t\tEnclosure struct {\n\t\t\t\t\t\t\tURL string `json:\"url\"`\n\t\t\t\t\t\t} `json:\"enclosure\"`\n\t\t\t\t\t} `json:\"episodes\"`\n\t\t\t\t} `json:\"podcast\"`\n\t\t\t} `json:\"pageProps\"`\n\t\t} `json:\"props\"`\n\t}\n\n\tif err := json.Unmarshal([]byte(jsonData), &nextData); err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"failed to parse episode data: %v\", err)\n\t}\n\n\tpodcast := nextData.Props.PageProps.Podcast\n\tif len(podcast.Episodes) == 0 {\n\t\treturn nil, podcast.Title, nil\n\t}\n\n\tvar episodes []PodcastEpisode\n\tfor _, e := range podcast.Episodes {\n\t\tepisodes = append(episodes, PodcastEpisode{\n\t\t\tID:          e.Eid,\n\t\t\tTitle:       e.Title,\n\t\t\tPodcastName: podcast.Title,\n\t\t\tDuration:    e.Duration,\n\t\t\tDownloadURL: e.Enclosure.URL,\n\t\t\tSource:      \"xiaoyuzhou\",\n\t\t})\n\t}\n\n\treturn episodes, podcast.Title, nil\n}\n\n// iTunes API functions\n\ntype iTunesSearchResponse struct {\n\tResultCount int `json:\"resultCount\"`\n\tResults     []struct {\n\t\tWrapperType          string `json:\"wrapperType\"`\n\t\tKind                 string `json:\"kind\"`\n\t\tCollectionID         int    `json:\"collectionId\"`\n\t\tTrackID              int    `json:\"trackId\"`\n\t\tArtistName           string `json:\"artistName\"`\n\t\tCollectionName       string `json:\"collectionName\"`\n\t\tTrackName            string `json:\"trackName\"`\n\t\tFeedURL              string `json:\"feedUrl\"`\n\t\tTrackCount           int    `json:\"trackCount\"`\n\t\tPrimaryGenreName     string `json:\"primaryGenreName\"`\n\t\tReleaseDate          string `json:\"releaseDate\"`\n\t\tTrackTimeMillis      int    `json:\"trackTimeMillis\"`\n\t\tEpisodeURL           string `json:\"episodeUrl\"`\n\t\tEpisodeFileExtension string `json:\"episodeFileExtension\"`\n\t\tShortDescription     string `json:\"shortDescription\"`\n\t} `json:\"results\"`\n}\n\nfunc searchITunesAPI(query string) (*PodcastSearchResult, error) {\n\tvar wg sync.WaitGroup\n\tvar podcastResult, episodeResult iTunesSearchResponse\n\tvar podcastErr, episodeErr error\n\n\twg.Add(2)\n\n\t// Fetch podcasts\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tpodcastURL := fmt.Sprintf(\"https://itunes.apple.com/search?term=%s&media=podcast&entity=podcast&limit=50\",\n\t\t\turl.QueryEscape(query))\n\t\tresp, err := http.Get(podcastURL)\n\t\tif err != nil {\n\t\t\tpodcastErr = err\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tif err := json.NewDecoder(resp.Body).Decode(&podcastResult); err != nil {\n\t\t\tpodcastErr = err\n\t\t}\n\t}()\n\n\t// Fetch episodes\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tepisodeURL := fmt.Sprintf(\"https://itunes.apple.com/search?term=%s&media=podcast&entity=podcastEpisode&limit=200\",\n\t\t\turl.QueryEscape(query))\n\t\tresp, err := http.Get(episodeURL)\n\t\tif err != nil {\n\t\t\tepisodeErr = err\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tif err := json.NewDecoder(resp.Body).Decode(&episodeResult); err != nil {\n\t\t\tepisodeErr = err\n\t\t}\n\t}()\n\n\twg.Wait()\n\n\tif podcastErr != nil && episodeErr != nil {\n\t\treturn nil, fmt.Errorf(\"both searches failed: %v; %v\", podcastErr, episodeErr)\n\t}\n\n\tsearchResult := &PodcastSearchResult{\n\t\tSource:   \"itunes\",\n\t\tPodcasts: make([]PodcastChannel, 0),\n\t\tEpisodes: make([]PodcastEpisode, 0),\n\t}\n\n\tfor _, p := range podcastResult.Results {\n\t\tsearchResult.Podcasts = append(searchResult.Podcasts, PodcastChannel{\n\t\t\tID:           fmt.Sprintf(\"%d\", p.CollectionID),\n\t\t\tTitle:        p.CollectionName,\n\t\t\tAuthor:       p.ArtistName,\n\t\t\tDescription:  p.ShortDescription,\n\t\t\tEpisodeCount: p.TrackCount,\n\t\t\tFeedURL:      p.FeedURL,\n\t\t\tSource:       \"itunes\",\n\t\t})\n\t}\n\n\tfor _, e := range episodeResult.Results {\n\t\tsearchResult.Episodes = append(searchResult.Episodes, PodcastEpisode{\n\t\t\tID:          fmt.Sprintf(\"%d\", e.TrackID),\n\t\t\tTitle:       e.TrackName,\n\t\t\tPodcastName: e.CollectionName,\n\t\t\tDuration:    e.TrackTimeMillis / 1000,\n\t\t\tPubDate:     e.ReleaseDate,\n\t\t\tDownloadURL: e.EpisodeURL,\n\t\t\tSource:      \"itunes\",\n\t\t})\n\t}\n\n\treturn searchResult, nil\n}\n\nfunc fetchITunesEpisodesAPI(podcastID string) ([]PodcastEpisode, string, error) {\n\tlookupURL := fmt.Sprintf(\"https://itunes.apple.com/lookup?id=%s&entity=podcastEpisode&limit=50\", podcastID)\n\n\tresp, err := http.Get(lookupURL)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result struct {\n\t\tResultCount int `json:\"resultCount\"`\n\t\tResults     []struct {\n\t\t\tWrapperType          string `json:\"wrapperType\"`\n\t\t\tTrackID              int    `json:\"trackId\"`\n\t\t\tTrackName            string `json:\"trackName\"`\n\t\t\tCollectionName       string `json:\"collectionName\"`\n\t\t\tArtistName           string `json:\"artistName\"`\n\t\t\tEpisodeURL           string `json:\"episodeUrl\"`\n\t\t\tEpisodeFileExtension string `json:\"episodeFileExtension\"`\n\t\t\tTrackTimeMillis      int    `json:\"trackTimeMillis\"`\n\t\t\tReleaseDate          string `json:\"releaseDate\"`\n\t\t} `json:\"results\"`\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tvar episodes []PodcastEpisode\n\tvar podcastTitle string\n\n\tfor _, r := range result.Results {\n\t\t// Skip the podcast itself (first result is usually the podcast info)\n\t\tif r.WrapperType != \"podcastEpisode\" {\n\t\t\tif podcastTitle == \"\" {\n\t\t\t\tpodcastTitle = r.CollectionName\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif podcastTitle == \"\" {\n\t\t\tpodcastTitle = r.CollectionName\n\t\t}\n\n\t\tepisodes = append(episodes, PodcastEpisode{\n\t\t\tID:          fmt.Sprintf(\"%d\", r.TrackID),\n\t\t\tTitle:       r.TrackName,\n\t\t\tPodcastName: r.CollectionName,\n\t\t\tDuration:    r.TrackTimeMillis / 1000,\n\t\t\tPubDate:     r.ReleaseDate,\n\t\t\tDownloadURL: r.EpisodeURL,\n\t\t\tSource:      \"itunes\",\n\t\t})\n\t}\n\n\treturn episodes, podcastTitle, nil\n}\n"
  },
  {
    "path": "internal/server/server.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/downloader\"\n\t\"github.com/guiyumin/vget/internal/core/extractor\"\n\t\"github.com/guiyumin/vget/internal/core/i18n\"\n\t\"github.com/guiyumin/vget/internal/core/tracker\"\n\t\"github.com/guiyumin/vget/internal/core/version\"\n\t\"github.com/guiyumin/vget/internal/core/webdav\"\n\t\"github.com/guiyumin/vget/internal/torrent\"\n)\n\n// Response is the standard API response structure\ntype Response struct {\n\tCode    int    `json:\"code\"`\n\tData    any    `json:\"data\"`\n\tMessage string `json:\"message\"`\n}\n\n// DownloadRequest is the request body for POST /download\ntype DownloadRequest struct {\n\tURL        string `json:\"url\" binding:\"required\"`\n\tFilename   string `json:\"filename,omitempty\"`\n\tReturnFile bool   `json:\"return_file,omitempty\"`\n}\n\n// BulkDownloadRequest is the request body for POST /bulk-download\ntype BulkDownloadRequest struct {\n\tURLs []string `json:\"urls\" binding:\"required\"`\n}\n\n// Server is the HTTP server for vget\ntype Server struct {\n\tport       int\n\toutputDir  string\n\tapiKey     string\n\tjobQueue  *JobQueue\n\thistoryDB *HistoryDB\n\tcfg        *config.Config\n\tserver     *http.Server\n\tengine     *gin.Engine\n}\n\n// NewServer creates a new HTTP server\nfunc NewServer(port int, outputDir, apiKey string, maxConcurrent int) *Server {\n\tcfg := config.LoadOrDefault()\n\n\ts := &Server{\n\t\tport:      port,\n\t\toutputDir: outputDir,\n\t\tapiKey:    apiKey,\n\t\tcfg:       cfg,\n\t}\n\n\t// Create job queue with download function\n\ts.jobQueue = NewJobQueue(maxConcurrent, outputDir, s.downloadWithExtractor)\n\n\t// Initialize history database\n\thistoryDB, err := NewHistoryDB()\n\tif err != nil {\n\t\tlog.Printf(\"Warning: failed to initialize history database: %v\", err)\n\t} else {\n\t\ts.historyDB = historyDB\n\t\ts.jobQueue.SetHistoryDB(historyDB)\n\t}\n\n\treturn s\n}\n\n// Start starts the HTTP server\nfunc (s *Server) Start() error {\n\t// Warn if no config file exists\n\tif !config.Exists() {\n\t\tlang := s.cfg.Language\n\t\tif lang == \"\" {\n\t\t\tlang = \"zh\"\n\t\t}\n\t\tt := i18n.GetTranslations(lang)\n\t\tlog.Printf(\"⚠️  %s\", t.Server.NoConfigWarning)\n\t\tlog.Printf(\"   %s\", t.Server.RunInitHint)\n\t}\n\n\t// Ensure output directory exists\n\tif err := os.MkdirAll(s.outputDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create output directory: %w\", err)\n\t}\n\n\t// Start job queue workers\n\ts.jobQueue.Start()\n\n\t// Set Gin mode\n\tgin.SetMode(gin.ReleaseMode)\n\n\t// Create Gin engine\n\ts.engine = gin.New()\n\n\t// Add middleware\n\ts.engine.Use(gin.Recovery())\n\ts.engine.Use(s.loggingMiddleware())\n\tif s.apiKey != \"\" {\n\t\ts.engine.Use(s.jwtAuthMiddleware())\n\t}\n\n\t// API routes\n\tapi := s.engine.Group(\"/api\")\n\tapi.GET(\"/health\", s.handleHealth)\n\n\t// Auth routes (don't require authentication)\n\tapi.GET(\"/auth/status\", s.handleAuthStatus)\n\tapi.POST(\"/auth/token\", s.handleGenerateToken)\n\n\tapi.GET(\"/download\", s.handleFileDownload) // Download local file by path\n\tapi.POST(\"/download\", s.handleDownload)\n\tapi.POST(\"/bulk-download\", s.handleBulkDownload)\n\tapi.GET(\"/status/:id\", s.handleStatus)\n\tapi.GET(\"/jobs\", s.handleGetJobs)\n\tapi.DELETE(\"/jobs\", s.handleClearJobs)\n\tapi.DELETE(\"/jobs/:id\", s.handleDeleteJob)\n\n\t// History routes\n\tapi.GET(\"/history\", s.handleGetHistory)\n\tapi.DELETE(\"/history\", s.handleClearHistory)\n\tapi.DELETE(\"/history/:id\", s.handleDeleteHistory)\n\n\tapi.GET(\"/config\", s.handleGetConfig)\n\tapi.POST(\"/config\", s.handleSetConfig)\n\tapi.PUT(\"/config\", s.handleUpdateConfig)\n\tapi.GET(\"/config/webdav\", s.handleGetWebDAV)\n\tapi.POST(\"/config/webdav\", s.handleAddWebDAV)\n\tapi.DELETE(\"/config/webdav/:name\", s.handleDeleteWebDAV)\n\tapi.GET(\"/i18n\", s.handleI18n)\n\tapi.POST(\"/kuaidi100\", s.handleKuaidi100)\n\n\t// WebDAV browsing routes\n\tapi.GET(\"/webdav/remotes\", s.handleWebDAVRemotes)\n\tapi.GET(\"/webdav/list\", s.handleWebDAVList)\n\tapi.POST(\"/webdav/download\", s.handleWebDAVDownload)\n\n\t// Torrent dispatch routes\n\tapi.GET(\"/config/torrent\", s.handleGetTorrentConfig)\n\tapi.POST(\"/config/torrent\", s.handleSetTorrentConfig)\n\tapi.POST(\"/config/torrent/test\", s.handleTestTorrentConnection)\n\tapi.POST(\"/torrent\", s.handleAddTorrent)\n\tapi.GET(\"/torrent\", s.handleListTorrents)\n\n\t// Podcast search routes\n\tapi.POST(\"/podcast/search\", s.handlePodcastSearch)\n\tapi.POST(\"/podcast/episodes\", s.handlePodcastEpisodes)\n\n\t// Bilibili login routes\n\tapi.POST(\"/bilibili/qr/generate\", s.handleBilibiliQRGenerate)\n\tapi.GET(\"/bilibili/qr/poll\", s.handleBilibiliQRPoll)\n\tapi.GET(\"/bilibili/status\", s.handleBilibiliStatus)\n\n\t// Serve embedded UI if available\n\tif distFS := GetDistFS(); distFS != nil {\n\t\ts.setupStaticFiles(distFS)\n\t\tlog.Println(\"Serving embedded WebUI at /\")\n\t}\n\n\ts.server = &http.Server{\n\t\tAddr:         fmt.Sprintf(\":%d\", s.port),\n\t\tHandler:      s.engine,\n\t\tReadTimeout:  30 * time.Second,\n\t\tWriteTimeout: 0, // No timeout for downloads\n\t\tIdleTimeout:  120 * time.Second,\n\t}\n\n\tlog.Printf(\"Starting vget server on port %d\", s.port)\n\tlog.Printf(\"Output directory: %s\", s.outputDir)\n\tif s.apiKey != \"\" {\n\t\tlog.Printf(\"API key authentication enabled\")\n\t}\n\n\treturn s.server.ListenAndServe()\n}\n\n// Stop gracefully shuts down the server\nfunc (s *Server) Stop(ctx context.Context) error {\n\ts.jobQueue.Stop()\n\tif s.historyDB != nil {\n\t\ts.historyDB.Close()\n\t}\n\treturn s.server.Shutdown(ctx)\n}\n\n// Middleware\n\nfunc (s *Server) loggingMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tc.Next()\n\t\tlog.Printf(\"%s %s %s\", c.Request.Method, c.Request.URL.Path, time.Since(start))\n\t}\n}\n\n// setupStaticFiles serves the embedded SPA with fallback to index.html\nfunc (s *Server) setupStaticFiles(distFS fs.FS) {\n\t// Serve static assets\n\ts.engine.GET(\"/assets/*filepath\", func(c *gin.Context) {\n\t\tc.FileFromFS(c.Request.URL.Path, http.FS(distFS))\n\t})\n\n\t// Serve other static files (favicon, etc)\n\ts.engine.GET(\"/vite.svg\", func(c *gin.Context) {\n\t\tc.FileFromFS(\"vite.svg\", http.FS(distFS))\n\t})\n\n\t// Fallback to index.html for SPA routing\n\ts.engine.NoRoute(func(c *gin.Context) {\n\t\t// Only serve index.html for non-API routes\n\t\tif strings.HasPrefix(c.Request.URL.Path, \"/api\") {\n\t\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\t\tCode:    404,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: \"not found\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// Set session cookie for web UI access\n\t\ts.setSessionCookie(c)\n\n\t\tindexFile, err := fs.ReadFile(distFS, \"index.html\")\n\t\tif err != nil {\n\t\t\tc.String(http.StatusNotFound, \"index.html not found\")\n\t\t\treturn\n\t\t}\n\n\t\tc.Header(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tc.String(http.StatusOK, string(indexFile))\n\t})\n}\n\n// Handlers\n\nfunc (s *Server) handleHealth(c *gin.Context) {\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"status\":  \"ok\",\n\t\t\t\"version\": version.Version,\n\t\t},\n\t\tMessage: \"everything is good\",\n\t})\n}\n\n// handleFileDownload serves a local file for download\nfunc (s *Server) handleFileDownload(c *gin.Context) {\n\tfilePath := c.Query(\"path\")\n\tif filePath == \"\" {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"path parameter is required\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Security: ensure the file is within the output directory\n\tabsPath, err := filepath.Abs(filePath)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid path\",\n\t\t})\n\t\treturn\n\t}\n\n\tabsOutputDir, _ := filepath.Abs(s.outputDir)\n\tif !strings.HasPrefix(absPath, absOutputDir) {\n\t\tc.JSON(http.StatusForbidden, Response{\n\t\t\tCode:    403,\n\t\t\tData:    nil,\n\t\t\tMessage: \"access denied: file outside output directory\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Check file exists\n\tif _, err := os.Stat(absPath); os.IsNotExist(err) {\n\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\tCode:    404,\n\t\t\tData:    nil,\n\t\t\tMessage: \"file not found\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Serve the file\n\tfilename := filepath.Base(absPath)\n\tc.Header(\"Content-Disposition\", fmt.Sprintf(\"attachment; filename=\\\"%s\\\"\", filename))\n\tc.File(absPath)\n}\n\nfunc (s *Server) handleDownload(c *gin.Context) {\n\tvar req DownloadRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid request body: url is required\",\n\t\t})\n\t\treturn\n\t}\n\n\t// If return_file is true, download and stream directly\n\tif req.ReturnFile {\n\t\ts.downloadAndStream(c, req.URL, req.Filename)\n\t\treturn\n\t}\n\n\t// Otherwise, queue the download\n\tjob, err := s.jobQueue.AddJob(req.URL, req.Filename)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"id\":     job.ID,\n\t\t\t\"status\": job.Status,\n\t\t},\n\t\tMessage: \"download started\",\n\t})\n}\n\nfunc (s *Server) handleBulkDownload(c *gin.Context) {\n\tvar req BulkDownloadRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid request body: urls array is required\",\n\t\t})\n\t\treturn\n\t}\n\n\tif len(req.URLs) == 0 {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"urls array cannot be empty\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Queue all downloads\n\tvar jobs []gin.H\n\tvar queued, failed int\n\n\tfor _, url := range req.URLs {\n\t\turl = strings.TrimSpace(url)\n\t\t// Skip empty lines and comments\n\t\tif url == \"\" || strings.HasPrefix(url, \"#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tjob, err := s.jobQueue.AddJob(url, \"\")\n\t\tif err != nil {\n\t\t\t// Create a failed job so it shows in the UI\n\t\t\tfailedJob := s.jobQueue.AddFailedJob(url, err.Error())\n\t\t\tjobs = append(jobs, gin.H{\n\t\t\t\t\"id\":     failedJob.ID,\n\t\t\t\t\"url\":    failedJob.URL,\n\t\t\t\t\"status\": failedJob.Status,\n\t\t\t\t\"error\":  failedJob.Error,\n\t\t\t})\n\t\t\tfailed++\n\t\t\tcontinue\n\t\t}\n\t\tjobs = append(jobs, gin.H{\n\t\t\t\"id\":     job.ID,\n\t\t\t\"url\":    job.URL,\n\t\t\t\"status\": job.Status,\n\t\t})\n\t\tqueued++\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"jobs\":   jobs,\n\t\t\t\"queued\": queued,\n\t\t\t\"failed\": failed,\n\t\t},\n\t\tMessage: fmt.Sprintf(\"%d downloads queued\", queued),\n\t})\n}\n\nfunc (s *Server) handleStatus(c *gin.Context) {\n\tid := c.Param(\"id\")\n\n\tjob := s.jobQueue.GetJob(id)\n\tif job == nil {\n\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\tCode:    404,\n\t\t\tData:    nil,\n\t\t\tMessage: \"job not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"id\":       job.ID,\n\t\t\t\"status\":   job.Status,\n\t\t\t\"progress\": job.Progress,\n\t\t\t\"filename\": job.Filename,\n\t\t\t\"error\":    job.Error,\n\t\t},\n\t\tMessage: string(job.Status),\n\t})\n}\n\nfunc (s *Server) handleGetJobs(c *gin.Context) {\n\tjobs := s.jobQueue.GetAllJobs()\n\n\tjobList := make([]gin.H, len(jobs))\n\tfor i, job := range jobs {\n\t\tjobList[i] = gin.H{\n\t\t\t\"id\":         job.ID,\n\t\t\t\"url\":        job.URL,\n\t\t\t\"status\":     job.Status,\n\t\t\t\"progress\":   job.Progress,\n\t\t\t\"downloaded\": job.Downloaded,\n\t\t\t\"total\":      job.Total,\n\t\t\t\"filename\":   job.Filename,\n\t\t\t\"error\":      job.Error,\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"jobs\": jobList,\n\t\t},\n\t\tMessage: fmt.Sprintf(\"%d jobs found\", len(jobs)),\n\t})\n}\n\nfunc (s *Server) handleClearJobs(c *gin.Context) {\n\tcount := s.jobQueue.ClearHistory()\n\n\t// Also clear persistent history\n\tif s.historyDB != nil {\n\t\ts.historyDB.ClearHistory()\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"cleared\": count,\n\t\t},\n\t\tMessage: fmt.Sprintf(\"%d jobs cleared\", count),\n\t})\n}\n\nfunc (s *Server) handleDeleteJob(c *gin.Context) {\n\tid := c.Param(\"id\")\n\n\t// Try to cancel active job first, then try to remove finished job\n\tif s.jobQueue.CancelJob(id) {\n\t\tc.JSON(http.StatusOK, Response{\n\t\t\tCode:    200,\n\t\t\tData:    gin.H{\"id\": id},\n\t\t\tMessage: \"job cancelled\",\n\t\t})\n\t} else if s.jobQueue.RemoveJob(id) {\n\t\tc.JSON(http.StatusOK, Response{\n\t\t\tCode:    200,\n\t\t\tData:    gin.H{\"id\": id},\n\t\t\tMessage: \"job removed\",\n\t\t})\n\t} else {\n\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\tCode:    404,\n\t\t\tData:    nil,\n\t\t\tMessage: \"job not found or cannot be cancelled/removed\",\n\t\t})\n\t}\n}\n\n// ConfigSetRequest is the request body for POST /config\ntype ConfigSetRequest struct {\n\tKey   string `json:\"key\" binding:\"required\"`\n\tValue string `json:\"value\"`\n}\n\n// ConfigRequest is the request body for PUT /config\ntype ConfigRequest struct {\n\tOutputDir string `json:\"output_dir,omitempty\"`\n}\n\nfunc (s *Server) handleGetConfig(c *gin.Context) {\n\tcfg := config.LoadOrDefault()\n\n\t// Convert WebDAV servers to a simpler format for JSON\n\twebdavServers := make(map[string]map[string]string)\n\tfor name, server := range cfg.WebDAVServers {\n\t\twebdavServers[name] = map[string]string{\n\t\t\t\"url\":      server.URL,\n\t\t\t\"username\": server.Username,\n\t\t\t\"password\": server.Password,\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"output_dir\":            s.outputDir,\n\t\t\t\"language\":              cfg.Language,\n\t\t\t\"format\":                cfg.Format,\n\t\t\t\"quality\":               cfg.Quality,\n\t\t\t\"twitter_auth_token\":    cfg.Twitter.AuthToken,\n\t\t\t\"server_port\":           cfg.Server.Port,\n\t\t\t\"server_max_concurrent\": cfg.Server.MaxConcurrent,\n\t\t\t\"server_api_key\":        cfg.Server.APIKey,\n\t\t\t\"webdav_servers\":        webdavServers,\n\t\t\t\"express\":               cfg.Express,\n\t\t\t\"torrent_enabled\":       cfg.Torrent.Enabled,\n\t\t\t\"bilibili_cookie\":       cfg.Bilibili.Cookie,\n\t\t\t\"telegram_tdata_path\":   cfg.Telegram.TDataPath,\n\t\t\t},\n\t\tMessage: \"config retrieved\",\n\t})\n}\n\nfunc (s *Server) handleSetConfig(c *gin.Context) {\n\tvar req ConfigSetRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid request body: key is required\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Load current config, update, save\n\tcfg := config.LoadOrDefault()\n\tif err := s.setConfigValue(cfg, req.Key, req.Value); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tif err := config.Save(cfg); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to save config: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\t// Update server's cached config\n\ts.cfg = cfg\n\n\t// Special handling for output_dir\n\tif req.Key == \"output_dir\" {\n\t\tif err := os.MkdirAll(req.Value, 0755); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\t\tCode:    400,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: fmt.Sprintf(\"invalid output directory: %v\", err),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ts.outputDir = req.Value\n\t\ts.jobQueue.outputDir = req.Value\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"key\":   req.Key,\n\t\t\t\"value\": req.Value,\n\t\t},\n\t\tMessage: fmt.Sprintf(\"config %s updated\", req.Key),\n\t})\n}\n\nfunc (s *Server) handleUpdateConfig(c *gin.Context) {\n\tvar req ConfigRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid request body\",\n\t\t})\n\t\treturn\n\t}\n\n\tif req.OutputDir != \"\" {\n\t\tif err := os.MkdirAll(req.OutputDir, 0755); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\t\tCode:    400,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: fmt.Sprintf(\"invalid output directory: %v\", err),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\ts.outputDir = req.OutputDir\n\t\ts.jobQueue.outputDir = req.OutputDir\n\n\t\tcfg := config.LoadOrDefault()\n\t\tcfg.OutputDir = req.OutputDir\n\t\tif err := config.Save(cfg); err != nil {\n\t\t\tlog.Printf(\"Warning: failed to save config: %v\", err)\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"output_dir\": s.outputDir,\n\t\t},\n\t\tMessage: \"config updated\",\n\t})\n}\n\nfunc (s *Server) handleI18n(c *gin.Context) {\n\tlang := s.cfg.Language\n\tif lang == \"\" {\n\t\tlang = \"zh\"\n\t}\n\n\tt := i18n.GetTranslations(lang)\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"language\":      lang,\n\t\t\t\"ui\":            t.UI,\n\t\t\t\"server\":        t.Server,\n\t\t\t\"config_exists\": config.Exists(),\n\t\t},\n\t\tMessage: \"translations retrieved\",\n\t})\n}\n\n// WebDAV handlers\n\n// WebDAVConfigRequest is the request body for WebDAV server operations\ntype WebDAVConfigRequest struct {\n\tName     string `json:\"name\" binding:\"required\"`\n\tURL      string `json:\"url\" binding:\"required\"`\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n}\n\nfunc (s *Server) handleGetWebDAV(c *gin.Context) {\n\tcfg := config.LoadOrDefault()\n\n\tservers := make(map[string]map[string]string)\n\tfor name, server := range cfg.WebDAVServers {\n\t\tservers[name] = map[string]string{\n\t\t\t\"url\":      server.URL,\n\t\t\t\"username\": server.Username,\n\t\t\t\"password\": server.Password,\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    servers,\n\t\tMessage: \"webdav servers retrieved\",\n\t})\n}\n\nfunc (s *Server) handleAddWebDAV(c *gin.Context) {\n\tvar req WebDAVConfigRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"name and url are required\",\n\t\t})\n\t\treturn\n\t}\n\n\tcfg := config.LoadOrDefault()\n\tcfg.SetWebDAVServer(req.Name, config.WebDAVServer{\n\t\tURL:      req.URL,\n\t\tUsername: req.Username,\n\t\tPassword: req.Password,\n\t})\n\n\tif err := config.Save(cfg); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to save config: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\ts.cfg = cfg\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    gin.H{\"name\": req.Name},\n\t\tMessage: \"webdav server added\",\n\t})\n}\n\nfunc (s *Server) handleDeleteWebDAV(c *gin.Context) {\n\tname := c.Param(\"name\")\n\n\tcfg := config.LoadOrDefault()\n\tif cfg.GetWebDAVServer(name) == nil {\n\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\tCode:    404,\n\t\t\tData:    nil,\n\t\t\tMessage: \"webdav server not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tcfg.DeleteWebDAVServer(name)\n\n\tif err := config.Save(cfg); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to save config: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\ts.cfg = cfg\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    gin.H{\"name\": name},\n\t\tMessage: \"webdav server deleted\",\n\t})\n}\n\n// Kuaidi100 handler\n\n// TrackRequest is the request body for POST /kuaidi100\ntype TrackRequest struct {\n\tTrackingNumber string `json:\"tracking_number\" binding:\"required\"`\n\tCourier        string `json:\"courier\" binding:\"required\"`\n}\n\nfunc (s *Server) handleKuaidi100(c *gin.Context) {\n\tvar req TrackRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"tracking_number and courier are required\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Load config to get kuaidi100 credentials\n\tcfg := config.LoadOrDefault()\n\texpressCfg := cfg.GetExpressConfig(\"kuaidi100\")\n\tif expressCfg == nil || expressCfg[\"key\"] == \"\" || expressCfg[\"customer\"] == \"\" {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"快递100凭证未配置。请在设置中配置 API Key 和 Customer ID。\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Create tracker and query\n\tt := tracker.NewKuaidi100Tracker(expressCfg[\"key\"], expressCfg[\"customer\"])\n\tcourierCode := tracker.GetCourierCode(req.Courier)\n\n\tresult, err := t.Track(courierCode, req.TrackingNumber)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"tracking failed: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\t// Get courier info for display\n\tcourierInfo := tracker.GetCourierInfo(req.Courier)\n\tcourierName := courierCode\n\tif courierInfo != nil {\n\t\tcourierName = courierInfo.Name\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"tracking_number\": result.Nu,\n\t\t\t\"courier_code\":    result.Com,\n\t\t\t\"courier_name\":    courierName,\n\t\t\t\"state\":           result.State,\n\t\t\t\"state_desc\":      result.StateDescription(),\n\t\t\t\"is_delivered\":    result.IsDelivered(),\n\t\t\t\"data\":            result.Data,\n\t\t},\n\t\tMessage: \"tracking info retrieved\",\n\t})\n}\n\n// Torrent handlers\n\n// TorrentConfigRequest is the request body for POST /config/torrent\ntype TorrentConfigRequest struct {\n\tEnabled         bool   `json:\"enabled\"`\n\tClient          string `json:\"client\"`\n\tHost            string `json:\"host\"`\n\tUsername        string `json:\"username\"`\n\tPassword        string `json:\"password\"`\n\tUseHTTPS        bool   `json:\"use_https\"`\n\tDefaultSavePath string `json:\"default_save_path\"`\n}\n\n// TorrentAddRequest is the request body for POST /torrent\ntype TorrentAddRequest struct {\n\tURL      string `json:\"url\" binding:\"required\"` // Magnet link or .torrent URL\n\tSavePath string `json:\"save_path,omitempty\"`\n\tPaused   bool   `json:\"paused,omitempty\"`\n}\n\nfunc (s *Server) handleGetTorrentConfig(c *gin.Context) {\n\tcfg := config.LoadOrDefault()\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"enabled\":           cfg.Torrent.Enabled,\n\t\t\t\"client\":            cfg.Torrent.Client,\n\t\t\t\"host\":              cfg.Torrent.Host,\n\t\t\t\"username\":          cfg.Torrent.Username,\n\t\t\t\"password\":          cfg.Torrent.Password,\n\t\t\t\"use_https\":         cfg.Torrent.UseHTTPS,\n\t\t\t\"default_save_path\": cfg.Torrent.DefaultSavePath,\n\t\t},\n\t\tMessage: \"torrent config retrieved\",\n\t})\n}\n\nfunc (s *Server) handleSetTorrentConfig(c *gin.Context) {\n\tvar req TorrentConfigRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid request body\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Validate client type if enabled\n\tif req.Enabled {\n\t\tswitch req.Client {\n\t\tcase \"transmission\", \"qbittorrent\", \"synology\":\n\t\t\t// Valid\n\t\tdefault:\n\t\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\t\tCode:    400,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: \"invalid client type: must be transmission, qbittorrent, or synology\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tif req.Host == \"\" {\n\t\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\t\tCode:    400,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: \"host is required when torrent is enabled\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tcfg := config.LoadOrDefault()\n\tcfg.Torrent = config.TorrentConfig{\n\t\tEnabled:         req.Enabled,\n\t\tClient:          req.Client,\n\t\tHost:            req.Host,\n\t\tUsername:        req.Username,\n\t\tPassword:        req.Password,\n\t\tUseHTTPS:        req.UseHTTPS,\n\t\tDefaultSavePath: req.DefaultSavePath,\n\t}\n\n\tif err := config.Save(cfg); err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to save config: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\ts.cfg = cfg\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    gin.H{\"enabled\": req.Enabled},\n\t\tMessage: \"torrent config saved\",\n\t})\n}\n\nfunc (s *Server) handleTestTorrentConnection(c *gin.Context) {\n\tcfg := config.LoadOrDefault()\n\n\tif !cfg.Torrent.Enabled {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"torrent is not enabled\",\n\t\t})\n\t\treturn\n\t}\n\n\tclient, err := s.createTorrentClient(&cfg.Torrent)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"invalid torrent config: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\tif err := client.Connect(); err != nil {\n\t\tc.JSON(http.StatusBadGateway, Response{\n\t\t\tCode:    502,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"connection failed: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\tdefer client.Close()\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    gin.H{\"client\": client.Name()},\n\t\tMessage: \"connection successful\",\n\t})\n}\n\nfunc (s *Server) handleAddTorrent(c *gin.Context) {\n\tvar req TorrentAddRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"url is required\",\n\t\t})\n\t\treturn\n\t}\n\n\tcfg := config.LoadOrDefault()\n\n\tif !cfg.Torrent.Enabled {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"torrent is not enabled. Configure it in settings first.\",\n\t\t})\n\t\treturn\n\t}\n\n\tclient, err := s.createTorrentClient(&cfg.Torrent)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"invalid torrent config: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\tif err := client.Connect(); err != nil {\n\t\tc.JSON(http.StatusBadGateway, Response{\n\t\t\tCode:    502,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to connect to torrent client: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\tdefer client.Close()\n\n\t// Prepare options\n\topts := &torrent.AddOptions{\n\t\tPaused: req.Paused,\n\t}\n\tif req.SavePath != \"\" {\n\t\topts.SavePath = req.SavePath\n\t} else if cfg.Torrent.DefaultSavePath != \"\" {\n\t\topts.SavePath = cfg.Torrent.DefaultSavePath\n\t}\n\n\t// Add torrent based on URL type\n\tvar result *torrent.AddResult\n\tif torrent.IsMagnetLink(req.URL) {\n\t\tresult, err = client.AddMagnet(req.URL, opts)\n\t} else {\n\t\tresult, err = client.AddTorrentURL(req.URL, opts)\n\t}\n\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to add torrent: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"id\":        result.ID,\n\t\t\t\"hash\":      result.Hash,\n\t\t\t\"name\":      result.Name,\n\t\t\t\"duplicate\": result.Duplicate,\n\t\t},\n\t\tMessage: \"torrent added successfully\",\n\t})\n}\n\nfunc (s *Server) handleListTorrents(c *gin.Context) {\n\tcfg := config.LoadOrDefault()\n\n\tif !cfg.Torrent.Enabled {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"torrent is not enabled\",\n\t\t})\n\t\treturn\n\t}\n\n\tclient, err := s.createTorrentClient(&cfg.Torrent)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"invalid torrent config: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\tif err := client.Connect(); err != nil {\n\t\tc.JSON(http.StatusBadGateway, Response{\n\t\t\tCode:    502,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to connect to torrent client: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\tdefer client.Close()\n\n\ttorrents, err := client.ListTorrents()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to list torrents: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\t// Convert to JSON-friendly format\n\ttorrentList := make([]gin.H, len(torrents))\n\tfor i, t := range torrents {\n\t\ttorrentList[i] = gin.H{\n\t\t\t\"id\":             t.ID,\n\t\t\t\"hash\":           t.Hash,\n\t\t\t\"name\":           t.Name,\n\t\t\t\"state\":          t.State.String(),\n\t\t\t\"progress\":       t.Progress,\n\t\t\t\"size\":           t.Size,\n\t\t\t\"downloaded\":     t.Downloaded,\n\t\t\t\"uploaded\":       t.Uploaded,\n\t\t\t\"download_speed\": t.DownloadSpeed,\n\t\t\t\"upload_speed\":   t.UploadSpeed,\n\t\t\t\"ratio\":          t.Ratio,\n\t\t\t\"eta\":            t.ETA,\n\t\t\t\"save_path\":      t.SavePath,\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"torrents\": torrentList,\n\t\t\t\"count\":    len(torrentList),\n\t\t},\n\t\tMessage: fmt.Sprintf(\"%d torrents found\", len(torrentList)),\n\t})\n}\n\n// createTorrentClient creates a torrent client from config\nfunc (s *Server) createTorrentClient(cfg *config.TorrentConfig) (torrent.Client, error) {\n\tclientCfg := &torrent.Config{\n\t\tType:     torrent.ClientType(cfg.Client),\n\t\tHost:     cfg.Host,\n\t\tUsername: cfg.Username,\n\t\tPassword: cfg.Password,\n\t\tUseHTTPS: cfg.UseHTTPS,\n\t}\n\treturn torrent.NewClient(clientCfg)\n}\n\n// Helper functions\n\n// setConfigValue sets a config value by key\nfunc (s *Server) setConfigValue(cfg *config.Config, key, value string) error {\n\t// Handle express.<provider>.<key> pattern\n\tif strings.HasPrefix(key, \"express.\") {\n\t\tparts := strings.SplitN(key, \".\", 3)\n\t\tif len(parts) != 3 {\n\t\t\treturn fmt.Errorf(\"invalid express config key format: %s (use express.<provider>.<key>)\", key)\n\t\t}\n\t\tprovider := parts[1]\n\t\tconfigKey := parts[2]\n\t\tcfg.SetExpressConfig(provider, configKey, value)\n\t\treturn nil\n\t}\n\n\tswitch key {\n\tcase \"language\":\n\t\tcfg.Language = value\n\tcase \"output_dir\":\n\t\tcfg.OutputDir = value\n\tcase \"format\":\n\t\tcfg.Format = value\n\tcase \"quality\":\n\t\tcfg.Quality = value\n\tcase \"twitter_auth_token\", \"twitter.auth_token\":\n\t\tcfg.Twitter.AuthToken = value\n\tcase \"server.max_concurrent\", \"server_max_concurrent\":\n\t\tvar val int\n\t\tif _, err := fmt.Sscanf(value, \"%d\", &val); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid value for max_concurrent: %s\", value)\n\t\t}\n\t\tcfg.Server.MaxConcurrent = val\n\tcase \"server.api_key\", \"server_api_key\":\n\t\tcfg.Server.APIKey = value\n\tcase \"bilibili.cookie\", \"bilibili_cookie\":\n\t\tcfg.Bilibili.Cookie = value\n\tcase \"telegram.tdata_path\", \"telegram_tdata_path\":\n\t\tcfg.Telegram.TDataPath = value\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown config key: %s\", key)\n\t}\n\treturn nil\n}\n\n// downloadWebDAV handles WebDAV URL downloads using multi-stream for better performance\nfunc (s *Server) downloadWebDAV(ctx context.Context, rawURL, filename string, progressFn func(downloaded, total int64)) error {\n\tvar client *webdav.Client\n\tvar filePath string\n\tvar err error\n\n\t// Check if it's a remote path (e.g., \"pikpak:/path/to/file\")\n\tif webdav.IsRemotePath(rawURL) {\n\t\tserverName, filePath, err := webdav.ParseRemotePath(rawURL)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tserver := s.cfg.GetWebDAVServer(serverName)\n\t\tif server == nil {\n\t\t\treturn fmt.Errorf(\"WebDAV server '%s' not found\", serverName)\n\t\t}\n\n\t\tclient, err = webdav.NewClientFromConfig(server)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t\t}\n\n\t\t// Get file info\n\t\tfileInfo, err := client.Stat(ctx, filePath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get file info: %w\", err)\n\t\t}\n\n\t\tif fileInfo.IsDir {\n\t\t\treturn fmt.Errorf(\"cannot download directory: %s\", filePath)\n\t\t}\n\n\t\t// Determine output filename\n\t\toutputFile := filename\n\t\tif outputFile == \"\" {\n\t\t\toutputFile = webdav.ExtractFilename(filePath)\n\t\t}\n\t\t// Sanitize the filename to remove invalid path characters\n\t\toutputPath := filepath.Join(s.outputDir, extractor.SanitizeFilename(outputFile))\n\n\t\t// Update job filename\n\t\ts.updateJobFilename(rawURL, outputPath)\n\n\t\t// Download using multi-stream for better performance (same as CLI)\n\t\tfileURL := client.GetFileURL(filePath)\n\t\tauthHeader := client.GetAuthHeader()\n\n\t\treturn downloadWebDAVMultiStream(ctx, fileURL, authHeader, outputPath, fileInfo.Size, progressFn)\n\t}\n\n\t// Handle full WebDAV URL\n\tclient, err = webdav.NewClient(rawURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create WebDAV client: %w\", err)\n\t}\n\n\tfilePath, err = webdav.ParseURL(rawURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid WebDAV URL: %w\", err)\n\t}\n\n\tfileInfo, err := client.Stat(ctx, filePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get file info: %w\", err)\n\t}\n\n\tif fileInfo.IsDir {\n\t\treturn fmt.Errorf(\"cannot download directory: %s\", filePath)\n\t}\n\n\toutputFile := filename\n\tif outputFile == \"\" {\n\t\toutputFile = webdav.ExtractFilename(filePath)\n\t}\n\t// Sanitize the filename to remove invalid path characters\n\toutputPath := filepath.Join(s.outputDir, extractor.SanitizeFilename(outputFile))\n\n\ts.updateJobFilename(rawURL, outputPath)\n\n\tfileURL := client.GetFileURL(filePath)\n\tauthHeader := client.GetAuthHeader()\n\n\treturn downloadWebDAVMultiStream(ctx, fileURL, authHeader, outputPath, fileInfo.Size, progressFn)\n}\n\n// downloadWebDAVMultiStream uses multi-stream download for better performance\nfunc downloadWebDAVMultiStream(ctx context.Context, url, authHeader, outputPath string, totalSize int64, progressFn func(downloaded, total int64)) error {\n\tmsConfig := downloader.DefaultMultiStreamConfig()\n\treturn downloader.RunMultiStreamDownloadWithAuthCallback(ctx, url, authHeader, outputPath, totalSize, msConfig, progressFn)\n}\n\n// downloadWithExtractor is the download function used by the job queue\nfunc (s *Server) downloadWithExtractor(ctx context.Context, url, filename string, progressFn func(downloaded, total int64)) error {\n\t// Handle WebDAV URLs specially\n\tif webdav.IsWebDAVURL(url) {\n\t\treturn s.downloadWebDAV(ctx, url, filename, progressFn)\n\t}\n\n\t// Find matching extractor\n\text := extractor.Match(url)\n\tif ext == nil {\n\t\tsitesConfig, _ := config.LoadSites()\n\t\tif sitesConfig != nil {\n\t\t\tif site := sitesConfig.MatchSite(url); site != nil {\n\t\t\t\text = extractor.NewBrowserExtractor(site, false)\n\t\t\t}\n\t\t}\n\t\tif ext == nil {\n\t\t\text = extractor.NewGenericBrowserExtractor(false)\n\t\t}\n\t}\n\n\t// Configure Twitter extractor with auth if available\n\tif twitterExt, ok := ext.(*extractor.TwitterExtractor); ok {\n\t\tif s.cfg.Twitter.AuthToken != \"\" {\n\t\t\ttwitterExt.SetAuth(s.cfg.Twitter.AuthToken)\n\t\t}\n\t}\n\n\t// Extract media info\n\tmedia, err := ext.Extract(url)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"extraction failed: %w\", err)\n\t}\n\n\t// Determine output path based on media type\n\tvar outputPath string\n\tvar downloadURL string\n\tvar headers map[string]string\n\n\tswitch m := media.(type) {\n\tcase *extractor.YouTubeDirectDownload:\n\t\treturn extractor.DownloadWithYtdlpProgress(ctx, m.URL, s.outputDir, progressFn)\n\n\tcase *extractor.VideoMedia:\n\t\tif len(m.Formats) == 0 {\n\t\t\treturn fmt.Errorf(\"no video formats available\")\n\t\t}\n\t\tformat := selectBestFormat(m.Formats)\n\t\tdownloadURL = format.URL\n\t\theaders = format.Headers\n\n\t\text := format.Ext\n\t\tif ext == \"m3u8\" {\n\t\t\text = \"ts\"\n\t\t}\n\n\t\tif filename != \"\" {\n\t\t\t// Sanitize the provided filename to remove invalid path characters\n\t\t\tsanitized := extractor.SanitizeFilename(filename)\n\t\t\t// Ensure the filename has the correct extension\n\t\t\tif !strings.HasSuffix(strings.ToLower(sanitized), \".\"+ext) {\n\t\t\t\tsanitized = fmt.Sprintf(\"%s.%s\", sanitized, ext)\n\t\t\t}\n\t\t\toutputPath = filepath.Join(s.outputDir, sanitized)\n\t\t} else {\n\t\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\t\tif title != \"\" {\n\t\t\t\toutputPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s.%s\", title, ext))\n\t\t\t} else {\n\t\t\t\toutputPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s.%s\", m.ID, ext))\n\t\t\t}\n\t\t}\n\n\t\ts.updateJobFilename(url, outputPath)\n\n\t\t// Handle separate audio stream (e.g., Bilibili DASH)\n\t\tif format.AudioURL != \"\" {\n\t\t\treturn s.downloadVideoWithAudio(ctx, format, outputPath, progressFn)\n\t\t}\n\n\tcase *extractor.AudioMedia:\n\t\tdownloadURL = m.URL\n\n\t\tif filename != \"\" {\n\t\t\t// Sanitize the provided filename to remove invalid path characters\n\t\t\tsanitized := extractor.SanitizeFilename(filename)\n\t\t\t// Ensure the filename has the correct extension\n\t\t\tif !strings.HasSuffix(strings.ToLower(sanitized), \".\"+m.Ext) {\n\t\t\t\tsanitized = fmt.Sprintf(\"%s.%s\", sanitized, m.Ext)\n\t\t\t}\n\t\t\toutputPath = filepath.Join(s.outputDir, sanitized)\n\t\t} else {\n\t\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\t\tif title != \"\" {\n\t\t\t\toutputPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s.%s\", title, m.Ext))\n\t\t\t} else {\n\t\t\t\toutputPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s.%s\", m.ID, m.Ext))\n\t\t\t}\n\t\t}\n\n\t\ts.updateJobFilename(url, outputPath)\n\n\tcase *extractor.ImageMedia:\n\t\tif len(m.Images) == 0 {\n\t\t\treturn fmt.Errorf(\"no images available\")\n\t\t}\n\n\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\tvar filenames []string\n\n\t\tfor i, img := range m.Images {\n\t\t\tvar imgPath string\n\t\t\tif len(m.Images) == 1 {\n\t\t\t\tif title != \"\" {\n\t\t\t\t\timgPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s.%s\", title, img.Ext))\n\t\t\t\t} else {\n\t\t\t\t\timgPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s.%s\", m.ID, img.Ext))\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif title != \"\" {\n\t\t\t\t\timgPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s_%d.%s\", title, i+1, img.Ext))\n\t\t\t\t} else {\n\t\t\t\t\timgPath = filepath.Join(s.outputDir, fmt.Sprintf(\"%s_%d.%s\", m.ID, i+1, img.Ext))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfilenames = append(filenames, imgPath)\n\n\t\t\tif err := downloadFile(ctx, img.URL, imgPath, nil, nil); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to download image %d: %w\", i+1, err)\n\t\t\t}\n\t\t}\n\n\t\ts.updateJobFilename(url, strings.Join(filenames, \", \"))\n\t\treturn nil\n\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported media type\")\n\t}\n\n\t// Check if this is an HLS stream\n\tif strings.HasSuffix(strings.ToLower(downloadURL), \".m3u8\") ||\n\t\tstrings.Contains(strings.ToLower(downloadURL), \".m3u8?\") {\n\t\tfinalPath, err := downloader.DownloadHLSWithProgress(ctx, downloadURL, outputPath, headers, progressFn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif finalPath != outputPath {\n\t\t\ts.updateJobFilename(url, finalPath)\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn downloadFile(ctx, downloadURL, outputPath, headers, progressFn)\n}\n\nfunc (s *Server) updateJobFilename(url, filename string) {\n\tjobs := s.jobQueue.GetAllJobs()\n\tfor _, job := range jobs {\n\t\tif job.URL == url {\n\t\t\ts.jobQueue.mu.Lock()\n\t\t\tif j, ok := s.jobQueue.jobs[job.ID]; ok {\n\t\t\t\tj.Filename = filename\n\t\t\t}\n\t\t\ts.jobQueue.mu.Unlock()\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// downloadVideoWithAudio downloads video and audio in parallel then merges them with ffmpeg\nfunc (s *Server) downloadVideoWithAudio(ctx context.Context, format *extractor.VideoFormat, outputPath string, progressFn func(downloaded, total int64)) error {\n\t// Determine audio extension based on video format\n\taudioExt := \"m4a\"\n\tif format.Ext == \"webm\" {\n\t\taudioExt = \"opus\"\n\t}\n\n\t// Build temp filenames for video and audio\n\text := filepath.Ext(outputPath)\n\tbaseName := strings.TrimSuffix(outputPath, ext)\n\tvideoFile := baseName + \"_video\" + ext\n\taudioFile := baseName + \"_audio.\" + audioExt\n\n\t// Track progress from both downloads\n\tvar videoDownloaded, videoTotal int64\n\tvar audioDownloaded, audioTotal int64\n\tvar mu sync.Mutex\n\n\treportProgress := func() {\n\t\tif progressFn != nil {\n\t\t\tmu.Lock()\n\t\t\ttotal := videoTotal + audioTotal\n\t\t\tdownloaded := videoDownloaded + audioDownloaded\n\t\t\tmu.Unlock()\n\t\t\tif total > 0 {\n\t\t\t\tprogressFn(downloaded, total)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Download video and audio in parallel\n\tvar wg sync.WaitGroup\n\tvar videoErr, audioErr error\n\n\twg.Add(2)\n\n\t// Download video stream\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tvideoErr = downloadFile(ctx, format.URL, videoFile, format.Headers, func(downloaded, total int64) {\n\t\t\tmu.Lock()\n\t\t\tvideoDownloaded = downloaded\n\t\t\tvideoTotal = total\n\t\t\tmu.Unlock()\n\t\t\treportProgress()\n\t\t})\n\t}()\n\n\t// Download audio stream\n\tgo func() {\n\t\tdefer wg.Done()\n\t\taudioErr = downloadFile(ctx, format.AudioURL, audioFile, format.Headers, func(downloaded, total int64) {\n\t\t\tmu.Lock()\n\t\t\taudioDownloaded = downloaded\n\t\t\taudioTotal = total\n\t\t\tmu.Unlock()\n\t\t\treportProgress()\n\t\t})\n\t}()\n\n\twg.Wait()\n\n\t// Check for errors\n\tif videoErr != nil {\n\t\treturn fmt.Errorf(\"failed to download video stream: %w\", videoErr)\n\t}\n\tif audioErr != nil {\n\t\treturn fmt.Errorf(\"failed to download audio stream: %w\", audioErr)\n\t}\n\n\t// Try to merge with ffmpeg if available\n\tif downloader.FFmpegAvailable() {\n\t\t// Merge to final output path and delete temp files on success\n\t\terr := downloader.MergeVideoAudio(videoFile, audioFile, outputPath, true)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Warning: ffmpeg merge failed: %v (temp files kept: %s, %s)\", err, videoFile, audioFile)\n\t\t}\n\t} else {\n\t\t// ffmpeg not available - just leave the separate files\n\t\tlog.Printf(\"ffmpeg not found, video and audio saved separately: %s, %s\", videoFile, audioFile)\n\t}\n\n\treturn nil\n}\n\n// downloadAndStream extracts and streams the file directly to the response\nfunc (s *Server) downloadAndStream(c *gin.Context, url, filename string) {\n\text := extractor.Match(url)\n\tif ext == nil {\n\t\tsitesConfig, _ := config.LoadSites()\n\t\tif sitesConfig != nil {\n\t\t\tif site := sitesConfig.MatchSite(url); site != nil {\n\t\t\t\text = extractor.NewBrowserExtractor(site, false)\n\t\t\t}\n\t\t}\n\t\tif ext == nil {\n\t\t\text = extractor.NewGenericBrowserExtractor(false)\n\t\t}\n\t}\n\n\tif twitterExt, ok := ext.(*extractor.TwitterExtractor); ok {\n\t\tif s.cfg.Twitter.AuthToken != \"\" {\n\t\t\ttwitterExt.SetAuth(s.cfg.Twitter.AuthToken)\n\t\t}\n\t}\n\n\tmedia, err := ext.Extract(url)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"extraction failed: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\tvar downloadURL string\n\tvar headers map[string]string\n\tvar outputFilename string\n\n\tswitch m := media.(type) {\n\tcase *extractor.YouTubeDirectDownload:\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"YouTube streaming not supported. Use queued download instead.\",\n\t\t})\n\t\treturn\n\n\tcase *extractor.VideoMedia:\n\t\tif len(m.Formats) == 0 {\n\t\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\t\tCode:    500,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: \"no video formats available\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tformat := selectBestFormat(m.Formats)\n\t\tdownloadURL = format.URL\n\t\theaders = format.Headers\n\n\t\tif filename != \"\" {\n\t\t\toutputFilename = filename\n\t\t} else {\n\t\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\t\text := format.Ext\n\t\t\tif ext == \"m3u8\" {\n\t\t\t\text = \"ts\"\n\t\t\t}\n\t\t\tif title != \"\" {\n\t\t\t\toutputFilename = fmt.Sprintf(\"%s.%s\", title, ext)\n\t\t\t} else {\n\t\t\t\toutputFilename = fmt.Sprintf(\"%s.%s\", m.ID, ext)\n\t\t\t}\n\t\t}\n\n\tcase *extractor.AudioMedia:\n\t\tdownloadURL = m.URL\n\t\tif filename != \"\" {\n\t\t\toutputFilename = filename\n\t\t} else {\n\t\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\t\tif title != \"\" {\n\t\t\t\toutputFilename = fmt.Sprintf(\"%s.%s\", title, m.Ext)\n\t\t\t} else {\n\t\t\t\toutputFilename = fmt.Sprintf(\"%s.%s\", m.ID, m.Ext)\n\t\t\t}\n\t\t}\n\n\tcase *extractor.ImageMedia:\n\t\tif len(m.Images) == 0 {\n\t\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\t\tCode:    500,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: \"no images available\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\timg := m.Images[0]\n\t\tdownloadURL = img.URL\n\t\tif filename != \"\" {\n\t\t\toutputFilename = filename\n\t\t} else {\n\t\t\ttitle := extractor.SanitizeFilename(m.Title)\n\t\t\tif title != \"\" {\n\t\t\t\toutputFilename = fmt.Sprintf(\"%s.%s\", title, img.Ext)\n\t\t\t} else {\n\t\t\t\toutputFilename = fmt.Sprintf(\"%s.%s\", m.ID, img.Ext)\n\t\t\t}\n\t\t}\n\n\tdefault:\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: \"unsupported media type\",\n\t\t})\n\t\treturn\n\t}\n\n\tstreamFile(c.Writer, downloadURL, outputFilename, headers)\n}\n\nfunc selectBestFormat(formats []extractor.VideoFormat) *extractor.VideoFormat {\n\tif len(formats) == 0 {\n\t\treturn nil\n\t}\n\n\tvar bestWithAudio *extractor.VideoFormat\n\tfor i := range formats {\n\t\tf := &formats[i]\n\t\tif f.AudioURL != \"\" {\n\t\t\tif bestWithAudio == nil || f.Bitrate > bestWithAudio.Bitrate {\n\t\t\t\tbestWithAudio = f\n\t\t\t}\n\t\t}\n\t}\n\tif bestWithAudio != nil {\n\t\treturn bestWithAudio\n\t}\n\n\tbest := &formats[0]\n\tfor i := range formats {\n\t\tif formats[i].Bitrate > best.Bitrate {\n\t\t\tbest = &formats[i]\n\t\t}\n\t}\n\treturn best\n}\n\nfunc downloadFile(ctx context.Context, url, outputPath string, headers map[string]string, progressFn func(downloaded, total int64)) error {\n\tclient := &http.Client{\n\t\tTimeout: 0,\n\t\tTransport: &http.Transport{\n\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t},\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\tif len(headers) > 0 {\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t} else {\n\t\treq.Header.Set(\"User-Agent\", downloader.DefaultUserAgent)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"download request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"download failed with status %d\", resp.StatusCode)\n\t}\n\n\ttotal := resp.ContentLength\n\n\tfile, err := os.Create(outputPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create output file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\tbuf := make([]byte, 32*1024)\n\tvar downloaded int64\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tdefault:\n\t\t}\n\n\t\tn, readErr := resp.Body.Read(buf)\n\t\tif n > 0 {\n\t\t\t_, writeErr := file.Write(buf[:n])\n\t\t\tif writeErr != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to write file: %w\", writeErr)\n\t\t\t}\n\t\t\tdownloaded += int64(n)\n\t\t\tif progressFn != nil {\n\t\t\t\tprogressFn(downloaded, total)\n\t\t\t}\n\t\t}\n\t\tif readErr == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif readErr != nil {\n\t\t\treturn fmt.Errorf(\"download failed: %w\", readErr)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc streamFile(w http.ResponseWriter, url, filename string, headers map[string]string) {\n\tclient := &http.Client{\n\t\tTimeout: 0,\n\t\tTransport: &http.Transport{\n\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\thttp.Error(w, \"failed to create request\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif len(headers) > 0 {\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t} else {\n\t\treq.Header.Set(\"User-Agent\", downloader.DefaultUserAgent)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\thttp.Error(w, \"download request failed\", http.StatusBadGateway)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\thttp.Error(w, fmt.Sprintf(\"upstream returned status %d\", resp.StatusCode), http.StatusBadGateway)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Disposition\", fmt.Sprintf(`attachment; filename=\"%s\"`, filename))\n\tif resp.ContentLength > 0 {\n\t\tw.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", resp.ContentLength))\n\t}\n\tif contentType := resp.Header.Get(\"Content-Type\"); contentType != \"\" {\n\t\tw.Header().Set(\"Content-Type\", contentType)\n\t}\n\n\tio.Copy(w, resp.Body)\n}\n\n// History handlers\n\nfunc (s *Server) handleGetHistory(c *gin.Context) {\n\tif s.historyDB == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, Response{\n\t\t\tCode:    503,\n\t\t\tData:    nil,\n\t\t\tMessage: \"history database not available\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Parse pagination params\n\tlimit := 50\n\toffset := 0\n\tif l := c.Query(\"limit\"); l != \"\" {\n\t\tfmt.Sscanf(l, \"%d\", &limit)\n\t\tif limit <= 0 || limit > 100 {\n\t\t\tlimit = 50\n\t\t}\n\t}\n\tif o := c.Query(\"offset\"); o != \"\" {\n\t\tfmt.Sscanf(o, \"%d\", &offset)\n\t\tif offset < 0 {\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\trecords, total, err := s.historyDB.GetHistory(limit, offset)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to get history: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\t// Get stats\n\tcompleted, failed, totalBytes, _ := s.historyDB.GetStats()\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"records\": records,\n\t\t\t\"total\":   total,\n\t\t\t\"limit\":   limit,\n\t\t\t\"offset\":  offset,\n\t\t\t\"stats\": gin.H{\n\t\t\t\t\"completed\":   completed,\n\t\t\t\t\"failed\":      failed,\n\t\t\t\t\"total_bytes\": totalBytes,\n\t\t\t},\n\t\t},\n\t\tMessage: fmt.Sprintf(\"%d records found\", len(records)),\n\t})\n}\n\nfunc (s *Server) handleClearHistory(c *gin.Context) {\n\tif s.historyDB == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, Response{\n\t\t\tCode:    503,\n\t\t\tData:    nil,\n\t\t\tMessage: \"history database not available\",\n\t\t})\n\t\treturn\n\t}\n\n\tcount, err := s.historyDB.ClearHistory()\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: fmt.Sprintf(\"failed to clear history: %v\", err),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"cleared\": count,\n\t\t},\n\t\tMessage: fmt.Sprintf(\"%d records cleared\", count),\n\t})\n}\n\nfunc (s *Server) handleDeleteHistory(c *gin.Context) {\n\tif s.historyDB == nil {\n\t\tc.JSON(http.StatusServiceUnavailable, Response{\n\t\t\tCode:    503,\n\t\t\tData:    nil,\n\t\t\tMessage: \"history database not available\",\n\t\t})\n\t\treturn\n\t}\n\n\tid := c.Param(\"id\")\n\n\terr := s.historyDB.DeleteRecord(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\tCode:    404,\n\t\t\tData:    nil,\n\t\t\tMessage: \"record not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    gin.H{\"id\": id},\n\t\tMessage: \"record deleted\",\n\t})\n}\n"
  },
  {
    "path": "internal/server/webdav_browse.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\t\"sort\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/guiyumin/vget/internal/core/config\"\n\t\"github.com/guiyumin/vget/internal/core/webdav\"\n)\n\n// WebDAVRemoteInfo is the response for a single remote\ntype WebDAVRemoteInfo struct {\n\tName    string `json:\"name\"`\n\tURL     string `json:\"url\"`\n\tHasAuth bool   `json:\"hasAuth\"`\n}\n\n// WebDAVFileInfo is the response for a single file/directory\ntype WebDAVFileInfo struct {\n\tName  string `json:\"name\"`\n\tPath  string `json:\"path\"`\n\tSize  int64  `json:\"size\"`\n\tIsDir bool   `json:\"isDir\"`\n}\n\n// WebDAVDownloadRequest is the request body for POST /api/webdav/download\ntype WebDAVDownloadRequest struct {\n\tRemote string   `json:\"remote\" binding:\"required\"`\n\tFiles  []string `json:\"files\" binding:\"required\"`\n}\n\n// GET /api/webdav/remotes - List all configured WebDAV servers\nfunc (s *Server) handleWebDAVRemotes(c *gin.Context) {\n\tcfg := config.LoadOrDefault()\n\n\tremotes := make([]WebDAVRemoteInfo, 0, len(cfg.WebDAVServers))\n\tfor name, server := range cfg.WebDAVServers {\n\t\tremotes = append(remotes, WebDAVRemoteInfo{\n\t\t\tName:    name,\n\t\t\tURL:     server.URL,\n\t\t\tHasAuth: server.Username != \"\",\n\t\t})\n\t}\n\n\t// Sort by name for consistent ordering\n\tsort.Slice(remotes, func(i, j int) bool {\n\t\treturn remotes[i].Name < remotes[j].Name\n\t})\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode:    200,\n\t\tData:    gin.H{\"remotes\": remotes},\n\t\tMessage: \"success\",\n\t})\n}\n\n// GET /api/webdav/list?remote=xxx&path=/xxx - List directory contents\nfunc (s *Server) handleWebDAVList(c *gin.Context) {\n\tremoteName := c.Query(\"remote\")\n\tif remoteName == \"\" {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"remote parameter is required\",\n\t\t})\n\t\treturn\n\t}\n\n\tpath := c.Query(\"path\")\n\tif path == \"\" {\n\t\tpath = \"/\"\n\t}\n\n\tcfg := config.LoadOrDefault()\n\tserver := cfg.GetWebDAVServer(remoteName)\n\tif server == nil {\n\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\tCode:    404,\n\t\t\tData:    nil,\n\t\t\tMessage: \"remote not found: \" + remoteName,\n\t\t})\n\t\treturn\n\t}\n\n\tclient, err := webdav.NewClientFromConfig(server)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: \"failed to connect to WebDAV server: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tfiles, err := client.List(c.Request.Context(), path)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\tCode:    500,\n\t\t\tData:    nil,\n\t\t\tMessage: \"failed to list directory: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Convert to response format\n\tresult := make([]WebDAVFileInfo, len(files))\n\tfor i, f := range files {\n\t\tresult[i] = WebDAVFileInfo{\n\t\t\tName:  f.Name,\n\t\t\tPath:  f.Path,\n\t\t\tSize:  f.Size,\n\t\t\tIsDir: f.IsDir,\n\t\t}\n\t}\n\n\t// Sort: directories first, then by name\n\tsort.Slice(result, func(i, j int) bool {\n\t\tif result[i].IsDir != result[j].IsDir {\n\t\t\treturn result[i].IsDir\n\t\t}\n\t\treturn result[i].Name < result[j].Name\n\t})\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"remote\": remoteName,\n\t\t\t\"path\":   path,\n\t\t\t\"files\":  result,\n\t\t},\n\t\tMessage: \"success\",\n\t})\n}\n\n// POST /api/webdav/download - Queue download(s) from WebDAV\nfunc (s *Server) handleWebDAVDownload(c *gin.Context) {\n\tvar req WebDAVDownloadRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"invalid request: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tif len(req.Files) == 0 {\n\t\tc.JSON(http.StatusBadRequest, Response{\n\t\t\tCode:    400,\n\t\t\tData:    nil,\n\t\t\tMessage: \"no files specified\",\n\t\t})\n\t\treturn\n\t}\n\n\tcfg := config.LoadOrDefault()\n\tserver := cfg.GetWebDAVServer(req.Remote)\n\tif server == nil {\n\t\tc.JSON(http.StatusNotFound, Response{\n\t\t\tCode:    404,\n\t\t\tData:    nil,\n\t\t\tMessage: \"remote not found: \" + req.Remote,\n\t\t})\n\t\treturn\n\t}\n\n\t// Queue downloads for each file\n\tjobIDs := make([]string, 0, len(req.Files))\n\tfor _, filePath := range req.Files {\n\t\t// Build the remote URL in the format the downloader expects\n\t\turl := req.Remote + \":\" + filePath\n\n\t\tjob, err := s.jobQueue.AddJob(url, \"\")\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, Response{\n\t\t\t\tCode:    500,\n\t\t\t\tData:    nil,\n\t\t\t\tMessage: \"failed to queue download: \" + err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tjobIDs = append(jobIDs, job.ID)\n\t}\n\n\tc.JSON(http.StatusOK, Response{\n\t\tCode: 200,\n\t\tData: gin.H{\n\t\t\t\"jobIds\": jobIDs,\n\t\t\t\"count\":  len(jobIDs),\n\t\t},\n\t\tMessage: \"downloads queued\",\n\t})\n}\n"
  },
  {
    "path": "internal/torrent/client.go",
    "content": "// Package torrent provides integration with various torrent clients for remote download management.\n// vget doesn't download torrents directly - it dispatches jobs to existing torrent clients\n// running on NAS devices or local machines.\npackage torrent\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\n// TorrentState represents the current state of a torrent\ntype TorrentState int\n\nconst (\n\tStateStopped TorrentState = iota\n\tStateQueued\n\tStateDownloading\n\tStateSeeding\n\tStatePaused\n\tStateChecking\n\tStateError\n\tStateUnknown\n)\n\nfunc (s TorrentState) String() string {\n\tswitch s {\n\tcase StateStopped:\n\t\treturn \"stopped\"\n\tcase StateQueued:\n\t\treturn \"queued\"\n\tcase StateDownloading:\n\t\treturn \"downloading\"\n\tcase StateSeeding:\n\t\treturn \"seeding\"\n\tcase StatePaused:\n\t\treturn \"paused\"\n\tcase StateChecking:\n\t\treturn \"checking\"\n\tcase StateError:\n\t\treturn \"error\"\n\tdefault:\n\t\treturn \"unknown\"\n\t}\n}\n\n// TorrentInfo contains information about a torrent\ntype TorrentInfo struct {\n\tID            string       // Client-specific ID (hash or numeric)\n\tHash          string       // InfoHash\n\tName          string       // Torrent name\n\tState         TorrentState // Current state\n\tProgress      float64      // Download progress (0.0 - 1.0)\n\tSize          int64        // Total size in bytes\n\tDownloaded    int64        // Downloaded bytes\n\tUploaded      int64        // Uploaded bytes\n\tDownloadSpeed int64        // Current download speed (bytes/sec)\n\tUploadSpeed   int64        // Current upload speed (bytes/sec)\n\tRatio         float64      // Upload/Download ratio\n\tETA           int64        // Estimated time remaining (seconds), -1 if unknown\n\tSavePath      string       // Download location\n\tError         string       // Error message if State == StateError\n}\n\n// AddOptions contains options for adding a torrent\ntype AddOptions struct {\n\t// SavePath overrides the default download directory\n\tSavePath string\n\n\t// Paused starts the torrent in paused state\n\tPaused bool\n\n\t// Labels/Tags to apply (not all clients support this)\n\tLabels []string\n\n\t// Category (qBittorrent specific, but useful abstraction)\n\tCategory string\n\n\t// DownloadSpeedLimit in bytes/sec (0 = unlimited)\n\tDownloadSpeedLimit int64\n\n\t// UploadSpeedLimit in bytes/sec (0 = unlimited)\n\tUploadSpeedLimit int64\n}\n\n// AddResult contains the result of adding a torrent\ntype AddResult struct {\n\tID        string // Client-specific ID\n\tHash      string // InfoHash\n\tName      string // Torrent name (may be empty if magnet hasn't resolved yet)\n\tDuplicate bool   // True if torrent was already in the client\n}\n\n// Client defines the interface for torrent client implementations\ntype Client interface {\n\t// Name returns the client name (e.g., \"transmission\", \"qbittorrent\")\n\tName() string\n\n\t// Connect establishes connection and authenticates with the client\n\t// Should be called before other operations\n\tConnect() error\n\n\t// Close cleans up any resources (e.g., logout)\n\tClose() error\n\n\t// AddMagnet adds a torrent via magnet link\n\tAddMagnet(magnetURL string, opts *AddOptions) (*AddResult, error)\n\n\t// AddTorrentURL adds a torrent via HTTP/HTTPS URL to a .torrent file\n\tAddTorrentURL(url string, opts *AddOptions) (*AddResult, error)\n\n\t// AddTorrentFile adds a torrent from a local .torrent file\n\tAddTorrentFile(path string, opts *AddOptions) (*AddResult, error)\n\n\t// GetTorrent retrieves info about a specific torrent by ID or hash\n\tGetTorrent(id string) (*TorrentInfo, error)\n\n\t// ListTorrents retrieves info about all torrents\n\tListTorrents() ([]TorrentInfo, error)\n\n\t// RemoveTorrent removes a torrent\n\t// If deleteData is true, also deletes downloaded files\n\tRemoveTorrent(id string, deleteData bool) error\n\n\t// PauseTorrent pauses a torrent\n\tPauseTorrent(id string) error\n\n\t// ResumeTorrent resumes a paused torrent\n\tResumeTorrent(id string) error\n}\n\n// ClientType represents supported torrent client types\ntype ClientType string\n\nconst (\n\tClientTransmission ClientType = \"transmission\"\n\tClientQBittorrent  ClientType = \"qbittorrent\"\n\tClientSynology     ClientType = \"synology\"\n)\n\n// Config holds configuration for connecting to a torrent client\ntype Config struct {\n\tType     ClientType\n\tHost     string // hostname:port\n\tUsername string\n\tPassword string\n\tUseHTTPS bool\n}\n\n// Common errors\nvar (\n\tErrNotConnected    = errors.New(\"not connected to torrent client\")\n\tErrAuthFailed      = errors.New(\"authentication failed\")\n\tErrTorrentNotFound = errors.New(\"torrent not found\")\n\tErrDuplicateTorrent = errors.New(\"torrent already exists\")\n\tErrInvalidMagnet   = errors.New(\"invalid magnet link\")\n\tErrInvalidTorrent  = errors.New(\"invalid torrent file\")\n\tErrConnectionFailed = errors.New(\"failed to connect to torrent client\")\n)\n\n// NewClient creates a new torrent client based on the config\nfunc NewClient(cfg *Config) (Client, error) {\n\tswitch cfg.Type {\n\tcase ClientTransmission:\n\t\treturn NewTransmissionClient(cfg), nil\n\tcase ClientQBittorrent:\n\t\treturn NewQBittorrentClient(cfg), nil\n\tcase ClientSynology:\n\t\treturn NewSynologyClient(cfg), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported torrent client type: %s\", cfg.Type)\n\t}\n}\n\n// IsMagnetLink checks if a URL is a magnet link\nfunc IsMagnetLink(url string) bool {\n\treturn len(url) > 8 && url[:8] == \"magnet:?\"\n}\n\n// IsTorrentURL checks if a URL points to a .torrent file\nfunc IsTorrentURL(url string) bool {\n\t// Simple check - could be more sophisticated\n\treturn len(url) > 8 && (url[:7] == \"http://\" || url[:8] == \"https://\")\n}\n"
  },
  {
    "path": "internal/torrent/qbittorrent.go",
    "content": "package torrent\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/http/cookiejar\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\n// QBittorrentClient implements the Client interface for qBittorrent Web UI\n// qBittorrent Web API: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)\ntype QBittorrentClient struct {\n\tconfig     *Config\n\tclient     *http.Client\n\tbaseURL    string\n\tapiVersion string\n}\n\n// qBittorrent torrent states\nconst (\n\tqbStateError              = \"error\"\n\tqbStateMissingFiles       = \"missingFiles\"\n\tqbStateUploading          = \"uploading\"\n\tqbStatePausedUP           = \"pausedUP\"\n\tqbStateQueuedUP           = \"queuedUP\"\n\tqbStateStalledUP          = \"stalledUP\"\n\tqbStateCheckingUP         = \"checkingUP\"\n\tqbStateForcedUP           = \"forcedUP\"\n\tqbStateAllocating         = \"allocating\"\n\tqbStateDownloading        = \"downloading\"\n\tqbStateMetaDL             = \"metaDL\"\n\tqbStatePausedDL           = \"pausedDL\"\n\tqbStateQueuedDL           = \"queuedDL\"\n\tqbStateStalledDL          = \"stalledDL\"\n\tqbStateCheckingDL         = \"checkingDL\"\n\tqbStateForcedDL           = \"forcedDL\"\n\tqbStateCheckingResumeData = \"checkingResumeData\"\n\tqbStateMoving             = \"moving\"\n\tqbStateUnknown            = \"unknown\"\n)\n\n// NewQBittorrentClient creates a new qBittorrent client\nfunc NewQBittorrentClient(cfg *Config) *QBittorrentClient {\n\tscheme := \"http\"\n\tif cfg.UseHTTPS {\n\t\tscheme = \"https\"\n\t}\n\n\t// Create cookie jar for session management\n\tjar, _ := cookiejar.New(nil)\n\n\treturn &QBittorrentClient{\n\t\tconfig:  cfg,\n\t\tbaseURL: fmt.Sprintf(\"%s://%s\", scheme, cfg.Host),\n\t\tclient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t\tJar:     jar,\n\t\t},\n\t}\n}\n\nfunc (c *QBittorrentClient) Name() string {\n\treturn \"qbittorrent\"\n}\n\n// Connect authenticates with qBittorrent\nfunc (c *QBittorrentClient) Connect() error {\n\t// Login endpoint\n\tloginURL := c.baseURL + \"/api/v2/auth/login\"\n\n\tdata := url.Values{}\n\tdata.Set(\"username\", c.config.Username)\n\tdata.Set(\"password\", c.config.Password)\n\n\tresp, err := c.client.PostForm(loginURL, data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: %v\", ErrConnectionFailed, err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, _ := io.ReadAll(resp.Body)\n\n\tif resp.StatusCode == http.StatusForbidden {\n\t\treturn ErrAuthFailed\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"login failed with status %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\t// qBittorrent returns \"Ok.\" on success, \"Fails.\" on failure\n\tif strings.TrimSpace(string(body)) == \"Fails.\" {\n\t\treturn ErrAuthFailed\n\t}\n\n\t// Get API version for compatibility checks\n\tc.getAPIVersion()\n\n\treturn nil\n}\n\nfunc (c *QBittorrentClient) getAPIVersion() {\n\tresp, err := c.client.Get(c.baseURL + \"/api/v2/app/webapiVersion\")\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, _ := io.ReadAll(resp.Body)\n\tc.apiVersion = strings.TrimSpace(string(body))\n}\n\n// Close logs out from qBittorrent\nfunc (c *QBittorrentClient) Close() error {\n\tresp, err := c.client.Post(c.baseURL+\"/api/v2/auth/logout\", \"\", nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresp.Body.Close()\n\treturn nil\n}\n\n// AddMagnet adds a torrent via magnet link\nfunc (c *QBittorrentClient) AddMagnet(magnetURL string, opts *AddOptions) (*AddResult, error) {\n\tif !IsMagnetLink(magnetURL) {\n\t\treturn nil, ErrInvalidMagnet\n\t}\n\n\treturn c.addTorrent(magnetURL, nil, opts)\n}\n\n// AddTorrentURL adds a torrent via HTTP/HTTPS URL\nfunc (c *QBittorrentClient) AddTorrentURL(torrentURL string, opts *AddOptions) (*AddResult, error) {\n\treturn c.addTorrent(torrentURL, nil, opts)\n}\n\n// AddTorrentFile adds a torrent from a local .torrent file\nfunc (c *QBittorrentClient) AddTorrentFile(path string, opts *AddOptions) (*AddResult, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read torrent file: %w\", err)\n\t}\n\n\treturn c.addTorrent(\"\", &torrentFile{\n\t\tname: filepath.Base(path),\n\t\tdata: data,\n\t}, opts)\n}\n\ntype torrentFile struct {\n\tname string\n\tdata []byte\n}\n\nfunc (c *QBittorrentClient) addTorrent(urls string, file *torrentFile, opts *AddOptions) (*AddResult, error) {\n\tvar body bytes.Buffer\n\twriter := multipart.NewWriter(&body)\n\n\t// Add URLs (magnet or http)\n\tif urls != \"\" {\n\t\tif err := writer.WriteField(\"urls\", urls); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Add torrent file\n\tif file != nil {\n\t\tpart, err := writer.CreateFormFile(\"torrents\", file.name)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif _, err := part.Write(file.data); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Add options\n\tif opts != nil {\n\t\tif opts.SavePath != \"\" {\n\t\t\twriter.WriteField(\"savepath\", opts.SavePath)\n\t\t}\n\t\tif opts.Paused {\n\t\t\twriter.WriteField(\"paused\", \"true\")\n\t\t}\n\t\tif opts.Category != \"\" {\n\t\t\twriter.WriteField(\"category\", opts.Category)\n\t\t}\n\t\tif len(opts.Labels) > 0 {\n\t\t\twriter.WriteField(\"tags\", strings.Join(opts.Labels, \",\"))\n\t\t}\n\t\tif opts.DownloadSpeedLimit > 0 {\n\t\t\twriter.WriteField(\"dlLimit\", fmt.Sprintf(\"%d\", opts.DownloadSpeedLimit))\n\t\t}\n\t\tif opts.UploadSpeedLimit > 0 {\n\t\t\twriter.WriteField(\"upLimit\", fmt.Sprintf(\"%d\", opts.UploadSpeedLimit))\n\t\t}\n\t}\n\n\twriter.Close()\n\n\treq, err := http.NewRequest(\"POST\", c.baseURL+\"/api/v2/torrents/add\", &body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\trespBody, _ := io.ReadAll(resp.Body)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"failed to add torrent: %s\", string(respBody))\n\t}\n\n\t// qBittorrent returns \"Ok.\" on success\n\tif strings.TrimSpace(string(respBody)) != \"Ok.\" {\n\t\treturn nil, fmt.Errorf(\"failed to add torrent: %s\", string(respBody))\n\t}\n\n\t// qBittorrent doesn't return the hash on add, we'd need to extract it from magnet\n\t// or wait and query. For now, return minimal info.\n\tresult := &AddResult{\n\t\tDuplicate: false,\n\t}\n\n\t// Try to extract hash from magnet link\n\tif IsMagnetLink(urls) {\n\t\tif hash := extractHashFromMagnet(urls); hash != \"\" {\n\t\t\tresult.Hash = hash\n\t\t\tresult.ID = hash\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\nfunc extractHashFromMagnet(magnet string) string {\n\t// magnet:?xt=urn:btih:HASH&...\n\tlower := strings.ToLower(magnet)\n\tidx := strings.Index(lower, \"btih:\")\n\tif idx == -1 {\n\t\treturn \"\"\n\t}\n\n\tstart := idx + 5\n\tend := start\n\tfor end < len(magnet) && magnet[end] != '&' {\n\t\tend++\n\t}\n\n\thash := magnet[start:end]\n\t// Hash can be hex (40 chars) or base32 (32 chars)\n\tif len(hash) == 40 || len(hash) == 32 {\n\t\treturn strings.ToLower(hash)\n\t}\n\treturn \"\"\n}\n\n// GetTorrent retrieves info about a specific torrent by hash\nfunc (c *QBittorrentClient) GetTorrent(id string) (*TorrentInfo, error) {\n\ttorrents, err := c.getTorrents(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(torrents) == 0 {\n\t\treturn nil, ErrTorrentNotFound\n\t}\n\n\treturn &torrents[0], nil\n}\n\n// ListTorrents retrieves info about all torrents\nfunc (c *QBittorrentClient) ListTorrents() ([]TorrentInfo, error) {\n\treturn c.getTorrents(\"\")\n}\n\nfunc (c *QBittorrentClient) getTorrents(hash string) ([]TorrentInfo, error) {\n\turl := c.baseURL + \"/api/v2/torrents/info\"\n\tif hash != \"\" {\n\t\turl += \"?hashes=\" + hash\n\t}\n\n\tresp, err := c.client.Get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"failed to get torrents: status %d\", resp.StatusCode)\n\t}\n\n\tvar qbTorrents []qbTorrent\n\tif err := json.NewDecoder(resp.Body).Decode(&qbTorrents); err != nil {\n\t\treturn nil, err\n\t}\n\n\ttorrents := make([]TorrentInfo, len(qbTorrents))\n\tfor i, t := range qbTorrents {\n\t\ttorrents[i] = c.convertTorrent(&t)\n\t}\n\n\treturn torrents, nil\n}\n\ntype qbTorrent struct {\n\tHash           string  `json:\"hash\"`\n\tName           string  `json:\"name\"`\n\tState          string  `json:\"state\"`\n\tProgress       float64 `json:\"progress\"`\n\tSize           int64   `json:\"size\"`\n\tDownloaded     int64   `json:\"downloaded\"`\n\tUploaded       int64   `json:\"uploaded\"`\n\tDlSpeed        int64   `json:\"dlspeed\"`\n\tUpSpeed        int64   `json:\"upspeed\"`\n\tRatio          float64 `json:\"ratio\"`\n\tETA            int64   `json:\"eta\"`\n\tSavePath       string  `json:\"save_path\"`\n\tCategory       string  `json:\"category\"`\n\tTags           string  `json:\"tags\"`\n\tAddedOn        int64   `json:\"added_on\"`\n\tCompletionOn   int64   `json:\"completion_on\"`\n\tTracker        string  `json:\"tracker\"`\n\tNumSeeds       int     `json:\"num_seeds\"`\n\tNumLeechers    int     `json:\"num_leechs\"`\n\tAvailablePeers int     `json:\"num_incomplete\"`\n}\n\nfunc (c *QBittorrentClient) convertTorrent(t *qbTorrent) TorrentInfo {\n\treturn TorrentInfo{\n\t\tID:            t.Hash,\n\t\tHash:          t.Hash,\n\t\tName:          t.Name,\n\t\tState:         c.convertState(t.State),\n\t\tProgress:      t.Progress,\n\t\tSize:          t.Size,\n\t\tDownloaded:    t.Downloaded,\n\t\tUploaded:      t.Uploaded,\n\t\tDownloadSpeed: t.DlSpeed,\n\t\tUploadSpeed:   t.UpSpeed,\n\t\tRatio:         t.Ratio,\n\t\tETA:           t.ETA,\n\t\tSavePath:      t.SavePath,\n\t}\n}\n\nfunc (c *QBittorrentClient) convertState(state string) TorrentState {\n\tswitch state {\n\tcase qbStateError, qbStateMissingFiles:\n\t\treturn StateError\n\tcase qbStateUploading, qbStateForcedUP, qbStateStalledUP:\n\t\treturn StateSeeding\n\tcase qbStatePausedUP, qbStatePausedDL:\n\t\treturn StatePaused\n\tcase qbStateQueuedUP, qbStateQueuedDL:\n\t\treturn StateQueued\n\tcase qbStateCheckingUP, qbStateCheckingDL, qbStateCheckingResumeData:\n\t\treturn StateChecking\n\tcase qbStateDownloading, qbStateForcedDL, qbStateStalledDL, qbStateMetaDL, qbStateAllocating:\n\t\treturn StateDownloading\n\tcase qbStateMoving:\n\t\treturn StateDownloading // Close enough\n\tdefault:\n\t\treturn StateUnknown\n\t}\n}\n\n// RemoveTorrent removes a torrent\nfunc (c *QBittorrentClient) RemoveTorrent(id string, deleteData bool) error {\n\tdata := url.Values{}\n\tdata.Set(\"hashes\", id)\n\tdata.Set(\"deleteFiles\", fmt.Sprintf(\"%t\", deleteData))\n\n\tresp, err := c.client.PostForm(c.baseURL+\"/api/v2/torrents/delete\", data)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"failed to remove torrent: status %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n\n// PauseTorrent pauses a torrent\nfunc (c *QBittorrentClient) PauseTorrent(id string) error {\n\tdata := url.Values{}\n\tdata.Set(\"hashes\", id)\n\n\tresp, err := c.client.PostForm(c.baseURL+\"/api/v2/torrents/pause\", data)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"failed to pause torrent: status %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n\n// ResumeTorrent resumes a paused torrent\nfunc (c *QBittorrentClient) ResumeTorrent(id string) error {\n\tdata := url.Values{}\n\tdata.Set(\"hashes\", id)\n\n\tresp, err := c.client.PostForm(c.baseURL+\"/api/v2/torrents/resume\", data)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"failed to resume torrent: status %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/torrent/synology.go",
    "content": "package torrent\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// SynologyClient implements the Client interface for Synology Download Station\n// Synology Download Station API: https://global.download.synology.com/download/Document/Software/DeveloperGuide/Package/DownloadStation/All/enu/Synology_Download_Station_Web_API.pdf\ntype SynologyClient struct {\n\tconfig  *Config\n\tclient  *http.Client\n\tbaseURL string\n\tsid     string // Session ID\n}\n\n// Synology API response structure\ntype synResponse struct {\n\tSuccess bool            `json:\"success\"`\n\tData    json.RawMessage `json:\"data,omitempty\"`\n\tError   *synError       `json:\"error,omitempty\"`\n}\n\ntype synError struct {\n\tCode int `json:\"code\"`\n}\n\n// Synology error codes (common)\nconst (\n\tsynErrUnknown           = 100\n\tsynErrInvalidParam      = 101\n\tsynErrAPINotExists      = 102\n\tsynErrMethodNotExists   = 103\n\tsynErrVersionNotSupport = 104\n\tsynErrPermDenied        = 105\n\tsynErrTimeout           = 106\n\tsynErrDuplicate         = 107\n)\n\n// Note: Synology uses overlapping error codes across APIs (Auth vs DownloadStation)\n// Error codes 400-405 have different meanings depending on the API being called.\n// We handle them numerically in convertError() to avoid constant duplication.\n\n// Synology task status\nconst (\n\tsynStatusWaiting     = \"waiting\"\n\tsynStatusDownloading = \"downloading\"\n\tsynStatusPaused      = \"paused\"\n\tsynStatusFinishing   = \"finishing\"\n\tsynStatusFinished    = \"finished\"\n\tsynStatusHashChecking = \"hash_checking\"\n\tsynStatusSeeding     = \"seeding\"\n\tsynStatusFileHosting = \"filehosting_waiting\"\n\tsynStatusExtracting  = \"extracting\"\n\tsynStatusError       = \"error\"\n)\n\n// NewSynologyClient creates a new Synology Download Station client\nfunc NewSynologyClient(cfg *Config) *SynologyClient {\n\tscheme := \"http\"\n\tif cfg.UseHTTPS {\n\t\tscheme = \"https\"\n\t}\n\n\t// Synology uses port 5000 for HTTP, 5001 for HTTPS by default\n\thost := cfg.Host\n\tif !strings.Contains(host, \":\") {\n\t\tif cfg.UseHTTPS {\n\t\t\thost += \":5001\"\n\t\t} else {\n\t\t\thost += \":5000\"\n\t\t}\n\t}\n\n\treturn &SynologyClient{\n\t\tconfig:  cfg,\n\t\tbaseURL: fmt.Sprintf(\"%s://%s\", scheme, host),\n\t\tclient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\nfunc (c *SynologyClient) Name() string {\n\treturn \"synology\"\n}\n\n// Connect authenticates with Synology DSM\nfunc (c *SynologyClient) Connect() error {\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.API.Auth\")\n\tparams.Set(\"version\", \"3\")\n\tparams.Set(\"method\", \"login\")\n\tparams.Set(\"account\", c.config.Username)\n\tparams.Set(\"passwd\", c.config.Password)\n\tparams.Set(\"session\", \"DownloadStation\")\n\tparams.Set(\"format\", \"sid\")\n\n\tresp, err := c.doRequest(\"/webapi/auth.cgi\", params, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: %v\", ErrConnectionFailed, err)\n\t}\n\n\tvar data struct {\n\t\tSid string `json:\"sid\"`\n\t}\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse auth response: %w\", err)\n\t}\n\n\tc.sid = data.Sid\n\treturn nil\n}\n\n// Close logs out from Synology DSM\nfunc (c *SynologyClient) Close() error {\n\tif c.sid == \"\" {\n\t\treturn nil\n\t}\n\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.API.Auth\")\n\tparams.Set(\"version\", \"1\")\n\tparams.Set(\"method\", \"logout\")\n\tparams.Set(\"session\", \"DownloadStation\")\n\tparams.Set(\"_sid\", c.sid)\n\n\tc.client.Get(c.baseURL + \"/webapi/auth.cgi?\" + params.Encode())\n\tc.sid = \"\"\n\treturn nil\n}\n\n// doRequest performs an API request\nfunc (c *SynologyClient) doRequest(path string, params url.Values, body io.Reader) (*synResponse, error) {\n\t// Add session ID to all requests\n\tif c.sid != \"\" && params.Get(\"_sid\") == \"\" {\n\t\tparams.Set(\"_sid\", c.sid)\n\t}\n\n\tvar resp *http.Response\n\tvar err error\n\n\tif body != nil {\n\t\t// POST request with body\n\t\treq, err := http.NewRequest(\"POST\", c.baseURL+path+\"?\"+params.Encode(), body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif mw, ok := body.(*bytes.Buffer); ok {\n\t\t\t_ = mw // For multipart, content-type is set separately\n\t\t}\n\t\tresp, err = c.client.Do(req)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t} else {\n\t\t// GET request\n\t\tresp, err = c.client.Get(c.baseURL + path + \"?\" + params.Encode())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tdefer resp.Body.Close()\n\n\tvar synResp synResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&synResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\tif !synResp.Success {\n\t\treturn nil, c.convertError(synResp.Error)\n\t}\n\n\treturn &synResp, nil\n}\n\nfunc (c *SynologyClient) convertError(err *synError) error {\n\tif err == nil {\n\t\treturn fmt.Errorf(\"unknown error\")\n\t}\n\n\t// Synology uses overlapping error codes across different APIs\n\t// Handle common codes first, then specific ranges\n\tswitch err.Code {\n\tcase synErrDuplicate:\n\t\treturn ErrDuplicateTorrent\n\tcase synErrInvalidParam:\n\t\treturn fmt.Errorf(\"invalid parameter\")\n\tcase synErrPermDenied:\n\t\treturn fmt.Errorf(\"permission denied\")\n\tcase 400: // Auth failed or DS file upload failed\n\t\treturn ErrAuthFailed\n\tcase 401: // Auth no permission or DS max tasks\n\t\treturn fmt.Errorf(\"permission denied or maximum tasks reached\")\n\tcase 402: // Auth account locked or DS dest denied\n\t\treturn fmt.Errorf(\"account locked or destination denied\")\n\tcase 403: // DS destination not exist\n\t\treturn fmt.Errorf(\"destination does not exist\")\n\tcase 405: // DS invalid task ID\n\t\treturn ErrTorrentNotFound\n\tdefault:\n\t\treturn fmt.Errorf(\"synology error code: %d\", err.Code)\n\t}\n}\n\n// AddMagnet adds a torrent via magnet link\nfunc (c *SynologyClient) AddMagnet(magnetURL string, opts *AddOptions) (*AddResult, error) {\n\tif !IsMagnetLink(magnetURL) {\n\t\treturn nil, ErrInvalidMagnet\n\t}\n\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.DownloadStation.Task\")\n\tparams.Set(\"version\", \"3\")\n\tparams.Set(\"method\", \"create\")\n\tparams.Set(\"uri\", magnetURL)\n\n\tif opts != nil && opts.SavePath != \"\" {\n\t\tparams.Set(\"destination\", opts.SavePath)\n\t}\n\n\t_, err := c.doRequest(\"/webapi/DownloadStation/task.cgi\", params, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Synology doesn't return task ID on create, extract hash from magnet\n\tresult := &AddResult{}\n\tif hash := extractHashFromMagnet(magnetURL); hash != \"\" {\n\t\tresult.Hash = hash\n\t\tresult.ID = hash\n\t}\n\n\treturn result, nil\n}\n\n// AddTorrentURL adds a torrent via HTTP/HTTPS URL\nfunc (c *SynologyClient) AddTorrentURL(torrentURL string, opts *AddOptions) (*AddResult, error) {\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.DownloadStation.Task\")\n\tparams.Set(\"version\", \"3\")\n\tparams.Set(\"method\", \"create\")\n\tparams.Set(\"uri\", torrentURL)\n\n\tif opts != nil && opts.SavePath != \"\" {\n\t\tparams.Set(\"destination\", opts.SavePath)\n\t}\n\n\t_, err := c.doRequest(\"/webapi/DownloadStation/task.cgi\", params, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &AddResult{}, nil\n}\n\n// AddTorrentFile adds a torrent from a local .torrent file\nfunc (c *SynologyClient) AddTorrentFile(path string, opts *AddOptions) (*AddResult, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read torrent file: %w\", err)\n\t}\n\n\tvar body bytes.Buffer\n\twriter := multipart.NewWriter(&body)\n\n\t// Add torrent file\n\tpart, err := writer.CreateFormFile(\"file\", filepath.Base(path))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif _, err := part.Write(data); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Add API parameters as form fields\n\twriter.WriteField(\"api\", \"SYNO.DownloadStation.Task\")\n\twriter.WriteField(\"version\", \"3\")\n\twriter.WriteField(\"method\", \"create\")\n\n\tif opts != nil && opts.SavePath != \"\" {\n\t\twriter.WriteField(\"destination\", opts.SavePath)\n\t}\n\n\twriter.Close()\n\n\tparams := url.Values{}\n\tparams.Set(\"_sid\", c.sid)\n\n\treq, err := http.NewRequest(\"POST\", c.baseURL+\"/webapi/DownloadStation/task.cgi?\"+params.Encode(), &body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar synResp synResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&synResp); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !synResp.Success {\n\t\treturn nil, c.convertError(synResp.Error)\n\t}\n\n\treturn &AddResult{}, nil\n}\n\n// GetTorrent retrieves info about a specific torrent by ID\nfunc (c *SynologyClient) GetTorrent(id string) (*TorrentInfo, error) {\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.DownloadStation.Task\")\n\tparams.Set(\"version\", \"1\")\n\tparams.Set(\"method\", \"getinfo\")\n\tparams.Set(\"id\", id)\n\tparams.Set(\"additional\", \"detail,transfer\")\n\n\tresp, err := c.doRequest(\"/webapi/DownloadStation/task.cgi\", params, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar data struct {\n\t\tTasks []synTask `json:\"tasks\"`\n\t}\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(data.Tasks) == 0 {\n\t\treturn nil, ErrTorrentNotFound\n\t}\n\n\treturn c.convertTask(&data.Tasks[0]), nil\n}\n\n// ListTorrents retrieves info about all torrents\nfunc (c *SynologyClient) ListTorrents() ([]TorrentInfo, error) {\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.DownloadStation.Task\")\n\tparams.Set(\"version\", \"1\")\n\tparams.Set(\"method\", \"list\")\n\tparams.Set(\"additional\", \"detail,transfer\")\n\n\tresp, err := c.doRequest(\"/webapi/DownloadStation/task.cgi\", params, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar data struct {\n\t\tTasks  []synTask `json:\"tasks\"`\n\t\tTotal  int       `json:\"total\"`\n\t\tOffset int       `json:\"offset\"`\n\t}\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\treturn nil, err\n\t}\n\n\ttorrents := make([]TorrentInfo, 0, len(data.Tasks))\n\tfor _, t := range data.Tasks {\n\t\t// Only include BT tasks (filter out HTTP downloads, etc.)\n\t\tif t.Type == \"bt\" {\n\t\t\ttorrents = append(torrents, *c.convertTask(&t))\n\t\t}\n\t}\n\n\treturn torrents, nil\n}\n\ntype synTask struct {\n\tID         string        `json:\"id\"`\n\tType       string        `json:\"type\"` // bt, http, ftp, etc.\n\tTitle      string        `json:\"title\"`\n\tSize       int64         `json:\"size\"`\n\tStatus     string        `json:\"status\"`\n\tAdditional synAdditional `json:\"additional\"`\n}\n\ntype synAdditional struct {\n\tDetail   synDetail   `json:\"detail\"`\n\tTransfer synTransfer `json:\"transfer\"`\n}\n\ntype synDetail struct {\n\tDestination       string `json:\"destination\"`\n\tURI               string `json:\"uri\"`\n\tCreateTime        int64  `json:\"create_time\"`\n\tCompletedTime     int64  `json:\"completed_time\"`\n\tTotalPeers        int    `json:\"total_peers\"`\n\tConnectedSeeders  int    `json:\"connected_seeders\"`\n\tConnectedLeechers int    `json:\"connected_leechers\"`\n}\n\ntype synTransfer struct {\n\tSizeDownloaded   int64 `json:\"size_downloaded\"`\n\tSizeUploaded     int64 `json:\"size_uploaded\"`\n\tSpeedDownload    int64 `json:\"speed_download\"`\n\tSpeedUpload      int64 `json:\"speed_upload\"`\n}\n\nfunc (c *SynologyClient) convertTask(t *synTask) *TorrentInfo {\n\tvar progress float64\n\tif t.Size > 0 {\n\t\tprogress = float64(t.Additional.Transfer.SizeDownloaded) / float64(t.Size)\n\t}\n\n\tvar ratio float64\n\tif t.Additional.Transfer.SizeDownloaded > 0 {\n\t\tratio = float64(t.Additional.Transfer.SizeUploaded) / float64(t.Additional.Transfer.SizeDownloaded)\n\t}\n\n\t// Estimate ETA\n\tvar eta int64 = -1\n\tif t.Additional.Transfer.SpeedDownload > 0 {\n\t\tremaining := t.Size - t.Additional.Transfer.SizeDownloaded\n\t\teta = remaining / t.Additional.Transfer.SpeedDownload\n\t}\n\n\treturn &TorrentInfo{\n\t\tID:            t.ID,\n\t\tHash:          \"\", // Synology doesn't expose hash directly in task list\n\t\tName:          t.Title,\n\t\tState:         c.convertStatus(t.Status),\n\t\tProgress:      progress,\n\t\tSize:          t.Size,\n\t\tDownloaded:    t.Additional.Transfer.SizeDownloaded,\n\t\tUploaded:      t.Additional.Transfer.SizeUploaded,\n\t\tDownloadSpeed: t.Additional.Transfer.SpeedDownload,\n\t\tUploadSpeed:   t.Additional.Transfer.SpeedUpload,\n\t\tRatio:         ratio,\n\t\tETA:           eta,\n\t\tSavePath:      t.Additional.Detail.Destination,\n\t}\n}\n\nfunc (c *SynologyClient) convertStatus(status string) TorrentState {\n\tswitch status {\n\tcase synStatusWaiting:\n\t\treturn StateQueued\n\tcase synStatusDownloading:\n\t\treturn StateDownloading\n\tcase synStatusPaused:\n\t\treturn StatePaused\n\tcase synStatusFinishing, synStatusFinished:\n\t\treturn StateStopped\n\tcase synStatusHashChecking:\n\t\treturn StateChecking\n\tcase synStatusSeeding:\n\t\treturn StateSeeding\n\tcase synStatusFileHosting, synStatusExtracting:\n\t\treturn StateDownloading\n\tcase synStatusError:\n\t\treturn StateError\n\tdefault:\n\t\treturn StateUnknown\n\t}\n}\n\n// RemoveTorrent removes a torrent\nfunc (c *SynologyClient) RemoveTorrent(id string, deleteData bool) error {\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.DownloadStation.Task\")\n\tparams.Set(\"version\", \"1\")\n\tparams.Set(\"method\", \"delete\")\n\tparams.Set(\"id\", id)\n\tparams.Set(\"force_complete\", strconv.FormatBool(deleteData))\n\n\t_, err := c.doRequest(\"/webapi/DownloadStation/task.cgi\", params, nil)\n\treturn err\n}\n\n// PauseTorrent pauses a torrent\nfunc (c *SynologyClient) PauseTorrent(id string) error {\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.DownloadStation.Task\")\n\tparams.Set(\"version\", \"1\")\n\tparams.Set(\"method\", \"pause\")\n\tparams.Set(\"id\", id)\n\n\t_, err := c.doRequest(\"/webapi/DownloadStation/task.cgi\", params, nil)\n\treturn err\n}\n\n// ResumeTorrent resumes a paused torrent\nfunc (c *SynologyClient) ResumeTorrent(id string) error {\n\tparams := url.Values{}\n\tparams.Set(\"api\", \"SYNO.DownloadStation.Task\")\n\tparams.Set(\"version\", \"1\")\n\tparams.Set(\"method\", \"resume\")\n\tparams.Set(\"id\", id)\n\n\t_, err := c.doRequest(\"/webapi/DownloadStation/task.cgi\", params, nil)\n\treturn err\n}\n"
  },
  {
    "path": "internal/torrent/transmission.go",
    "content": "package torrent\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n)\n\n// TransmissionClient implements the Client interface for Transmission daemon\n// Transmission RPC specification: https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md\ntype TransmissionClient struct {\n\tconfig    *Config\n\tclient    *http.Client\n\tbaseURL   string\n\tsessionID string // X-Transmission-Session-Id for CSRF protection\n}\n\n// Transmission RPC request/response structures\ntype trRequest struct {\n\tMethod    string      `json:\"method\"`\n\tArguments interface{} `json:\"arguments,omitempty\"`\n\tTag       int         `json:\"tag,omitempty\"`\n}\n\ntype trResponse struct {\n\tResult    string          `json:\"result\"`\n\tArguments json.RawMessage `json:\"arguments,omitempty\"`\n\tTag       int             `json:\"tag,omitempty\"`\n}\n\n// Transmission torrent status codes\nconst (\n\ttrStatusStopped      = 0\n\ttrStatusQueuedVerify = 1\n\ttrStatusVerifying    = 2\n\ttrStatusQueuedDown   = 3\n\ttrStatusDownloading  = 4\n\ttrStatusQueuedSeed   = 5\n\ttrStatusSeeding      = 6\n)\n\n// NewTransmissionClient creates a new Transmission client\nfunc NewTransmissionClient(cfg *Config) *TransmissionClient {\n\tscheme := \"http\"\n\tif cfg.UseHTTPS {\n\t\tscheme = \"https\"\n\t}\n\n\treturn &TransmissionClient{\n\t\tconfig:  cfg,\n\t\tbaseURL: fmt.Sprintf(\"%s://%s/transmission/rpc\", scheme, cfg.Host),\n\t\tclient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\nfunc (c *TransmissionClient) Name() string {\n\treturn \"transmission\"\n}\n\n// Connect establishes connection and gets session ID\nfunc (c *TransmissionClient) Connect() error {\n\t// Transmission uses CSRF protection via X-Transmission-Session-Id\n\t// Make a dummy request to get the session ID\n\t_, err := c.doRequest(\"session-get\", nil)\n\tif err != nil && c.sessionID == \"\" {\n\t\treturn fmt.Errorf(\"%w: %v\", ErrConnectionFailed, err)\n\t}\n\treturn nil\n}\n\nfunc (c *TransmissionClient) Close() error {\n\t// Transmission doesn't have explicit logout\n\tc.sessionID = \"\"\n\treturn nil\n}\n\n// doRequest performs an RPC request with automatic session ID handling\nfunc (c *TransmissionClient) doRequest(method string, args interface{}) (*trResponse, error) {\n\treq := trRequest{\n\t\tMethod:    method,\n\t\tArguments: args,\n\t}\n\n\tbody, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Try up to 2 times (for session ID refresh)\n\tfor i := 0; i < 2; i++ {\n\t\thttpReq, err := http.NewRequest(\"POST\", c.baseURL, bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tif c.sessionID != \"\" {\n\t\t\thttpReq.Header.Set(\"X-Transmission-Session-Id\", c.sessionID)\n\t\t}\n\n\t\t// Add basic auth if configured\n\t\tif c.config.Username != \"\" {\n\t\t\thttpReq.SetBasicAuth(c.config.Username, c.config.Password)\n\t\t}\n\n\t\tresp, err := c.client.Do(httpReq)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Handle 409 Conflict - need to update session ID\n\t\tif resp.StatusCode == http.StatusConflict {\n\t\t\tnewSessionID := resp.Header.Get(\"X-Transmission-Session-Id\")\n\t\t\tresp.Body.Close()\n\t\t\tif newSessionID != \"\" {\n\t\t\t\tc.sessionID = newSessionID\n\t\t\t\tcontinue // Retry with new session ID\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"received 409 but no session ID in response\")\n\t\t}\n\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode == http.StatusUnauthorized {\n\t\t\treturn nil, ErrAuthFailed\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\treturn nil, fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t\t}\n\n\t\trespBody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar trResp trResponse\n\t\tif err := json.Unmarshal(respBody, &trResp); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif trResp.Result != \"success\" {\n\t\t\treturn nil, fmt.Errorf(\"transmission error: %s\", trResp.Result)\n\t\t}\n\n\t\treturn &trResp, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"failed to get valid session ID\")\n}\n\n// AddMagnet adds a torrent via magnet link\nfunc (c *TransmissionClient) AddMagnet(magnetURL string, opts *AddOptions) (*AddResult, error) {\n\tif !IsMagnetLink(magnetURL) {\n\t\treturn nil, ErrInvalidMagnet\n\t}\n\n\targs := map[string]interface{}{\n\t\t\"filename\": magnetURL,\n\t}\n\n\tif opts != nil {\n\t\tif opts.SavePath != \"\" {\n\t\t\targs[\"download-dir\"] = opts.SavePath\n\t\t}\n\t\tif opts.Paused {\n\t\t\targs[\"paused\"] = true\n\t\t}\n\t}\n\n\treturn c.addTorrent(args)\n}\n\n// AddTorrentURL adds a torrent via HTTP/HTTPS URL\nfunc (c *TransmissionClient) AddTorrentURL(url string, opts *AddOptions) (*AddResult, error) {\n\targs := map[string]interface{}{\n\t\t\"filename\": url,\n\t}\n\n\tif opts != nil {\n\t\tif opts.SavePath != \"\" {\n\t\t\targs[\"download-dir\"] = opts.SavePath\n\t\t}\n\t\tif opts.Paused {\n\t\t\targs[\"paused\"] = true\n\t\t}\n\t}\n\n\treturn c.addTorrent(args)\n}\n\n// AddTorrentFile adds a torrent from a local .torrent file\nfunc (c *TransmissionClient) AddTorrentFile(path string, opts *AddOptions) (*AddResult, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read torrent file: %w\", err)\n\t}\n\n\targs := map[string]interface{}{\n\t\t\"metainfo\": base64.StdEncoding.EncodeToString(data),\n\t}\n\n\tif opts != nil {\n\t\tif opts.SavePath != \"\" {\n\t\t\targs[\"download-dir\"] = opts.SavePath\n\t\t}\n\t\tif opts.Paused {\n\t\t\targs[\"paused\"] = true\n\t\t}\n\t}\n\n\treturn c.addTorrent(args)\n}\n\nfunc (c *TransmissionClient) addTorrent(args map[string]interface{}) (*AddResult, error) {\n\tresp, err := c.doRequest(\"torrent-add\", args)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar result struct {\n\t\tTorrentAdded     *trTorrentAdded `json:\"torrent-added\"`\n\t\tTorrentDuplicate *trTorrentAdded `json:\"torrent-duplicate\"`\n\t}\n\n\tif err := json.Unmarshal(resp.Arguments, &result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif result.TorrentDuplicate != nil {\n\t\treturn &AddResult{\n\t\t\tID:        fmt.Sprintf(\"%d\", result.TorrentDuplicate.ID),\n\t\t\tHash:      result.TorrentDuplicate.HashString,\n\t\t\tName:      result.TorrentDuplicate.Name,\n\t\t\tDuplicate: true,\n\t\t}, nil\n\t}\n\n\tif result.TorrentAdded != nil {\n\t\treturn &AddResult{\n\t\t\tID:        fmt.Sprintf(\"%d\", result.TorrentAdded.ID),\n\t\t\tHash:      result.TorrentAdded.HashString,\n\t\t\tName:      result.TorrentAdded.Name,\n\t\t\tDuplicate: false,\n\t\t}, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"unexpected response: no torrent info returned\")\n}\n\ntype trTorrentAdded struct {\n\tID         int    `json:\"id\"`\n\tHashString string `json:\"hashString\"`\n\tName       string `json:\"name\"`\n}\n\n// GetTorrent retrieves info about a specific torrent\nfunc (c *TransmissionClient) GetTorrent(id string) (*TorrentInfo, error) {\n\targs := map[string]interface{}{\n\t\t\"ids\":    []string{id},\n\t\t\"fields\": trTorrentFields,\n\t}\n\n\tresp, err := c.doRequest(\"torrent-get\", args)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar result struct {\n\t\tTorrents []trTorrent `json:\"torrents\"`\n\t}\n\n\tif err := json.Unmarshal(resp.Arguments, &result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(result.Torrents) == 0 {\n\t\treturn nil, ErrTorrentNotFound\n\t}\n\n\treturn c.convertTorrent(&result.Torrents[0]), nil\n}\n\n// ListTorrents retrieves info about all torrents\nfunc (c *TransmissionClient) ListTorrents() ([]TorrentInfo, error) {\n\targs := map[string]interface{}{\n\t\t\"fields\": trTorrentFields,\n\t}\n\n\tresp, err := c.doRequest(\"torrent-get\", args)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar result struct {\n\t\tTorrents []trTorrent `json:\"torrents\"`\n\t}\n\n\tif err := json.Unmarshal(resp.Arguments, &result); err != nil {\n\t\treturn nil, err\n\t}\n\n\ttorrents := make([]TorrentInfo, len(result.Torrents))\n\tfor i, t := range result.Torrents {\n\t\ttorrents[i] = *c.convertTorrent(&t)\n\t}\n\n\treturn torrents, nil\n}\n\nvar trTorrentFields = []string{\n\t\"id\", \"hashString\", \"name\", \"status\", \"percentDone\",\n\t\"totalSize\", \"downloadedEver\", \"uploadedEver\",\n\t\"rateDownload\", \"rateUpload\", \"uploadRatio\",\n\t\"eta\", \"downloadDir\", \"errorString\", \"error\",\n}\n\ntype trTorrent struct {\n\tID             int     `json:\"id\"`\n\tHashString     string  `json:\"hashString\"`\n\tName           string  `json:\"name\"`\n\tStatus         int     `json:\"status\"`\n\tPercentDone    float64 `json:\"percentDone\"`\n\tTotalSize      int64   `json:\"totalSize\"`\n\tDownloadedEver int64   `json:\"downloadedEver\"`\n\tUploadedEver   int64   `json:\"uploadedEver\"`\n\tRateDownload   int64   `json:\"rateDownload\"`\n\tRateUpload     int64   `json:\"rateUpload\"`\n\tUploadRatio    float64 `json:\"uploadRatio\"`\n\tETA            int64   `json:\"eta\"`\n\tDownloadDir    string  `json:\"downloadDir\"`\n\tErrorString    string  `json:\"errorString\"`\n\tError          int     `json:\"error\"`\n}\n\nfunc (c *TransmissionClient) convertTorrent(t *trTorrent) *TorrentInfo {\n\tstate := c.convertStatus(t.Status)\n\tif t.Error != 0 {\n\t\tstate = StateError\n\t}\n\n\treturn &TorrentInfo{\n\t\tID:            fmt.Sprintf(\"%d\", t.ID),\n\t\tHash:          t.HashString,\n\t\tName:          t.Name,\n\t\tState:         state,\n\t\tProgress:      t.PercentDone,\n\t\tSize:          t.TotalSize,\n\t\tDownloaded:    t.DownloadedEver,\n\t\tUploaded:      t.UploadedEver,\n\t\tDownloadSpeed: t.RateDownload,\n\t\tUploadSpeed:   t.RateUpload,\n\t\tRatio:         t.UploadRatio,\n\t\tETA:           t.ETA,\n\t\tSavePath:      t.DownloadDir,\n\t\tError:         t.ErrorString,\n\t}\n}\n\nfunc (c *TransmissionClient) convertStatus(status int) TorrentState {\n\tswitch status {\n\tcase trStatusStopped:\n\t\treturn StateStopped\n\tcase trStatusQueuedVerify, trStatusVerifying:\n\t\treturn StateChecking\n\tcase trStatusQueuedDown:\n\t\treturn StateQueued\n\tcase trStatusDownloading:\n\t\treturn StateDownloading\n\tcase trStatusQueuedSeed, trStatusSeeding:\n\t\treturn StateSeeding\n\tdefault:\n\t\treturn StateUnknown\n\t}\n}\n\n// RemoveTorrent removes a torrent\nfunc (c *TransmissionClient) RemoveTorrent(id string, deleteData bool) error {\n\targs := map[string]interface{}{\n\t\t\"ids\":               []string{id},\n\t\t\"delete-local-data\": deleteData,\n\t}\n\n\t_, err := c.doRequest(\"torrent-remove\", args)\n\treturn err\n}\n\n// PauseTorrent pauses a torrent\nfunc (c *TransmissionClient) PauseTorrent(id string) error {\n\targs := map[string]interface{}{\n\t\t\"ids\": []string{id},\n\t}\n\n\t_, err := c.doRequest(\"torrent-stop\", args)\n\treturn err\n}\n\n// ResumeTorrent resumes a paused torrent\nfunc (c *TransmissionClient) ResumeTorrent(id string) error {\n\targs := map[string]interface{}{\n\t\t\"ids\": []string{id},\n\t}\n\n\t_, err := c.doRequest(\"torrent-start\", args)\n\treturn err\n}\n\n"
  },
  {
    "path": "internal/updater/updater.go",
    "content": "package updater\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"runtime\"\n\n\t\"github.com/creativeprojects/go-selfupdate\"\n\t\"github.com/guiyumin/vget/internal/core/version\"\n)\n\nconst (\n\trepoOwner = \"guiyumin\"\n\trepoName  = \"vget\"\n)\n\n// CheckUpdate checks if a new version is available\nfunc CheckUpdate() (*selfupdate.Release, bool, error) {\n\tsource, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{})\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\n\tupdater, err := selfupdate.NewUpdater(selfupdate.Config{\n\t\tSource: source,\n\t})\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\n\tlatest, found, err := updater.DetectLatest(context.Background(), selfupdate.NewRepositorySlug(repoOwner, repoName))\n\tif err != nil {\n\t\treturn nil, false, fmt.Errorf(\"failed to check for updates: %w\", err)\n\t}\n\n\tif !found {\n\t\treturn nil, false, nil\n\t}\n\n\tcurrentVersion := version.Version\n\t// Remove 'v' prefix if present for comparison\n\tif len(currentVersion) > 0 && currentVersion[0] == 'v' {\n\t\tcurrentVersion = currentVersion[1:]\n\t}\n\n\tif latest.LessOrEqual(currentVersion) {\n\t\treturn latest, false, nil\n\t}\n\n\treturn latest, true, nil\n}\n\n// Update performs the self-update\nfunc Update() error {\n\tsource, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tupdater, err := selfupdate.NewUpdater(selfupdate.Config{\n\t\tSource: source,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlatest, found, err := updater.DetectLatest(context.Background(), selfupdate.NewRepositorySlug(repoOwner, repoName))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check for updates: %w\", err)\n\t}\n\n\tif !found {\n\t\treturn fmt.Errorf(\"no releases found for %s/%s\", repoOwner, repoName)\n\t}\n\n\tcurrentVersion := version.Version\n\tif len(currentVersion) > 0 && currentVersion[0] == 'v' {\n\t\tcurrentVersion = currentVersion[1:]\n\t}\n\n\tif latest.LessOrEqual(currentVersion) {\n\t\tfmt.Printf(\"Already up to date (v%s)\\n\", currentVersion)\n\t\treturn nil\n\t}\n\n\tfmt.Printf(\"Updating from v%s to %s...\\n\", currentVersion, latest.Version())\n\n\texe, err := selfupdate.ExecutablePath()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get executable path: %w\", err)\n\t}\n\n\terr = updater.UpdateTo(context.Background(), latest, exe)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to update: %w\", err)\n\t}\n\n\tfmt.Printf(\"Successfully updated to %s\\n\", latest.Version())\n\treturn nil\n}\n\n// GetPlatformAssetName returns the expected asset name for the current platform\nfunc GetPlatformAssetName() string {\n\tos := runtime.GOOS\n\tarch := runtime.GOARCH\n\n\treturn fmt.Sprintf(\"vget_%s_%s\", os, arch)\n}\n"
  },
  {
    "path": "sites.md",
    "content": "# Supported Sites\n\n## General\n\n| Source                    | URL                      | Type            |\n| ------------------------- | ------------------------ | --------------- |\n| Twitter/X                 | twitter.com, x.com       | Video           |\n| Telegram                  | t.me                     | Video/Image     |\n| Xiaoyuzhou FM (小宇宙)    | xiaoyuzhoufm.com         | Audio (Podcast) |\n| Apple Podcasts            | podcasts.apple.com       | Audio (Podcast) |\n| Xiaohongshu (小红书)      | xiaohongshu.com          | Video/Image     |\n\n## NSFW\n\n| Source         | URL                      | Type            |\n| -------------- | ------------------------ | --------------- |\n| hsex.icu       | hsex.icu                 | Video           |\n| kanav.ad       | kanav.ad                 | Video           |\n\n## Notes\n\n### Twitter/X Age-Restricted Content\n\nTo download age-restricted (NSFW) content from Twitter/X, you need to set your auth token:\n\n1. Open x.com in your browser and log in\n2. Open DevTools (F12) → Application → Cookies → x.com\n3. Find `auth_token` and copy its value\n4. Run:\n   ```bash\n   vget config set twitter.auth_token YOUR_AUTH_TOKEN\n   ```\n\n### Twitter/X 年龄限制内容\n\n要下载 Twitter/X 上的年龄限制（NSFW）内容，需要设置 auth token：\n\n1. 在浏览器中打开 x.com 并登录\n2. 打开开发者工具（F12）→ Application → Cookies → x.com\n3. 找到 `auth_token` 并复制其值\n4. 运行：\n   ```bash\n   vget config set twitter.auth_token YOUR_AUTH_TOKEN\n   ```\n\n### Telegram\n\nTo download videos and images from Telegram, you need to import your session from Telegram Desktop:\n\n1. Update vget to v0.7.0 or later\n2. Make sure you have [Telegram Desktop](https://desktop.telegram.org/) installed and logged in\n3. Run the login command to import your session:\n   ```bash\n   vget telegram login --import-desktop\n   ```\n4. Download media like any other URL:\n   ```bash\n   vget https://t.me/channel/123\n   ```\n\n### Telegram (中文)\n\n要从 Telegram 下载视频和图片，需要从 Telegram Desktop 导入会话：\n\n1. 更新 vget 到 v0.7.0 或更高版本\n2. 确保已安装并登录 [Telegram Desktop](https://desktop.telegram.org/)\n3. 运行登录命令导入会话：\n   ```bash\n   vget telegram login --import-desktop\n   ```\n4. 像其他 URL 一样下载媒体：\n   ```bash\n   vget https://t.me/channel/123\n   ```\n"
  },
  {
    "path": "tauri/.gitignore",
    "content": "# Dependencies\nnode_modules/\n\n# Build\ndist/\n.tanstack/\n\n# Logs\n*.log\n\n# Editor\n.vscode/\n.idea/\n\n# OS\n.DS_Store\n\n# Tauri\nsrc-tauri/target/\n\n# FFmpeg binaries (downloaded for development, bundled in release)\nsrc-tauri/binaries/ffmpeg-*\n!src-tauri/binaries/.gitkeep\n"
  },
  {
    "path": "tauri/Makefile",
    "content": ".PHONY: dev build install clean\n\n# Development\ndev:\n\tbun tauri dev\n\n# Build for release\nbuild:\n\tbun tauri build\n\n# Install dependencies\ninstall:\n\tbun install\n\n# Clean build artifacts\nclean:\n\trm -rf dist\n\trm -rf src-tauri/target\n\n# Generate icons (requires source icon)\nicons:\n\tbun tauri icon public/app-icon.png\n\n# Type check\ncheck:\n\tbun tsc --noEmit\n\tcd src-tauri && cargo check\n"
  },
  {
    "path": "tauri/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"registries\": {}\n}\n"
  },
  {
    "path": "tauri/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/app-icon.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>VGet</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "tauri/package.json",
    "content": "{\n  \"name\": \"vget-desktop\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"preview\": \"vite preview\",\n    \"tauri\": \"tauri\"\n  },\n  \"dependencies\": {\n    \"@hookform/resolvers\": \"^5.2.2\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-aspect-ratio\": \"^1.1.8\",\n    \"@radix-ui/react-avatar\": \"^1.1.11\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-collapsible\": \"^1.1.12\",\n    \"@radix-ui/react-context-menu\": \"^2.2.16\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-hover-card\": \"^1.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.8\",\n    \"@radix-ui/react-menubar\": \"^1.1.16\",\n    \"@radix-ui/react-navigation-menu\": \"^1.2.14\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.8\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slider\": \"^1.3.6\",\n    \"@radix-ui/react-slot\": \"^1.2.4\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-toggle\": \"^1.1.10\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@tailwindcss/typography\": \"^0.5.19\",\n    \"@tailwindcss/vite\": \"^4.1.18\",\n    \"@tanstack/react-router\": \"^1.147.3\",\n    \"@tauri-apps/api\": \"^2\",\n    \"@tauri-apps/plugin-dialog\": \"^2.5.0\",\n    \"@tauri-apps/plugin-opener\": \"^2\",\n    \"@tauri-apps/plugin-process\": \"^2.3.1\",\n    \"@tauri-apps/plugin-updater\": \"^2.9.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"embla-carousel-react\": \"^8.6.0\",\n    \"i18next\": \"^25.7.4\",\n    \"input-otp\": \"^1.4.2\",\n    \"lucide-react\": \"^0.562.0\",\n    \"next-themes\": \"^0.4.6\",\n    \"qrcode.react\": \"^4.2.0\",\n    \"react\": \"^19.1.0\",\n    \"react-day-picker\": \"^9.13.0\",\n    \"react-dom\": \"^19.1.0\",\n    \"react-hook-form\": \"^7.71.1\",\n    \"react-i18next\": \"^16.5.3\",\n    \"react-resizable-panels\": \"^4.4.1\",\n    \"recharts\": \"2.15.4\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.4.0\",\n    \"tailwindcss\": \"^4.1.18\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"vaul\": \"^1.1.2\",\n    \"zod\": \"^4.3.5\",\n    \"zustand\": \"^5.0.10\"\n  },\n  \"devDependencies\": {\n    \"@tanstack/router-plugin\": \"^1.149.0\",\n    \"@tauri-apps/cli\": \"^2\",\n    \"@types/js-yaml\": \"^4.0.9\",\n    \"@types/node\": \"22\",\n    \"@types/react\": \"^19.1.8\",\n    \"@types/react-dom\": \"^19.1.6\",\n    \"@vitejs/plugin-react\": \"^4.6.0\",\n    \"js-yaml\": \"^4.1.1\",\n    \"typescript\": \"~5.8.3\",\n    \"vite\": \"^7.0.4\"\n  }\n}\n"
  },
  {
    "path": "tauri/src/components/AppSidebar.tsx",
    "content": "import { Link, useLocation } from \"@tanstack/react-router\";\nimport { Download, Settings, ChevronLeft, Wrench } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { PdfIcon } from \"./icons/PdfIcon\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport logo from \"@/assets/logo.png\";\n\ninterface NavItem {\n  to: string;\n  icon: React.ReactNode;\n  label: string;\n}\n\ninterface AppSidebarProps {\n  collapsed: boolean;\n  onToggle: () => void;\n}\n\nexport function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {\n  const location = useLocation();\n  const { t } = useTranslation();\n\n  const navItems: NavItem[] = [\n    {\n      to: \"/\",\n      icon: <Download className=\"h-5 w-5\" />,\n      label: t(\"nav.download\"),\n    },\n    {\n      to: \"/media-tools\",\n      icon: <Wrench className=\"h-5 w-5\" />,\n      label: t(\"nav.mediaTools\"),\n    },\n    {\n      to: \"/pdf-tools\",\n      icon: <PdfIcon className=\"h-5 w-5\" />,\n      label: t(\"nav.pdfTools\"),\n    },\n    {\n      to: \"/settings\",\n      icon: <Settings className=\"h-5 w-5\" />,\n      label: t(\"nav.settings\"),\n    },\n  ];\n\n  const isActive = (path: string) => {\n    if (path === \"/\") {\n      return location.pathname === \"/\";\n    }\n    return location.pathname.startsWith(path);\n  };\n\n  return (\n    <TooltipProvider delayDuration={0}>\n      <aside\n        className={cn(\n          \"flex flex-col h-full bg-muted/30 border-r transition-all duration-300\",\n          collapsed ? \"w-16\" : \"w-48\"\n        )}\n      >\n        {/* Header with logo */}\n        <div\n          className={cn(\n            \"flex items-center border-b h-14\",\n            collapsed ? \"justify-center px-2\" : \"justify-between px-3\"\n          )}\n        >\n          {collapsed ? (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <button\n                  onClick={onToggle}\n                  className=\"flex items-center justify-center h-10 w-10 rounded-md hover:bg-muted transition-colors\"\n                >\n                  <img src={logo} alt=\"VGet\" className=\"h-8 w-8\" />\n                </button>\n              </TooltipTrigger>\n              <TooltipContent side=\"right\">{t(\"nav.expandMenu\")}</TooltipContent>\n            </Tooltip>\n          ) : (\n            <>\n              <div className=\"flex items-center gap-2\">\n                <img src={logo} alt=\"VGet\" className=\"h-8 w-8\" />\n                <span className=\"font-semibold text-lg\">VGet</span>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"h-8 w-8\"\n                onClick={onToggle}\n              >\n                <ChevronLeft className=\"h-4 w-4\" />\n              </Button>\n            </>\n          )}\n        </div>\n\n        {/* Navigation */}\n        <nav className=\"flex-1 py-4\">\n          <ul className=\"space-y-1 px-2\">\n            {navItems.map((item) => {\n              const active = isActive(item.to);\n\n              if (collapsed) {\n                return (\n                  <li key={item.to}>\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <Link\n                          to={item.to}\n                          className={cn(\n                            \"flex items-center justify-center h-10 w-full rounded-md transition-colors\",\n                            active\n                              ? \"bg-primary text-primary-foreground\"\n                              : \"text-muted-foreground hover:bg-muted hover:text-foreground\"\n                          )}\n                        >\n                          {item.icon}\n                        </Link>\n                      </TooltipTrigger>\n                      <TooltipContent side=\"right\">{item.label}</TooltipContent>\n                    </Tooltip>\n                  </li>\n                );\n              }\n\n              return (\n                <li key={item.to}>\n                  <Link\n                    to={item.to}\n                    className={cn(\n                      \"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors\",\n                      active\n                        ? \"bg-primary text-primary-foreground\"\n                        : \"text-muted-foreground hover:bg-muted hover:text-foreground\"\n                    )}\n                  >\n                    {item.icon}\n                    <span>{item.label}</span>\n                  </Link>\n                </li>\n              );\n            })}\n          </ul>\n        </nav>\n\n        {/* Footer */}\n        <div className=\"p-2 border-t\">\n          {!collapsed && (\n            <p className=\"text-xs text-muted-foreground px-2\">{t(\"nav.vgetDesktop\")}</p>\n          )}\n        </div>\n      </aside>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/home/DownloadItem.tsx",
    "content": "import { X, CheckCircle2, AlertCircle, Loader2 } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { Button } from \"@/components/ui/button\";\nimport { cancelDownload, type Download } from \"@/stores/downloads\";\nimport { formatBytes, formatSpeed } from \"./types\";\n\ninterface DownloadItemProps {\n  download: Download;\n}\n\nexport function DownloadItem({ download }: DownloadItemProps) {\n  const { t } = useTranslation();\n\n  const handleCancel = async () => {\n    try {\n      await cancelDownload(download.id);\n    } catch (err) {\n      toast.error(t(\"home.failedToCancel\"));\n    }\n  };\n\n  return (\n    <div className=\"border border-border rounded-lg p-4 space-y-2\">\n      <div className=\"flex items-start justify-between\">\n        <div className=\"flex-1 min-w-0\">\n          <p className=\"font-medium truncate\">{download.title}</p>\n          <p className=\"text-sm text-muted-foreground truncate\">\n            {download.url}\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2 ml-4\">\n          {download.status === \"downloading\" && (\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"h-8 w-8\"\n              onClick={handleCancel}\n            >\n              <X className=\"h-4 w-4\" />\n            </Button>\n          )}\n          {download.status === \"completed\" && (\n            <CheckCircle2 className=\"h-5 w-5 text-green-500\" />\n          )}\n          {download.status === \"failed\" && (\n            <AlertCircle className=\"h-5 w-5 text-destructive\" />\n          )}\n          {download.status === \"cancelled\" && (\n            <X className=\"h-5 w-5 text-muted-foreground\" />\n          )}\n        </div>\n      </div>\n\n      {download.status === \"downloading\" && download.progress && (\n        <div className=\"space-y-1\">\n          <Progress value={download.progress.percent} className=\"h-2\" />\n          <div className=\"flex justify-between text-xs text-muted-foreground\">\n            <span>\n              {formatBytes(download.progress.downloaded)}\n              {download.progress.total\n                ? ` / ${formatBytes(download.progress.total)}`\n                : \"\"}\n            </span>\n            <span>{formatSpeed(download.progress.speed)}</span>\n          </div>\n        </div>\n      )}\n\n      {download.status === \"pending\" && (\n        <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n          <Loader2 className=\"h-4 w-4 animate-spin\" />\n          <span>{t(\"home.startingDownload\")}</span>\n        </div>\n      )}\n\n      {download.error && (\n        <p className=\"text-sm text-destructive\">{download.error}</p>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/home/HomePage.tsx",
    "content": "import { useEffect, useState, useRef } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { Download, Folder, Link, Loader2, Upload, FileText } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  useDownloadsStore,\n  setupDownloadListeners,\n  startDownload,\n} from \"@/stores/downloads\";\nimport { MediaInfo, Config } from \"./types\";\nimport { DownloadItem } from \"./DownloadItem\";\nimport { cn } from \"@/lib/utils\";\nimport { useDropZone } from \"@/hooks/useDropZone\";\nimport {\n  isYouTubeUrl,\n  startDockerDownload,\n  getDockerJobStatus,\n  getDockerServerUrl,\n} from \"@/services/dockerApi\";\n\nexport function HomePage() {\n  const [url, setUrl] = useState(\"\");\n  const [isExtracting, setIsExtracting] = useState(false);\n  const [config, setConfig] = useState<Config | null>(null);\n  const [bulkProgress, setBulkProgress] = useState<{ current: number; total: number } | null>(null);\n  const downloads = useDownloadsStore((state) => state.downloads);\n  const clearCompleted = useDownloadsStore((state) => state.clearCompleted);\n  const addDownload = useDownloadsStore((state) => state.addDownload);\n  const updateDownload = useDownloadsStore((state) => state.updateDownload);\n  const { t } = useTranslation();\n  const dockerPollingRef = useRef<Map<string, NodeJS.Timeout>>(new Map());\n\n  // Handle bulk file import\n  const handleFileImport = async (filePath: string) => {\n    try {\n      const content = await invoke<string>(\"read_text_file\", { path: filePath });\n      const urls = content\n        .split(\"\\n\")\n        .map((line) => line.trim())\n        .filter((line) => line && (line.startsWith(\"http://\") || line.startsWith(\"https://\")));\n\n      if (urls.length === 0) {\n        toast.error(t(\"home.noUrlsInFile\") || \"No valid URLs found in file\");\n        return;\n      }\n\n      toast.success(t(\"home.foundUrls\", { count: urls.length }) || `Found ${urls.length} URLs`);\n\n      // Process URLs one by one\n      setBulkProgress({ current: 0, total: urls.length });\n      for (let i = 0; i < urls.length; i++) {\n        setBulkProgress({ current: i + 1, total: urls.length });\n        await processUrl(urls[i]);\n      }\n      setBulkProgress(null);\n    } catch (err) {\n      console.error(\"Failed to read file:\", err);\n      toast.error(t(\"home.failedToReadFile\") || \"Failed to read file\");\n    }\n  };\n\n  // Drop zone for bulk download (.txt files)\n  const { ref: dropZoneRef, isDragging } = useDropZone<HTMLDivElement>({\n    accept: [\"txt\"],\n    onDrop: (paths) => {\n      handleFileImport(paths[0]);\n    },\n    onInvalidDrop: (_paths, ext) => {\n      if (ext === \"md\" || ext === \"markdown\") {\n        toast.error(t(\"home.dropMdHint\") || \"For Markdown files, go to PDF Tools → Markdown to PDF\");\n      } else {\n        toast.error(t(\"home.dropTxtFile\") || \"Please drop a .txt file containing URLs\");\n      }\n    },\n  });\n\n  useEffect(() => {\n    setupDownloadListeners();\n\n    invoke<Config>(\"get_config\")\n      .then(setConfig)\n      .catch(console.error);\n\n    // Cleanup polling intervals on unmount\n    return () => {\n      dockerPollingRef.current.forEach((interval) => clearInterval(interval));\n      dockerPollingRef.current.clear();\n    };\n  }, []);\n\n  const handleSelectFile = async () => {\n    const selected = await open({\n      multiple: false,\n      filters: [{ name: \"Text\", extensions: [\"txt\"] }],\n    });\n    if (selected && typeof selected === \"string\") {\n      await handleFileImport(selected);\n    }\n  };\n\n  // Handle YouTube download via Docker server\n  const handleYouTubeDownload = async (inputUrl: string): Promise<boolean> => {\n    try {\n      // Try to start download on Docker server directly\n      const response = await startDockerDownload(inputUrl);\n      const jobId = response.data.id;\n\n      // Add to local downloads list with docker prefix to distinguish\n      const localId = `docker-${jobId}`;\n      addDownload({\n        id: localId,\n        url: inputUrl,\n        title: `YouTube: ${inputUrl}`,\n        outputPath: \"Docker Server\",\n        status: \"pending\",\n        progress: null,\n        error: null,\n      });\n\n      // Poll for status updates\n      const pollInterval = setInterval(async () => {\n        try {\n          const status = await getDockerJobStatus(jobId);\n          const job = status.data;\n\n          if (job.status === \"downloading\") {\n            updateDownload(localId, {\n              status: \"downloading\",\n              title: job.filename || `YouTube: ${inputUrl}`,\n              progress: {\n                job_id: localId,\n                downloaded: job.downloaded || 0,\n                total: job.total || null,\n                speed: 0,\n                percent: job.progress || 0,\n              },\n            });\n          } else if (job.status === \"completed\") {\n            updateDownload(localId, {\n              status: \"completed\",\n              title: job.filename || `YouTube: ${inputUrl}`,\n              outputPath: job.filename || \"Docker Server\",\n              progress: null,\n            });\n            clearInterval(pollInterval);\n            dockerPollingRef.current.delete(localId);\n            toast.success(t(\"home.downloadComplete\") || \"Download complete!\");\n          } else if (job.status === \"failed\") {\n            updateDownload(localId, {\n              status: \"failed\",\n              error: job.error || \"Download failed\",\n              progress: null,\n            });\n            clearInterval(pollInterval);\n            dockerPollingRef.current.delete(localId);\n          } else if (job.status === \"cancelled\") {\n            updateDownload(localId, {\n              status: \"cancelled\",\n              progress: null,\n            });\n            clearInterval(pollInterval);\n            dockerPollingRef.current.delete(localId);\n          }\n        } catch (pollError) {\n          console.error(\"Polling error:\", pollError);\n          // Don't stop polling on transient errors\n        }\n      }, 1000);\n\n      dockerPollingRef.current.set(localId, pollInterval);\n\n      toast.success(\n        t(\"home.youtubeDownloadStarted\") ||\n          \"YouTube download started via Docker server\"\n      );\n      return true;\n    } catch (err) {\n      console.error(\"Docker download error:\", err);\n\n      const errorMessage = err instanceof Error ? err.message : String(err);\n\n      // Check if it's an authentication error\n      if (errorMessage.includes(\"Authentication required\") || errorMessage.includes(\"401\")) {\n        toast.error(\n          t(\"home.dockerAuthRequired\") ||\n            \"Docker server requires authentication. Go to Settings → Sites → Docker Server to add your JWT token.\",\n          { duration: 8000 }\n        );\n        return false;\n      }\n\n      // Check if server is not reachable (network error)\n      if (errorMessage.includes(\"Failed to fetch\") || errorMessage.includes(\"NetworkError\") || errorMessage.includes(\"fetch\")) {\n        const serverUrl = getDockerServerUrl();\n        toast.error(\n          t(\"home.dockerNotRunning\") ||\n            `YouTube downloads require vget-server. Please run Docker container or start the server at ${serverUrl}`,\n          { duration: 8000 }\n        );\n        return false;\n      }\n\n      // Other errors\n      toast.error(errorMessage);\n      return false;\n    }\n  };\n\n  const processUrl = async (inputUrl: string) => {\n    if (!inputUrl.trim() || !config) return;\n\n    // Check if it's a YouTube URL\n    if (isYouTubeUrl(inputUrl)) {\n      await handleYouTubeDownload(inputUrl);\n      return;\n    }\n\n    try {\n      const mediaInfo = await invoke<MediaInfo>(\"extract_media\", { url: inputUrl });\n\n      if (mediaInfo.formats.length === 0) {\n        console.warn(`No formats found for: ${inputUrl}`);\n        return;\n      }\n\n      const format = mediaInfo.formats[0];\n      const ext = format.ext || \"mp4\";\n      const sanitizedTitle = mediaInfo.title\n        .replace(/[/\\\\?%*:|\"<>]/g, \"-\")\n        .substring(0, 100);\n      const outputPath = `${config.output_dir}/${sanitizedTitle}.${ext}`;\n\n      await startDownload(\n        format.url,\n        mediaInfo.title,\n        outputPath,\n        format.headers,\n        format.audio_url || undefined\n      );\n    } catch (err) {\n      console.error(`Failed to process URL ${inputUrl}:`, err);\n    }\n  };\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!url.trim() || !config) return;\n\n    setIsExtracting(true);\n    try {\n      // Check if it's a YouTube URL - handle via Docker\n      if (isYouTubeUrl(url)) {\n        const success = await handleYouTubeDownload(url);\n        if (success) {\n          setUrl(\"\");\n        }\n        return;\n      }\n\n      const mediaInfo = await invoke<MediaInfo>(\"extract_media\", { url });\n\n      if (mediaInfo.formats.length === 0) {\n        toast.error(t(\"home.noFormats\"));\n        return;\n      }\n\n      const format = mediaInfo.formats[0];\n      const ext = format.ext || \"mp4\";\n      const sanitizedTitle = mediaInfo.title\n        .replace(/[/\\\\?%*:|\"<>]/g, \"-\")\n        .substring(0, 100);\n      const outputPath = `${config.output_dir}/${sanitizedTitle}.${ext}`;\n\n      await startDownload(\n        format.url,\n        mediaInfo.title,\n        outputPath,\n        format.headers,\n        format.audio_url || undefined\n      );\n      setUrl(\"\");\n      toast.success(t(\"home.downloadStarted\"));\n    } catch (err) {\n      console.error(\"Extraction failed:\", err);\n      toast.error(err instanceof Error ? err.message : String(err));\n    } finally {\n      setIsExtracting(false);\n    }\n  };\n\n  const handleOpenFolder = async () => {\n    if (config?.output_dir) {\n      try {\n        await invoke(\"open_output_folder\", { path: config.output_dir });\n      } catch (err) {\n        toast.error(t(\"home.failedToOpenFolder\"));\n        console.error(err);\n      }\n    }\n  };\n\n  const activeDownloads = downloads.filter(\n    (d) => d.status === \"downloading\" || d.status === \"pending\"\n  );\n  const completedDownloads = downloads.filter(\n    (d) =>\n      d.status === \"completed\" ||\n      d.status === \"failed\" ||\n      d.status === \"cancelled\"\n  );\n\n  return (\n    <div className=\"h-full\">\n      <header className=\"h-14 border-b border-border flex items-center px-6\">\n        <h1 className=\"text-xl font-semibold\">{t(\"home.title\")}</h1>\n        {bulkProgress && (\n          <span className=\"ml-4 text-sm text-muted-foreground\">\n            {t(\"home.processingBulk\", { current: bulkProgress.current, total: bulkProgress.total }) ||\n              `Processing ${bulkProgress.current}/${bulkProgress.total}`}\n          </span>\n        )}\n      </header>\n\n      <div className=\"p-6\">\n        {/* Single URL input */}\n        <form onSubmit={handleSubmit} className=\"max-w-2xl\">\n          <div className=\"relative\">\n            <Link className=\"absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground\" />\n            <input\n              type=\"text\"\n              value={url}\n              onChange={(e) => setUrl(e.target.value)}\n              placeholder={t(\"home.urlPlaceholder\")}\n              className=\"w-full pl-12 pr-32 py-4 rounded-xl border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring\"\n            />\n            <button\n              type=\"submit\"\n              disabled={isExtracting || !url.trim()}\n              className=\"absolute right-2 top-1/2 -translate-y-1/2 px-4 py-2 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition-opacity flex items-center gap-2\"\n            >\n              {isExtracting && <Loader2 className=\"h-4 w-4 animate-spin\" />}\n              {isExtracting ? t(\"home.extracting\") : t(\"home.download\")}\n            </button>\n          </div>\n        </form>\n\n        <div className=\"mt-3 max-w-2xl\">\n          <p className=\"text-sm text-muted-foreground\">\n            {t(\"home.supportsHint\")}\n          </p>\n        </div>\n\n        {/* Bulk download drop zone */}\n        <div className=\"mt-6 max-w-2xl\">\n          <div\n            ref={dropZoneRef}\n            onClick={handleSelectFile}\n            className={cn(\n              \"border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all\",\n              isDragging\n                ? \"border-primary bg-primary/5\"\n                : \"border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/30\"\n            )}\n          >\n            <div className=\"flex items-center justify-center gap-3\">\n              {isDragging ? (\n                <Upload className=\"h-8 w-8 text-primary\" />\n              ) : (\n                <FileText className=\"h-8 w-8 text-muted-foreground\" />\n              )}\n              <div className=\"text-left\">\n                <p className={cn(\n                  \"font-medium\",\n                  isDragging ? \"text-primary\" : \"text-foreground\"\n                )}>\n                  {t(\"home.bulkDownloadTitle\") || \"Bulk Download\"}\n                </p>\n                <p className=\"text-sm text-muted-foreground\">\n                  {t(\"home.bulkDownloadHint\") || \"Drop a .txt file with URLs or click to select\"}\n                </p>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {/* Downloads list */}\n        <div className=\"mt-8 max-w-2xl\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <h2 className=\"text-lg font-medium\">{t(\"home.downloads\")}</h2>\n            <div className=\"flex items-center gap-2\">\n              {completedDownloads.length > 0 && (\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={clearCompleted}\n                  className=\"text-muted-foreground\"\n                >\n                  {t(\"home.clearCompleted\")}\n                </Button>\n              )}\n              <button\n                onClick={handleOpenFolder}\n                className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors\"\n              >\n                <Folder className=\"h-4 w-4\" />\n                {t(\"home.openFolder\")}\n              </button>\n            </div>\n          </div>\n\n          {downloads.length === 0 ? (\n            <div className=\"border border-dashed border-border rounded-xl p-12 text-center\">\n              <Download className=\"h-12 w-12 text-muted-foreground/50 mx-auto mb-4\" />\n              <p className=\"text-muted-foreground\">\n                {t(\"home.noDownloadsYet\")}\n              </p>\n            </div>\n          ) : (\n            <div className=\"space-y-3\">\n              {activeDownloads.map((download) => (\n                <DownloadItem key={download.id} download={download} />\n              ))}\n              {completedDownloads.map((download) => (\n                <DownloadItem key={download.id} download={download} />\n              ))}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/home/types.ts",
    "content": "export interface MediaInfo {\n  id: string;\n  title: string;\n  uploader: string | null;\n  thumbnail: string | null;\n  duration: number | null;\n  media_type: string;\n  formats: {\n    id: string;\n    url: string;\n    ext: string;\n    quality: string | null;\n    filesize: number | null;\n    audio_url: string | null;\n    headers?: Record<string, string>;\n  }[];\n}\n\nexport interface Config {\n  output_dir: string;\n}\n\nexport function formatBytes(bytes: number): string {\n  if (bytes === 0) return \"0 B\";\n  const k = 1024;\n  const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n}\n\nexport function formatSpeed(bytesPerSecond: number): string {\n  return formatBytes(bytesPerSecond) + \"/s\";\n}\n"
  },
  {
    "path": "tauri/src/components/icons/PdfIcon.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function PdfIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      {...props}\n    >\n      {/* File shape with folded corner */}\n      <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\" />\n      <polyline points=\"14 2 14 8 20 8\" />\n      {/* PDF text */}\n      <text\n        x=\"12\"\n        y=\"16\"\n        textAnchor=\"middle\"\n        fontSize=\"6\"\n        fontWeight=\"bold\"\n        fill=\"currentColor\"\n        stroke=\"none\"\n        fontFamily=\"system-ui, sans-serif\"\n      >\n        PDF\n      </text>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/MediaToolsPage.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  FileVideo,\n  FileAudio,\n  Scissors,\n  Minimize2,\n  Image,\n  FileType,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { MediaInfo, ToolId, Config } from \"./types\";\nimport {\n  ConvertPanel,\n  CompressPanel,\n  TrimPanel,\n  ExtractAudioPanel,\n  ExtractFramesPanel,\n  AudioConvertPanel,\n} from \"./panels\";\n\ninterface Tool {\n  id: ToolId;\n  titleKey: string;\n  descKey: string;\n  icon: React.ReactNode;\n}\n\nconst toolsConfig: Tool[] = [\n  {\n    id: \"convert\",\n    titleKey: \"mediaTools.tools.convert.title\",\n    descKey: \"mediaTools.tools.convert.desc\",\n    icon: <FileVideo className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"compress\",\n    titleKey: \"mediaTools.tools.compress.title\",\n    descKey: \"mediaTools.tools.compress.desc\",\n    icon: <Minimize2 className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"trim\",\n    titleKey: \"mediaTools.tools.trim.title\",\n    descKey: \"mediaTools.tools.trim.desc\",\n    icon: <Scissors className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"extract-audio\",\n    titleKey: \"mediaTools.tools.extractAudio.title\",\n    descKey: \"mediaTools.tools.extractAudio.desc\",\n    icon: <FileAudio className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"extract-frames\",\n    titleKey: \"mediaTools.tools.extractFrames.title\",\n    descKey: \"mediaTools.tools.extractFrames.desc\",\n    icon: <Image className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"audio-convert\",\n    titleKey: \"mediaTools.tools.audioConvert.title\",\n    descKey: \"mediaTools.tools.audioConvert.desc\",\n    icon: <FileType className=\"h-4 w-4\" />,\n  },\n];\n\nexport function MediaToolsPage() {\n  const { t } = useTranslation();\n  const [activeTool, setActiveTool] = useState<ToolId>(\"convert\");\n  const [inputFile, setInputFile] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n  const [progress, setProgress] = useState(0);\n  const [mediaInfo, setMediaInfo] = useState<MediaInfo | null>(null);\n  const [jobId, setJobId] = useState<string | null>(null);\n  const [config, setConfig] = useState<Config | null>(null);\n\n  useEffect(() => {\n    invoke<Config>(\"get_config\")\n      .then(setConfig)\n      .catch(console.error);\n  }, []);\n\n  useEffect(() => {\n    const unlistenProgress = listen<{ jobId: string; progress: number }>(\n      \"ffmpeg-progress\",\n      (event) => {\n        if (event.payload.jobId === jobId && mediaInfo?.duration) {\n          const percent = Math.min(\n            100,\n            (event.payload.progress / mediaInfo.duration) * 100\n          );\n          setProgress(percent);\n        }\n      }\n    );\n\n    const unlistenComplete = listen<{ jobId: string; outputPath: string }>(\n      \"ffmpeg-complete\",\n      (event) => {\n        if (event.payload.jobId === jobId) {\n          setLoading(false);\n          setProgress(100);\n          toast.success(t(\"mediaTools.operationComplete\"));\n          setTimeout(() => {\n            resetState();\n          }, 1500);\n        }\n      }\n    );\n\n    const unlistenError = listen<{ jobId: string; error: string }>(\n      \"ffmpeg-error\",\n      (event) => {\n        if (event.payload.jobId === jobId) {\n          setLoading(false);\n          toast.error(event.payload.error);\n        }\n      }\n    );\n\n    return () => {\n      unlistenProgress.then((fn) => fn());\n      unlistenComplete.then((fn) => fn());\n      unlistenError.then((fn) => fn());\n    };\n  }, [jobId, mediaInfo]);\n\n  const resetState = () => {\n    setInputFile(\"\");\n    setMediaInfo(null);\n    setProgress(0);\n    setJobId(null);\n  };\n\n  const handleFileSelected = async (file: string) => {\n    setInputFile(file);\n    try {\n      const info = await invoke<MediaInfo>(\"ffmpeg_get_media_info\", {\n        inputPath: file,\n      });\n      setMediaInfo(info);\n    } catch (e) {\n      console.error(\"Failed to get media info:\", e);\n    }\n  };\n\n  const selectInputFile = async () => {\n    const file = await open({\n      multiple: false,\n      filters: [\n        { name: \"Media\", extensions: [\"mp4\", \"mkv\", \"webm\", \"mov\", \"avi\", \"mp3\", \"aac\", \"flac\", \"wav\", \"ogg\"] },\n      ],\n    });\n    if (file) {\n      await handleFileSelected(file);\n    }\n  };\n\n  const handleFileDrop = async (path: string) => {\n    await handleFileSelected(path);\n  };\n\n  const handleToolChange = (toolId: ToolId) => {\n    if (!loading) {\n      setActiveTool(toolId);\n      resetState();\n    }\n  };\n\n  const panelProps = {\n    inputFile,\n    outputDir: config?.output_dir || \"\",\n    loading,\n    progress,\n    mediaInfo,\n    onSelectInput: selectInputFile,\n    onFileDrop: handleFileDrop,\n    setLoading,\n    setProgress,\n    setJobId,\n  };\n\n  const activeToolData = toolsConfig.find((tool) => tool.id === activeTool);\n\n  const renderPanel = () => {\n    switch (activeTool) {\n      case \"convert\":\n        return <ConvertPanel {...panelProps} />;\n      case \"compress\":\n        return <CompressPanel {...panelProps} />;\n      case \"trim\":\n        return <TrimPanel {...panelProps} />;\n      case \"extract-audio\":\n        return <ExtractAudioPanel {...panelProps} />;\n      case \"extract-frames\":\n        return <ExtractFramesPanel {...panelProps} />;\n      case \"audio-convert\":\n        return <AudioConvertPanel {...panelProps} />;\n      default:\n        return null;\n    }\n  };\n\n  return (\n    <div className=\"h-full flex flex-col\">\n      <header className=\"h-14 border-b border-border flex items-center px-6 shrink-0\">\n        <h1 className=\"text-xl font-semibold\">{t(\"mediaTools.title\")}</h1>\n      </header>\n\n      <div className=\"flex-1 flex min-h-0\">\n        {/* Left pane - Tool list */}\n        <div className=\"w-56 border-r border-border p-2 overflow-y-auto shrink-0\">\n          <div className=\"space-y-1\">\n            {toolsConfig.map((tool) => (\n              <button\n                key={tool.id}\n                onClick={() => handleToolChange(tool.id)}\n                disabled={loading}\n                className={cn(\n                  \"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors\",\n                  \"hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed\",\n                  activeTool === tool.id\n                    ? \"bg-accent text-accent-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                <span className={cn(\n                  \"shrink-0\",\n                  activeTool === tool.id ? \"text-primary\" : \"\"\n                )}>\n                  {tool.icon}\n                </span>\n                <span className=\"text-sm font-medium truncate\">{t(tool.titleKey)}</span>\n              </button>\n            ))}\n          </div>\n        </div>\n\n        {/* Right pane - Tool content */}\n        <div className=\"flex-1 p-6 overflow-y-auto\">\n          {activeToolData && (\n            <div className=\"max-w-lg\">\n              <div className=\"mb-6\">\n                <h2 className=\"text-lg font-semibold\">{t(activeToolData.titleKey)}</h2>\n                <p className=\"text-sm text-muted-foreground mt-1\">\n                  {t(activeToolData.descKey)}\n                </p>\n              </div>\n              {renderPanel()}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/panels/AudioConvertPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PanelProps, generateOutputPath } from \"../types\";\n\nconst AUDIO_EXTENSIONS = [\"mp3\", \"aac\", \"flac\", \"wav\", \"ogg\", \"m4a\"];\n\nexport function AudioConvertPanel({\n  inputFile,\n  outputDir,\n  loading,\n  progress,\n  onSelectInput,\n  onFileDrop,\n  setLoading,\n  setProgress,\n  setJobId,\n}: PanelProps) {\n  const [audioFormat, setAudioFormat] = useState(\"mp3\");\n\n  const outputPath = inputFile ? generateOutputPath(outputDir, inputFile, audioFormat, \"converted\") : \"\";\n\n  const handleConvertAudio = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    setProgress(0);\n    try {\n      const id = await invoke<string>(\"ffmpeg_convert_audio\", {\n        inputPath: inputFile,\n        outputPath,\n        format: audioFormat,\n        bitrate: null,\n      });\n      setJobId(id);\n    } catch (e) {\n      setLoading(false);\n      toast.error(String(e));\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Input Audio</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop an audio file here or click to select\"\n          accept={AUDIO_EXTENSIONS}\n          acceptHint=\".mp3, .aac, .flac, .wav, .ogg, .m4a\"\n          onSelectClick={onSelectInput}\n          onDrop={onFileDrop}\n          disabled={loading}\n          invalidDropMessage=\"Please drop an audio file (mp3, aac, flac, wav, ogg, m4a)\"\n        />\n      </div>\n      <div className=\"space-y-2\">\n        <Label>Output Format</Label>\n        <Select value={audioFormat} onValueChange={setAudioFormat}>\n          <SelectTrigger>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"mp3\">MP3</SelectItem>\n            <SelectItem value=\"aac\">AAC</SelectItem>\n            <SelectItem value=\"flac\">FLAC (Lossless)</SelectItem>\n            <SelectItem value=\"wav\">WAV (Uncompressed)</SelectItem>\n            <SelectItem value=\"ogg\">OGG Vorbis</SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>{outputPath}</p>\n        </div>\n      )}\n      {loading && <Progress value={progress} />}\n      <div className=\"pt-2\">\n        <Button onClick={handleConvertAudio} disabled={!inputFile || !outputDir || loading}>\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Convert\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/panels/CompressPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PanelProps, generateOutputPath } from \"../types\";\n\nconst VIDEO_EXTENSIONS = [\"mp4\", \"mkv\", \"webm\", \"mov\", \"avi\"];\n\nexport function CompressPanel({\n  inputFile,\n  outputDir,\n  loading,\n  progress,\n  onSelectInput,\n  onFileDrop,\n  setLoading,\n  setProgress,\n  setJobId,\n}: PanelProps) {\n  const [quality, setQuality] = useState(23);\n\n  const outputPath = inputFile ? generateOutputPath(outputDir, inputFile, \"mp4\", \"compressed\") : \"\";\n\n  const handleCompress = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    setProgress(0);\n    try {\n      const id = await invoke<string>(\"ffmpeg_compress_video\", {\n        inputPath: inputFile,\n        outputPath,\n        quality,\n      });\n      setJobId(id);\n    } catch (e) {\n      setLoading(false);\n      toast.error(String(e));\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Input File</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop a video here or click to select\"\n          accept={VIDEO_EXTENSIONS}\n          acceptHint=\".mp4, .mkv, .webm, .mov, .avi\"\n          onSelectClick={onSelectInput}\n          onDrop={onFileDrop}\n          disabled={loading}\n          invalidDropMessage=\"Please drop a video file (mp4, mkv, webm, mov, avi)\"\n        />\n      </div>\n      <div className=\"space-y-2\">\n        <Label>Quality (CRF: {quality})</Label>\n        <div className=\"flex items-center gap-4\">\n          <span className=\"text-xs text-muted-foreground\">High</span>\n          <Slider\n            value={[quality]}\n            onValueChange={([v]) => setQuality(v)}\n            min={18}\n            max={28}\n            step={1}\n            className=\"flex-1\"\n          />\n          <span className=\"text-xs text-muted-foreground\">Low</span>\n        </div>\n        <p className=\"text-xs text-muted-foreground\">\n          Lower values = higher quality, larger file size\n        </p>\n      </div>\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>{outputPath}</p>\n        </div>\n      )}\n      {loading && <Progress value={progress} />}\n      <div className=\"pt-2\">\n        <Button onClick={handleCompress} disabled={!inputFile || !outputDir || loading}>\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Compress\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/panels/ConvertPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PanelProps, generateOutputPath } from \"../types\";\n\nconst VIDEO_EXTENSIONS = [\"mp4\", \"mkv\", \"webm\", \"mov\", \"avi\"];\n\nexport function ConvertPanel({\n  inputFile,\n  outputDir,\n  loading,\n  progress,\n  onSelectInput,\n  onFileDrop,\n  setLoading,\n  setProgress,\n  setJobId,\n}: PanelProps) {\n  const [outputFormat, setOutputFormat] = useState(\"mp4\");\n\n  const outputPath = inputFile ? generateOutputPath(outputDir, inputFile, outputFormat, \"converted\") : \"\";\n\n  const handleConvert = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    setProgress(0);\n    try {\n      const id = await invoke<string>(\"ffmpeg_convert_video\", {\n        inputPath: inputFile,\n        outputPath,\n      });\n      setJobId(id);\n    } catch (e) {\n      setLoading(false);\n      toast.error(String(e));\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Input File</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop a video here or click to select\"\n          accept={VIDEO_EXTENSIONS}\n          acceptHint=\".mp4, .mkv, .webm, .mov, .avi\"\n          onSelectClick={onSelectInput}\n          onDrop={onFileDrop}\n          disabled={loading}\n          invalidDropMessage=\"Please drop a video file (mp4, mkv, webm, mov, avi)\"\n        />\n      </div>\n      <div className=\"space-y-2\">\n        <Label>Output Format</Label>\n        <Select value={outputFormat} onValueChange={setOutputFormat}>\n          <SelectTrigger>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"mp4\">MP4</SelectItem>\n            <SelectItem value=\"mkv\">MKV</SelectItem>\n            <SelectItem value=\"webm\">WebM</SelectItem>\n            <SelectItem value=\"mov\">MOV</SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>{outputPath}</p>\n        </div>\n      )}\n      {loading && <Progress value={progress} />}\n      <div className=\"pt-2\">\n        <Button onClick={handleConvert} disabled={!inputFile || !outputDir || loading}>\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Convert\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/panels/ExtractAudioPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PanelProps, generateOutputPath } from \"../types\";\n\nconst VIDEO_EXTENSIONS = [\"mp4\", \"mkv\", \"webm\", \"mov\", \"avi\"];\n\nexport function ExtractAudioPanel({\n  inputFile,\n  outputDir,\n  loading,\n  progress,\n  onSelectInput,\n  onFileDrop,\n  setLoading,\n  setProgress,\n  setJobId,\n}: PanelProps) {\n  const [audioFormat, setAudioFormat] = useState(\"mp3\");\n\n  const outputPath = inputFile ? generateOutputPath(outputDir, inputFile, audioFormat) : \"\";\n\n  const handleExtractAudio = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    setProgress(0);\n    try {\n      const id = await invoke<string>(\"ffmpeg_extract_audio\", {\n        inputPath: inputFile,\n        outputPath,\n        format: audioFormat,\n      });\n      setJobId(id);\n    } catch (e) {\n      setLoading(false);\n      toast.error(String(e));\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Input Video</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop a video here or click to select\"\n          accept={VIDEO_EXTENSIONS}\n          acceptHint=\".mp4, .mkv, .webm, .mov, .avi\"\n          onSelectClick={onSelectInput}\n          onDrop={onFileDrop}\n          disabled={loading}\n          invalidDropMessage=\"Please drop a video file (mp4, mkv, webm, mov, avi)\"\n        />\n      </div>\n      <div className=\"space-y-2\">\n        <Label>Audio Format</Label>\n        <Select value={audioFormat} onValueChange={setAudioFormat}>\n          <SelectTrigger>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"mp3\">MP3</SelectItem>\n            <SelectItem value=\"aac\">AAC</SelectItem>\n            <SelectItem value=\"flac\">FLAC</SelectItem>\n            <SelectItem value=\"wav\">WAV</SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>{outputPath}</p>\n        </div>\n      )}\n      {loading && <Progress value={progress} />}\n      <div className=\"pt-2\">\n        <Button onClick={handleExtractAudio} disabled={!inputFile || !outputDir || loading}>\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Extract\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/panels/ExtractFramesPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PanelProps, getBasename } from \"../types\";\n\nconst VIDEO_EXTENSIONS = [\"mp4\", \"mkv\", \"webm\", \"mov\", \"avi\"];\n\nexport function ExtractFramesPanel({\n  inputFile,\n  outputDir,\n  loading,\n  progress,\n  onSelectInput,\n  onFileDrop,\n  setLoading,\n  setProgress,\n  setJobId,\n}: PanelProps) {\n  const [fps, setFps] = useState(1);\n\n  const outputFolder = inputFile ? `${outputDir}/${getBasename(inputFile)}_frames` : \"\";\n\n  const handleExtractFrames = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    setProgress(0);\n    try {\n      const id = await invoke<string>(\"ffmpeg_extract_frames\", {\n        inputPath: inputFile,\n        outputDir: outputFolder,\n        fps,\n      });\n      setJobId(id);\n    } catch (e) {\n      setLoading(false);\n      toast.error(String(e));\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Input Video</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop a video here or click to select\"\n          accept={VIDEO_EXTENSIONS}\n          acceptHint=\".mp4, .mkv, .webm, .mov, .avi\"\n          onSelectClick={onSelectInput}\n          onDrop={onFileDrop}\n          disabled={loading}\n          invalidDropMessage=\"Please drop a video file (mp4, mkv, webm, mov, avi)\"\n        />\n      </div>\n      <div className=\"space-y-2\">\n        <Label>Frames per Second: {fps}</Label>\n        <Slider\n          value={[fps]}\n          onValueChange={([v]) => setFps(v)}\n          min={0.1}\n          max={5}\n          step={0.1}\n        />\n        <p className=\"text-xs text-muted-foreground\">\n          1 = one frame per second, 0.1 = one frame every 10 seconds\n        </p>\n      </div>\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output Folder</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputFolder}>{outputFolder}</p>\n        </div>\n      )}\n      {loading && <Progress value={progress} />}\n      <div className=\"pt-2\">\n        <Button onClick={handleExtractFrames} disabled={!inputFile || !outputDir || loading}>\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Extract\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/panels/TrimPanel.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PanelProps, formatDuration, generateOutputPath } from \"../types\";\n\nconst VIDEO_EXTENSIONS = [\"mp4\", \"mkv\", \"webm\", \"mov\", \"avi\"];\n\nexport function TrimPanel({\n  inputFile,\n  outputDir,\n  loading,\n  progress,\n  mediaInfo,\n  onSelectInput,\n  onFileDrop,\n  setLoading,\n  setProgress,\n  setJobId,\n}: PanelProps) {\n  const [startTime, setStartTime] = useState(\"00:00:00\");\n  const [endTime, setEndTime] = useState(\"00:00:10\");\n\n  const outputPath = inputFile ? generateOutputPath(outputDir, inputFile, \"mp4\", \"trimmed\") : \"\";\n\n  useEffect(() => {\n    if (mediaInfo?.duration) {\n      setEndTime(formatDuration(mediaInfo.duration));\n    }\n  }, [mediaInfo]);\n\n  const handleTrim = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    setProgress(0);\n    try {\n      const id = await invoke<string>(\"ffmpeg_trim_video\", {\n        inputPath: inputFile,\n        outputPath,\n        startTime,\n        endTime,\n      });\n      setJobId(id);\n    } catch (e) {\n      setLoading(false);\n      toast.error(String(e));\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Input File</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop a video here or click to select\"\n          accept={VIDEO_EXTENSIONS}\n          acceptHint=\".mp4, .mkv, .webm, .mov, .avi\"\n          onSelectClick={onSelectInput}\n          onDrop={onFileDrop}\n          disabled={loading}\n          invalidDropMessage=\"Please drop a video file (mp4, mkv, webm, mov, avi)\"\n        />\n      </div>\n      <div className=\"grid grid-cols-2 gap-4\">\n        <div className=\"space-y-2\">\n          <Label>Start Time</Label>\n          <Input\n            value={startTime}\n            onChange={(e) => setStartTime(e.target.value)}\n            placeholder=\"00:00:00\"\n          />\n        </div>\n        <div className=\"space-y-2\">\n          <Label>End Time</Label>\n          <Input\n            value={endTime}\n            onChange={(e) => setEndTime(e.target.value)}\n            placeholder=\"00:00:10\"\n          />\n        </div>\n      </div>\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>{outputPath}</p>\n        </div>\n      )}\n      {loading && <Progress value={progress} />}\n      <div className=\"pt-2\">\n        <Button onClick={handleTrim} disabled={!inputFile || !outputDir || loading}>\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Trim\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/media-tools/panels/index.ts",
    "content": "export { ConvertPanel } from \"./ConvertPanel\";\nexport { CompressPanel } from \"./CompressPanel\";\nexport { TrimPanel } from \"./TrimPanel\";\nexport { ExtractAudioPanel } from \"./ExtractAudioPanel\";\nexport { ExtractFramesPanel } from \"./ExtractFramesPanel\";\nexport { AudioConvertPanel } from \"./AudioConvertPanel\";\n"
  },
  {
    "path": "tauri/src/components/media-tools/types.ts",
    "content": "export interface MediaInfo {\n  filename: string;\n  format_name: string;\n  format_long_name: string;\n  duration: number | null;\n  size: number;\n  bit_rate: number | null;\n  streams: StreamInfo[];\n}\n\nexport interface StreamInfo {\n  index: number;\n  codec_type: string;\n  codec_name: string;\n  codec_long_name: string | null;\n  width: number | null;\n  height: number | null;\n  sample_rate: string | null;\n  channels: number | null;\n  bit_rate: string | null;\n  duration: string | null;\n}\n\nexport interface Config {\n  output_dir: string;\n}\n\nexport type ToolId =\n  | \"convert\"\n  | \"compress\"\n  | \"trim\"\n  | \"extract-audio\"\n  | \"extract-frames\"\n  | \"audio-convert\";\n\nexport interface PanelProps {\n  inputFile: string;\n  outputDir: string;\n  loading: boolean;\n  progress: number;\n  mediaInfo: MediaInfo | null;\n  onSelectInput: () => Promise<void>;\n  onFileDrop: (path: string) => Promise<void>;\n  setLoading: (loading: boolean) => void;\n  setProgress: (progress: number) => void;\n  setJobId: (jobId: string | null) => void;\n}\n\nexport function formatBytes(bytes: number): string {\n  if (bytes === 0) return \"0 B\";\n  const k = 1024;\n  const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n}\n\nexport function formatDuration(seconds: number): string {\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  const s = Math.floor(seconds % 60);\n  if (h > 0) return `${h}:${m.toString().padStart(2, \"0\")}:${s.toString().padStart(2, \"0\")}`;\n  return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\nexport function getBasename(filePath: string): string {\n  const name = filePath.split(/[/\\\\]/).pop() || \"\";\n  const lastDot = name.lastIndexOf(\".\");\n  return lastDot > 0 ? name.substring(0, lastDot) : name;\n}\n\nexport function generateOutputPath(outputDir: string, inputFile: string, ext: string, suffix?: string): string {\n  const basename = getBasename(inputFile);\n  const safeName = basename.replace(/[/\\\\?%*:|\"<>]/g, \"-\");\n  const suffixStr = suffix ? `_${suffix}` : \"\";\n  return `${outputDir}/${safeName}${suffixStr}.${ext}`;\n}\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/PDFToolsPage.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"@/lib/utils\";\nimport { Combine, Image, Trash2, Droplets, FileText } from \"lucide-react\";\nimport { PdfToolId, Config } from \"./types\";\nimport { MergePdfPanel, ImagesToPdfPanel, DeletePagesPanel, RemoveWatermarkPanel, Md2PdfPanel } from \"./panels\";\n\ninterface Tool {\n  id: PdfToolId;\n  titleKey: string;\n  descKey: string;\n  icon: React.ReactNode;\n}\n\nconst toolsConfig: Tool[] = [\n  {\n    id: \"merge\",\n    titleKey: \"pdfTools.tools.merge.title\",\n    descKey: \"pdfTools.tools.merge.desc\",\n    icon: <Combine className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"images-to-pdf\",\n    titleKey: \"pdfTools.tools.imagesToPdf.title\",\n    descKey: \"pdfTools.tools.imagesToPdf.desc\",\n    icon: <Image className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"delete-pages\",\n    titleKey: \"pdfTools.tools.deletePages.title\",\n    descKey: \"pdfTools.tools.deletePages.desc\",\n    icon: <Trash2 className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"remove-watermark\",\n    titleKey: \"pdfTools.tools.removeWatermark.title\",\n    descKey: \"pdfTools.tools.removeWatermark.desc\",\n    icon: <Droplets className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"md-to-pdf\",\n    titleKey: \"pdfTools.tools.md2pdf.title\",\n    descKey: \"pdfTools.tools.md2pdf.desc\",\n    icon: <FileText className=\"h-4 w-4\" />,\n  },\n];\n\nexport function PDFToolsPage() {\n  const { t } = useTranslation();\n  const [activeTool, setActiveTool] = useState<PdfToolId>(\"merge\");\n  const [loading, setLoading] = useState(false);\n  const [config, setConfig] = useState<Config | null>(null);\n\n  useEffect(() => {\n    invoke<Config>(\"get_config\")\n      .then(setConfig)\n      .catch(console.error);\n  }, []);\n\n  const handleToolChange = (toolId: PdfToolId) => {\n    if (!loading) {\n      setActiveTool(toolId);\n    }\n  };\n\n  const panelProps = {\n    outputDir: config?.output_dir || \"\",\n    loading,\n    setLoading,\n  };\n\n  const activeToolData = toolsConfig.find((tool) => tool.id === activeTool);\n\n  const renderPanel = () => {\n    switch (activeTool) {\n      case \"merge\":\n        return <MergePdfPanel {...panelProps} />;\n      case \"images-to-pdf\":\n        return <ImagesToPdfPanel {...panelProps} />;\n      case \"delete-pages\":\n        return <DeletePagesPanel {...panelProps} />;\n      case \"remove-watermark\":\n        return <RemoveWatermarkPanel {...panelProps} />;\n      case \"md-to-pdf\":\n        return <Md2PdfPanel {...panelProps} />;\n      default:\n        return null;\n    }\n  };\n\n  return (\n    <div className=\"h-full flex flex-col\">\n      <header className=\"h-14 border-b border-border flex items-center px-6 shrink-0\">\n        <h1 className=\"text-xl font-semibold\">{t(\"pdfTools.title\")}</h1>\n      </header>\n\n      <div className=\"flex-1 flex min-h-0\">\n        {/* Left pane - Tool list */}\n        <div className=\"w-56 border-r border-border p-2 overflow-y-auto shrink-0\">\n          <div className=\"space-y-1\">\n            {toolsConfig.map((tool) => (\n              <button\n                key={tool.id}\n                onClick={() => handleToolChange(tool.id)}\n                disabled={loading}\n                className={cn(\n                  \"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors\",\n                  \"hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed\",\n                  activeTool === tool.id\n                    ? \"bg-accent text-accent-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                <span\n                  className={cn(\n                    \"shrink-0\",\n                    activeTool === tool.id ? \"text-primary\" : \"\"\n                  )}\n                >\n                  {tool.icon}\n                </span>\n                <span className=\"text-sm font-medium truncate\">{t(tool.titleKey)}</span>\n              </button>\n            ))}\n          </div>\n        </div>\n\n        {/* Right pane - Tool content */}\n        <div className=\"flex-1 p-6 overflow-y-auto\">\n          {activeToolData && (\n            <div className=\"max-w-lg\">\n              <div className=\"mb-6\">\n                <h2 className=\"text-lg font-semibold\">{t(activeToolData.titleKey)}</h2>\n                <p className=\"text-sm text-muted-foreground mt-1\">\n                  {t(activeToolData.descKey)}\n                </p>\n              </div>\n              {renderPanel()}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/panels/DeletePagesPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PdfPanelProps, PdfInfo, getBasename, generateOutputPath } from \"../types\";\n\nconst PDF_EXTENSIONS = [\"pdf\"];\n\nexport function DeletePagesPanel({ outputDir, loading, setLoading }: PdfPanelProps) {\n  const [inputFile, setInputFile] = useState(\"\");\n  const [pdfInfo, setPdfInfo] = useState<PdfInfo | null>(null);\n  const [pagesToDelete, setPagesToDelete] = useState(\"\");\n\n  const outputPath = inputFile\n    ? generateOutputPath(outputDir, getBasename(inputFile), \"pages_removed\")\n    : \"\";\n\n  const handleFileSelected = async (file: string) => {\n    setInputFile(file);\n    setPagesToDelete(\"\");\n    try {\n      const info = await invoke<PdfInfo>(\"pdf_get_info\", { inputPath: file });\n      setPdfInfo(info);\n    } catch (e) {\n      console.error(\"Failed to get PDF info:\", e);\n      setPdfInfo(null);\n    }\n  };\n\n  const selectFile = async () => {\n    const selected = await open({\n      multiple: false,\n      filters: [{ name: \"PDF\", extensions: [\"pdf\"] }],\n    });\n    if (selected) {\n      await handleFileSelected(selected);\n    }\n  };\n\n  const parsePages = (input: string): number[] => {\n    const pages: Set<number> = new Set();\n    const parts = input.split(\",\").map((s) => s.trim());\n\n    for (const part of parts) {\n      if (part.includes(\"-\")) {\n        const [start, end] = part.split(\"-\").map((s) => parseInt(s.trim(), 10));\n        if (!isNaN(start) && !isNaN(end)) {\n          for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {\n            pages.add(i);\n          }\n        }\n      } else {\n        const num = parseInt(part, 10);\n        if (!isNaN(num)) {\n          pages.add(num);\n        }\n      }\n    }\n\n    return Array.from(pages).sort((a, b) => a - b);\n  };\n\n  const handleDelete = async () => {\n    if (!inputFile || !outputDir || !pagesToDelete.trim()) return;\n\n    const pages = parsePages(pagesToDelete);\n    if (pages.length === 0) {\n      toast.error(\"Please enter valid page numbers\");\n      return;\n    }\n\n    if (pdfInfo && pages.length >= pdfInfo.pages) {\n      toast.error(\"Cannot delete all pages from PDF\");\n      return;\n    }\n\n    setLoading(true);\n    try {\n      await invoke(\"pdf_delete_pages\", {\n        inputPath: inputFile,\n        outputPath,\n        pages,\n      });\n      toast.success(`Deleted ${pages.length} page(s) successfully!`);\n      setInputFile(\"\");\n      setPdfInfo(null);\n      setPagesToDelete(\"\");\n    } catch (e) {\n      toast.error(String(e));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const parsedPages = parsePages(pagesToDelete);\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Input PDF</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop a PDF here or click to select\"\n          accept={PDF_EXTENSIONS}\n          acceptHint=\".pdf\"\n          onSelectClick={selectFile}\n          onDrop={handleFileSelected}\n          disabled={loading}\n          invalidDropMessage=\"Please drop a PDF file\"\n        />\n      </div>\n\n      {pdfInfo && (\n        <div className=\"p-3 bg-muted rounded-md space-y-1\">\n          <p className=\"text-sm\">\n            <span className=\"text-muted-foreground\">Total pages:</span>{\" \"}\n            <span className=\"font-medium\">{pdfInfo.pages}</span>\n          </p>\n          {pdfInfo.title && (\n            <p className=\"text-sm\">\n              <span className=\"text-muted-foreground\">Title:</span> {pdfInfo.title}\n            </p>\n          )}\n        </div>\n      )}\n\n      <div className=\"space-y-2\">\n        <Label>Pages to Delete</Label>\n        <Input\n          value={pagesToDelete}\n          onChange={(e) => setPagesToDelete(e.target.value)}\n          placeholder=\"e.g., 1, 3, 5-7\"\n          disabled={!inputFile}\n        />\n        <p className=\"text-xs text-muted-foreground\">\n          Enter page numbers separated by commas. Use ranges like 5-7 for consecutive pages.\n        </p>\n        {parsedPages.length > 0 && (\n          <p className=\"text-xs text-muted-foreground\">\n            Will delete pages: {parsedPages.join(\", \")}\n          </p>\n        )}\n      </div>\n\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>\n            {outputPath}\n          </p>\n        </div>\n      )}\n\n      <div className=\"pt-2\">\n        <Button\n          onClick={handleDelete}\n          disabled={!inputFile || !outputDir || parsedPages.length === 0 || loading}\n        >\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Delete Pages\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/panels/ImagesToPdfPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { Loader2, Plus, X, GripVertical } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { cn } from \"@/lib/utils\";\nimport { useDropZone } from \"@/hooks/useDropZone\";\nimport { PdfPanelProps, generateOutputPath } from \"../types\";\n\nconst IMAGE_EXTENSIONS = [\"png\", \"jpg\", \"jpeg\", \"gif\", \"bmp\", \"webp\"];\n\nexport function ImagesToPdfPanel({ outputDir, loading, setLoading }: PdfPanelProps) {\n  const [images, setImages] = useState<string[]>([]);\n\n  const outputPath =\n    images.length > 0 ? generateOutputPath(outputDir, \"images\", undefined) : \"\";\n\n  const { ref: dropZoneRef, isDragging } = useDropZone<HTMLDivElement>({\n    accept: IMAGE_EXTENSIONS,\n    onDrop: (paths) => {\n      setImages((prev) => [...prev, ...paths]);\n    },\n    onInvalidDrop: () => {\n      toast.error(\"Please drop image files only (png, jpg, gif, bmp, webp)\");\n    },\n    enabled: !loading,\n  });\n\n  const selectImages = async () => {\n    const selected = await open({\n      multiple: true,\n      filters: [{ name: \"Images\", extensions: [\"png\", \"jpg\", \"jpeg\", \"gif\", \"bmp\", \"webp\"] }],\n    });\n    if (selected) {\n      const newImages = Array.isArray(selected) ? selected : [selected];\n      setImages((prev) => [...prev, ...newImages]);\n    }\n  };\n\n  const removeImage = (index: number) => {\n    setImages((prev) => prev.filter((_, i) => i !== index));\n  };\n\n  const moveImage = (from: number, to: number) => {\n    if (to < 0 || to >= images.length) return;\n    setImages((prev) => {\n      const newImages = [...prev];\n      const [removed] = newImages.splice(from, 1);\n      newImages.splice(to, 0, removed);\n      return newImages;\n    });\n  };\n\n  const handleConvert = async () => {\n    if (images.length === 0 || !outputDir) return;\n    setLoading(true);\n    try {\n      await invoke(\"pdf_images_to_pdf\", {\n        imagePaths: images,\n        outputPath,\n      });\n      toast.success(\"Images converted to PDF successfully!\");\n      setImages([]);\n    } catch (e) {\n      toast.error(String(e));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const getFileName = (path: string) => path.split(/[/\\\\]/).pop() || path;\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>Images (drag to reorder)</Label>\n        <div className=\"space-y-2\">\n          {images.map((image, index) => (\n            <div\n              key={`${image}-${index}`}\n              className=\"flex items-center gap-2 p-2 bg-muted rounded-md\"\n            >\n              <div className=\"flex flex-col gap-0.5\">\n                <button\n                  onClick={() => moveImage(index, index - 1)}\n                  disabled={index === 0}\n                  className=\"p-0.5 hover:bg-background rounded disabled:opacity-30\"\n                >\n                  <GripVertical className=\"h-3 w-3 rotate-180\" />\n                </button>\n                <button\n                  onClick={() => moveImage(index, index + 1)}\n                  disabled={index === images.length - 1}\n                  className=\"p-0.5 hover:bg-background rounded disabled:opacity-30\"\n                >\n                  <GripVertical className=\"h-3 w-3\" />\n                </button>\n              </div>\n              <span className=\"text-sm text-muted-foreground w-6\">{index + 1}.</span>\n              <span className=\"flex-1 text-sm truncate\" title={image}>\n                {getFileName(image)}\n              </span>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"h-6 w-6\"\n                onClick={() => removeImage(index)}\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ))}\n        </div>\n        <div\n          ref={dropZoneRef}\n          className={cn(\n            \"rounded-md transition-all\",\n            isDragging && \"ring-2 ring-primary ring-offset-2 ring-offset-background\"\n          )}\n        >\n          <Button\n            variant=\"outline\"\n            onClick={selectImages}\n            disabled={loading}\n            className={cn(\n              \"w-full\",\n              isDragging && \"border-primary bg-primary/5\"\n            )}\n          >\n            <Plus className=\"h-4 w-4 mr-2\" />\n            {isDragging ? \"Drop images here...\" : \"Add Images or Drop Here\"}\n          </Button>\n        </div>\n      </div>\n\n      {images.length > 0 && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>\n            {outputPath}\n          </p>\n        </div>\n      )}\n\n      <div className=\"pt-2\">\n        <Button\n          onClick={handleConvert}\n          disabled={images.length === 0 || !outputDir || loading}\n        >\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Convert {images.length > 0 ? `(${images.length} images)` : \"\"}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/panels/Md2PdfPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { revealItemInDir } from \"@tauri-apps/plugin-opener\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { FileText, Loader2, Upload, FolderOpen, CheckCircle2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PdfPanelProps, getBasename } from \"../types\";\nimport { cn } from \"@/lib/utils\";\nimport { useDropZone } from \"@/hooks/useDropZone\";\n\nexport function Md2PdfPanel({ outputDir, loading, setLoading }: PdfPanelProps) {\n  const { t } = useTranslation();\n  const [inputFile, setInputFile] = useState(\"\");\n  const [pageSize, setPageSize] = useState(\"A4\");\n  const [theme, setTheme] = useState(\"light\");\n  const [generatedPdf, setGeneratedPdf] = useState(\"\");\n\n  const outputPath = inputFile\n    ? `${outputDir}/${getBasename(inputFile)}.pdf`\n    : \"\";\n\n  // Drop zone for markdown files\n  const { ref: dropZoneRef, isDragging } = useDropZone<HTMLDivElement>({\n    accept: [\"md\", \"markdown\", \"txt\"],\n    onDrop: (paths) => {\n      setInputFile(paths[0]);\n    },\n    onInvalidDrop: () => {\n      toast.error(t(\"pdfTools.tools.md2pdf.invalidFile\") || \"Please drop a Markdown file (.md, .markdown, .txt)\");\n    },\n    enabled: !inputFile, // Only enable when no file is selected\n  });\n\n  const selectFile = async () => {\n    const selected = await open({\n      multiple: false,\n      filters: [{ name: \"Markdown\", extensions: [\"md\", \"markdown\", \"txt\"] }],\n    });\n    if (selected && typeof selected === \"string\") {\n      setInputFile(selected);\n    }\n  };\n\n  const handleConvert = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    try {\n      await invoke(\"md_to_pdf\", {\n        inputPath: inputFile,\n        outputPath,\n        theme,\n        pageSize,\n      });\n      toast.success(t(\"pdfTools.tools.md2pdf.success\"));\n      setGeneratedPdf(outputPath);\n      setInputFile(\"\");\n    } catch (e) {\n      toast.error(String(e));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleRevealPdf = async () => {\n    if (!generatedPdf) return;\n    try {\n      await revealItemInDir(generatedPdf);\n    } catch (e) {\n      toast.error(String(e));\n    }\n  };\n\n  const handleConvertAnother = () => {\n    setGeneratedPdf(\"\");\n  };\n\n  const getFileName = (path: string) => path.split(/[/\\\\]/).pop() || path;\n\n  // Show success state if PDF was just generated\n  if (generatedPdf) {\n    return (\n      <div className=\"space-y-4\">\n        <div className=\"flex flex-col items-center justify-center py-6 space-y-4\">\n          <div className=\"flex items-center justify-center w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30\">\n            <CheckCircle2 className=\"h-6 w-6 text-green-600 dark:text-green-400\" />\n          </div>\n          <div className=\"text-center space-y-1\">\n            <p className=\"font-medium\">{t(\"pdfTools.tools.md2pdf.success\")}</p>\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"pdfTools.tools.md2pdf.clickToReveal\") || \"Click below to reveal the file\"}\n            </p>\n          </div>\n        </div>\n\n        <div\n          onClick={handleRevealPdf}\n          className=\"flex items-center gap-3 p-3 bg-muted rounded-md cursor-pointer hover:bg-muted/80 transition-colors group\"\n        >\n          <FileText className=\"h-5 w-5 shrink-0 text-red-500\" />\n          <span className=\"flex-1 text-sm truncate\" title={generatedPdf}>\n            {getFileName(generatedPdf)}\n          </span>\n          <FolderOpen className=\"h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors\" />\n        </div>\n\n        <div className=\"pt-2\">\n          <Button variant=\"outline\" onClick={handleConvertAnother} className=\"w-full\">\n            {t(\"pdfTools.tools.md2pdf.convertAnother\") || \"Convert Another\"}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>{t(\"pdfTools.tools.md2pdf.inputFile\")}</Label>\n        {inputFile ? (\n          <div className=\"flex items-center gap-2 p-3 bg-muted rounded-md\">\n            <FileText className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n            <span className=\"flex-1 text-sm truncate\" title={inputFile}>\n              {getFileName(inputFile)}\n            </span>\n            <Button variant=\"ghost\" size=\"sm\" onClick={selectFile}>\n              {t(\"pdfTools.tools.md2pdf.change\")}\n            </Button>\n          </div>\n        ) : (\n          <div\n            ref={dropZoneRef}\n            onClick={selectFile}\n            className={cn(\n              \"border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors\",\n              isDragging\n                ? \"border-primary bg-primary/5\"\n                : \"border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/50\"\n            )}\n          >\n            <Upload className=\"h-8 w-8 mx-auto mb-2 text-muted-foreground\" />\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"pdfTools.tools.md2pdf.dropHint\") || \"Drop a Markdown file here or click to select\"}\n            </p>\n            <p className=\"text-xs text-muted-foreground/70 mt-1\">\n              .md, .markdown, .txt\n            </p>\n          </div>\n        )}\n      </div>\n\n      <div className=\"space-y-2\">\n        <Label>{t(\"pdfTools.tools.md2pdf.pageSize\")}</Label>\n        <Select value={pageSize} onValueChange={setPageSize}>\n          <SelectTrigger>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"A4\">A4</SelectItem>\n            <SelectItem value=\"Letter\">Letter</SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n\n      <div className=\"space-y-2\">\n        <Label>{t(\"pdfTools.tools.md2pdf.theme\")}</Label>\n        <Select value={theme} onValueChange={setTheme}>\n          <SelectTrigger>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"light\">\n              {t(\"pdfTools.tools.md2pdf.themeLight\") || \"Light (GitHub Style)\"}\n            </SelectItem>\n            <SelectItem value=\"dark\">\n              {t(\"pdfTools.tools.md2pdf.themeDark\") || \"Dark\"}\n            </SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">\n            {t(\"pdfTools.tools.md2pdf.output\")}\n          </Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>\n            {outputPath}\n          </p>\n        </div>\n      )}\n\n      <div className=\"pt-2\">\n        <Button onClick={handleConvert} disabled={!inputFile || !outputDir || loading}>\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          {t(\"pdfTools.tools.md2pdf.convert\")}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/panels/MergePdfPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { Loader2, Plus, X, GripVertical } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { cn } from \"@/lib/utils\";\nimport { useDropZone } from \"@/hooks/useDropZone\";\nimport { PdfPanelProps, generateOutputPath } from \"../types\";\n\nconst PDF_EXTENSIONS = [\"pdf\"];\n\nexport function MergePdfPanel({ outputDir, loading, setLoading }: PdfPanelProps) {\n  const [files, setFiles] = useState<string[]>([]);\n\n  const outputPath =\n    files.length > 0 ? generateOutputPath(outputDir, \"merged\", undefined) : \"\";\n\n  const { ref: dropZoneRef, isDragging } = useDropZone<HTMLDivElement>({\n    accept: PDF_EXTENSIONS,\n    onDrop: (paths) => {\n      setFiles((prev) => [...prev, ...paths]);\n    },\n    onInvalidDrop: () => {\n      toast.error(\"Please drop PDF files only\");\n    },\n    enabled: !loading,\n  });\n\n  const selectFiles = async () => {\n    const selected = await open({\n      multiple: true,\n      filters: [{ name: \"PDF\", extensions: [\"pdf\"] }],\n    });\n    if (selected) {\n      const newFiles = Array.isArray(selected) ? selected : [selected];\n      setFiles((prev) => [...prev, ...newFiles]);\n    }\n  };\n\n  const removeFile = (index: number) => {\n    setFiles((prev) => prev.filter((_, i) => i !== index));\n  };\n\n  const moveFile = (from: number, to: number) => {\n    if (to < 0 || to >= files.length) return;\n    setFiles((prev) => {\n      const newFiles = [...prev];\n      const [removed] = newFiles.splice(from, 1);\n      newFiles.splice(to, 0, removed);\n      return newFiles;\n    });\n  };\n\n  const handleMerge = async () => {\n    if (files.length < 2 || !outputDir) return;\n    setLoading(true);\n    try {\n      await invoke(\"pdf_merge\", {\n        inputPaths: files,\n        outputPath,\n      });\n      toast.success(\"PDFs merged successfully!\");\n      setFiles([]);\n    } catch (e) {\n      toast.error(String(e));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const getFileName = (path: string) => path.split(/[/\\\\]/).pop() || path;\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <Label>PDF Files (drag to reorder)</Label>\n        <div className=\"space-y-2\">\n          {files.map((file, index) => (\n            <div\n              key={`${file}-${index}`}\n              className=\"flex items-center gap-2 p-2 bg-muted rounded-md\"\n            >\n              <div className=\"flex flex-col gap-0.5\">\n                <button\n                  onClick={() => moveFile(index, index - 1)}\n                  disabled={index === 0}\n                  className=\"p-0.5 hover:bg-background rounded disabled:opacity-30\"\n                >\n                  <GripVertical className=\"h-3 w-3 rotate-180\" />\n                </button>\n                <button\n                  onClick={() => moveFile(index, index + 1)}\n                  disabled={index === files.length - 1}\n                  className=\"p-0.5 hover:bg-background rounded disabled:opacity-30\"\n                >\n                  <GripVertical className=\"h-3 w-3\" />\n                </button>\n              </div>\n              <span className=\"text-sm text-muted-foreground w-6\">{index + 1}.</span>\n              <span className=\"flex-1 text-sm truncate\" title={file}>\n                {getFileName(file)}\n              </span>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"h-6 w-6\"\n                onClick={() => removeFile(index)}\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ))}\n        </div>\n        <div\n          ref={dropZoneRef}\n          className={cn(\n            \"rounded-md transition-all\",\n            isDragging && \"ring-2 ring-primary ring-offset-2 ring-offset-background\"\n          )}\n        >\n          <Button\n            variant=\"outline\"\n            onClick={selectFiles}\n            disabled={loading}\n            className={cn(\n              \"w-full\",\n              isDragging && \"border-primary bg-primary/5\"\n            )}\n          >\n            <Plus className=\"h-4 w-4 mr-2\" />\n            {isDragging ? \"Drop PDF files here...\" : \"Add PDF Files or Drop Here\"}\n          </Button>\n        </div>\n      </div>\n\n      {files.length > 0 && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>\n            {outputPath}\n          </p>\n        </div>\n      )}\n\n      <div className=\"pt-2\">\n        <Button\n          onClick={handleMerge}\n          disabled={files.length < 2 || !outputDir || loading}\n        >\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Merge {files.length > 0 ? `(${files.length} files)` : \"\"}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/panels/RemoveWatermarkPanel.tsx",
    "content": "import { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { FileDropInput } from \"@/components/ui/file-drop-input\";\nimport { Loader2, FlaskConical, Info } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { PdfPanelProps, WatermarkRemovalResult, getBasename, generateOutputPath } from \"../types\";\n\nconst PDF_EXTENSIONS = [\"pdf\"];\n\nexport function RemoveWatermarkPanel({ outputDir, loading, setLoading }: PdfPanelProps) {\n  const [inputFile, setInputFile] = useState(\"\");\n  const [result, setResult] = useState<WatermarkRemovalResult | null>(null);\n\n  const outputPath = inputFile\n    ? generateOutputPath(outputDir, getBasename(inputFile), \"no_watermark\")\n    : \"\";\n\n  const handleFileSelected = (file: string) => {\n    setInputFile(file);\n    setResult(null);\n  };\n\n  const selectFile = async () => {\n    const selected = await open({\n      multiple: false,\n      filters: [{ name: \"PDF\", extensions: [\"pdf\"] }],\n    });\n    if (selected) {\n      handleFileSelected(selected);\n    }\n  };\n\n  const handleRemove = async () => {\n    if (!inputFile || !outputDir) return;\n    setLoading(true);\n    setResult(null);\n    try {\n      const res = await invoke<WatermarkRemovalResult>(\"pdf_remove_watermark\", {\n        inputPath: inputFile,\n        outputPath,\n      });\n      setResult(res);\n      if (res.success) {\n        toast.success(\"Watermark removal completed!\");\n      } else {\n        toast.info(\"No watermarks detected\");\n      }\n    } catch (e) {\n      toast.error(String(e));\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Beta notice */}\n      <Alert className=\"border-amber-500/50 bg-amber-500/10\">\n        <FlaskConical className=\"h-4 w-4 text-amber-500\" />\n        <AlertDescription className=\"text-sm\">\n          <span className=\"font-medium text-amber-600 dark:text-amber-400\">Beta Feature</span>\n          <p className=\"mt-1 text-muted-foreground\">\n            This tool can remove <span className=\"font-medium\">some</span> watermarks, but not all.\n            It works best with overlay-type watermarks (text or images added as separate layers).\n            Watermarks that are \"baked into\" the page content may not be removable.\n          </p>\n          <p className=\"mt-2 text-muted-foreground\">\n            Give it a try - it might just work for your PDF!\n          </p>\n        </AlertDescription>\n      </Alert>\n\n      <div className=\"space-y-2\">\n        <Label>Input PDF</Label>\n        <FileDropInput\n          value={inputFile}\n          placeholder=\"Drop a PDF here or click to select\"\n          accept={PDF_EXTENSIONS}\n          acceptHint=\".pdf\"\n          onSelectClick={selectFile}\n          onDrop={handleFileSelected}\n          disabled={loading}\n          invalidDropMessage=\"Please drop a PDF file\"\n        />\n      </div>\n\n      {inputFile && (\n        <div className=\"space-y-2\">\n          <Label className=\"text-muted-foreground\">Output</Label>\n          <p className=\"text-sm text-muted-foreground break-all\" title={outputPath}>\n            {outputPath}\n          </p>\n        </div>\n      )}\n\n      {result && (\n        <Alert className={result.success ? \"border-green-500/50 bg-green-500/10\" : \"border-blue-500/50 bg-blue-500/10\"}>\n          <Info className={`h-4 w-4 ${result.success ? \"text-green-500\" : \"text-blue-500\"}`} />\n          <AlertDescription className=\"text-sm\">\n            {result.message}\n          </AlertDescription>\n        </Alert>\n      )}\n\n      <div className=\"pt-2\">\n        <Button\n          onClick={handleRemove}\n          disabled={!inputFile || !outputDir || loading}\n        >\n          {loading ? <Loader2 className=\"h-4 w-4 animate-spin mr-2\" /> : null}\n          Try to Remove Watermark\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/panels/index.ts",
    "content": "export { MergePdfPanel } from \"./MergePdfPanel\";\nexport { ImagesToPdfPanel } from \"./ImagesToPdfPanel\";\nexport { DeletePagesPanel } from \"./DeletePagesPanel\";\nexport { RemoveWatermarkPanel } from \"./RemoveWatermarkPanel\";\nexport { Md2PdfPanel } from \"./Md2PdfPanel\";\n"
  },
  {
    "path": "tauri/src/components/pdf-tools/types.ts",
    "content": "export interface PdfInfo {\n  path: string;\n  pages: number;\n  title: string | null;\n  author: string | null;\n}\n\nexport interface WatermarkRemovalResult {\n  success: boolean;\n  items_removed: number;\n  message: string;\n}\n\nexport interface Config {\n  output_dir: string;\n}\n\nexport type PdfToolId = \"merge\" | \"images-to-pdf\" | \"delete-pages\" | \"remove-watermark\" | \"md-to-pdf\";\n\nexport interface PdfPanelProps {\n  outputDir: string;\n  loading: boolean;\n  setLoading: (loading: boolean) => void;\n}\n\nexport function getBasename(filePath: string): string {\n  const name = filePath.split(/[/\\\\]/).pop() || \"\";\n  const lastDot = name.lastIndexOf(\".\");\n  return lastDot > 0 ? name.substring(0, lastDot) : name;\n}\n\nexport function generateOutputPath(\n  outputDir: string,\n  baseName: string,\n  suffix?: string\n): string {\n  const safeName = baseName.replace(/[/\\\\?%*:|\"<>]/g, \"-\");\n  const suffixStr = suffix ? `_${suffix}` : \"\";\n  return `${outputDir}/${safeName}${suffixStr}.pdf`;\n}\n"
  },
  {
    "path": "tauri/src/components/settings/AboutSettings.tsx",
    "content": "import { useState } from \"react\";\nimport { check } from \"@tauri-apps/plugin-updater\";\nimport { relaunch } from \"@tauri-apps/plugin-process\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { ExternalLink, RefreshCw } from \"lucide-react\";\nimport logo from \"@/assets/logo.png\";\n\nexport function AboutSettings() {\n  const { t } = useTranslation();\n  const [checking, setChecking] = useState(false);\n  const [updateAvailable, setUpdateAvailable] = useState<string | null>(null);\n  const [downloading, setDownloading] = useState(false);\n\n  const checkForUpdates = async () => {\n    setChecking(true);\n    try {\n      const update = await check();\n      if (update) {\n        setUpdateAvailable(update.version);\n      } else {\n        setUpdateAvailable(null);\n      }\n    } catch (err) {\n      console.error(\"Update check failed:\", err);\n    } finally {\n      setChecking(false);\n    }\n  };\n\n  const downloadAndInstall = async () => {\n    setDownloading(true);\n    try {\n      const update = await check();\n      if (update) {\n        await update.downloadAndInstall();\n        await relaunch();\n      }\n    } catch (err) {\n      console.error(\"Update failed:\", err);\n    } finally {\n      setDownloading(false);\n    }\n  };\n\n  return (\n    <div className=\"space-y-6\">\n      <Card>\n        <CardHeader>\n          <CardTitle>{t(\"settings.about.title\")}</CardTitle>\n          <CardDescription>{t(\"settings.about.desc\")}</CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-6\">\n          <div className=\"flex items-center gap-4\">\n            <img src={logo} alt=\"VGet\" className=\"h-16 w-16\" />\n            <div>\n              <h3 className=\"text-lg font-semibold\">{t(\"nav.vgetDesktop\")}</h3>\n              <p className=\"text-sm text-muted-foreground\">{t(\"settings.about.version\")} 0.1.0</p>\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-3\">\n            <Button\n              variant=\"outline\"\n              onClick={checkForUpdates}\n              disabled={checking || downloading}\n            >\n              <RefreshCw\n                className={`h-4 w-4 mr-2 ${checking ? \"animate-spin\" : \"\"}`}\n              />\n              {checking ? t(\"settings.about.checking\") : t(\"settings.about.checkForUpdates\")}\n            </Button>\n\n            {updateAvailable && (\n              <Button onClick={downloadAndInstall} disabled={downloading}>\n                {downloading\n                  ? t(\"settings.about.downloading\")\n                  : t(\"settings.about.updateTo\", { version: updateAvailable })}\n              </Button>\n            )}\n          </div>\n\n          {updateAvailable === null && !checking && (\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"settings.about.latestVersion\")}\n            </p>\n          )}\n        </CardContent>\n      </Card>\n\n      <Card>\n        <CardHeader>\n          <CardTitle>{t(\"settings.about.links\")}</CardTitle>\n        </CardHeader>\n        <CardContent className=\"space-y-3\">\n          <a\n            href=\"https://github.com/guiyumin/vget\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors\"\n          >\n            <ExternalLink className=\"h-4 w-4\" />\n            {t(\"settings.about.githubRepo\")}\n          </a>\n          <a\n            href=\"https://github.com/guiyumin/vget/issues\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors\"\n          >\n            <ExternalLink className=\"h-4 w-4\" />\n            {t(\"settings.about.reportIssue\")}\n          </a>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/settings/GeneralSettings.tsx",
    "content": "import { useEffect } from \"react\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { useTranslation } from \"react-i18next\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Folder } from \"lucide-react\";\nimport type { Config } from \"./types\";\n\n// Use global functions from main.tsx\nconst applyTheme = (window as any).__applyTheme as (theme: string) => void;\nconst changeLanguage = (window as any).__changeLanguage as (lang: string) => void;\n\ninterface GeneralSettingsProps {\n  config: Config;\n  onUpdate: (updates: Partial<Config>) => void;\n}\n\nexport function GeneralSettings({ config, onUpdate }: GeneralSettingsProps) {\n  const { t } = useTranslation();\n  const theme = config.theme || \"light\";\n  const language = config.language || \"en\";\n\n  useEffect(() => {\n    applyTheme?.(theme);\n  }, [theme]);\n\n  useEffect(() => {\n    changeLanguage?.(language);\n  }, [language]);\n\n  const handleSelectFolder = async () => {\n    const selected = await open({\n      directory: true,\n      multiple: false,\n      title: t(\"settings.general.selectDirectory\"),\n    });\n    if (selected) {\n      onUpdate({ output_dir: selected as string });\n    }\n  };\n\n  return (\n    <div className=\"space-y-6\">\n      <Card>\n        <CardHeader>\n          <CardTitle>{t(\"settings.general.downloads\")}</CardTitle>\n          <CardDescription>{t(\"settings.general.downloadsDesc\")}</CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-4\">\n          <div className=\"grid gap-2\">\n            <Label htmlFor=\"output_dir\">{t(\"settings.general.downloadLocation\")}</Label>\n            <div className=\"flex gap-2\">\n              <Input\n                id=\"output_dir\"\n                value={config.output_dir}\n                onChange={(e) => onUpdate({ output_dir: e.target.value })}\n                className=\"flex-1\"\n              />\n              <Button variant=\"outline\" size=\"icon\" onClick={handleSelectFolder}>\n                <Folder className=\"h-4 w-4\" />\n              </Button>\n            </div>\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"settings.general.downloadLocationHint\")}\n            </p>\n          </div>\n\n          <div className=\"grid gap-2\">\n            <Label htmlFor=\"format\">{t(\"settings.general.defaultFormat\")}</Label>\n            <Select\n              value={config.format}\n              onValueChange={(value) => onUpdate({ format: value })}\n            >\n              <SelectTrigger>\n                <SelectValue placeholder={t(\"settings.general.selectFormat\")} />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"mp4\">MP4</SelectItem>\n                <SelectItem value=\"webm\">WebM</SelectItem>\n                <SelectItem value=\"best\">{t(\"settings.general.bestAvailable\")}</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n\n          <div className=\"grid gap-2\">\n            <Label htmlFor=\"quality\">{t(\"settings.general.defaultQuality\")}</Label>\n            <Select\n              value={config.quality}\n              onValueChange={(value) => onUpdate({ quality: value })}\n            >\n              <SelectTrigger>\n                <SelectValue placeholder={t(\"settings.general.selectQuality\")} />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"best\">{t(\"settings.general.bestAvailable\")}</SelectItem>\n                <SelectItem value=\"1080p\">1080p</SelectItem>\n                <SelectItem value=\"720p\">720p</SelectItem>\n                <SelectItem value=\"480p\">480p</SelectItem>\n              </SelectContent>\n            </Select>\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"settings.general.qualityHint\")}\n            </p>\n          </div>\n        </CardContent>\n      </Card>\n\n      <Card>\n        <CardHeader>\n          <CardTitle>{t(\"settings.general.language\")}</CardTitle>\n          <CardDescription>{t(\"settings.general.languageDesc\")}</CardDescription>\n        </CardHeader>\n        <CardContent>\n          <Select\n            value={config.language}\n            onValueChange={(value) => onUpdate({ language: value })}\n          >\n            <SelectTrigger>\n              <SelectValue placeholder={t(\"settings.general.selectLanguage\")} />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"en\">English</SelectItem>\n              <SelectItem value=\"zh\">中文</SelectItem>\n              <SelectItem value=\"jp\">日本語</SelectItem>\n              <SelectItem value=\"kr\">한국어</SelectItem>\n              <SelectItem value=\"es\">Español</SelectItem>\n              <SelectItem value=\"fr\">Français</SelectItem>\n              <SelectItem value=\"de\">Deutsch</SelectItem>\n            </SelectContent>\n          </Select>\n        </CardContent>\n      </Card>\n\n      <Card>\n        <CardHeader>\n          <CardTitle>{t(\"settings.general.theme\")}</CardTitle>\n          <CardDescription>{t(\"settings.general.themeDesc\")}</CardDescription>\n        </CardHeader>\n        <CardContent>\n          <Select value={theme} onValueChange={(value) => onUpdate({ theme: value })}>\n            <SelectTrigger>\n              <SelectValue placeholder={t(\"settings.general.selectTheme\")} />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"light\">{t(\"settings.general.light\")}</SelectItem>\n              <SelectItem value=\"dark\">{t(\"settings.general.dark\")}</SelectItem>\n              <SelectItem value=\"system\">{t(\"settings.general.system\")}</SelectItem>\n            </SelectContent>\n          </Select>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/settings/SettingsPage.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"@/components/ui/button\";\nimport { ArrowLeft, Settings, Globe, Info } from \"lucide-react\";\nimport { Link } from \"@tanstack/react-router\";\nimport { GeneralSettings } from \"./GeneralSettings\";\nimport { SiteSettings } from \"./SiteSettings\";\nimport { AboutSettings } from \"./AboutSettings\";\nimport type { Config } from \"./types\";\nimport { cn } from \"@/lib/utils\";\n\ntype SettingsSection = \"general\" | \"sites\" | \"about\";\n\nconst sectionIcons: Record<SettingsSection, React.ComponentType<{ className?: string }>> = {\n  general: Settings,\n  sites: Globe,\n  about: Info,\n};\n\nexport function SettingsPage() {\n  const { t } = useTranslation();\n  const [config, setConfig] = useState<Config | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [dirty, setDirty] = useState(false);\n  const [activeSection, setActiveSection] =\n    useState<SettingsSection>(\"general\");\n\n  useEffect(() => {\n    invoke<Config>(\"get_config\")\n      .then((configData) => {\n        // Ensure nested objects have defaults\n        setConfig({\n          ...configData,\n          twitter: configData.twitter ?? { auth_token: null },\n          bilibili: configData.bilibili ?? { cookie: null },\n          server: configData.server ?? { max_concurrent: 10 },\n          webdav_servers: configData.webdav_servers ?? {},\n          express: configData.express ?? { kuaidi100: null },\n        });\n      })\n      .catch(console.error)\n      .finally(() => setLoading(false));\n  }, []);\n\n  const updateConfig = (updates: Partial<Config>) => {\n    if (!config) return;\n    setConfig({ ...config, ...updates });\n    setDirty(true);\n  };\n\n  const saveConfig = async () => {\n    if (!config) return;\n    setSaving(true);\n    try {\n      await invoke(\"save_config\", { config });\n      setDirty(false);\n    } catch (err) {\n      console.error(\"Failed to save config:\", err);\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const sections: { id: SettingsSection; label: string; icon: React.ComponentType<{ className?: string }> }[] = [\n    { id: \"general\", label: t(\"settings.sections.general\"), icon: sectionIcons.general },\n    { id: \"sites\", label: t(\"settings.sections.sites\"), icon: sectionIcons.sites },\n    { id: \"about\", label: t(\"settings.sections.about\"), icon: sectionIcons.about },\n  ];\n\n  if (loading) {\n    return (\n      <div className=\"flex h-screen items-center justify-center\">\n        <p className=\"text-muted-foreground\">{t(\"settings.loading\")}</p>\n      </div>\n    );\n  }\n\n  if (!config) {\n    return (\n      <div className=\"flex h-screen items-center justify-center\">\n        <p className=\"text-destructive\">{t(\"settings.loadFailed\")}</p>\n      </div>\n    );\n  }\n\n  const renderSection = () => {\n    switch (activeSection) {\n      case \"general\":\n        return <GeneralSettings config={config} onUpdate={updateConfig} />;\n      case \"sites\":\n        return <SiteSettings config={config} onUpdate={updateConfig} />;\n      case \"about\":\n        return <AboutSettings />;\n      default:\n        return null;\n    }\n  };\n\n  return (\n    <div className=\"flex h-screen overflow-hidden bg-background\">\n      {/* Sidebar */}\n      <aside className=\"w-56 border-r bg-muted/30 flex flex-col shrink-0\">\n        <div className=\"h-14 px-4 border-b flex items-center\">\n          <Link\n            to=\"/\"\n            className=\"flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors\"\n          >\n            <ArrowLeft className=\"h-4 w-4\" />\n            <span className=\"text-sm font-medium\">{t(\"settings.back\")}</span>\n          </Link>\n        </div>\n\n        <nav className=\"flex-1 p-2\">\n          <ul className=\"space-y-1\">\n            {sections.map((section) => {\n              const Icon = section.icon;\n              return (\n                <li key={section.id}>\n                  <button\n                    onClick={() => setActiveSection(section.id)}\n                    className={cn(\n                      \"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors\",\n                      activeSection === section.id\n                        ? \"bg-primary text-primary-foreground\"\n                        : \"text-muted-foreground hover:bg-muted hover:text-foreground\"\n                    )}\n                  >\n                    <Icon className=\"h-4 w-4\" />\n                    {section.label}\n                  </button>\n                </li>\n              );\n            })}\n          </ul>\n        </nav>\n\n        <div className=\"mt-auto p-4 border-t\">\n          <p className=\"text-xs text-muted-foreground\">{t(\"nav.vgetDesktop\")}</p>\n        </div>\n      </aside>\n\n      {/* Main Content */}\n      <main className=\"flex-1 flex flex-col min-w-0 overflow-hidden\">\n        <header className=\"h-14 border-b flex items-center justify-between px-6 shrink-0\">\n          <h1 className=\"text-lg font-semibold\">\n            {sections.find((s) => s.id === activeSection)?.label}\n          </h1>\n          <div className=\"flex items-center gap-3\">\n            {dirty && (\n              <span className=\"text-sm text-muted-foreground\">\n                {t(\"settings.unsavedChanges\")}\n              </span>\n            )}\n            <Button onClick={saveConfig} disabled={!dirty || saving} size=\"sm\">\n              {saving ? t(\"settings.saving\") : t(\"settings.save\")}\n            </Button>\n          </div>\n        </header>\n\n        <div className=\"flex-1 overflow-auto\">\n          <div className=\"max-w-2xl p-6\">{renderSection()}</div>\n        </div>\n      </main>\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/settings/SiteSettings.tsx",
    "content": "import { useState, useEffect, useRef, useCallback } from \"react\";\nimport { QRCodeSVG } from \"qrcode.react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Eye,\n  EyeOff,\n  CheckCircle2,\n  Loader2,\n  RefreshCw,\n  LogOut,\n  ExternalLink,\n  Server,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport type { Config } from \"./types\";\nimport {\n  useAuthStore,\n  generateBilibiliQR,\n  pollBilibiliQR,\n  saveBilibiliCookie,\n  openXhsLoginWindow,\n  QR_WAITING,\n  QR_SCANNED,\n  QR_EXPIRED,\n  QR_CONFIRMED,\n  type QRSession,\n} from \"@/stores/auth\";\nimport {\n  getDockerServerUrl,\n  setDockerServerUrl,\n  checkDockerHealth,\n  getDockerJwtToken,\n  setDockerJwtToken,\n} from \"@/services/dockerApi\";\n\ninterface SiteSettingsProps {\n  config: Config;\n  onUpdate: (updates: Partial<Config>) => void;\n}\n\ninterface CookieFields {\n  sessdata: string;\n  biliJct: string;\n  dedeUserId: string;\n}\n\nfunction buildCookie(fields: CookieFields): string {\n  const parts: string[] = [];\n  if (fields.sessdata) parts.push(`SESSDATA=${fields.sessdata}`);\n  if (fields.biliJct) parts.push(`bili_jct=${fields.biliJct}`);\n  if (fields.dedeUserId) parts.push(`DedeUserID=${fields.dedeUserId}`);\n  return parts.join(\"; \");\n}\n\nexport function SiteSettings({ config, onUpdate }: SiteSettingsProps) {\n  const { t } = useTranslation();\n  const [showTwitterToken, setShowTwitterToken] = useState(false);\n  const { bilibili, xiaohongshu, setBilibiliStatus, logout, checkAuthStatus } =\n    useAuthStore();\n\n  useEffect(() => {\n    checkAuthStatus();\n  }, [checkAuthStatus]);\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Twitter */}\n      <Card>\n        <CardHeader>\n          <CardTitle>{t(\"settings.sites.twitter.title\")}</CardTitle>\n          <CardDescription>\n            {t(\"settings.sites.twitter.desc\")}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-4\">\n          <div className=\"grid gap-2\">\n            <Label htmlFor=\"twitter_auth_token\">{t(\"settings.sites.twitter.authToken\")}</Label>\n            <div className=\"flex gap-2\">\n              <Input\n                id=\"twitter_auth_token\"\n                type={showTwitterToken ? \"text\" : \"password\"}\n                value={config.twitter?.auth_token || \"\"}\n                onChange={(e) =>\n                  onUpdate({\n                    twitter: {\n                      ...config.twitter,\n                      auth_token: e.target.value || null,\n                    },\n                  })\n                }\n                placeholder={t(\"settings.sites.twitter.tokenPlaceholder\")}\n                className=\"flex-1\"\n              />\n              <Button\n                variant=\"outline\"\n                size=\"icon\"\n                onClick={() => setShowTwitterToken(!showTwitterToken)}\n              >\n                {showTwitterToken ? (\n                  <EyeOff className=\"h-4 w-4\" />\n                ) : (\n                  <Eye className=\"h-4 w-4\" />\n                )}\n              </Button>\n            </div>\n            <p className=\"text-sm text-muted-foreground\">\n              {t(\"settings.sites.twitter.tokenHint\")}\n            </p>\n          </div>\n        </CardContent>\n      </Card>\n\n      {/* Bilibili */}\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"flex items-center gap-2\">\n            {t(\"settings.sites.bilibili.title\")}\n            {bilibili.status === \"logged_in\" && (\n              <CheckCircle2 className=\"h-4 w-4 text-green-500\" />\n            )}\n          </CardTitle>\n          <CardDescription>\n            {bilibili.status === \"logged_in\"\n              ? bilibili.username\n                ? t(\"settings.sites.bilibili.loggedInAs\", { username: bilibili.username })\n                : t(\"settings.sites.bilibili.loggedIn\")\n              : t(\"settings.sites.bilibili.desc\")}\n          </CardDescription>\n        </CardHeader>\n        <CardContent>\n          {bilibili.status === \"logged_in\" ? (\n            <Button\n              variant=\"outline\"\n              onClick={async () => {\n                try {\n                  await logout(\"bilibili\");\n                  toast.success(t(\"settings.sites.logoutSuccess\"));\n                } catch {\n                  toast.error(t(\"settings.sites.logoutFailed\"));\n                }\n              }}\n            >\n              <LogOut className=\"h-4 w-4 mr-2\" />\n              {t(\"settings.sites.bilibili.logout\")}\n            </Button>\n          ) : bilibili.status === \"checking\" ? (\n            <div className=\"flex items-center gap-2 text-muted-foreground\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n              {t(\"settings.sites.bilibili.checkingStatus\")}\n            </div>\n          ) : (\n            <Tabs defaultValue=\"qr\">\n              <TabsList className=\"grid w-full grid-cols-2\">\n                <TabsTrigger value=\"qr\">{t(\"settings.sites.bilibili.qrCode\")}</TabsTrigger>\n                <TabsTrigger value=\"cookie\">{t(\"settings.sites.bilibili.cookie\")}</TabsTrigger>\n              </TabsList>\n              <TabsContent value=\"qr\" className=\"mt-4\">\n                <BilibiliQRLogin\n                  onSuccess={(username) => {\n                    setBilibiliStatus({ status: \"logged_in\", username });\n                    toast.success(t(\"settings.sites.welcome\", { username: username || \"User\" }));\n                  }}\n                />\n              </TabsContent>\n              <TabsContent value=\"cookie\" className=\"mt-4\">\n                <BilibiliCookieLogin\n                  onSuccess={(username) => {\n                    setBilibiliStatus({ status: \"logged_in\", username });\n                    toast.success(t(\"settings.sites.loginSuccess\"));\n                  }}\n                />\n              </TabsContent>\n            </Tabs>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Xiaohongshu */}\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"flex items-center gap-2\">\n            {t(\"settings.sites.xiaohongshu.title\")}\n            {xiaohongshu.status === \"logged_in\" && (\n              <CheckCircle2 className=\"h-4 w-4 text-green-500\" />\n            )}\n          </CardTitle>\n          <CardDescription>\n            {xiaohongshu.status === \"logged_in\"\n              ? xiaohongshu.username\n                ? t(\"settings.sites.xiaohongshu.loggedInAs\", { username: xiaohongshu.username })\n                : t(\"settings.sites.xiaohongshu.sessionSaved\")\n              : t(\"settings.sites.xiaohongshu.desc\")}\n          </CardDescription>\n        </CardHeader>\n        <CardContent>\n          {xiaohongshu.status === \"logged_in\" ? (\n            <Button\n              variant=\"outline\"\n              onClick={async () => {\n                try {\n                  await logout(\"xiaohongshu\");\n                  toast.success(t(\"settings.sites.logoutSuccess\"));\n                } catch {\n                  toast.error(t(\"settings.sites.logoutFailed\"));\n                }\n              }}\n            >\n              <LogOut className=\"h-4 w-4 mr-2\" />\n              {t(\"settings.sites.bilibili.logout\")}\n            </Button>\n          ) : xiaohongshu.status === \"checking\" ? (\n            <div className=\"flex items-center gap-2 text-muted-foreground\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n              {t(\"settings.sites.bilibili.checkingStatus\")}\n            </div>\n          ) : (\n            <XiaohongshuLogin />\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Docker Server (for YouTube) */}\n      <DockerServerSettings />\n    </div>\n  );\n}\n\nfunction BilibiliQRLogin({\n  onSuccess,\n}: {\n  onSuccess: (username?: string) => void;\n}) {\n  const { t } = useTranslation();\n  const [qrSession, setQrSession] = useState<QRSession | null>(null);\n  const [qrStatus, setQrStatus] = useState<number | null>(null);\n  const [generating, setGenerating] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const pollIntervalRef = useRef<number | null>(null);\n\n  const generateQR = useCallback(async () => {\n    setGenerating(true);\n    setError(null);\n    setQrStatus(null);\n\n    try {\n      const session = await generateBilibiliQR();\n      setQrSession(session);\n      setQrStatus(QR_WAITING);\n    } catch (err) {\n      setError(\n        err instanceof Error ? err.message : t(\"settings.sites.bilibili.failedToGenerateQR\")\n      );\n    } finally {\n      setGenerating(false);\n    }\n  }, []);\n\n  const pollStatus = useCallback(async () => {\n    if (!qrSession) return;\n\n    try {\n      const result = await pollBilibiliQR(qrSession.qrcode_key);\n      setQrStatus(result.status);\n\n      if (result.status === QR_CONFIRMED) {\n        if (pollIntervalRef.current) {\n          clearInterval(pollIntervalRef.current);\n          pollIntervalRef.current = null;\n        }\n        onSuccess(result.username);\n      } else if (result.status === QR_EXPIRED) {\n        if (pollIntervalRef.current) {\n          clearInterval(pollIntervalRef.current);\n          pollIntervalRef.current = null;\n        }\n      }\n    } catch (err) {\n      console.error(\"Poll error:\", err);\n    }\n  }, [qrSession, onSuccess]);\n\n  useEffect(() => {\n    generateQR();\n  }, [generateQR]);\n\n  useEffect(() => {\n    const shouldPoll =\n      qrSession && (qrStatus === QR_WAITING || qrStatus === QR_SCANNED);\n\n    if (shouldPoll) {\n      if (pollIntervalRef.current) {\n        clearInterval(pollIntervalRef.current);\n      }\n      pollIntervalRef.current = window.setInterval(pollStatus, 1500);\n    }\n\n    return () => {\n      if (pollIntervalRef.current) {\n        clearInterval(pollIntervalRef.current);\n        pollIntervalRef.current = null;\n      }\n    };\n  }, [qrSession, qrStatus, pollStatus]);\n\n  const getStatusText = () => {\n    switch (qrStatus) {\n      case QR_WAITING:\n        return t(\"settings.sites.bilibili.scanWithApp\");\n      case QR_SCANNED:\n        return t(\"settings.sites.bilibili.confirmLogin\");\n      case QR_EXPIRED:\n        return t(\"settings.sites.bilibili.qrExpired\");\n      case QR_CONFIRMED:\n        return t(\"settings.sites.bilibili.loginSuccess\");\n      default:\n        return \"\";\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col items-center\">\n      <div className=\"mb-4 p-4 bg-white rounded-lg\">\n        {generating ? (\n          <div className=\"w-40 h-40 flex items-center justify-center\">\n            <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n          </div>\n        ) : error ? (\n          <div className=\"w-40 h-40 flex items-center justify-center text-destructive text-center text-sm p-4\">\n            {error}\n          </div>\n        ) : qrSession ? (\n          <QRCodeSVG\n            value={qrSession.url}\n            size={160}\n            level=\"L\"\n            className={qrStatus === QR_EXPIRED ? \"opacity-30\" : \"\"}\n          />\n        ) : (\n          <div className=\"w-40 h-40 flex items-center justify-center text-muted-foreground\">\n            {t(\"settings.sites.bilibili.waiting\")}\n          </div>\n        )}\n      </div>\n\n      <div className=\"mb-4 text-center text-sm\">\n        {qrStatus === QR_SCANNED ? (\n          <span className=\"text-green-600 font-medium flex items-center gap-2\">\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n            {getStatusText()}\n          </span>\n        ) : qrStatus === QR_EXPIRED ? (\n          <span className=\"text-destructive\">{getStatusText()}</span>\n        ) : (\n          <span className=\"text-muted-foreground\">{getStatusText()}</span>\n        )}\n      </div>\n\n      {(qrStatus === QR_EXPIRED || error) && (\n        <Button onClick={generateQR} disabled={generating} variant=\"outline\" size=\"sm\">\n          <RefreshCw className=\"h-4 w-4 mr-2\" />\n          {t(\"settings.sites.bilibili.refreshQR\")}\n        </Button>\n      )}\n    </div>\n  );\n}\n\nfunction BilibiliCookieLogin({\n  onSuccess,\n}: {\n  onSuccess: (username?: string) => void;\n}) {\n  const { t } = useTranslation();\n  const [fields, setFields] = useState<CookieFields>({\n    sessdata: \"\",\n    biliJct: \"\",\n    dedeUserId: \"\",\n  });\n  const [saving, setSaving] = useState(false);\n\n  const handleSave = async () => {\n    const cookie = buildCookie(fields);\n    if (!cookie) {\n      toast.error(t(\"settings.sites.bilibili.fillOneField\"));\n      return;\n    }\n\n    setSaving(true);\n    try {\n      await saveBilibiliCookie(cookie);\n      onSuccess();\n    } catch (err) {\n      toast.error(err instanceof Error ? err.message : t(\"settings.sites.bilibili.failedToSave\"));\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const hasAnyInput = fields.sessdata || fields.biliJct || fields.dedeUserId;\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"p-3 bg-muted rounded-lg text-sm text-muted-foreground\">\n        <p className=\"font-medium mb-2\">{t(\"settings.sites.bilibili.cookieInstructions\")}</p>\n        <ol className=\"list-decimal list-inside space-y-1\">\n          <li>{t(\"settings.sites.bilibili.step1\")}</li>\n          <li>{t(\"settings.sites.bilibili.step2\")}</li>\n          <li>{t(\"settings.sites.bilibili.step3\")}</li>\n          <li>{t(\"settings.sites.bilibili.step4\")}</li>\n        </ol>\n      </div>\n\n      <div className=\"space-y-3\">\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"sessdata\">SESSDATA</Label>\n          <Input\n            id=\"sessdata\"\n            value={fields.sessdata}\n            onChange={(e) =>\n              setFields((f) => ({ ...f, sessdata: e.target.value }))\n            }\n            placeholder={t(\"settings.sites.bilibili.pasteSessdata\")}\n            className=\"font-mono text-sm\"\n          />\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"bili_jct\">bili_jct</Label>\n          <Input\n            id=\"bili_jct\"\n            value={fields.biliJct}\n            onChange={(e) =>\n              setFields((f) => ({ ...f, biliJct: e.target.value }))\n            }\n            placeholder={t(\"settings.sites.bilibili.pasteBiliJct\")}\n            className=\"font-mono text-sm\"\n          />\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"dedeUserId\">DedeUserID</Label>\n          <Input\n            id=\"dedeUserId\"\n            value={fields.dedeUserId}\n            onChange={(e) =>\n              setFields((f) => ({ ...f, dedeUserId: e.target.value }))\n            }\n            placeholder={t(\"settings.sites.bilibili.pasteDedeUserId\")}\n            className=\"font-mono text-sm\"\n          />\n        </div>\n      </div>\n\n      <Button onClick={handleSave} disabled={saving || !hasAnyInput} className=\"w-full\">\n        {saving && <Loader2 className=\"h-4 w-4 mr-2 animate-spin\" />}\n        {t(\"common.save\")}\n      </Button>\n    </div>\n  );\n}\n\nfunction XiaohongshuLogin() {\n  const { t } = useTranslation();\n  const { checkAuthStatus } = useAuthStore();\n  const [opening, setOpening] = useState(false);\n\n  const handleOpenLogin = async () => {\n    setOpening(true);\n    try {\n      await openXhsLoginWindow();\n      setTimeout(async () => {\n        await checkAuthStatus();\n        const state = useAuthStore.getState();\n        if (state.xiaohongshu.status === \"logged_in\") {\n          toast.success(t(\"settings.sites.loginSuccess\"));\n        }\n      }, 1000);\n    } catch (err) {\n      toast.error(\n        err instanceof Error ? err.message : t(\"settings.sites.xiaohongshu.failedToOpenLogin\")\n      );\n    } finally {\n      setOpening(false);\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"p-3 bg-muted rounded-lg text-sm text-muted-foreground\">\n        <p className=\"mb-2\">{t(\"settings.sites.xiaohongshu.loginInstructions\")}</p>\n        <ul className=\"list-disc list-inside space-y-1\">\n          <li>{t(\"settings.sites.xiaohongshu.scanWithApp\")}</li>\n          <li>{t(\"settings.sites.xiaohongshu.orLoginPhone\")}</li>\n          <li>{t(\"settings.sites.xiaohongshu.closeWhenDone\")}</li>\n        </ul>\n      </div>\n\n      <div className=\"flex gap-2\">\n        <Button onClick={handleOpenLogin} disabled={opening} className=\"flex-1\">\n          {opening ? (\n            <Loader2 className=\"h-4 w-4 mr-2 animate-spin\" />\n          ) : (\n            <ExternalLink className=\"h-4 w-4 mr-2\" />\n          )}\n          {opening ? t(\"settings.sites.xiaohongshu.opening\") : t(\"settings.sites.xiaohongshu.openLoginWindow\")}\n        </Button>\n\n        <Button variant=\"outline\" onClick={checkAuthStatus}>\n          <RefreshCw className=\"h-4 w-4\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nfunction DockerServerSettings() {\n  const { t } = useTranslation();\n  const [serverUrl, setServerUrl] = useState(getDockerServerUrl());\n  const [jwtToken, setJwtToken] = useState(getDockerJwtToken());\n  const [showToken, setShowToken] = useState(false);\n  const [testing, setTesting] = useState(false);\n  const [connectionStatus, setConnectionStatus] = useState<\"unknown\" | \"connected\" | \"failed\">(\"unknown\");\n\n  const handleTestConnection = async () => {\n    setTesting(true);\n    setConnectionStatus(\"unknown\");\n    try {\n      // Save settings first\n      setDockerServerUrl(serverUrl);\n      if (jwtToken) {\n        setDockerJwtToken(jwtToken);\n      }\n\n      const isHealthy = await checkDockerHealth();\n      setConnectionStatus(isHealthy ? \"connected\" : \"failed\");\n      if (isHealthy) {\n        toast.success(t(\"settings.sites.docker.connectionSuccess\") || \"Connected to Docker server!\");\n      } else {\n        toast.error(t(\"settings.sites.docker.connectionFailed\") || \"Failed to connect to Docker server\");\n      }\n    } catch {\n      setConnectionStatus(\"failed\");\n      toast.error(t(\"settings.sites.docker.connectionFailed\") || \"Failed to connect to Docker server\");\n    } finally {\n      setTesting(false);\n    }\n  };\n\n  const handleSave = () => {\n    setDockerServerUrl(serverUrl);\n    setDockerJwtToken(jwtToken);\n    toast.success(t(\"settings.sites.docker.saved\") || \"Docker server settings saved\");\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2\">\n          <Server className=\"h-5 w-5\" />\n          {t(\"settings.sites.docker.title\") || \"Docker Server (YouTube)\"}\n          {connectionStatus === \"connected\" && (\n            <CheckCircle2 className=\"h-4 w-4 text-green-500\" />\n          )}\n        </CardTitle>\n        <CardDescription>\n          {t(\"settings.sites.docker.desc\") || \"Configure vget-server for YouTube downloads. Run the Docker container or vget-server locally.\"}\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <div className=\"grid gap-2\">\n          <Label htmlFor=\"docker_server_url\">\n            {t(\"settings.sites.docker.serverUrl\") || \"Server URL\"}\n          </Label>\n          <Input\n            id=\"docker_server_url\"\n            type=\"text\"\n            value={serverUrl}\n            onChange={(e) => setServerUrl(e.target.value)}\n            placeholder=\"http://localhost:8080\"\n          />\n          <p className=\"text-sm text-muted-foreground\">\n            {t(\"settings.sites.docker.serverUrlHint\") || \"URL of the vget-server (default: http://localhost:8080)\"}\n          </p>\n        </div>\n\n        <div className=\"grid gap-2\">\n          <Label htmlFor=\"docker_jwt_token\">\n            {t(\"settings.sites.docker.jwtToken\") || \"JWT Token (optional)\"}\n          </Label>\n          <div className=\"flex gap-2\">\n            <Input\n              id=\"docker_jwt_token\"\n              type={showToken ? \"text\" : \"password\"}\n              value={jwtToken}\n              onChange={(e) => setJwtToken(e.target.value)}\n              placeholder={t(\"settings.sites.docker.jwtPlaceholder\") || \"Paste JWT token if server requires authentication\"}\n              className=\"flex-1 font-mono text-sm\"\n            />\n            <Button\n              variant=\"outline\"\n              size=\"icon\"\n              onClick={() => setShowToken(!showToken)}\n            >\n              {showToken ? (\n                <EyeOff className=\"h-4 w-4\" />\n              ) : (\n                <Eye className=\"h-4 w-4\" />\n              )}\n            </Button>\n          </div>\n          <p className=\"text-sm text-muted-foreground\">\n            {t(\"settings.sites.docker.jwtHint\") || \"Only needed if the server has api_key configured. Get token from: POST /api/auth/token\"}\n          </p>\n        </div>\n\n        <div className=\"flex gap-2\">\n          <Button onClick={handleSave} variant=\"outline\">\n            {t(\"common.save\")}\n          </Button>\n          <Button onClick={handleTestConnection} disabled={testing}>\n            {testing ? (\n              <Loader2 className=\"h-4 w-4 mr-2 animate-spin\" />\n            ) : (\n              <RefreshCw className=\"h-4 w-4 mr-2\" />\n            )}\n            {t(\"settings.sites.docker.testConnection\") || \"Test Connection\"}\n          </Button>\n        </div>\n\n        {connectionStatus === \"failed\" && (\n          <div className=\"p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive\">\n            {t(\"settings.sites.docker.notRunningHint\") || \"Docker server is not running. Start it with: docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\"}\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/settings/index.ts",
    "content": "export { SettingsPage } from \"./SettingsPage\";\nexport { GeneralSettings } from \"./GeneralSettings\";\nexport { SiteSettings } from \"./SiteSettings\";\nexport { AboutSettings } from \"./AboutSettings\";\nexport type { Config } from \"./types\";\n"
  },
  {
    "path": "tauri/src/components/settings/types.ts",
    "content": "export interface WebDAVServer {\n  url: string;\n  username: string;\n  password: string;\n}\n\nexport interface TwitterConfig {\n  auth_token: string | null;\n}\n\nexport interface ServerConfig {\n  max_concurrent: number;\n}\n\nexport interface BilibiliConfig {\n  cookie: string | null;\n}\n\nexport interface Kuaidi100Config {\n  customer: string | null;\n  key: string | null;\n}\n\nexport interface ExpressConfig {\n  kuaidi100: Kuaidi100Config | null;\n}\n\nexport interface Config {\n  language: string;\n  output_dir: string;\n  format: string;\n  quality: string;\n  theme: string;\n  webdav_servers: Record<string, WebDAVServer>;\n  twitter: TwitterConfig;\n  server: ServerConfig;\n  express: ExpressConfig;\n  bilibili: BilibiliConfig;\n}\n"
  },
  {
    "path": "tauri/src/components/ui/accordion.tsx",
    "content": "import * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Accordion({\n  ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Root>) {\n  return <AccordionPrimitive.Root data-slot=\"accordion\" {...props} />\n}\n\nfunction AccordionItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Item>) {\n  return (\n    <AccordionPrimitive.Item\n      data-slot=\"accordion-item\"\n      className={cn(\"border-b last:border-b-0\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AccordionTrigger({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {\n  return (\n    <AccordionPrimitive.Header className=\"flex\">\n      <AccordionPrimitive.Trigger\n        data-slot=\"accordion-trigger\"\n        className={cn(\n          \"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <ChevronDownIcon className=\"text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200\" />\n      </AccordionPrimitive.Trigger>\n    </AccordionPrimitive.Header>\n  )\n}\n\nfunction AccordionContent({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Content>) {\n  return (\n    <AccordionPrimitive.Content\n      data-slot=\"accordion-content\"\n      className=\"data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm\"\n      {...props}\n    >\n      <div className={cn(\"pt-0 pb-4\", className)}>{children}</div>\n    </AccordionPrimitive.Content>\n  )\n}\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n"
  },
  {
    "path": "tauri/src/components/ui/alert-dialog.tsx",
    "content": "import * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n  )\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n  )\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  )\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\"text-lg font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogAction({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {\n  return (\n    <AlertDialogPrimitive.Action\n      className={cn(buttonVariants(), className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogCancel({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {\n  return (\n    <AlertDialogPrimitive.Cancel\n      className={cn(buttonVariants({ variant: \"outline\" }), className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/alert.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-card text-card-foreground\",\n        destructive:\n          \"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nfunction Alert({\n  className,\n  variant,\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof alertVariants>) {\n  return (\n    <div\n      data-slot=\"alert\"\n      role=\"alert\"\n      className={cn(alertVariants({ variant }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-title\"\n      className={cn(\n        \"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDescription({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-description\"\n      className={cn(\n        \"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Alert, AlertTitle, AlertDescription }\n"
  },
  {
    "path": "tauri/src/components/ui/aspect-ratio.tsx",
    "content": "\"use client\"\n\nimport * as AspectRatioPrimitive from \"@radix-ui/react-aspect-ratio\"\n\nfunction AspectRatio({\n  ...props\n}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {\n  return <AspectRatioPrimitive.Root data-slot=\"aspect-ratio\" {...props} />\n}\n\nexport { AspectRatio }\n"
  },
  {
    "path": "tauri/src/components/ui/avatar.tsx",
    "content": "import * as React from \"react\"\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Avatar({\n  className,\n  ...props\n}: React.ComponentProps<typeof AvatarPrimitive.Root>) {\n  return (\n    <AvatarPrimitive.Root\n      data-slot=\"avatar\"\n      className={cn(\n        \"relative flex size-8 shrink-0 overflow-hidden rounded-full\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AvatarImage({\n  className,\n  ...props\n}: React.ComponentProps<typeof AvatarPrimitive.Image>) {\n  return (\n    <AvatarPrimitive.Image\n      data-slot=\"avatar-image\"\n      className={cn(\"aspect-square size-full\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AvatarFallback({\n  className,\n  ...props\n}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {\n  return (\n    <AvatarPrimitive.Fallback\n      data-slot=\"avatar-fallback\"\n      className={cn(\n        \"bg-muted flex size-full items-center justify-center rounded-full\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Avatar, AvatarImage, AvatarFallback }\n"
  },
  {
    "path": "tauri/src/components/ui/badge.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst badgeVariants = cva(\n  \"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90\",\n        destructive:\n          \"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nfunction Badge({\n  className,\n  variant,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"span\"> &\n  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"span\"\n\n  return (\n    <Comp\n      data-slot=\"badge\"\n      className={cn(badgeVariants({ variant }), className)}\n      {...props}\n    />\n  )\n}\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "tauri/src/components/ui/breadcrumb.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { ChevronRight, MoreHorizontal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Breadcrumb({ ...props }: React.ComponentProps<\"nav\">) {\n  return <nav aria-label=\"breadcrumb\" data-slot=\"breadcrumb\" {...props} />\n}\n\nfunction BreadcrumbList({ className, ...props }: React.ComponentProps<\"ol\">) {\n  return (\n    <ol\n      data-slot=\"breadcrumb-list\"\n      className={cn(\n        \"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"breadcrumb-item\"\n      className={cn(\"inline-flex items-center gap-1.5\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbLink({\n  asChild,\n  className,\n  ...props\n}: React.ComponentProps<\"a\"> & {\n  asChild?: boolean\n}) {\n  const Comp = asChild ? Slot : \"a\"\n\n  return (\n    <Comp\n      data-slot=\"breadcrumb-link\"\n      className={cn(\"hover:text-foreground transition-colors\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbPage({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"breadcrumb-page\"\n      role=\"link\"\n      aria-disabled=\"true\"\n      aria-current=\"page\"\n      className={cn(\"text-foreground font-normal\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbSeparator({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"breadcrumb-separator\"\n      role=\"presentation\"\n      aria-hidden=\"true\"\n      className={cn(\"[&>svg]:size-3.5\", className)}\n      {...props}\n    >\n      {children ?? <ChevronRight />}\n    </li>\n  )\n}\n\nfunction BreadcrumbEllipsis({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"breadcrumb-ellipsis\"\n      role=\"presentation\"\n      aria-hidden=\"true\"\n      className={cn(\"flex size-9 items-center justify-center\", className)}\n      {...props}\n    >\n      <MoreHorizontal className=\"size-4\" />\n      <span className=\"sr-only\">More</span>\n    </span>\n  )\n}\n\nexport {\n  Breadcrumb,\n  BreadcrumbList,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n  BreadcrumbEllipsis,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/button-group.tsx",
    "content": "import { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Separator } from \"@/components/ui/separator\"\n\nconst buttonGroupVariants = cva(\n  \"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2\",\n  {\n    variants: {\n      orientation: {\n        horizontal:\n          \"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none\",\n        vertical:\n          \"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none\",\n      },\n    },\n    defaultVariants: {\n      orientation: \"horizontal\",\n    },\n  }\n)\n\nfunction ButtonGroup({\n  className,\n  orientation,\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof buttonGroupVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"button-group\"\n      data-orientation={orientation}\n      className={cn(buttonGroupVariants({ orientation }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction ButtonGroupText({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  asChild?: boolean\n}) {\n  const Comp = asChild ? Slot : \"div\"\n\n  return (\n    <Comp\n      className={cn(\n        \"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ButtonGroupSeparator({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"button-group-separator\"\n      orientation={orientation}\n      className={cn(\n        \"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  ButtonGroup,\n  ButtonGroupSeparator,\n  ButtonGroupText,\n  buttonGroupVariants,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n        \"icon-sm\": \"size-8\",\n        \"icon-lg\": \"size-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Button({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "tauri/src/components/ui/calendar.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronDownIcon,\n  ChevronLeftIcon,\n  ChevronRightIcon,\n} from \"lucide-react\"\nimport {\n  DayPicker,\n  getDefaultClassNames,\n  type DayButton,\n} from \"react-day-picker\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button, buttonVariants } from \"@/components/ui/button\"\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  captionLayout = \"label\",\n  buttonVariant = \"ghost\",\n  formatters,\n  components,\n  ...props\n}: React.ComponentProps<typeof DayPicker> & {\n  buttonVariant?: React.ComponentProps<typeof Button>[\"variant\"]\n}) {\n  const defaultClassNames = getDefaultClassNames()\n\n  return (\n    <DayPicker\n      showOutsideDays={showOutsideDays}\n      className={cn(\n        \"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent\",\n        String.raw`rtl:**:[.rdp-button\\_next>svg]:rotate-180`,\n        String.raw`rtl:**:[.rdp-button\\_previous>svg]:rotate-180`,\n        className\n      )}\n      captionLayout={captionLayout}\n      formatters={{\n        formatMonthDropdown: (date) =>\n          date.toLocaleString(\"default\", { month: \"short\" }),\n        ...formatters,\n      }}\n      classNames={{\n        root: cn(\"w-fit\", defaultClassNames.root),\n        months: cn(\n          \"flex gap-4 flex-col md:flex-row relative\",\n          defaultClassNames.months\n        ),\n        month: cn(\"flex flex-col w-full gap-4\", defaultClassNames.month),\n        nav: cn(\n          \"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between\",\n          defaultClassNames.nav\n        ),\n        button_previous: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none\",\n          defaultClassNames.button_previous\n        ),\n        button_next: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none\",\n          defaultClassNames.button_next\n        ),\n        month_caption: cn(\n          \"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)\",\n          defaultClassNames.month_caption\n        ),\n        dropdowns: cn(\n          \"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5\",\n          defaultClassNames.dropdowns\n        ),\n        dropdown_root: cn(\n          \"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md\",\n          defaultClassNames.dropdown_root\n        ),\n        dropdown: cn(\n          \"absolute bg-popover inset-0 opacity-0\",\n          defaultClassNames.dropdown\n        ),\n        caption_label: cn(\n          \"select-none font-medium\",\n          captionLayout === \"label\"\n            ? \"text-sm\"\n            : \"rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5\",\n          defaultClassNames.caption_label\n        ),\n        table: \"w-full border-collapse\",\n        weekdays: cn(\"flex\", defaultClassNames.weekdays),\n        weekday: cn(\n          \"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none\",\n          defaultClassNames.weekday\n        ),\n        week: cn(\"flex w-full mt-2\", defaultClassNames.week),\n        week_number_header: cn(\n          \"select-none w-(--cell-size)\",\n          defaultClassNames.week_number_header\n        ),\n        week_number: cn(\n          \"text-[0.8rem] select-none text-muted-foreground\",\n          defaultClassNames.week_number\n        ),\n        day: cn(\n          \"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none\",\n          props.showWeekNumber\n            ? \"[&:nth-child(2)[data-selected=true]_button]:rounded-l-md\"\n            : \"[&:first-child[data-selected=true]_button]:rounded-l-md\",\n          defaultClassNames.day\n        ),\n        range_start: cn(\n          \"rounded-l-md bg-accent\",\n          defaultClassNames.range_start\n        ),\n        range_middle: cn(\"rounded-none\", defaultClassNames.range_middle),\n        range_end: cn(\"rounded-r-md bg-accent\", defaultClassNames.range_end),\n        today: cn(\n          \"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none\",\n          defaultClassNames.today\n        ),\n        outside: cn(\n          \"text-muted-foreground aria-selected:text-muted-foreground\",\n          defaultClassNames.outside\n        ),\n        disabled: cn(\n          \"text-muted-foreground opacity-50\",\n          defaultClassNames.disabled\n        ),\n        hidden: cn(\"invisible\", defaultClassNames.hidden),\n        ...classNames,\n      }}\n      components={{\n        Root: ({ className, rootRef, ...props }) => {\n          return (\n            <div\n              data-slot=\"calendar\"\n              ref={rootRef}\n              className={cn(className)}\n              {...props}\n            />\n          )\n        },\n        Chevron: ({ className, orientation, ...props }) => {\n          if (orientation === \"left\") {\n            return (\n              <ChevronLeftIcon className={cn(\"size-4\", className)} {...props} />\n            )\n          }\n\n          if (orientation === \"right\") {\n            return (\n              <ChevronRightIcon\n                className={cn(\"size-4\", className)}\n                {...props}\n              />\n            )\n          }\n\n          return (\n            <ChevronDownIcon className={cn(\"size-4\", className)} {...props} />\n          )\n        },\n        DayButton: CalendarDayButton,\n        WeekNumber: ({ children, ...props }) => {\n          return (\n            <td {...props}>\n              <div className=\"flex size-(--cell-size) items-center justify-center text-center\">\n                {children}\n              </div>\n            </td>\n          )\n        },\n        ...components,\n      }}\n      {...props}\n    />\n  )\n}\n\nfunction CalendarDayButton({\n  className,\n  day,\n  modifiers,\n  ...props\n}: React.ComponentProps<typeof DayButton>) {\n  const defaultClassNames = getDefaultClassNames()\n\n  const ref = React.useRef<HTMLButtonElement>(null)\n  React.useEffect(() => {\n    if (modifiers.focused) ref.current?.focus()\n  }, [modifiers.focused])\n\n  return (\n    <Button\n      ref={ref}\n      variant=\"ghost\"\n      size=\"icon\"\n      data-day={day.date.toLocaleDateString()}\n      data-selected-single={\n        modifiers.selected &&\n        !modifiers.range_start &&\n        !modifiers.range_end &&\n        !modifiers.range_middle\n      }\n      data-range-start={modifiers.range_start}\n      data-range-end={modifiers.range_end}\n      data-range-middle={modifiers.range_middle}\n      className={cn(\n        \"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70\",\n        defaultClassNames.day,\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Calendar, CalendarDayButton }\n"
  },
  {
    "path": "tauri/src/components/ui/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        \"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn(\"leading-none font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-content\"\n      className={cn(\"px-6\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn(\"flex items-center px-6 [.border-t]:pt-6\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/carousel.tsx",
    "content": "import * as React from \"react\"\nimport useEmblaCarousel, {\n  type UseEmblaCarouselType,\n} from \"embla-carousel-react\"\nimport { ArrowLeft, ArrowRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\ntype CarouselApi = UseEmblaCarouselType[1]\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>\ntype CarouselOptions = UseCarouselParameters[0]\ntype CarouselPlugin = UseCarouselParameters[1]\n\ntype CarouselProps = {\n  opts?: CarouselOptions\n  plugins?: CarouselPlugin\n  orientation?: \"horizontal\" | \"vertical\"\n  setApi?: (api: CarouselApi) => void\n}\n\ntype CarouselContextProps = {\n  carouselRef: ReturnType<typeof useEmblaCarousel>[0]\n  api: ReturnType<typeof useEmblaCarousel>[1]\n  scrollPrev: () => void\n  scrollNext: () => void\n  canScrollPrev: boolean\n  canScrollNext: boolean\n} & CarouselProps\n\nconst CarouselContext = React.createContext<CarouselContextProps | null>(null)\n\nfunction useCarousel() {\n  const context = React.useContext(CarouselContext)\n\n  if (!context) {\n    throw new Error(\"useCarousel must be used within a <Carousel />\")\n  }\n\n  return context\n}\n\nfunction Carousel({\n  orientation = \"horizontal\",\n  opts,\n  setApi,\n  plugins,\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & CarouselProps) {\n  const [carouselRef, api] = useEmblaCarousel(\n    {\n      ...opts,\n      axis: orientation === \"horizontal\" ? \"x\" : \"y\",\n    },\n    plugins\n  )\n  const [canScrollPrev, setCanScrollPrev] = React.useState(false)\n  const [canScrollNext, setCanScrollNext] = React.useState(false)\n\n  const onSelect = React.useCallback((api: CarouselApi) => {\n    if (!api) return\n    setCanScrollPrev(api.canScrollPrev())\n    setCanScrollNext(api.canScrollNext())\n  }, [])\n\n  const scrollPrev = React.useCallback(() => {\n    api?.scrollPrev()\n  }, [api])\n\n  const scrollNext = React.useCallback(() => {\n    api?.scrollNext()\n  }, [api])\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (event.key === \"ArrowLeft\") {\n        event.preventDefault()\n        scrollPrev()\n      } else if (event.key === \"ArrowRight\") {\n        event.preventDefault()\n        scrollNext()\n      }\n    },\n    [scrollPrev, scrollNext]\n  )\n\n  React.useEffect(() => {\n    if (!api || !setApi) return\n    setApi(api)\n  }, [api, setApi])\n\n  React.useEffect(() => {\n    if (!api) return\n    onSelect(api)\n    api.on(\"reInit\", onSelect)\n    api.on(\"select\", onSelect)\n\n    return () => {\n      api?.off(\"select\", onSelect)\n    }\n  }, [api, onSelect])\n\n  return (\n    <CarouselContext.Provider\n      value={{\n        carouselRef,\n        api: api,\n        opts,\n        orientation:\n          orientation || (opts?.axis === \"y\" ? \"vertical\" : \"horizontal\"),\n        scrollPrev,\n        scrollNext,\n        canScrollPrev,\n        canScrollNext,\n      }}\n    >\n      <div\n        onKeyDownCapture={handleKeyDown}\n        className={cn(\"relative\", className)}\n        role=\"region\"\n        aria-roledescription=\"carousel\"\n        data-slot=\"carousel\"\n        {...props}\n      >\n        {children}\n      </div>\n    </CarouselContext.Provider>\n  )\n}\n\nfunction CarouselContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { carouselRef, orientation } = useCarousel()\n\n  return (\n    <div\n      ref={carouselRef}\n      className=\"overflow-hidden\"\n      data-slot=\"carousel-content\"\n    >\n      <div\n        className={cn(\n          \"flex\",\n          orientation === \"horizontal\" ? \"-ml-4\" : \"-mt-4 flex-col\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction CarouselItem({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { orientation } = useCarousel()\n\n  return (\n    <div\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      data-slot=\"carousel-item\"\n      className={cn(\n        \"min-w-0 shrink-0 grow-0 basis-full\",\n        orientation === \"horizontal\" ? \"pl-4\" : \"pt-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CarouselPrevious({\n  className,\n  variant = \"outline\",\n  size = \"icon\",\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { orientation, scrollPrev, canScrollPrev } = useCarousel()\n\n  return (\n    <Button\n      data-slot=\"carousel-previous\"\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute size-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"top-1/2 -left-12 -translate-y-1/2\"\n          : \"-top-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className\n      )}\n      disabled={!canScrollPrev}\n      onClick={scrollPrev}\n      {...props}\n    >\n      <ArrowLeft />\n      <span className=\"sr-only\">Previous slide</span>\n    </Button>\n  )\n}\n\nfunction CarouselNext({\n  className,\n  variant = \"outline\",\n  size = \"icon\",\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { orientation, scrollNext, canScrollNext } = useCarousel()\n\n  return (\n    <Button\n      data-slot=\"carousel-next\"\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute size-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"top-1/2 -right-12 -translate-y-1/2\"\n          : \"-bottom-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className\n      )}\n      disabled={!canScrollNext}\n      onClick={scrollNext}\n      {...props}\n    >\n      <ArrowRight />\n      <span className=\"sr-only\">Next slide</span>\n    </Button>\n  )\n}\n\nexport {\n  type CarouselApi,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselPrevious,\n  CarouselNext,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/chart.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as RechartsPrimitive from \"recharts\"\n\nimport { cn } from \"@/lib/utils\"\n\n// Format: { THEME_NAME: CSS_SELECTOR }\nconst THEMES = { light: \"\", dark: \".dark\" } as const\n\nexport type ChartConfig = {\n  [k in string]: {\n    label?: React.ReactNode\n    icon?: React.ComponentType\n  } & (\n    | { color?: string; theme?: never }\n    | { color?: never; theme: Record<keyof typeof THEMES, string> }\n  )\n}\n\ntype ChartContextProps = {\n  config: ChartConfig\n}\n\nconst ChartContext = React.createContext<ChartContextProps | null>(null)\n\nfunction useChart() {\n  const context = React.useContext(ChartContext)\n\n  if (!context) {\n    throw new Error(\"useChart must be used within a <ChartContainer />\")\n  }\n\n  return context\n}\n\nfunction ChartContainer({\n  id,\n  className,\n  children,\n  config,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  config: ChartConfig\n  children: React.ComponentProps<\n    typeof RechartsPrimitive.ResponsiveContainer\n  >[\"children\"]\n}) {\n  const uniqueId = React.useId()\n  const chartId = `chart-${id || uniqueId.replace(/:/g, \"\")}`\n\n  return (\n    <ChartContext.Provider value={{ config }}>\n      <div\n        data-slot=\"chart\"\n        data-chart={chartId}\n        className={cn(\n          \"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden\",\n          className\n        )}\n        {...props}\n      >\n        <ChartStyle id={chartId} config={config} />\n        <RechartsPrimitive.ResponsiveContainer>\n          {children}\n        </RechartsPrimitive.ResponsiveContainer>\n      </div>\n    </ChartContext.Provider>\n  )\n}\n\nconst ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {\n  const colorConfig = Object.entries(config).filter(\n    ([, config]) => config.theme || config.color\n  )\n\n  if (!colorConfig.length) {\n    return null\n  }\n\n  return (\n    <style\n      dangerouslySetInnerHTML={{\n        __html: Object.entries(THEMES)\n          .map(\n            ([theme, prefix]) => `\n${prefix} [data-chart=${id}] {\n${colorConfig\n  .map(([key, itemConfig]) => {\n    const color =\n      itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||\n      itemConfig.color\n    return color ? `  --color-${key}: ${color};` : null\n  })\n  .join(\"\\n\")}\n}\n`\n          )\n          .join(\"\\n\"),\n      }}\n    />\n  )\n}\n\nconst ChartTooltip = RechartsPrimitive.Tooltip\n\nfunction ChartTooltipContent({\n  active,\n  payload,\n  className,\n  indicator = \"dot\",\n  hideLabel = false,\n  hideIndicator = false,\n  label,\n  labelFormatter,\n  labelClassName,\n  formatter,\n  color,\n  nameKey,\n  labelKey,\n}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &\n  React.ComponentProps<\"div\"> & {\n    hideLabel?: boolean\n    hideIndicator?: boolean\n    indicator?: \"line\" | \"dot\" | \"dashed\"\n    nameKey?: string\n    labelKey?: string\n  }) {\n  const { config } = useChart()\n\n  const tooltipLabel = React.useMemo(() => {\n    if (hideLabel || !payload?.length) {\n      return null\n    }\n\n    const [item] = payload\n    const key = `${labelKey || item?.dataKey || item?.name || \"value\"}`\n    const itemConfig = getPayloadConfigFromPayload(config, item, key)\n    const value =\n      !labelKey && typeof label === \"string\"\n        ? config[label as keyof typeof config]?.label || label\n        : itemConfig?.label\n\n    if (labelFormatter) {\n      return (\n        <div className={cn(\"font-medium\", labelClassName)}>\n          {labelFormatter(value, payload)}\n        </div>\n      )\n    }\n\n    if (!value) {\n      return null\n    }\n\n    return <div className={cn(\"font-medium\", labelClassName)}>{value}</div>\n  }, [\n    label,\n    labelFormatter,\n    payload,\n    hideLabel,\n    labelClassName,\n    config,\n    labelKey,\n  ])\n\n  if (!active || !payload?.length) {\n    return null\n  }\n\n  const nestLabel = payload.length === 1 && indicator !== \"dot\"\n\n  return (\n    <div\n      className={cn(\n        \"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl\",\n        className\n      )}\n    >\n      {!nestLabel ? tooltipLabel : null}\n      <div className=\"grid gap-1.5\">\n        {payload\n          .filter((item) => item.type !== \"none\")\n          .map((item, index) => {\n            const key = `${nameKey || item.name || item.dataKey || \"value\"}`\n            const itemConfig = getPayloadConfigFromPayload(config, item, key)\n            const indicatorColor = color || item.payload.fill || item.color\n\n            return (\n              <div\n                key={item.dataKey}\n                className={cn(\n                  \"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5\",\n                  indicator === \"dot\" && \"items-center\"\n                )}\n              >\n                {formatter && item?.value !== undefined && item.name ? (\n                  formatter(item.value, item.name, item, index, item.payload)\n                ) : (\n                  <>\n                    {itemConfig?.icon ? (\n                      <itemConfig.icon />\n                    ) : (\n                      !hideIndicator && (\n                        <div\n                          className={cn(\n                            \"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)\",\n                            {\n                              \"h-2.5 w-2.5\": indicator === \"dot\",\n                              \"w-1\": indicator === \"line\",\n                              \"w-0 border-[1.5px] border-dashed bg-transparent\":\n                                indicator === \"dashed\",\n                              \"my-0.5\": nestLabel && indicator === \"dashed\",\n                            }\n                          )}\n                          style={\n                            {\n                              \"--color-bg\": indicatorColor,\n                              \"--color-border\": indicatorColor,\n                            } as React.CSSProperties\n                          }\n                        />\n                      )\n                    )}\n                    <div\n                      className={cn(\n                        \"flex flex-1 justify-between leading-none\",\n                        nestLabel ? \"items-end\" : \"items-center\"\n                      )}\n                    >\n                      <div className=\"grid gap-1.5\">\n                        {nestLabel ? tooltipLabel : null}\n                        <span className=\"text-muted-foreground\">\n                          {itemConfig?.label || item.name}\n                        </span>\n                      </div>\n                      {item.value && (\n                        <span className=\"text-foreground font-mono font-medium tabular-nums\">\n                          {item.value.toLocaleString()}\n                        </span>\n                      )}\n                    </div>\n                  </>\n                )}\n              </div>\n            )\n          })}\n      </div>\n    </div>\n  )\n}\n\nconst ChartLegend = RechartsPrimitive.Legend\n\nfunction ChartLegendContent({\n  className,\n  hideIcon = false,\n  payload,\n  verticalAlign = \"bottom\",\n  nameKey,\n}: React.ComponentProps<\"div\"> &\n  Pick<RechartsPrimitive.LegendProps, \"payload\" | \"verticalAlign\"> & {\n    hideIcon?: boolean\n    nameKey?: string\n  }) {\n  const { config } = useChart()\n\n  if (!payload?.length) {\n    return null\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-center gap-4\",\n        verticalAlign === \"top\" ? \"pb-3\" : \"pt-3\",\n        className\n      )}\n    >\n      {payload\n        .filter((item) => item.type !== \"none\")\n        .map((item) => {\n          const key = `${nameKey || item.dataKey || \"value\"}`\n          const itemConfig = getPayloadConfigFromPayload(config, item, key)\n\n          return (\n            <div\n              key={item.value}\n              className={cn(\n                \"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3\"\n              )}\n            >\n              {itemConfig?.icon && !hideIcon ? (\n                <itemConfig.icon />\n              ) : (\n                <div\n                  className=\"h-2 w-2 shrink-0 rounded-[2px]\"\n                  style={{\n                    backgroundColor: item.color,\n                  }}\n                />\n              )}\n              {itemConfig?.label}\n            </div>\n          )\n        })}\n    </div>\n  )\n}\n\n// Helper to extract item config from a payload.\nfunction getPayloadConfigFromPayload(\n  config: ChartConfig,\n  payload: unknown,\n  key: string\n) {\n  if (typeof payload !== \"object\" || payload === null) {\n    return undefined\n  }\n\n  const payloadPayload =\n    \"payload\" in payload &&\n    typeof payload.payload === \"object\" &&\n    payload.payload !== null\n      ? payload.payload\n      : undefined\n\n  let configLabelKey: string = key\n\n  if (\n    key in payload &&\n    typeof payload[key as keyof typeof payload] === \"string\"\n  ) {\n    configLabelKey = payload[key as keyof typeof payload] as string\n  } else if (\n    payloadPayload &&\n    key in payloadPayload &&\n    typeof payloadPayload[key as keyof typeof payloadPayload] === \"string\"\n  ) {\n    configLabelKey = payloadPayload[\n      key as keyof typeof payloadPayload\n    ] as string\n  }\n\n  return configLabelKey in config\n    ? config[configLabelKey]\n    : config[key as keyof typeof config]\n}\n\nexport {\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n  ChartLegend,\n  ChartLegendContent,\n  ChartStyle,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/checkbox.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { CheckIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Checkbox({\n  className,\n  ...props\n}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n  return (\n    <CheckboxPrimitive.Root\n      data-slot=\"checkbox\"\n      className={cn(\n        \"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        data-slot=\"checkbox-indicator\"\n        className=\"grid place-content-center text-current transition-none\"\n      >\n        <CheckIcon className=\"size-3.5\" />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  )\n}\n\nexport { Checkbox }\n"
  },
  {
    "path": "tauri/src/components/ui/collapsible.tsx",
    "content": "import * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\"\n\nfunction Collapsible({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />\n}\n\nfunction CollapsibleTrigger({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleTrigger\n      data-slot=\"collapsible-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction CollapsibleContent({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleContent\n      data-slot=\"collapsible-content\"\n      {...props}\n    />\n  )\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n"
  },
  {
    "path": "tauri/src/components/ui/command.tsx",
    "content": "import * as React from \"react\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { SearchIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\n\nfunction Command({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive>) {\n  return (\n    <CommandPrimitive\n      data-slot=\"command\"\n      className={cn(\n        \"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandDialog({\n  title = \"Command Palette\",\n  description = \"Search for a command to run...\",\n  children,\n  className,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof Dialog> & {\n  title?: string\n  description?: string\n  className?: string\n  showCloseButton?: boolean\n}) {\n  return (\n    <Dialog {...props}>\n      <DialogHeader className=\"sr-only\">\n        <DialogTitle>{title}</DialogTitle>\n        <DialogDescription>{description}</DialogDescription>\n      </DialogHeader>\n      <DialogContent\n        className={cn(\"overflow-hidden p-0\", className)}\n        showCloseButton={showCloseButton}\n      >\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nfunction CommandInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Input>) {\n  return (\n    <div\n      data-slot=\"command-input-wrapper\"\n      className=\"flex h-9 items-center gap-2 border-b px-3\"\n    >\n      <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n      <CommandPrimitive.Input\n        data-slot=\"command-input\"\n        className={cn(\n          \"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction CommandList({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.List>) {\n  return (\n    <CommandPrimitive.List\n      data-slot=\"command-list\"\n      className={cn(\n        \"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandEmpty({\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n  return (\n    <CommandPrimitive.Empty\n      data-slot=\"command-empty\"\n      className=\"py-6 text-center text-sm\"\n      {...props}\n    />\n  )\n}\n\nfunction CommandGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Group>) {\n  return (\n    <CommandPrimitive.Group\n      data-slot=\"command-group\"\n      className={cn(\n        \"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n  return (\n    <CommandPrimitive.Separator\n      data-slot=\"command-separator\"\n      className={cn(\"bg-border -mx-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CommandItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Item>) {\n  return (\n    <CommandPrimitive.Item\n      data-slot=\"command-item\"\n      className={cn(\n        \"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"command-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/context-menu.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\"\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ContextMenu({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n  return <ContextMenuPrimitive.Root data-slot=\"context-menu\" {...props} />\n}\n\nfunction ContextMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {\n  return (\n    <ContextMenuPrimitive.Trigger data-slot=\"context-menu-trigger\" {...props} />\n  )\n}\n\nfunction ContextMenuGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {\n  return (\n    <ContextMenuPrimitive.Group data-slot=\"context-menu-group\" {...props} />\n  )\n}\n\nfunction ContextMenuPortal({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {\n  return (\n    <ContextMenuPrimitive.Portal data-slot=\"context-menu-portal\" {...props} />\n  )\n}\n\nfunction ContextMenuSub({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {\n  return <ContextMenuPrimitive.Sub data-slot=\"context-menu-sub\" {...props} />\n}\n\nfunction ContextMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {\n  return (\n    <ContextMenuPrimitive.RadioGroup\n      data-slot=\"context-menu-radio-group\"\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.SubTrigger\n      data-slot=\"context-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto\" />\n    </ContextMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction ContextMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {\n  return (\n    <ContextMenuPrimitive.SubContent\n      data-slot=\"context-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n  return (\n    <ContextMenuPrimitive.Portal>\n      <ContextMenuPrimitive.Content\n        data-slot=\"context-menu-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md\",\n          className\n        )}\n        {...props}\n      />\n    </ContextMenuPrimitive.Portal>\n  )\n}\n\nfunction ContextMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <ContextMenuPrimitive.Item\n      data-slot=\"context-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {\n  return (\n    <ContextMenuPrimitive.CheckboxItem\n      data-slot=\"context-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction ContextMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {\n  return (\n    <ContextMenuPrimitive.RadioItem\n      data-slot=\"context-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.RadioItem>\n  )\n}\n\nfunction ContextMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <ContextMenuPrimitive.Label\n      data-slot=\"context-menu-label\"\n      data-inset={inset}\n      className={cn(\n        \"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {\n  return (\n    <ContextMenuPrimitive.Separator\n      data-slot=\"context-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction ContextMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"context-menu-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/dialog.tsx",
    "content": "import * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Dialog({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />\n}\n\nfunction DialogTrigger({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\nfunction DialogPortal({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />\n}\n\nfunction DialogClose({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean\n}) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            data-slot=\"dialog-close\"\n            className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n          >\n            <XIcon />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  )\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn(\"text-lg leading-none font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/drawer.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Drawer as DrawerPrimitive } from \"vaul\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Drawer({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Root>) {\n  return <DrawerPrimitive.Root data-slot=\"drawer\" {...props} />\n}\n\nfunction DrawerTrigger({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {\n  return <DrawerPrimitive.Trigger data-slot=\"drawer-trigger\" {...props} />\n}\n\nfunction DrawerPortal({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {\n  return <DrawerPrimitive.Portal data-slot=\"drawer-portal\" {...props} />\n}\n\nfunction DrawerClose({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Close>) {\n  return <DrawerPrimitive.Close data-slot=\"drawer-close\" {...props} />\n}\n\nfunction DrawerOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {\n  return (\n    <DrawerPrimitive.Overlay\n      data-slot=\"drawer-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerContent({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Content>) {\n  return (\n    <DrawerPortal data-slot=\"drawer-portal\">\n      <DrawerOverlay />\n      <DrawerPrimitive.Content\n        data-slot=\"drawer-content\"\n        className={cn(\n          \"group/drawer-content bg-background fixed z-50 flex h-auto flex-col\",\n          \"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b\",\n          \"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t\",\n          \"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm\",\n          \"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm\",\n          className\n        )}\n        {...props}\n      >\n        <div className=\"bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block\" />\n        {children}\n      </DrawerPrimitive.Content>\n    </DrawerPortal>\n  )\n}\n\nfunction DrawerHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-header\"\n      className={cn(\n        \"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-footer\"\n      className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Title>) {\n  return (\n    <DrawerPrimitive.Title\n      data-slot=\"drawer-title\"\n      className={cn(\"text-foreground font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Description>) {\n  return (\n    <DrawerPrimitive.Description\n      data-slot=\"drawer-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Drawer,\n  DrawerPortal,\n  DrawerOverlay,\n  DrawerTrigger,\n  DrawerClose,\n  DrawerContent,\n  DrawerHeader,\n  DrawerFooter,\n  DrawerTitle,\n  DrawerDescription,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/dropdown-menu.tsx",
    "content": "import * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n  )\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md\",\n          className\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  )\n}\n\nfunction DropdownMenuGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return (\n    <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n  )\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot=\"dropdown-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot=\"dropdown-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  )\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot=\"dropdown-menu-label\"\n      data-inset={inset}\n      className={cn(\n        \"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSub({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot=\"dropdown-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot=\"dropdown-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/empty.tsx",
    "content": "import { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Empty({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty\"\n      className={cn(\n        \"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty-header\"\n      className={cn(\n        \"flex max-w-sm flex-col items-center gap-2 text-center\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst emptyMediaVariants = cva(\n  \"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        icon: \"bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nfunction EmptyMedia({\n  className,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof emptyMediaVariants>) {\n  return (\n    <div\n      data-slot=\"empty-icon\"\n      data-variant={variant}\n      className={cn(emptyMediaVariants({ variant, className }))}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty-title\"\n      className={cn(\"text-lg font-medium tracking-tight\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  return (\n    <div\n      data-slot=\"empty-description\"\n      className={cn(\n        \"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty-content\"\n      className={cn(\n        \"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Empty,\n  EmptyHeader,\n  EmptyTitle,\n  EmptyDescription,\n  EmptyContent,\n  EmptyMedia,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/field.tsx",
    "content": "import { useMemo } from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\nimport { Separator } from \"@/components/ui/separator\"\n\nfunction FieldSet({ className, ...props }: React.ComponentProps<\"fieldset\">) {\n  return (\n    <fieldset\n      data-slot=\"field-set\"\n      className={cn(\n        \"flex flex-col gap-6\",\n        \"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldLegend({\n  className,\n  variant = \"legend\",\n  ...props\n}: React.ComponentProps<\"legend\"> & { variant?: \"legend\" | \"label\" }) {\n  return (\n    <legend\n      data-slot=\"field-legend\"\n      data-variant={variant}\n      className={cn(\n        \"mb-3 font-medium\",\n        \"data-[variant=legend]:text-base\",\n        \"data-[variant=label]:text-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-group\"\n      className={cn(\n        \"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst fieldVariants = cva(\n  \"group/field flex w-full gap-3 data-[invalid=true]:text-destructive\",\n  {\n    variants: {\n      orientation: {\n        vertical: [\"flex-col [&>*]:w-full [&>.sr-only]:w-auto\"],\n        horizontal: [\n          \"flex-row items-center\",\n          \"[&>[data-slot=field-label]]:flex-auto\",\n          \"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n        ],\n        responsive: [\n          \"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto\",\n          \"@md/field-group:[&>[data-slot=field-label]]:flex-auto\",\n          \"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n        ],\n      },\n    },\n    defaultVariants: {\n      orientation: \"vertical\",\n    },\n  }\n)\n\nfunction Field({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof fieldVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"field\"\n      data-orientation={orientation}\n      className={cn(fieldVariants({ orientation }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction FieldContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-content\"\n      className={cn(\n        \"group/field-content flex flex-1 flex-col gap-1.5 leading-snug\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof Label>) {\n  return (\n    <Label\n      data-slot=\"field-label\"\n      className={cn(\n        \"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50\",\n        \"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4\",\n        \"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-label\"\n      className={cn(\n        \"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"field-description\"\n      className={cn(\n        \"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance\",\n        \"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5\",\n        \"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldSeparator({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  children?: React.ReactNode\n}) {\n  return (\n    <div\n      data-slot=\"field-separator\"\n      data-content={!!children}\n      className={cn(\n        \"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2\",\n        className\n      )}\n      {...props}\n    >\n      <Separator className=\"absolute inset-0 top-1/2\" />\n      {children && (\n        <span\n          className=\"bg-background text-muted-foreground relative mx-auto block w-fit px-2\"\n          data-slot=\"field-separator-content\"\n        >\n          {children}\n        </span>\n      )}\n    </div>\n  )\n}\n\nfunction FieldError({\n  className,\n  children,\n  errors,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  errors?: Array<{ message?: string } | undefined>\n}) {\n  const content = useMemo(() => {\n    if (children) {\n      return children\n    }\n\n    if (!errors?.length) {\n      return null\n    }\n\n    const uniqueErrors = [\n      ...new Map(errors.map((error) => [error?.message, error])).values(),\n    ]\n\n    if (uniqueErrors?.length == 1) {\n      return uniqueErrors[0]?.message\n    }\n\n    return (\n      <ul className=\"ml-4 flex list-disc flex-col gap-1\">\n        {uniqueErrors.map(\n          (error, index) =>\n            error?.message && <li key={index}>{error.message}</li>\n        )}\n      </ul>\n    )\n  }, [children, errors])\n\n  if (!content) {\n    return null\n  }\n\n  return (\n    <div\n      role=\"alert\"\n      data-slot=\"field-error\"\n      className={cn(\"text-destructive text-sm font-normal\", className)}\n      {...props}\n    >\n      {content}\n    </div>\n  )\n}\n\nexport {\n  Field,\n  FieldLabel,\n  FieldDescription,\n  FieldError,\n  FieldGroup,\n  FieldLegend,\n  FieldSeparator,\n  FieldSet,\n  FieldContent,\n  FieldTitle,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/file-drop-input.tsx",
    "content": "import { Button } from \"./button\";\nimport { Upload, FileText, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { useDropZone } from \"@/hooks/useDropZone\";\nimport { toast } from \"sonner\";\n\ninterface FileDropInputProps {\n  /** Current file path value */\n  value: string;\n  /** Placeholder text when no file is selected */\n  placeholder?: string;\n  /** File extensions to accept (without dot), e.g. [\"mp4\", \"mkv\"] */\n  accept?: string[];\n  /** Callback when file is selected via dialog */\n  onSelectClick: () => void;\n  /** Callback when file is dropped */\n  onDrop: (path: string) => void;\n  /** Callback to clear the file */\n  onClear?: () => void;\n  /** Whether the input is disabled */\n  disabled?: boolean;\n  /** Custom class name for the container */\n  className?: string;\n  /** Error message for invalid file drop */\n  invalidDropMessage?: string;\n  /** Hint text showing accepted file types */\n  acceptHint?: string;\n}\n\nexport function FileDropInput({\n  value,\n  placeholder = \"Drop a file here or click to select\",\n  accept,\n  onSelectClick,\n  onDrop,\n  onClear,\n  disabled = false,\n  className,\n  invalidDropMessage = \"Invalid file type\",\n  acceptHint,\n}: FileDropInputProps) {\n  const { ref, isDragging } = useDropZone<HTMLDivElement>({\n    accept,\n    onDrop: (paths) => {\n      if (paths.length > 0) {\n        onDrop(paths[0]);\n      }\n    },\n    onInvalidDrop: () => {\n      toast.error(invalidDropMessage);\n    },\n    enabled: !disabled && !value,\n  });\n\n  const getFileName = (path: string) => path.split(/[/\\\\]/).pop() || path;\n\n  // Show selected file state\n  if (value) {\n    return (\n      <div\n        className={cn(\n          \"flex items-center gap-3 p-3 bg-muted rounded-lg border border-border\",\n          className\n        )}\n      >\n        <FileText className=\"h-5 w-5 shrink-0 text-muted-foreground\" />\n        <span className=\"flex-1 text-sm truncate\" title={value}>\n          {getFileName(value)}\n        </span>\n        <div className=\"flex gap-1\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={onSelectClick}\n            disabled={disabled}\n          >\n            Change\n          </Button>\n          {onClear && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={onClear}\n              disabled={disabled}\n              className=\"px-2\"\n            >\n              <X className=\"h-4 w-4\" />\n            </Button>\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  // Show drop zone state\n  return (\n    <div\n      ref={ref}\n      onClick={disabled ? undefined : onSelectClick}\n      className={cn(\n        \"border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors\",\n        isDragging && !disabled\n          ? \"border-primary bg-primary/5\"\n          : \"border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/50\",\n        disabled && \"opacity-50 cursor-not-allowed\",\n        className\n      )}\n    >\n      <Upload className=\"h-8 w-8 mx-auto mb-2 text-muted-foreground\" />\n      <p className=\"text-sm text-muted-foreground\">\n        {isDragging ? \"Drop file here...\" : placeholder}\n      </p>\n      {acceptHint && (\n        <p className=\"text-xs text-muted-foreground/70 mt-1\">\n          {acceptHint}\n        </p>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "tauri/src/components/ui/form.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport type * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport {\n  Controller,\n  FormProvider,\n  useFormContext,\n  useFormState,\n  type ControllerProps,\n  type FieldPath,\n  type FieldValues,\n} from \"react-hook-form\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\n\nconst Form = FormProvider\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n> = {\n  name: TName\n}\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue\n)\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  )\n}\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext)\n  const itemContext = React.useContext(FormItemContext)\n  const { getFieldState } = useFormContext()\n  const formState = useFormState({ name: fieldContext.name })\n  const fieldState = getFieldState(fieldContext.name, formState)\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\")\n  }\n\n  const { id } = itemContext\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  }\n}\n\ntype FormItemContextValue = {\n  id: string\n}\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n  {} as FormItemContextValue\n)\n\nfunction FormItem({ className, ...props }: React.ComponentProps<\"div\">) {\n  const id = React.useId()\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div\n        data-slot=\"form-item\"\n        className={cn(\"grid gap-2\", className)}\n        {...props}\n      />\n    </FormItemContext.Provider>\n  )\n}\n\nfunction FormLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  const { error, formItemId } = useFormField()\n\n  return (\n    <Label\n      data-slot=\"form-label\"\n      data-error={!!error}\n      className={cn(\"data-[error=true]:text-destructive\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  )\n}\n\nfunction FormControl({ ...props }: React.ComponentProps<typeof Slot>) {\n  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()\n\n  return (\n    <Slot\n      data-slot=\"form-control\"\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  )\n}\n\nfunction FormDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  const { formDescriptionId } = useFormField()\n\n  return (\n    <p\n      data-slot=\"form-description\"\n      id={formDescriptionId}\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction FormMessage({ className, ...props }: React.ComponentProps<\"p\">) {\n  const { error, formMessageId } = useFormField()\n  const body = error ? String(error?.message ?? \"\") : props.children\n\n  if (!body) {\n    return null\n  }\n\n  return (\n    <p\n      data-slot=\"form-message\"\n      id={formMessageId}\n      className={cn(\"text-destructive text-sm\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  )\n}\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/hover-card.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction HoverCard({\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {\n  return <HoverCardPrimitive.Root data-slot=\"hover-card\" {...props} />\n}\n\nfunction HoverCardTrigger({\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {\n  return (\n    <HoverCardPrimitive.Trigger data-slot=\"hover-card-trigger\" {...props} />\n  )\n}\n\nfunction HoverCardContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {\n  return (\n    <HoverCardPrimitive.Portal data-slot=\"hover-card-portal\">\n      <HoverCardPrimitive.Content\n        data-slot=\"hover-card-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden\",\n          className\n        )}\n        {...props}\n      />\n    </HoverCardPrimitive.Portal>\n  )\n}\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent }\n"
  },
  {
    "path": "tauri/src/components/ui/input-group.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nfunction InputGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"input-group\"\n      role=\"group\"\n      className={cn(\n        \"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none\",\n        \"h-9 min-w-0 has-[>textarea]:h-auto\",\n\n        // Variants based on alignment.\n        \"has-[>[data-align=inline-start]]:[&>input]:pl-2\",\n        \"has-[>[data-align=inline-end]]:[&>input]:pr-2\",\n        \"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3\",\n        \"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3\",\n\n        // Focus state.\n        \"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]\",\n\n        // Error state.\n        \"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40\",\n\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst inputGroupAddonVariants = cva(\n  \"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50\",\n  {\n    variants: {\n      align: {\n        \"inline-start\":\n          \"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]\",\n        \"inline-end\":\n          \"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]\",\n        \"block-start\":\n          \"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5\",\n        \"block-end\":\n          \"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5\",\n      },\n    },\n    defaultVariants: {\n      align: \"inline-start\",\n    },\n  }\n)\n\nfunction InputGroupAddon({\n  className,\n  align = \"inline-start\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof inputGroupAddonVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"input-group-addon\"\n      data-align={align}\n      className={cn(inputGroupAddonVariants({ align }), className)}\n      onClick={(e) => {\n        if ((e.target as HTMLElement).closest(\"button\")) {\n          return\n        }\n        e.currentTarget.parentElement?.querySelector(\"input\")?.focus()\n      }}\n      {...props}\n    />\n  )\n}\n\nconst inputGroupButtonVariants = cva(\n  \"text-sm shadow-none flex gap-2 items-center\",\n  {\n    variants: {\n      size: {\n        xs: \"h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2\",\n        sm: \"h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5\",\n        \"icon-xs\":\n          \"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0\",\n        \"icon-sm\": \"size-8 p-0 has-[>svg]:p-0\",\n      },\n    },\n    defaultVariants: {\n      size: \"xs\",\n    },\n  }\n)\n\nfunction InputGroupButton({\n  className,\n  type = \"button\",\n  variant = \"ghost\",\n  size = \"xs\",\n  ...props\n}: Omit<React.ComponentProps<typeof Button>, \"size\"> &\n  VariantProps<typeof inputGroupButtonVariants>) {\n  return (\n    <Button\n      type={type}\n      data-size={size}\n      variant={variant}\n      className={cn(inputGroupButtonVariants({ size }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction InputGroupText({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      className={cn(\n        \"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction InputGroupInput({\n  className,\n  ...props\n}: React.ComponentProps<\"input\">) {\n  return (\n    <Input\n      data-slot=\"input-group-control\"\n      className={cn(\n        \"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction InputGroupTextarea({\n  className,\n  ...props\n}: React.ComponentProps<\"textarea\">) {\n  return (\n    <Textarea\n      data-slot=\"input-group-control\"\n      className={cn(\n        \"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupText,\n  InputGroupInput,\n  InputGroupTextarea,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/input-otp.tsx",
    "content": "import * as React from \"react\"\nimport { OTPInput, OTPInputContext } from \"input-otp\"\nimport { MinusIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction InputOTP({\n  className,\n  containerClassName,\n  ...props\n}: React.ComponentProps<typeof OTPInput> & {\n  containerClassName?: string\n}) {\n  return (\n    <OTPInput\n      data-slot=\"input-otp\"\n      containerClassName={cn(\n        \"flex items-center gap-2 has-disabled:opacity-50\",\n        containerClassName\n      )}\n      className={cn(\"disabled:cursor-not-allowed\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction InputOTPGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"input-otp-group\"\n      className={cn(\"flex items-center\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction InputOTPSlot({\n  index,\n  className,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  index: number\n}) {\n  const inputOTPContext = React.useContext(OTPInputContext)\n  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}\n\n  return (\n    <div\n      data-slot=\"input-otp-slot\"\n      data-active={isActive}\n      className={cn(\n        \"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]\",\n        className\n      )}\n      {...props}\n    >\n      {char}\n      {hasFakeCaret && (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <div className=\"animate-caret-blink bg-foreground h-4 w-px duration-1000\" />\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction InputOTPSeparator({ ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div data-slot=\"input-otp-separator\" role=\"separator\" {...props}>\n      <MinusIcon />\n    </div>\n  )\n}\n\nexport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }\n"
  },
  {
    "path": "tauri/src/components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Input }\n"
  },
  {
    "path": "tauri/src/components/ui/item.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Separator } from \"@/components/ui/separator\"\n\nfunction ItemGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      role=\"list\"\n      data-slot=\"item-group\"\n      className={cn(\"group/item-group flex flex-col\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction ItemSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"item-separator\"\n      orientation=\"horizontal\"\n      className={cn(\"my-0\", className)}\n      {...props}\n    />\n  )\n}\n\nconst itemVariants = cva(\n  \"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline: \"border-border\",\n        muted: \"bg-muted/50\",\n      },\n      size: {\n        default: \"p-4 gap-4 \",\n        sm: \"py-3 px-4 gap-2.5\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Item({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> &\n  VariantProps<typeof itemVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"div\"\n  return (\n    <Comp\n      data-slot=\"item\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(itemVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nconst itemMediaVariants = cva(\n  \"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        icon: \"size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4\",\n        image:\n          \"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nfunction ItemMedia({\n  className,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof itemMediaVariants>) {\n  return (\n    <div\n      data-slot=\"item-media\"\n      data-variant={variant}\n      className={cn(itemMediaVariants({ variant, className }))}\n      {...props}\n    />\n  )\n}\n\nfunction ItemContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-content\"\n      className={cn(\n        \"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-title\"\n      className={cn(\n        \"flex w-fit items-center gap-2 text-sm leading-snug font-medium\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"item-description\"\n      className={cn(\n        \"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance\",\n        \"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemActions({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-actions\"\n      className={cn(\"flex items-center gap-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction ItemHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-header\"\n      className={cn(\n        \"flex basis-full items-center justify-between gap-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-footer\"\n      className={cn(\n        \"flex basis-full items-center justify-between gap-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemActions,\n  ItemGroup,\n  ItemSeparator,\n  ItemTitle,\n  ItemDescription,\n  ItemHeader,\n  ItemFooter,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/kbd.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Kbd({ className, ...props }: React.ComponentProps<\"kbd\">) {\n  return (\n    <kbd\n      data-slot=\"kbd\"\n      className={cn(\n        \"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none\",\n        \"[&_svg:not([class*='size-'])]:size-3\",\n        \"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction KbdGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <kbd\n      data-slot=\"kbd-group\"\n      className={cn(\"inline-flex items-center gap-1\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Kbd, KbdGroup }\n"
  },
  {
    "path": "tauri/src/components/ui/label.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        \"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Label }\n"
  },
  {
    "path": "tauri/src/components/ui/menubar.tsx",
    "content": "import * as React from \"react\"\nimport * as MenubarPrimitive from \"@radix-ui/react-menubar\"\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Menubar({\n  className,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Root>) {\n  return (\n    <MenubarPrimitive.Root\n      data-slot=\"menubar\"\n      className={cn(\n        \"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction MenubarMenu({\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {\n  return <MenubarPrimitive.Menu data-slot=\"menubar-menu\" {...props} />\n}\n\nfunction MenubarGroup({\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Group>) {\n  return <MenubarPrimitive.Group data-slot=\"menubar-group\" {...props} />\n}\n\nfunction MenubarPortal({\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {\n  return <MenubarPrimitive.Portal data-slot=\"menubar-portal\" {...props} />\n}\n\nfunction MenubarRadioGroup({\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {\n  return (\n    <MenubarPrimitive.RadioGroup data-slot=\"menubar-radio-group\" {...props} />\n  )\n}\n\nfunction MenubarTrigger({\n  className,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {\n  return (\n    <MenubarPrimitive.Trigger\n      data-slot=\"menubar-trigger\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction MenubarContent({\n  className,\n  align = \"start\",\n  alignOffset = -4,\n  sideOffset = 8,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Content>) {\n  return (\n    <MenubarPortal>\n      <MenubarPrimitive.Content\n        data-slot=\"menubar-content\"\n        align={align}\n        alignOffset={alignOffset}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md\",\n          className\n        )}\n        {...props}\n      />\n    </MenubarPortal>\n  )\n}\n\nfunction MenubarItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Item> & {\n  inset?: boolean\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <MenubarPrimitive.Item\n      data-slot=\"menubar-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction MenubarCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {\n  return (\n    <MenubarPrimitive.CheckboxItem\n      data-slot=\"menubar-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <MenubarPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </MenubarPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </MenubarPrimitive.CheckboxItem>\n  )\n}\n\nfunction MenubarRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {\n  return (\n    <MenubarPrimitive.RadioItem\n      data-slot=\"menubar-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <MenubarPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </MenubarPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </MenubarPrimitive.RadioItem>\n  )\n}\n\nfunction MenubarLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <MenubarPrimitive.Label\n      data-slot=\"menubar-label\"\n      data-inset={inset}\n      className={cn(\n        \"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction MenubarSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {\n  return (\n    <MenubarPrimitive.Separator\n      data-slot=\"menubar-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction MenubarShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"menubar-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction MenubarSub({\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {\n  return <MenubarPrimitive.Sub data-slot=\"menubar-sub\" {...props} />\n}\n\nfunction MenubarSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <MenubarPrimitive.SubTrigger\n      data-slot=\"menubar-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto h-4 w-4\" />\n    </MenubarPrimitive.SubTrigger>\n  )\n}\n\nfunction MenubarSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {\n  return (\n    <MenubarPrimitive.SubContent\n      data-slot=\"menubar-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Menubar,\n  MenubarPortal,\n  MenubarMenu,\n  MenubarTrigger,\n  MenubarContent,\n  MenubarGroup,\n  MenubarSeparator,\n  MenubarLabel,\n  MenubarItem,\n  MenubarShortcut,\n  MenubarCheckboxItem,\n  MenubarRadioGroup,\n  MenubarRadioItem,\n  MenubarSub,\n  MenubarSubTrigger,\n  MenubarSubContent,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/navigation-menu.tsx",
    "content": "import * as React from \"react\"\nimport * as NavigationMenuPrimitive from \"@radix-ui/react-navigation-menu\"\nimport { cva } from \"class-variance-authority\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction NavigationMenu({\n  className,\n  children,\n  viewport = true,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {\n  viewport?: boolean\n}) {\n  return (\n    <NavigationMenuPrimitive.Root\n      data-slot=\"navigation-menu\"\n      data-viewport={viewport}\n      className={cn(\n        \"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {viewport && <NavigationMenuViewport />}\n    </NavigationMenuPrimitive.Root>\n  )\n}\n\nfunction NavigationMenuList({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {\n  return (\n    <NavigationMenuPrimitive.List\n      data-slot=\"navigation-menu-list\"\n      className={cn(\n        \"group flex flex-1 list-none items-center justify-center gap-1\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction NavigationMenuItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {\n  return (\n    <NavigationMenuPrimitive.Item\n      data-slot=\"navigation-menu-item\"\n      className={cn(\"relative\", className)}\n      {...props}\n    />\n  )\n}\n\nconst navigationMenuTriggerStyle = cva(\n  \"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1\"\n)\n\nfunction NavigationMenuTrigger({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {\n  return (\n    <NavigationMenuPrimitive.Trigger\n      data-slot=\"navigation-menu-trigger\"\n      className={cn(navigationMenuTriggerStyle(), \"group\", className)}\n      {...props}\n    >\n      {children}{\" \"}\n      <ChevronDownIcon\n        className=\"relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180\"\n        aria-hidden=\"true\"\n      />\n    </NavigationMenuPrimitive.Trigger>\n  )\n}\n\nfunction NavigationMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {\n  return (\n    <NavigationMenuPrimitive.Content\n      data-slot=\"navigation-menu-content\"\n      className={cn(\n        \"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto\",\n        \"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction NavigationMenuViewport({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {\n  return (\n    <div\n      className={cn(\n        \"absolute top-full left-0 isolate z-50 flex justify-center\"\n      )}\n    >\n      <NavigationMenuPrimitive.Viewport\n        data-slot=\"navigation-menu-viewport\"\n        className={cn(\n          \"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction NavigationMenuLink({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {\n  return (\n    <NavigationMenuPrimitive.Link\n      data-slot=\"navigation-menu-link\"\n      className={cn(\n        \"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction NavigationMenuIndicator({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {\n  return (\n    <NavigationMenuPrimitive.Indicator\n      data-slot=\"navigation-menu-indicator\"\n      className={cn(\n        \"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden\",\n        className\n      )}\n      {...props}\n    >\n      <div className=\"bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md\" />\n    </NavigationMenuPrimitive.Indicator>\n  )\n}\n\nexport {\n  NavigationMenu,\n  NavigationMenuList,\n  NavigationMenuItem,\n  NavigationMenuContent,\n  NavigationMenuTrigger,\n  NavigationMenuLink,\n  NavigationMenuIndicator,\n  NavigationMenuViewport,\n  navigationMenuTriggerStyle,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/pagination.tsx",
    "content": "import * as React from \"react\"\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  MoreHorizontalIcon,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants, type Button } from \"@/components/ui/button\"\n\nfunction Pagination({ className, ...props }: React.ComponentProps<\"nav\">) {\n  return (\n    <nav\n      role=\"navigation\"\n      aria-label=\"pagination\"\n      data-slot=\"pagination\"\n      className={cn(\"mx-auto flex w-full justify-center\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction PaginationContent({\n  className,\n  ...props\n}: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"pagination-content\"\n      className={cn(\"flex flex-row items-center gap-1\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction PaginationItem({ ...props }: React.ComponentProps<\"li\">) {\n  return <li data-slot=\"pagination-item\" {...props} />\n}\n\ntype PaginationLinkProps = {\n  isActive?: boolean\n} & Pick<React.ComponentProps<typeof Button>, \"size\"> &\n  React.ComponentProps<\"a\">\n\nfunction PaginationLink({\n  className,\n  isActive,\n  size = \"icon\",\n  ...props\n}: PaginationLinkProps) {\n  return (\n    <a\n      aria-current={isActive ? \"page\" : undefined}\n      data-slot=\"pagination-link\"\n      data-active={isActive}\n      className={cn(\n        buttonVariants({\n          variant: isActive ? \"outline\" : \"ghost\",\n          size,\n        }),\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction PaginationPrevious({\n  className,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to previous page\"\n      size=\"default\"\n      className={cn(\"gap-1 px-2.5 sm:pl-2.5\", className)}\n      {...props}\n    >\n      <ChevronLeftIcon />\n      <span className=\"hidden sm:block\">Previous</span>\n    </PaginationLink>\n  )\n}\n\nfunction PaginationNext({\n  className,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to next page\"\n      size=\"default\"\n      className={cn(\"gap-1 px-2.5 sm:pr-2.5\", className)}\n      {...props}\n    >\n      <span className=\"hidden sm:block\">Next</span>\n      <ChevronRightIcon />\n    </PaginationLink>\n  )\n}\n\nfunction PaginationEllipsis({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"pagination-ellipsis\"\n      className={cn(\"flex size-9 items-center justify-center\", className)}\n      {...props}\n    >\n      <MoreHorizontalIcon className=\"size-4\" />\n      <span className=\"sr-only\">More pages</span>\n    </span>\n  )\n}\n\nexport {\n  Pagination,\n  PaginationContent,\n  PaginationLink,\n  PaginationItem,\n  PaginationPrevious,\n  PaginationNext,\n  PaginationEllipsis,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/popover.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Popover({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />\n}\n\nfunction PopoverTrigger({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />\n}\n\nfunction PopoverContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        data-slot=\"popover-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden\",\n          className\n        )}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  )\n}\n\nfunction PopoverAnchor({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />\n}\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "tauri/src/components/ui/progress.tsx",
    "content": "import * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Progress({\n  className,\n  value,\n  ...props\n}: React.ComponentProps<typeof ProgressPrimitive.Root>) {\n  return (\n    <ProgressPrimitive.Root\n      data-slot=\"progress\"\n      className={cn(\n        \"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full\",\n        className\n      )}\n      {...props}\n    >\n      <ProgressPrimitive.Indicator\n        data-slot=\"progress-indicator\"\n        className=\"bg-primary h-full w-full flex-1 transition-all\"\n        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n      />\n    </ProgressPrimitive.Root>\n  )\n}\n\nexport { Progress }\n"
  },
  {
    "path": "tauri/src/components/ui/radio-group.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\"\nimport { CircleIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction RadioGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {\n  return (\n    <RadioGroupPrimitive.Root\n      data-slot=\"radio-group\"\n      className={cn(\"grid gap-3\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction RadioGroupItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {\n  return (\n    <RadioGroupPrimitive.Item\n      data-slot=\"radio-group-item\"\n      className={cn(\n        \"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator\n        data-slot=\"radio-group-indicator\"\n        className=\"relative flex items-center justify-center\"\n      >\n        <CircleIcon className=\"fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  )\n}\n\nexport { RadioGroup, RadioGroupItem }\n"
  },
  {
    "path": "tauri/src/components/ui/resizable.tsx",
    "content": "import * as React from \"react\"\nimport { GripVerticalIcon } from \"lucide-react\"\nimport { Group, Panel, Separator } from \"react-resizable-panels\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ResizablePanelGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof Group>) {\n  return (\n    <Group\n      data-slot=\"resizable-panel-group\"\n      className={cn(\n        \"flex h-full w-full data-[panel-group-direction=vertical]:flex-col\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ResizablePanel({\n  ...props\n}: React.ComponentProps<typeof Panel>) {\n  return <Panel data-slot=\"resizable-panel\" {...props} />\n}\n\nfunction ResizableHandle({\n  withHandle,\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator> & {\n  withHandle?: boolean\n}) {\n  return (\n    <Separator\n      data-slot=\"resizable-handle\"\n      className={cn(\n        \"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90\",\n        className\n      )}\n      {...props}\n    >\n      {withHandle && (\n        <div className=\"bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border\">\n          <GripVerticalIcon className=\"size-2.5\" />\n        </div>\n      )}\n    </Separator>\n  )\n}\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle }\n"
  },
  {
    "path": "tauri/src/components/ui/scroll-area.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {\n  return (\n    <ScrollAreaPrimitive.Root\n      data-slot=\"scroll-area\"\n      className={cn(\"relative\", className)}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        data-slot=\"scroll-area-viewport\"\n        className=\"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  )\n}\n\nfunction ScrollBar({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      className={cn(\n        \"flex touch-none p-px transition-colors select-none\",\n        orientation === \"vertical\" &&\n          \"h-full w-2.5 border-l border-l-transparent\",\n        orientation === \"horizontal\" &&\n          \"h-2.5 flex-col border-t border-t-transparent\",\n        className\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        data-slot=\"scroll-area-thumb\"\n        className=\"bg-border relative flex-1 rounded-full\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  )\n}\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "tauri/src/components/ui/select.tsx",
    "content": "import * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\"\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  )\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"item-aligned\",\n  align = \"center\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className\n        )}\n        position={position}\n        align={align}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\"\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  )\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"text-muted-foreground px-2 py-1.5 text-xs\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className\n      )}\n      {...props}\n    >\n      <span\n        data-slot=\"select-item-indicator\"\n        className=\"absolute right-2 flex size-3.5 items-center justify-center\"\n      >\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  )\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"bg-border pointer-events-none -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  )\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  )\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/separator.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Separator }\n"
  },
  {
    "path": "tauri/src/components/ui/sheet.tsx",
    "content": "import * as React from \"react\"\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\"\nimport { XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />\n}\n\nfunction SheetTrigger({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />\n}\n\nfunction SheetClose({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />\n}\n\nfunction SheetPortal({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      data-slot=\"sheet-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = \"right\",\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\"\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        data-slot=\"sheet-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500\",\n          side === \"right\" &&\n            \"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm\",\n          side === \"left\" &&\n            \"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm\",\n          side === \"top\" &&\n            \"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b\",\n          side === \"bottom\" &&\n            \"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <SheetPrimitive.Close className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none\">\n          <XIcon className=\"size-4\" />\n          <span className=\"sr-only\">Close</span>\n        </SheetPrimitive.Close>\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  )\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sheet-header\"\n      className={cn(\"flex flex-col gap-1.5 p-4\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sheet-footer\"\n      className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SheetTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      data-slot=\"sheet-title\"\n      className={cn(\"text-foreground font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      data-slot=\"sheet-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/sidebar.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { PanelLeftIcon } from \"lucide-react\"\n\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Separator } from \"@/components/ui/separator\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\"\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7\nconst SIDEBAR_WIDTH = \"16rem\"\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\"\nconst SIDEBAR_WIDTH_ICON = \"3rem\"\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\"\n\ntype SidebarContextProps = {\n  state: \"expanded\" | \"collapsed\"\n  open: boolean\n  setOpen: (open: boolean) => void\n  openMobile: boolean\n  setOpenMobile: (open: boolean) => void\n  isMobile: boolean\n  toggleSidebar: () => void\n}\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null)\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext)\n  if (!context) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider.\")\n  }\n\n  return context\n}\n\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange: setOpenProp,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  defaultOpen?: boolean\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n}) {\n  const isMobile = useIsMobile()\n  const [openMobile, setOpenMobile] = React.useState(false)\n\n  // This is the internal state of the sidebar.\n  // We use openProp and setOpenProp for control from outside the component.\n  const [_open, _setOpen] = React.useState(defaultOpen)\n  const open = openProp ?? _open\n  const setOpen = React.useCallback(\n    (value: boolean | ((value: boolean) => boolean)) => {\n      const openState = typeof value === \"function\" ? value(open) : value\n      if (setOpenProp) {\n        setOpenProp(openState)\n      } else {\n        _setOpen(openState)\n      }\n\n      // This sets the cookie to keep the sidebar state.\n      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`\n    },\n    [setOpenProp, open]\n  )\n\n  // Helper to toggle the sidebar.\n  const toggleSidebar = React.useCallback(() => {\n    return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)\n  }, [isMobile, setOpen, setOpenMobile])\n\n  // Adds a keyboard shortcut to toggle the sidebar.\n  React.useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (\n        event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n        (event.metaKey || event.ctrlKey)\n      ) {\n        event.preventDefault()\n        toggleSidebar()\n      }\n    }\n\n    window.addEventListener(\"keydown\", handleKeyDown)\n    return () => window.removeEventListener(\"keydown\", handleKeyDown)\n  }, [toggleSidebar])\n\n  // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n  // This makes it easier to style the sidebar with Tailwind classes.\n  const state = open ? \"expanded\" : \"collapsed\"\n\n  const contextValue = React.useMemo<SidebarContextProps>(\n    () => ({\n      state,\n      open,\n      setOpen,\n      isMobile,\n      openMobile,\n      setOpenMobile,\n      toggleSidebar,\n    }),\n    [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]\n  )\n\n  return (\n    <SidebarContext.Provider value={contextValue}>\n      <TooltipProvider delayDuration={0}>\n        <div\n          data-slot=\"sidebar-wrapper\"\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH,\n              \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n              ...style,\n            } as React.CSSProperties\n          }\n          className={cn(\n            \"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full\",\n            className\n          )}\n          {...props}\n        >\n          {children}\n        </div>\n      </TooltipProvider>\n    </SidebarContext.Provider>\n  )\n}\n\nfunction Sidebar({\n  side = \"left\",\n  variant = \"sidebar\",\n  collapsible = \"offcanvas\",\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  side?: \"left\" | \"right\"\n  variant?: \"sidebar\" | \"floating\" | \"inset\"\n  collapsible?: \"offcanvas\" | \"icon\" | \"none\"\n}) {\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar()\n\n  if (collapsible === \"none\") {\n    return (\n      <div\n        data-slot=\"sidebar\"\n        className={cn(\n          \"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    )\n  }\n\n  if (isMobile) {\n    return (\n      <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n        <SheetContent\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar\"\n          data-mobile=\"true\"\n          className=\"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden\"\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n            } as React.CSSProperties\n          }\n          side={side}\n        >\n          <SheetHeader className=\"sr-only\">\n            <SheetTitle>Sidebar</SheetTitle>\n            <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n          </SheetHeader>\n          <div className=\"flex h-full w-full flex-col\">{children}</div>\n        </SheetContent>\n      </Sheet>\n    )\n  }\n\n  return (\n    <div\n      className=\"group peer text-sidebar-foreground hidden md:block\"\n      data-state={state}\n      data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n      data-variant={variant}\n      data-side={side}\n      data-slot=\"sidebar\"\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        data-slot=\"sidebar-gap\"\n        className={cn(\n          \"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear\",\n          \"group-data-[collapsible=offcanvas]:w-0\",\n          \"group-data-[side=right]:rotate-180\",\n          variant === \"floating\" || variant === \"inset\"\n            ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon)\"\n        )}\n      />\n      <div\n        data-slot=\"sidebar-container\"\n        className={cn(\n          \"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex\",\n          side === \"left\"\n            ? \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\"\n            : \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\",\n          // Adjust the padding for floating and inset variants.\n          variant === \"floating\" || variant === \"inset\"\n            ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n          className\n        )}\n        {...props}\n      >\n        <div\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar-inner\"\n          className=\"bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm\"\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction SidebarTrigger({\n  className,\n  onClick,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <Button\n      data-sidebar=\"trigger\"\n      data-slot=\"sidebar-trigger\"\n      variant=\"ghost\"\n      size=\"icon\"\n      className={cn(\"size-7\", className)}\n      onClick={(event) => {\n        onClick?.(event)\n        toggleSidebar()\n      }}\n      {...props}\n    >\n      <PanelLeftIcon />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  )\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<\"button\">) {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <button\n      data-sidebar=\"rail\"\n      data-slot=\"sidebar-rail\"\n      aria-label=\"Toggle Sidebar\"\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title=\"Toggle Sidebar\"\n      className={cn(\n        \"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex\",\n        \"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize\",\n        \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n        \"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full\",\n        \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n        \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<\"main\">) {\n  return (\n    <main\n      data-slot=\"sidebar-inset\"\n      className={cn(\n        \"bg-background relative flex w-full flex-1 flex-col\",\n        \"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof Input>) {\n  return (\n    <Input\n      data-slot=\"sidebar-input\"\n      data-sidebar=\"input\"\n      className={cn(\"bg-background h-8 w-full shadow-none\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-header\"\n      data-sidebar=\"header\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-footer\"\n      data-sidebar=\"footer\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"sidebar-separator\"\n      data-sidebar=\"separator\"\n      className={cn(\"bg-sidebar-border mx-2 w-auto\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-content\"\n      data-sidebar=\"content\"\n      className={cn(\n        \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-group\"\n      data-sidebar=\"group\"\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupLabel({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"div\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-label\"\n      data-sidebar=\"group-label\"\n      className={cn(\n        \"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupAction({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-action\"\n      data-sidebar=\"group-action\"\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 md:after:hidden\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupContent({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-group-content\"\n      data-sidebar=\"group-content\"\n      className={cn(\"w-full text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu\"\n      data-sidebar=\"menu\"\n      className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-item\"\n      data-sidebar=\"menu-item\"\n      className={cn(\"group/menu-item relative\", className)}\n      {...props}\n    />\n  )\n}\n\nconst sidebarMenuButtonVariants = cva(\n  \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-7 text-xs\",\n        lg: \"h-12 text-sm group-data-[collapsible=icon]:p-0!\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction SidebarMenuButton({\n  asChild = false,\n  isActive = false,\n  variant = \"default\",\n  size = \"default\",\n  tooltip,\n  className,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean\n  isActive?: boolean\n  tooltip?: string | React.ComponentProps<typeof TooltipContent>\n} & VariantProps<typeof sidebarMenuButtonVariants>) {\n  const Comp = asChild ? Slot : \"button\"\n  const { isMobile, state } = useSidebar()\n\n  const button = (\n    <Comp\n      data-slot=\"sidebar-menu-button\"\n      data-sidebar=\"menu-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n      {...props}\n    />\n  )\n\n  if (!tooltip) {\n    return button\n  }\n\n  if (typeof tooltip === \"string\") {\n    tooltip = {\n      children: tooltip,\n    }\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{button}</TooltipTrigger>\n      <TooltipContent\n        side=\"right\"\n        align=\"center\"\n        hidden={state !== \"collapsed\" || isMobile}\n        {...tooltip}\n      />\n    </Tooltip>\n  )\n}\n\nfunction SidebarMenuAction({\n  className,\n  asChild = false,\n  showOnHover = false,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean\n  showOnHover?: boolean\n}) {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-action\"\n      data-sidebar=\"menu-action\"\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 md:after:hidden\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        showOnHover &&\n          \"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuBadge({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-menu-badge\"\n      data-sidebar=\"menu-badge\"\n      className={cn(\n        \"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none\",\n        \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  showIcon?: boolean\n}) {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`\n  }, [])\n\n  return (\n    <div\n      data-slot=\"sidebar-menu-skeleton\"\n      data-sidebar=\"menu-skeleton\"\n      className={cn(\"flex h-8 items-center gap-2 rounded-md px-2\", className)}\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton\n          className=\"size-4 rounded-md\"\n          data-sidebar=\"menu-skeleton-icon\"\n        />\n      )}\n      <Skeleton\n        className=\"h-4 max-w-(--skeleton-width) flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  )\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu-sub\"\n      data-sidebar=\"menu-sub\"\n      className={cn(\n        \"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSubItem({\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-sub-item\"\n      data-sidebar=\"menu-sub-item\"\n      className={cn(\"group/menu-sub-item relative\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSubButton({\n  asChild = false,\n  size = \"md\",\n  isActive = false,\n  className,\n  ...props\n}: React.ComponentProps<\"a\"> & {\n  asChild?: boolean\n  size?: \"sm\" | \"md\"\n  isActive?: boolean\n}) {\n  const Comp = asChild ? Slot : \"a\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-sub-button\"\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n        size === \"sm\" && \"text-xs\",\n        size === \"md\" && \"text-sm\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"skeleton\"\n      className={cn(\"bg-accent animate-pulse rounded-md\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "tauri/src/components/ui/slider.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SliderPrimitive from \"@radix-ui/react-slider\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Slider({\n  className,\n  defaultValue,\n  value,\n  min = 0,\n  max = 100,\n  ...props\n}: React.ComponentProps<typeof SliderPrimitive.Root>) {\n  const _values = React.useMemo(\n    () =>\n      Array.isArray(value)\n        ? value\n        : Array.isArray(defaultValue)\n          ? defaultValue\n          : [min, max],\n    [value, defaultValue, min, max]\n  )\n\n  return (\n    <SliderPrimitive.Root\n      data-slot=\"slider\"\n      defaultValue={defaultValue}\n      value={value}\n      min={min}\n      max={max}\n      className={cn(\n        \"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col\",\n        className\n      )}\n      {...props}\n    >\n      <SliderPrimitive.Track\n        data-slot=\"slider-track\"\n        className={cn(\n          \"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5\"\n        )}\n      >\n        <SliderPrimitive.Range\n          data-slot=\"slider-range\"\n          className={cn(\n            \"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full\"\n          )}\n        />\n      </SliderPrimitive.Track>\n      {Array.from({ length: _values.length }, (_, index) => (\n        <SliderPrimitive.Thumb\n          data-slot=\"slider-thumb\"\n          key={index}\n          className=\"border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50\"\n        />\n      ))}\n    </SliderPrimitive.Root>\n  )\n}\n\nexport { Slider }\n"
  },
  {
    "path": "tauri/src/components/ui/sonner.tsx",
    "content": "import {\n  CircleCheckIcon,\n  InfoIcon,\n  Loader2Icon,\n  OctagonXIcon,\n  TriangleAlertIcon,\n} from \"lucide-react\"\nimport { useTheme } from \"next-themes\"\nimport { Toaster as Sonner, type ToasterProps } from \"sonner\"\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme()\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      icons={{\n        success: <CircleCheckIcon className=\"size-4\" />,\n        info: <InfoIcon className=\"size-4\" />,\n        warning: <TriangleAlertIcon className=\"size-4\" />,\n        error: <OctagonXIcon className=\"size-4\" />,\n        loading: <Loader2Icon className=\"size-4 animate-spin\" />,\n      }}\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n          \"--border-radius\": \"var(--radius)\",\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n"
  },
  {
    "path": "tauri/src/components/ui/spinner.tsx",
    "content": "import { Loader2Icon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Spinner({ className, ...props }: React.ComponentProps<\"svg\">) {\n  return (\n    <Loader2Icon\n      role=\"status\"\n      aria-label=\"Loading\"\n      className={cn(\"size-4 animate-spin\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Spinner }\n"
  },
  {
    "path": "tauri/src/components/ui/switch.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Switch({\n  className,\n  ...props\n}: React.ComponentProps<typeof SwitchPrimitive.Root>) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      className={cn(\n        \"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className={cn(\n          \"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0\"\n        )}\n      />\n    </SwitchPrimitive.Root>\n  )\n}\n\nexport { Switch }\n"
  },
  {
    "path": "tauri/src/components/ui/table.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Table({ className, ...props }: React.ComponentProps<\"table\">) {\n  return (\n    <div\n      data-slot=\"table-container\"\n      className=\"relative w-full overflow-x-auto\"\n    >\n      <table\n        data-slot=\"table\"\n        className={cn(\"w-full caption-bottom text-sm\", className)}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction TableHeader({ className, ...props }: React.ComponentProps<\"thead\">) {\n  return (\n    <thead\n      data-slot=\"table-header\"\n      className={cn(\"[&_tr]:border-b\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableBody({ className, ...props }: React.ComponentProps<\"tbody\">) {\n  return (\n    <tbody\n      data-slot=\"table-body\"\n      className={cn(\"[&_tr:last-child]:border-0\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableFooter({ className, ...props }: React.ComponentProps<\"tfoot\">) {\n  return (\n    <tfoot\n      data-slot=\"table-footer\"\n      className={cn(\n        \"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableRow({ className, ...props }: React.ComponentProps<\"tr\">) {\n  return (\n    <tr\n      data-slot=\"table-row\"\n      className={cn(\n        \"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableHead({ className, ...props }: React.ComponentProps<\"th\">) {\n  return (\n    <th\n      data-slot=\"table-head\"\n      className={cn(\n        \"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCell({ className, ...props }: React.ComponentProps<\"td\">) {\n  return (\n    <td\n      data-slot=\"table-cell\"\n      className={cn(\n        \"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCaption({\n  className,\n  ...props\n}: React.ComponentProps<\"caption\">) {\n  return (\n    <caption\n      data-slot=\"table-caption\"\n      className={cn(\"text-muted-foreground mt-4 text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n}\n"
  },
  {
    "path": "tauri/src/components/ui/tabs.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Tabs({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Root>) {\n  return (\n    <TabsPrimitive.Root\n      data-slot=\"tabs\"\n      className={cn(\"flex flex-col gap-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TabsList({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.List>) {\n  return (\n    <TabsPrimitive.List\n      data-slot=\"tabs-list\"\n      className={cn(\n        \"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TabsTrigger({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {\n  return (\n    <TabsPrimitive.Trigger\n      data-slot=\"tabs-trigger\"\n      className={cn(\n        \"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TabsContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Content>) {\n  return (\n    <TabsPrimitive.Content\n      data-slot=\"tabs-content\"\n      className={cn(\"flex-1 outline-none\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n"
  },
  {
    "path": "tauri/src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Textarea }\n"
  },
  {
    "path": "tauri/src/components/ui/toggle-group.tsx",
    "content": "import * as React from \"react\"\nimport * as ToggleGroupPrimitive from \"@radix-ui/react-toggle-group\"\nimport { type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { toggleVariants } from \"@/components/ui/toggle\"\n\nconst ToggleGroupContext = React.createContext<\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number\n  }\n>({\n  size: \"default\",\n  variant: \"default\",\n  spacing: 0,\n})\n\nfunction ToggleGroup({\n  className,\n  variant,\n  size,\n  spacing = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number\n  }) {\n  return (\n    <ToggleGroupPrimitive.Root\n      data-slot=\"toggle-group\"\n      data-variant={variant}\n      data-size={size}\n      data-spacing={spacing}\n      style={{ \"--gap\": spacing } as React.CSSProperties}\n      className={cn(\n        \"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs\",\n        className\n      )}\n      {...props}\n    >\n      <ToggleGroupContext.Provider value={{ variant, size, spacing }}>\n        {children}\n      </ToggleGroupContext.Provider>\n    </ToggleGroupPrimitive.Root>\n  )\n}\n\nfunction ToggleGroupItem({\n  className,\n  children,\n  variant,\n  size,\n  ...props\n}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &\n  VariantProps<typeof toggleVariants>) {\n  const context = React.useContext(ToggleGroupContext)\n\n  return (\n    <ToggleGroupPrimitive.Item\n      data-slot=\"toggle-group-item\"\n      data-variant={context.variant || variant}\n      data-size={context.size || size}\n      data-spacing={context.spacing}\n      className={cn(\n        toggleVariants({\n          variant: context.variant || variant,\n          size: context.size || size,\n        }),\n        \"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10\",\n        \"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </ToggleGroupPrimitive.Item>\n  )\n}\n\nexport { ToggleGroup, ToggleGroupItem }\n"
  },
  {
    "path": "tauri/src/components/ui/toggle.tsx",
    "content": "import * as React from \"react\"\nimport * as TogglePrimitive from \"@radix-ui/react-toggle\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst toggleVariants = cva(\n  \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline:\n          \"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground\",\n      },\n      size: {\n        default: \"h-9 px-2 min-w-9\",\n        sm: \"h-8 px-1.5 min-w-8\",\n        lg: \"h-10 px-2.5 min-w-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Toggle({\n  className,\n  variant,\n  size,\n  ...props\n}: React.ComponentProps<typeof TogglePrimitive.Root> &\n  VariantProps<typeof toggleVariants>) {\n  return (\n    <TogglePrimitive.Root\n      data-slot=\"toggle\"\n      className={cn(toggleVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Toggle, toggleVariants }\n"
  },
  {
    "path": "tauri/src/components/ui/tooltip.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "tauri/src/hooks/use-mobile.ts",
    "content": "import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener(\"change\", onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "tauri/src/hooks/useDropZone.ts",
    "content": "import { useEffect, useRef, useState, RefObject } from \"react\";\nimport { listen } from \"@tauri-apps/api/event\";\n\ninterface DragDropPayload {\n  paths: string[];\n  position: { x: number; y: number };\n}\n\ninterface UseDropZoneOptions<T extends HTMLElement> {\n  /**\n   * Optional ref to use - if not provided, hook creates its own\n   */\n  ref?: RefObject<T | null>;\n  /**\n   * File extensions to accept (without dot), e.g. [\"txt\", \"md\"]\n   */\n  accept?: string[];\n  /**\n   * Callback when valid file(s) are dropped\n   */\n  onDrop: (paths: string[]) => void;\n  /**\n   * Callback when invalid file is dropped (wrong extension)\n   */\n  onInvalidDrop?: (paths: string[], extension: string | undefined) => void;\n  /**\n   * Whether the drop zone is enabled\n   */\n  enabled?: boolean;\n}\n\n/**\n * Check if a point is within an element's bounding box\n */\nfunction isPointInElement(x: number, y: number, element: HTMLElement): boolean {\n  const rect = element.getBoundingClientRect();\n  return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;\n}\n\n/**\n * Hook for creating a drop zone that responds to Tauri drag-drop events.\n * Uses drop position to check if drop occurred within the element's bounds.\n */\nexport function useDropZone<T extends HTMLElement = HTMLDivElement>(\n  options: UseDropZoneOptions<T>\n) {\n  const { ref: externalRef, accept, onDrop, onInvalidDrop, enabled = true } = options;\n  const internalRef = useRef<T>(null);\n  const ref = externalRef || internalRef;\n  const [isDragging, setIsDragging] = useState(false);\n\n  useEffect(() => {\n    if (!enabled) return;\n\n    let unlistenDrop: (() => void) | undefined;\n    let unlistenEnter: (() => void) | undefined;\n    let unlistenLeave: (() => void) | undefined;\n\n    const setupListeners = async () => {\n      // Listen for file drop - check if position is within this element\n      unlistenDrop = await listen<DragDropPayload>(\n        \"tauri://drag-drop\",\n        (event) => {\n          setIsDragging(false);\n\n          const element = ref.current;\n          if (!element) return;\n\n          const { paths, position } = event.payload;\n          if (!paths || paths.length === 0) return;\n\n          // Check if drop position is within this element's bounds\n          if (!isPointInElement(position.x, position.y, element)) {\n            return; // Drop was outside this element\n          }\n\n          // Check file extension if accept list is provided\n          if (accept && accept.length > 0) {\n            const filePath = paths[0];\n            const ext = filePath.toLowerCase().split(\".\").pop();\n\n            if (!ext || !accept.includes(ext)) {\n              onInvalidDrop?.(paths, ext);\n              return;\n            }\n          }\n\n          onDrop(paths);\n        }\n      );\n\n      // Track when dragging enters the window\n      unlistenEnter = await listen(\"tauri://drag-enter\", () => {\n        setIsDragging(true);\n      });\n\n      // Track when dragging leaves the window\n      unlistenLeave = await listen(\"tauri://drag-leave\", () => {\n        setIsDragging(false);\n      });\n    };\n\n    setupListeners();\n\n    return () => {\n      unlistenDrop?.();\n      unlistenEnter?.();\n      unlistenLeave?.();\n    };\n  }, [enabled, accept, onDrop, onInvalidDrop, ref]);\n\n  return {\n    ref,\n    isDragging, // true when files are being dragged over the window\n  };\n}\n"
  },
  {
    "path": "tauri/src/i18n/index.ts",
    "content": "import i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\nimport yaml from 'js-yaml';\n\nimport enYml from './locales/en.yml?raw';\nimport zhYml from './locales/zh.yml?raw';\nimport jpYml from './locales/jp.yml?raw';\nimport krYml from './locales/kr.yml?raw';\nimport esYml from './locales/es.yml?raw';\nimport frYml from './locales/fr.yml?raw';\nimport deYml from './locales/de.yml?raw';\n\nconst en = yaml.load(enYml) as Record<string, unknown>;\nconst zh = yaml.load(zhYml) as Record<string, unknown>;\nconst jp = yaml.load(jpYml) as Record<string, unknown>;\nconst kr = yaml.load(krYml) as Record<string, unknown>;\nconst es = yaml.load(esYml) as Record<string, unknown>;\nconst fr = yaml.load(frYml) as Record<string, unknown>;\nconst de = yaml.load(deYml) as Record<string, unknown>;\n\nexport const resources = {\n  en: { translation: en },\n  zh: { translation: zh },\n  jp: { translation: jp },\n  kr: { translation: kr },\n  es: { translation: es },\n  fr: { translation: fr },\n  de: { translation: de },\n};\n\nexport const supportedLanguages = [\n  { code: 'en', name: 'English' },\n  { code: 'zh', name: '中文' },\n  { code: 'jp', name: '日本語' },\n  { code: 'kr', name: '한국어' },\n  { code: 'es', name: 'Español' },\n  { code: 'fr', name: 'Français' },\n  { code: 'de', name: 'Deutsch' },\n];\n\ni18n\n  .use(initReactI18next)\n  .init({\n    resources,\n    lng: 'en',\n    fallbackLng: 'en',\n    interpolation: {\n      escapeValue: false,\n    },\n  });\n\nexport default i18n;\n\nexport function changeLanguage(lang: string) {\n  i18n.changeLanguage(lang);\n}\n"
  },
  {
    "path": "tauri/src/i18n/locales/de.yml",
    "content": "nav:\n  download: Herunterladen\n  mediaTools: Medien-Tools\n  pdfTools: PDF-Tools\n  settings: Einstellungen\n  expandMenu: Menü erweitern\n  vgetDesktop: VGet Desktop\nhome:\n  title: Herunterladen\n  urlPlaceholder: Video-URL hier einfügen...\n  download: Herunterladen\n  extracting: Extrahiere...\n  noFormats: Keine herunterladbaren Formate gefunden\n  downloadStarted: Download gestartet\n  supportsHint: Unterstützt Twitter/X, Bilibili, Xiaohongshu, YouTube, Apple Podcasts und direkte URLs\n  downloads: Downloads\n  clearCompleted: Abgeschlossene löschen\n  openFolder: Ordner öffnen\n  failedToOpenFolder: Ordner konnte nicht geöffnet werden\n  noDownloadsYet: Noch keine Downloads. Fügen Sie oben eine URL ein, um zu beginnen.\n  startingDownload: Download wird gestartet...\n  failedToCancel: Download konnte nicht abgebrochen werden\n  bulkDownloadTitle: Massendownload\n  bulkDownloadHint: Eine .txt-Datei mit URLs ablegen oder klicken zum Auswählen\n  dropTxtFile: Bitte eine .txt-Datei mit URLs ablegen\n  dropMdHint: Für Markdown-Dateien gehen Sie zu PDF-Tools → Markdown zu PDF\n  noUrlsInFile: Keine gültigen URLs in der Datei gefunden\n  foundUrls: \"{{count}} URLs gefunden\"\n  processingBulk: Verarbeite {{current}}/{{total}}\n  failedToReadFile: Datei konnte nicht gelesen werden\n  dockerNotRunning: YouTube-Downloads erfordern vget-server. Bitte starten Sie den Docker-Container oder den Server.\n  dockerAuthRequired: Der Docker-Server erfordert Authentifizierung. Gehen Sie zu Einstellungen → Websites → Docker-Server, um Ihr JWT-Token hinzuzufügen.\n  youtubeDownloadStarted: YouTube-Download über Docker-Server gestartet\n  downloadComplete: Download abgeschlossen!\nsettings:\n  back: Zurück\n  save: Speichern\n  saving: Speichern...\n  unsavedChanges: Nicht gespeicherte Änderungen\n  loading: Einstellungen werden geladen...\n  loadFailed: Einstellungen konnten nicht geladen werden\n  sections:\n    general: Allgemein\n    sites: Seiten\n    about: Über\n  general:\n    downloads: Downloads\n    downloadsDesc: Download-Einstellungen konfigurieren\n    downloadLocation: Download-Speicherort\n    downloadLocationHint: Wo heruntergeladene Dateien gespeichert werden\n    selectDirectory: Download-Verzeichnis auswählen\n    defaultFormat: Standardformat\n    selectFormat: Format auswählen\n    bestAvailable: Beste Verfügbar\n    defaultQuality: Standardqualität\n    selectQuality: Qualität auswählen\n    qualityHint: Bevorzugte Videoqualität, wenn mehrere Optionen verfügbar sind\n    language: Sprache\n    languageDesc: Anzeigesprache der Anwendung\n    selectLanguage: Sprache auswählen\n    theme: Design\n    themeDesc: Wählen Sie Ihr bevorzugtes Erscheinungsbild\n    selectTheme: Design auswählen\n    light: Hell\n    dark: Dunkel\n    system: System\n  sites:\n    twitter:\n      title: Twitter / X\n      desc: Erforderlich zum Herunterladen von NSFW- oder geschützten Inhalten\n      authToken: Auth-Token\n      tokenPlaceholder: Geben Sie Ihren auth_token-Cookie-Wert ein\n      tokenHint: Finden Sie ihn in den Cookies Ihres Browsers nach der Anmeldung bei Twitter/X\n    bilibili:\n      title: Bilibili\n      loggedInAs: Angemeldet als {{username}}\n      loggedIn: Angemeldet\n      desc: Erforderlich für hochwertige Downloads und Mitglieder-Inhalte\n      logout: Abmelden\n      checkingStatus: Anmeldestatus wird überprüft...\n      qrCode: QR-Code\n      cookie: Cookie\n      scanWithApp: Mit der Bilibili-App scannen\n      confirmLogin: Anmeldung auf Ihrem Telefon bestätigen\n      qrExpired: QR-Code abgelaufen\n      loginSuccess: Anmeldung erfolgreich!\n      refreshQR: QR-Code aktualisieren\n      waiting: Warten...\n      failedToGenerateQR: QR-Code konnte nicht generiert werden\n      cookieInstructions: \"So erhalten Sie Cookies:\"\n      step1: Öffnen Sie bilibili.com und melden Sie sich an\n      step2: Drücken Sie F12, um DevTools zu öffnen\n      step3: Gehen Sie zur Registerkarte Anwendung → Cookies\n      step4: Kopieren Sie die untenstehenden Werte\n      fillOneField: Bitte füllen Sie mindestens ein Feld aus\n      failedToSave: Cookie konnte nicht gespeichert werden\n      pasteSessdata: SESSDATA-Wert einfügen\n      pasteBiliJct: bili_jct-Wert einfügen\n      pasteDedeUserId: DedeUserID-Wert einfügen\n    xiaohongshu:\n      title: Xiaohongshu\n      sessionSaved: Sitzungs-Cookies gespeichert\n      loggedInAs: Angemeldet als {{username}}\n      desc: Erforderlich zum Herunterladen von Videos und Bildern\n      loginInstructions: \"Klicken Sie unten, um ein Anmeldefenster zu öffnen:\"\n      scanWithApp: QR mit der Xiaohongshu-App scannen\n      orLoginPhone: Oder mit Telefonnummer anmelden\n      closeWhenDone: Fenster schließen, wenn fertig\n      openLoginWindow: Anmeldefenster Öffnen\n      opening: Öffnen...\n      failedToOpenLogin: Anmeldefenster konnte nicht geöffnet werden\n    logoutSuccess: Erfolgreich abgemeldet\n    logoutFailed: Abmeldung fehlgeschlagen\n    loginSuccess: Anmeldung erfolgreich!\n    welcome: Willkommen, {{username}}!\n    docker:\n      title: Docker-Server (YouTube)\n      desc: Konfigurieren Sie vget-server für YouTube-Downloads. Führen Sie den Docker-Container oder vget-server lokal aus.\n      serverUrl: Server-URL\n      serverUrlHint: \"URL des vget-servers (Standard: http://localhost:8080)\"\n      jwtToken: JWT-Token (optional)\n      jwtPlaceholder: JWT-Token einfügen, wenn der Server Authentifizierung erfordert\n      jwtHint: \"Nur erforderlich, wenn der Server api_key konfiguriert hat. Token abrufen: POST /api/auth/token\"\n      testConnection: Verbindung testen\n      connectionSuccess: Mit Docker-Server verbunden!\n      connectionFailed: Verbindung zum Docker-Server fehlgeschlagen\n      saved: Docker-Server-Einstellungen gespeichert\n      notRunningHint: \"Docker-Server läuft nicht. Starten Sie mit: docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\"\n  about:\n    title: Über VGet\n    desc: Versions- und Update-Informationen\n    version: Version\n    checkForUpdates: Nach Updates Suchen\n    checking: Prüfe...\n    updateTo: Auf v{{version}} aktualisieren\n    downloading: Herunterladen...\n    latestVersion: Sie haben die neueste Version\n    links: Links\n    githubRepo: GitHub-Repository\n    reportIssue: Problem Melden\nmediaTools:\n  title: Medien-Tools\n  tools:\n    convert:\n      title: Video Konvertieren\n      desc: Zwischen Videoformaten konvertieren (MP4, WebM, MKV, MOV)\n    compress:\n      title: Video Komprimieren\n      desc: Dateigröße reduzieren bei Erhalt der Qualität\n    trim:\n      title: Video Schneiden\n      desc: Clips aus Videos mit Start- und Endzeiten schneiden\n    extractAudio:\n      title: Audio Extrahieren\n      desc: Audiospur aus Videodateien extrahieren\n    extractFrames:\n      title: Bilder Extrahieren\n      desc: Bilder oder Thumbnails aus Video extrahieren\n    audioConvert:\n      title: Audio Konvertieren\n      desc: Zwischen Audioformaten konvertieren (MP3, AAC, FLAC, WAV)\n  operationComplete: Vorgang erfolgreich abgeschlossen!\npdfTools:\n  title: PDF-Tools\n  tools:\n    merge:\n      title: PDFs Zusammenführen\n      desc: Mehrere PDF-Dateien zu einer kombinieren\n    imagesToPdf:\n      title: Bilder zu PDF\n      desc: Bilder in ein einzelnes PDF-Dokument konvertieren\n    deletePages:\n      title: Seiten Löschen\n      desc: Bestimmte Seiten aus einem PDF entfernen\n    removeWatermark:\n      title: Wasserzeichen Entfernen\n      desc: Versuchen, Wasserzeichen aus einem PDF zu entfernen\n    md2pdf:\n      title: Markdown zu PDF\n      desc: Markdown-Dateien in formatierte PDFs konvertieren\n      inputFile: Markdown-Datei\n      selectFile: Markdown-Datei Auswählen\n      dropHint: Markdown-Datei hier ablegen oder klicken zum Auswählen\n      dropError: Bitte verwenden Sie den Dateiauswahl für diese Datei\n      invalidFile: Bitte eine Markdown-Datei ablegen (.md, .markdown, .txt)\n      change: Ändern\n      theme: Design\n      themeLight: Hell (GitHub-Stil)\n      themeDark: Dunkel\n      pageSize: Seitengröße\n      output: Ausgabe\n      convert: In PDF Konvertieren\n      success: Markdown erfolgreich in PDF konvertiert!\n      clickToReveal: Klicken Sie unten, um die Datei anzuzeigen\n      convertAnother: Weitere Konvertieren\ncommon:\n  loading: Laden...\n  error: Fehler\n  success: Erfolg\n  cancel: Abbrechen\n  save: Speichern\n  delete: Löschen\n  add: Hinzufügen\n  edit: Bearbeiten\n  close: Schließen\n  confirm: Bestätigen\n  \"yes\": Ja\n  \"no\": Nein\n"
  },
  {
    "path": "tauri/src/i18n/locales/en.yml",
    "content": "nav:\n  download: Download\n  mediaTools: Media Tools\n  pdfTools: PDF Tools\n  settings: Settings\n  expandMenu: Expand menu\n  vgetDesktop: VGet Desktop\nhome:\n  title: Download\n  urlPlaceholder: Paste video URL here...\n  download: Download\n  extracting: Extracting...\n  noFormats: No downloadable formats found\n  downloadStarted: Download started\n  supportsHint: Supports Twitter/X, Bilibili, Xiaohongshu, YouTube, Apple Podcasts, and direct URLs\n  downloads: Downloads\n  clearCompleted: Clear completed\n  openFolder: Open folder\n  failedToOpenFolder: Failed to open folder\n  noDownloadsYet: No downloads yet. Paste a URL above to get started.\n  startingDownload: Starting download...\n  failedToCancel: Failed to cancel download\n  bulkDownloadTitle: Bulk Download\n  bulkDownloadHint: Drop a .txt file with URLs or click to select\n  dropTxtFile: Please drop a .txt file containing URLs\n  dropMdHint: For Markdown files, go to PDF Tools → Markdown to PDF\n  noUrlsInFile: No valid URLs found in file\n  foundUrls: Found {{count}} URLs\n  processingBulk: Processing {{current}}/{{total}}\n  failedToReadFile: Failed to read file\n  dockerNotRunning: YouTube downloads require vget-server. Please run Docker container or start the server.\n  dockerAuthRequired: Docker server requires authentication. Go to Settings → Sites → Docker Server to add your JWT token.\n  youtubeDownloadStarted: YouTube download started via Docker server\n  downloadComplete: Download complete!\nsettings:\n  back: Back\n  save: Save\n  saving: Saving...\n  unsavedChanges: Unsaved changes\n  loading: Loading settings...\n  loadFailed: Failed to load settings\n  sections:\n    general: General\n    sites: Sites\n    about: About\n  general:\n    downloads: Downloads\n    downloadsDesc: Configure download preferences\n    downloadLocation: Download Location\n    downloadLocationHint: Where downloaded files will be saved\n    selectDirectory: Select Download Directory\n    defaultFormat: Default Format\n    selectFormat: Select format\n    bestAvailable: Best Available\n    defaultQuality: Default Quality\n    selectQuality: Select quality\n    qualityHint: Preferred video quality when multiple options are available\n    language: Language\n    languageDesc: Application display language\n    selectLanguage: Select language\n    theme: Theme\n    themeDesc: Choose your preferred appearance\n    selectTheme: Select theme\n    light: Light\n    dark: Dark\n    system: System\n  sites:\n    twitter:\n      title: Twitter / X\n      desc: Required for downloading NSFW or protected content\n      authToken: Auth Token\n      tokenPlaceholder: Enter your auth_token cookie value\n      tokenHint: Find this in your browser's cookies after logging into Twitter/X\n    bilibili:\n      title: Bilibili\n      loggedInAs: Logged in as {{username}}\n      loggedIn: Logged in\n      desc: Required for high-quality downloads and member-only content\n      logout: Logout\n      checkingStatus: Checking login status...\n      qrCode: QR Code\n      cookie: Cookie\n      scanWithApp: Scan with Bilibili app\n      confirmLogin: Confirm login on your phone\n      qrExpired: QR code expired\n      loginSuccess: Login successful!\n      refreshQR: Refresh QR Code\n      waiting: Waiting...\n      failedToGenerateQR: Failed to generate QR code\n      cookieInstructions: \"How to get cookies:\"\n      step1: Open bilibili.com and login\n      step2: Press F12 to open DevTools\n      step3: Go to Application tab → Cookies\n      step4: Copy the values below\n      fillOneField: Please fill in at least one field\n      failedToSave: Failed to save cookie\n      pasteSessdata: Paste SESSDATA value\n      pasteBiliJct: Paste bili_jct value\n      pasteDedeUserId: Paste DedeUserID value\n    xiaohongshu:\n      title: Xiaohongshu\n      sessionSaved: Session cookies saved\n      loggedInAs: Logged in as {{username}}\n      desc: Required for downloading videos and images\n      loginInstructions: \"Click below to open a login window:\"\n      scanWithApp: Scan QR with Xiaohongshu app\n      orLoginPhone: Or login with phone number\n      closeWhenDone: Close the window when done\n      openLoginWindow: Open Login Window\n      opening: Opening...\n      failedToOpenLogin: Failed to open login window\n    logoutSuccess: Logged out successfully\n    logoutFailed: Failed to logout\n    loginSuccess: Login successful!\n    welcome: Welcome, {{username}}!\n    docker:\n      title: Docker Server (YouTube)\n      desc: Configure vget-server for YouTube downloads. Run the Docker container or vget-server locally.\n      serverUrl: Server URL\n      serverUrlHint: \"URL of the vget-server (default: http://localhost:8080)\"\n      jwtToken: JWT Token (optional)\n      jwtPlaceholder: Paste JWT token if server requires authentication\n      jwtHint: \"Only needed if the server has api_key configured. Get token from: POST /api/auth/token\"\n      testConnection: Test Connection\n      connectionSuccess: Connected to Docker server!\n      connectionFailed: Failed to connect to Docker server\n      saved: Docker server settings saved\n      notRunningHint: \"Docker server is not running. Start it with: docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\"\n  about:\n    title: About VGet\n    desc: Version and update information\n    version: Version\n    checkForUpdates: Check for Updates\n    checking: Checking...\n    updateTo: Update to v{{version}}\n    downloading: Downloading...\n    latestVersion: You're on the latest version\n    links: Links\n    githubRepo: GitHub Repository\n    reportIssue: Report an Issue\nmediaTools:\n  title: Media Tools\n  tools:\n    convert:\n      title: Convert Video\n      desc: Convert between video formats (MP4, WebM, MKV, MOV)\n    compress:\n      title: Compress Video\n      desc: Reduce file size while maintaining quality\n    trim:\n      title: Trim Video\n      desc: Cut clips from videos with start and end times\n    extractAudio:\n      title: Extract Audio\n      desc: Extract audio track from video files\n    extractFrames:\n      title: Extract Frames\n      desc: Extract images or thumbnails from video\n    audioConvert:\n      title: Convert Audio\n      desc: Convert between audio formats (MP3, AAC, FLAC, WAV)\n  operationComplete: Operation completed successfully!\npdfTools:\n  title: PDF Tools\n  tools:\n    merge:\n      title: Merge PDFs\n      desc: Combine multiple PDF files into one\n    imagesToPdf:\n      title: Images to PDF\n      desc: Convert images to a single PDF document\n    deletePages:\n      title: Delete Pages\n      desc: Remove specific pages from a PDF\n    removeWatermark:\n      title: Remove Watermark\n      desc: Try to remove watermarks from a PDF\n    md2pdf:\n      title: Markdown to PDF\n      desc: Convert Markdown files to beautifully formatted PDFs\n      inputFile: Markdown File\n      selectFile: Select Markdown File\n      dropHint: Drop a Markdown file here or click to select\n      dropError: Please use the file picker for this file\n      invalidFile: Please drop a Markdown file (.md, .markdown, .txt)\n      change: Change\n      theme: Theme\n      themeLight: Light (GitHub Style)\n      themeDark: Dark\n      pageSize: Page Size\n      output: Output\n      convert: Convert to PDF\n      success: Markdown converted to PDF successfully!\n      clickToReveal: Click below to reveal the file\n      convertAnother: Convert Another\ncommon:\n  loading: Loading...\n  error: Error\n  success: Success\n  cancel: Cancel\n  save: Save\n  delete: Delete\n  add: Add\n  edit: Edit\n  close: Close\n  confirm: Confirm\n  \"yes\": \"Yes\"\n  \"no\": \"No\"\n"
  },
  {
    "path": "tauri/src/i18n/locales/es.yml",
    "content": "nav:\n  download: Descargar\n  mediaTools: Herramientas de Medios\n  pdfTools: Herramientas PDF\n  settings: Configuración\n  expandMenu: Expandir menú\n  vgetDesktop: VGet Escritorio\nhome:\n  title: Descargar\n  urlPlaceholder: Pega la URL del video aquí...\n  download: Descargar\n  extracting: Extrayendo...\n  noFormats: No se encontraron formatos descargables\n  downloadStarted: Descarga iniciada\n  supportsHint: Soporta Twitter/X, Bilibili, Xiaohongshu, YouTube, Apple Podcasts y URLs directas\n  downloads: Descargas\n  clearCompleted: Limpiar completados\n  openFolder: Abrir carpeta\n  failedToOpenFolder: Error al abrir la carpeta\n  noDownloadsYet: No hay descargas aún. Pega una URL arriba para comenzar.\n  startingDownload: Iniciando descarga...\n  failedToCancel: Error al cancelar descarga\n  bulkDownloadTitle: Descarga Masiva\n  bulkDownloadHint: Arrastra un archivo .txt con URLs o haz clic para seleccionar\n  dropTxtFile: Por favor arrastra un archivo .txt con URLs\n  dropMdHint: Para archivos Markdown, ve a Herramientas PDF → Markdown a PDF\n  noUrlsInFile: No se encontraron URLs válidas en el archivo\n  foundUrls: Se encontraron {{count}} URLs\n  processingBulk: Procesando {{current}}/{{total}}\n  failedToReadFile: Error al leer el archivo\n  dockerNotRunning: Las descargas de YouTube requieren vget-server. Por favor ejecuta el contenedor Docker o inicia el servidor.\n  dockerAuthRequired: El servidor Docker requiere autenticación. Ve a Configuración → Sitios → Servidor Docker para agregar tu token JWT.\n  youtubeDownloadStarted: Descarga de YouTube iniciada a través del servidor Docker\n  downloadComplete: ¡Descarga completada!\nsettings:\n  back: Volver\n  save: Guardar\n  saving: Guardando...\n  unsavedChanges: Cambios sin guardar\n  loading: Cargando configuración...\n  loadFailed: Error al cargar configuración\n  sections:\n    general: General\n    sites: Sitios\n    about: Acerca de\n  general:\n    downloads: Descargas\n    downloadsDesc: Configurar preferencias de descarga\n    downloadLocation: Ubicación de Descarga\n    downloadLocationHint: Donde se guardarán los archivos descargados\n    selectDirectory: Seleccionar Directorio de Descarga\n    defaultFormat: Formato Predeterminado\n    selectFormat: Seleccionar formato\n    bestAvailable: Mejor Disponible\n    defaultQuality: Calidad Predeterminada\n    selectQuality: Seleccionar calidad\n    qualityHint: Calidad de video preferida cuando hay múltiples opciones\n    language: Idioma\n    languageDesc: Idioma de la aplicación\n    selectLanguage: Seleccionar idioma\n    theme: Tema\n    themeDesc: Elige tu apariencia preferida\n    selectTheme: Seleccionar tema\n    light: Claro\n    dark: Oscuro\n    system: Sistema\n  sites:\n    twitter:\n      title: Twitter / X\n      desc: Requerido para descargar contenido NSFW o protegido\n      authToken: Token de Autenticación\n      tokenPlaceholder: Ingresa el valor de tu cookie auth_token\n      tokenHint: Encuéntralo en las cookies de tu navegador después de iniciar sesión en Twitter/X\n    bilibili:\n      title: Bilibili\n      loggedInAs: Conectado como {{username}}\n      loggedIn: Conectado\n      desc: Requerido para descargas de alta calidad y contenido para miembros\n      logout: Cerrar sesión\n      checkingStatus: Verificando estado de sesión...\n      qrCode: Código QR\n      cookie: Cookie\n      scanWithApp: Escanea con la app de Bilibili\n      confirmLogin: Confirma el inicio de sesión en tu teléfono\n      qrExpired: Código QR expirado\n      loginSuccess: ¡Inicio de sesión exitoso!\n      refreshQR: Actualizar Código QR\n      waiting: Esperando...\n      failedToGenerateQR: Error al generar código QR\n      cookieInstructions: \"Cómo obtener cookies:\"\n      step1: Abre bilibili.com e inicia sesión\n      step2: Presiona F12 para abrir DevTools\n      step3: Ve a la pestaña Application → Cookies\n      step4: Copia los valores a continuación\n      fillOneField: Por favor completa al menos un campo\n      failedToSave: Error al guardar cookie\n      pasteSessdata: Pega el valor de SESSDATA\n      pasteBiliJct: Pega el valor de bili_jct\n      pasteDedeUserId: Pega el valor de DedeUserID\n    xiaohongshu:\n      title: Xiaohongshu\n      sessionSaved: Cookies de sesión guardadas\n      loggedInAs: Conectado como {{username}}\n      desc: Requerido para descargar videos e imágenes\n      loginInstructions: \"Haz clic abajo para abrir una ventana de inicio de sesión:\"\n      scanWithApp: Escanea el QR con la app de Xiaohongshu\n      orLoginPhone: O inicia sesión con número de teléfono\n      closeWhenDone: Cierra la ventana cuando termines\n      openLoginWindow: Abrir Ventana de Inicio de Sesión\n      opening: Abriendo...\n      failedToOpenLogin: Error al abrir ventana de inicio de sesión\n    logoutSuccess: Sesión cerrada exitosamente\n    logoutFailed: Error al cerrar sesión\n    loginSuccess: ¡Inicio de sesión exitoso!\n    welcome: ¡Bienvenido, {{username}}!\n    docker:\n      title: Servidor Docker (YouTube)\n      desc: Configura vget-server para descargas de YouTube. Ejecuta el contenedor Docker o vget-server localmente.\n      serverUrl: URL del servidor\n      serverUrlHint: \"URL del vget-server (por defecto: http://localhost:8080)\"\n      jwtToken: Token JWT (opcional)\n      jwtPlaceholder: Pega el token JWT si el servidor requiere autenticación\n      jwtHint: \"Solo necesario si el servidor tiene api_key configurado. Obtener token: POST /api/auth/token\"\n      testConnection: Probar conexión\n      connectionSuccess: ¡Conectado al servidor Docker!\n      connectionFailed: Error al conectar con el servidor Docker\n      saved: Configuración del servidor Docker guardada\n      notRunningHint: \"El servidor Docker no está ejecutándose. Inícialo con: docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\"\n  about:\n    title: Acerca de VGet\n    desc: Información de versión y actualizaciones\n    version: Versión\n    checkForUpdates: Buscar Actualizaciones\n    checking: Verificando...\n    updateTo: Actualizar a v{{version}}\n    downloading: Descargando...\n    latestVersion: Tienes la última versión\n    links: Enlaces\n    githubRepo: Repositorio GitHub\n    reportIssue: Reportar un Problema\nmediaTools:\n  title: Herramientas de Medios\n  tools:\n    convert:\n      title: Convertir Video\n      desc: Convierte entre formatos de video (MP4, WebM, MKV, MOV)\n    compress:\n      title: Comprimir Video\n      desc: Reduce el tamaño del archivo manteniendo la calidad\n    trim:\n      title: Recortar Video\n      desc: Corta clips de videos con tiempos de inicio y fin\n    extractAudio:\n      title: Extraer Audio\n      desc: Extrae la pista de audio de archivos de video\n    extractFrames:\n      title: Extraer Fotogramas\n      desc: Extrae imágenes o miniaturas del video\n    audioConvert:\n      title: Convertir Audio\n      desc: Convierte entre formatos de audio (MP3, AAC, FLAC, WAV)\n  operationComplete: ¡Operación completada exitosamente!\npdfTools:\n  title: Herramientas PDF\n  tools:\n    merge:\n      title: Fusionar PDFs\n      desc: Combina múltiples archivos PDF en uno\n    imagesToPdf:\n      title: Imágenes a PDF\n      desc: Convierte imágenes a un solo documento PDF\n    deletePages:\n      title: Eliminar Páginas\n      desc: Elimina páginas específicas de un PDF\n    removeWatermark:\n      title: Eliminar Marca de Agua\n      desc: Intenta eliminar marcas de agua de un PDF\n    md2pdf:\n      title: Markdown a PDF\n      desc: Convierte archivos Markdown a PDFs con formato elegante\n      inputFile: Archivo Markdown\n      selectFile: Seleccionar Archivo Markdown\n      dropHint: Arrastra un archivo Markdown aquí o haz clic para seleccionar\n      dropError: Por favor usa el selector de archivos para este archivo\n      invalidFile: Por favor arrastra un archivo Markdown (.md, .markdown, .txt)\n      change: Cambiar\n      theme: Tema\n      themeLight: Claro (Estilo GitHub)\n      themeDark: Oscuro\n      pageSize: Tamaño de Página\n      output: Salida\n      convert: Convertir a PDF\n      success: ¡Markdown convertido a PDF exitosamente!\n      clickToReveal: Haz clic abajo para mostrar el archivo\n      convertAnother: Convertir Otro\ncommon:\n  loading: Cargando...\n  error: Error\n  success: Éxito\n  cancel: Cancelar\n  save: Guardar\n  delete: Eliminar\n  add: Añadir\n  edit: Editar\n  close: Cerrar\n  confirm: Confirmar\n  \"yes\": Sí\n  \"no\": \"No\"\n"
  },
  {
    "path": "tauri/src/i18n/locales/fr.yml",
    "content": "nav:\n  download: Télécharger\n  mediaTools: Outils Média\n  pdfTools: Outils PDF\n  settings: Paramètres\n  expandMenu: Développer le menu\n  vgetDesktop: VGet Bureau\nhome:\n  title: Télécharger\n  urlPlaceholder: Collez l'URL de la vidéo ici...\n  download: Télécharger\n  extracting: Extraction...\n  noFormats: Aucun format téléchargeable trouvé\n  downloadStarted: Téléchargement démarré\n  supportsHint: Prend en charge Twitter/X, Bilibili, Xiaohongshu, YouTube, Apple Podcasts et les URLs directes\n  downloads: Téléchargements\n  clearCompleted: Effacer terminés\n  openFolder: Ouvrir le dossier\n  failedToOpenFolder: Impossible d'ouvrir le dossier\n  noDownloadsYet: Aucun téléchargement pour l'instant. Collez une URL ci-dessus pour commencer.\n  startingDownload: Démarrage du téléchargement...\n  failedToCancel: Échec de l'annulation du téléchargement\n  bulkDownloadTitle: Téléchargement en Masse\n  bulkDownloadHint: Déposez un fichier .txt avec des URLs ou cliquez pour sélectionner\n  dropTxtFile: Veuillez déposer un fichier .txt contenant des URLs\n  dropMdHint: Pour les fichiers Markdown, allez dans Outils PDF → Markdown en PDF\n  noUrlsInFile: Aucune URL valide trouvée dans le fichier\n  foundUrls: \"{{count}} URLs trouvées\"\n  processingBulk: Traitement {{current}}/{{total}}\n  failedToReadFile: Échec de la lecture du fichier\n  dockerNotRunning: Les téléchargements YouTube nécessitent vget-server. Veuillez exécuter le conteneur Docker ou démarrer le serveur.\n  dockerAuthRequired: Le serveur Docker nécessite une authentification. Allez dans Paramètres → Sites → Serveur Docker pour ajouter votre token JWT.\n  youtubeDownloadStarted: Téléchargement YouTube démarré via le serveur Docker\n  downloadComplete: Téléchargement terminé !\nsettings:\n  back: Retour\n  save: Enregistrer\n  saving: Enregistrement...\n  unsavedChanges: Modifications non enregistrées\n  loading: Chargement des paramètres...\n  loadFailed: Échec du chargement des paramètres\n  sections:\n    general: Général\n    sites: Sites\n    about: À propos\n  general:\n    downloads: Téléchargements\n    downloadsDesc: Configurer les préférences de téléchargement\n    downloadLocation: Emplacement de Téléchargement\n    downloadLocationHint: Où les fichiers téléchargés seront enregistrés\n    selectDirectory: Sélectionner le Répertoire de Téléchargement\n    defaultFormat: Format par Défaut\n    selectFormat: Sélectionner le format\n    bestAvailable: Meilleur Disponible\n    defaultQuality: Qualité par Défaut\n    selectQuality: Sélectionner la qualité\n    qualityHint: Qualité vidéo préférée lorsque plusieurs options sont disponibles\n    language: Langue\n    languageDesc: Langue d'affichage de l'application\n    selectLanguage: Sélectionner la langue\n    theme: Thème\n    themeDesc: Choisissez votre apparence préférée\n    selectTheme: Sélectionner le thème\n    light: Clair\n    dark: Sombre\n    system: Système\n  sites:\n    twitter:\n      title: Twitter / X\n      desc: Requis pour télécharger du contenu NSFW ou protégé\n      authToken: Jeton d'Authentification\n      tokenPlaceholder: Entrez la valeur de votre cookie auth_token\n      tokenHint: Trouvez-le dans les cookies de votre navigateur après vous être connecté à Twitter/X\n    bilibili:\n      title: Bilibili\n      loggedInAs: Connecté en tant que {{username}}\n      loggedIn: Connecté\n      desc: Requis pour les téléchargements haute qualité et le contenu réservé aux membres\n      logout: Déconnexion\n      checkingStatus: Vérification du statut de connexion...\n      qrCode: Code QR\n      cookie: Cookie\n      scanWithApp: Scanner avec l'app Bilibili\n      confirmLogin: Confirmez la connexion sur votre téléphone\n      qrExpired: Code QR expiré\n      loginSuccess: Connexion réussie !\n      refreshQR: Actualiser le Code QR\n      waiting: En attente...\n      failedToGenerateQR: Échec de la génération du code QR\n      cookieInstructions: \"Comment obtenir les cookies :\"\n      step1: Ouvrez bilibili.com et connectez-vous\n      step2: Appuyez sur F12 pour ouvrir DevTools\n      step3: Allez dans l'onglet Application → Cookies\n      step4: Copiez les valeurs ci-dessous\n      fillOneField: Veuillez remplir au moins un champ\n      failedToSave: Échec de l'enregistrement du cookie\n      pasteSessdata: Collez la valeur SESSDATA\n      pasteBiliJct: Collez la valeur bili_jct\n      pasteDedeUserId: Collez la valeur DedeUserID\n    xiaohongshu:\n      title: Xiaohongshu\n      sessionSaved: Cookies de session enregistrés\n      loggedInAs: Connecté en tant que {{username}}\n      desc: Requis pour télécharger des vidéos et des images\n      loginInstructions: \"Cliquez ci-dessous pour ouvrir une fenêtre de connexion :\"\n      scanWithApp: Scanner le QR avec l'app Xiaohongshu\n      orLoginPhone: Ou connectez-vous avec votre numéro de téléphone\n      closeWhenDone: Fermez la fenêtre une fois terminé\n      openLoginWindow: Ouvrir la Fenêtre de Connexion\n      opening: Ouverture...\n      failedToOpenLogin: Impossible d'ouvrir la fenêtre de connexion\n    logoutSuccess: Déconnexion réussie\n    logoutFailed: Échec de la déconnexion\n    loginSuccess: Connexion réussie !\n    welcome: Bienvenue, {{username}} !\n    docker:\n      title: Serveur Docker (YouTube)\n      desc: Configurez vget-server pour les téléchargements YouTube. Exécutez le conteneur Docker ou vget-server localement.\n      serverUrl: URL du serveur\n      serverUrlHint: \"URL du vget-server (par défaut : http://localhost:8080)\"\n      jwtToken: Token JWT (optionnel)\n      jwtPlaceholder: Collez le token JWT si le serveur nécessite une authentification\n      jwtHint: \"Nécessaire uniquement si le serveur a api_key configuré. Obtenir le token : POST /api/auth/token\"\n      testConnection: Tester la connexion\n      connectionSuccess: Connecté au serveur Docker !\n      connectionFailed: Échec de la connexion au serveur Docker\n      saved: Paramètres du serveur Docker enregistrés\n      notRunningHint: \"Le serveur Docker n'est pas en cours d'exécution. Démarrez-le avec : docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\"\n  about:\n    title: À propos de VGet\n    desc: Informations de version et mises à jour\n    version: Version\n    checkForUpdates: Rechercher des Mises à Jour\n    checking: Vérification...\n    updateTo: Mettre à jour vers v{{version}}\n    downloading: Téléchargement...\n    latestVersion: Vous avez la dernière version\n    links: Liens\n    githubRepo: Dépôt GitHub\n    reportIssue: Signaler un Problème\nmediaTools:\n  title: Outils Média\n  tools:\n    convert:\n      title: Convertir Vidéo\n      desc: Convertir entre les formats vidéo (MP4, WebM, MKV, MOV)\n    compress:\n      title: Compresser Vidéo\n      desc: Réduire la taille du fichier en maintenant la qualité\n    trim:\n      title: Couper Vidéo\n      desc: Couper des clips de vidéos avec des temps de début et de fin\n    extractAudio:\n      title: Extraire Audio\n      desc: Extraire la piste audio des fichiers vidéo\n    extractFrames:\n      title: Extraire Images\n      desc: Extraire des images ou des miniatures de la vidéo\n    audioConvert:\n      title: Convertir Audio\n      desc: Convertir entre les formats audio (MP3, AAC, FLAC, WAV)\n  operationComplete: Opération terminée avec succès !\npdfTools:\n  title: Outils PDF\n  tools:\n    merge:\n      title: Fusionner PDFs\n      desc: Combiner plusieurs fichiers PDF en un seul\n    imagesToPdf:\n      title: Images en PDF\n      desc: Convertir des images en un seul document PDF\n    deletePages:\n      title: Supprimer Pages\n      desc: Supprimer des pages spécifiques d'un PDF\n    removeWatermark:\n      title: Supprimer Filigrane\n      desc: Essayer de supprimer les filigranes d'un PDF\n    md2pdf:\n      title: Markdown en PDF\n      desc: Convertir des fichiers Markdown en PDF formatés\n      inputFile: Fichier Markdown\n      selectFile: Sélectionner un Fichier Markdown\n      dropHint: Déposez un fichier Markdown ici ou cliquez pour sélectionner\n      dropError: Veuillez utiliser le sélecteur de fichiers pour ce fichier\n      invalidFile: Veuillez déposer un fichier Markdown (.md, .markdown, .txt)\n      change: Changer\n      theme: Thème\n      themeLight: Clair (Style GitHub)\n      themeDark: Sombre\n      pageSize: Taille de Page\n      output: Sortie\n      convert: Convertir en PDF\n      success: Markdown converti en PDF avec succès !\n      clickToReveal: Cliquez ci-dessous pour révéler le fichier\n      convertAnother: Convertir un Autre\ncommon:\n  loading: Chargement...\n  error: Erreur\n  success: Succès\n  cancel: Annuler\n  save: Enregistrer\n  delete: Supprimer\n  add: Ajouter\n  edit: Modifier\n  close: Fermer\n  confirm: Confirmer\n  \"yes\": Oui\n  \"no\": Non\n"
  },
  {
    "path": "tauri/src/i18n/locales/jp.yml",
    "content": "nav:\n  download: ダウンロード\n  mediaTools: メディアツール\n  pdfTools: PDFツール\n  settings: 設定\n  expandMenu: メニューを展開\n  vgetDesktop: VGet デスクトップ\nhome:\n  title: ダウンロード\n  urlPlaceholder: 動画のURLをここに貼り付け...\n  download: ダウンロード\n  extracting: 解析中...\n  noFormats: ダウンロード可能な形式が見つかりません\n  downloadStarted: ダウンロードを開始しました\n  supportsHint: Twitter/X、Bilibili、小紅書、YouTube、Apple Podcastsおよび直接URLに対応\n  downloads: ダウンロード一覧\n  clearCompleted: 完了を消去\n  openFolder: フォルダを開く\n  failedToOpenFolder: フォルダを開けませんでした\n  noDownloadsYet: ダウンロードはまだありません。上にURLを貼り付けて開始してください。\n  startingDownload: ダウンロードを開始中...\n  failedToCancel: ダウンロードのキャンセルに失敗しました\n  bulkDownloadTitle: 一括ダウンロード\n  bulkDownloadHint: URLを含む.txtファイルをドロップ、またはクリックして選択\n  dropTxtFile: URLを含む.txtファイルをドロップしてください\n  dropMdHint: Markdownファイルは PDFツール → MarkdownからPDF へ\n  noUrlsInFile: ファイルに有効なURLが見つかりません\n  foundUrls: \"{{count}}件のURLが見つかりました\"\n  processingBulk: 処理中 {{current}}/{{total}}\n  failedToReadFile: ファイルの読み込みに失敗しました\n  dockerNotRunning: YouTubeダウンロードにはvget-serverが必要です。Dockerコンテナを実行するか、サーバーを起動してください。\n  dockerAuthRequired: Dockerサーバーには認証が必要です。設定 → サイト → Dockerサーバー でJWTトークンを追加してください。\n  youtubeDownloadStarted: YouTubeダウンロードがDockerサーバー経由で開始されました\n  downloadComplete: ダウンロード完了！\nsettings:\n  back: 戻る\n  save: 保存\n  saving: 保存中...\n  unsavedChanges: 未保存の変更があります\n  loading: 設定を読み込み中...\n  loadFailed: 設定の読み込みに失敗しました\n  sections:\n    general: 一般\n    sites: サイト\n    about: 情報\n  general:\n    downloads: ダウンロード\n    downloadsDesc: ダウンロード設定を構成\n    downloadLocation: ダウンロード場所\n    downloadLocationHint: ダウンロードしたファイルの保存先\n    selectDirectory: ダウンロードディレクトリを選択\n    defaultFormat: デフォルト形式\n    selectFormat: 形式を選択\n    bestAvailable: 最高画質\n    defaultQuality: デフォルト画質\n    selectQuality: 画質を選択\n    qualityHint: 複数のオプションがある場合の優先ビデオ画質\n    language: 言語\n    languageDesc: アプリの表示言語\n    selectLanguage: 言語を選択\n    theme: テーマ\n    themeDesc: お好みの外観を選択\n    selectTheme: テーマを選択\n    light: ライト\n    dark: ダーク\n    system: システム\n  sites:\n    twitter:\n      title: Twitter / X\n      desc: NSFWまたは保護されたコンテンツのダウンロードに必要\n      authToken: 認証トークン\n      tokenPlaceholder: auth_token cookieの値を入力\n      tokenHint: Twitter/Xにログイン後、ブラウザのcookieで確認できます\n    bilibili:\n      title: Bilibili\n      loggedInAs: \"{{username}}としてログイン中\"\n      loggedIn: ログイン済み\n      desc: 高画質ダウンロードや会員限定コンテンツに必要\n      logout: ログアウト\n      checkingStatus: ログイン状態を確認中...\n      qrCode: QRコード\n      cookie: Cookie\n      scanWithApp: Bilibiliアプリでスキャン\n      confirmLogin: スマートフォンでログインを確認\n      qrExpired: QRコードの有効期限が切れました\n      loginSuccess: ログイン成功！\n      refreshQR: QRコードを更新\n      waiting: 待機中...\n      failedToGenerateQR: QRコードの生成に失敗しました\n      cookieInstructions: Cookieの取得方法：\n      step1: bilibili.comを開いてログイン\n      step2: F12キーを押して開発者ツールを開く\n      step3: Applicationタブ → Cookiesへ移動\n      step4: 以下の値をコピー\n      fillOneField: 少なくとも1つのフィールドを入力してください\n      failedToSave: Cookieの保存に失敗しました\n      pasteSessdata: SESSDAの値を貼り付け\n      pasteBiliJct: bili_jctの値を貼り付け\n      pasteDedeUserId: DedeUserIDの値を貼り付け\n    xiaohongshu:\n      title: 小紅書\n      sessionSaved: セッションCookieが保存されました\n      loggedInAs: \"{{username}}としてログイン中\"\n      desc: 動画と画像のダウンロードに必要\n      loginInstructions: 下のボタンをクリックしてログインウィンドウを開く：\n      scanWithApp: 小紅書アプリでQRをスキャン\n      orLoginPhone: または電話番号でログイン\n      closeWhenDone: 完了したらウィンドウを閉じる\n      openLoginWindow: ログインウィンドウを開く\n      opening: 開いています...\n      failedToOpenLogin: ログインウィンドウを開けませんでした\n    logoutSuccess: ログアウトしました\n    logoutFailed: ログアウトに失敗しました\n    loginSuccess: ログイン成功！\n    welcome: ようこそ、{{username}}さん！\n    docker:\n      title: Dockerサーバー（YouTube）\n      desc: YouTubeダウンロード用のvget-serverを設定します。Dockerコンテナまたはローカルでvget-serverを実行してください。\n      serverUrl: サーバーURL\n      serverUrlHint: \"vget-serverのURL（デフォルト: http://localhost:8080）\"\n      jwtToken: JWTトークン（オプション）\n      jwtPlaceholder: サーバーが認証を必要とする場合はJWTトークンを貼り付けてください\n      jwtHint: \"サーバーにapi_keyが設定されている場合のみ必要です。トークン取得: POST /api/auth/token\"\n      testConnection: 接続テスト\n      connectionSuccess: Dockerサーバーに接続しました！\n      connectionFailed: Dockerサーバーへの接続に失敗しました\n      saved: Dockerサーバー設定を保存しました\n      notRunningHint: \"Dockerサーバーが実行されていません。起動コマンド: docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\"\n  about:\n    title: VGetについて\n    desc: バージョンと更新情報\n    version: バージョン\n    checkForUpdates: 更新を確認\n    checking: 確認中...\n    updateTo: v{{version}}に更新\n    downloading: ダウンロード中...\n    latestVersion: 最新バージョンです\n    links: リンク\n    githubRepo: GitHubリポジトリ\n    reportIssue: 問題を報告\nmediaTools:\n  title: メディアツール\n  tools:\n    convert:\n      title: 動画変換\n      desc: 動画形式間で変換 (MP4, WebM, MKV, MOV)\n    compress:\n      title: 動画圧縮\n      desc: 品質を維持しながらファイルサイズを削減\n    trim:\n      title: 動画トリミング\n      desc: 開始時間と終了時間でクリップを切り出す\n    extractAudio:\n      title: 音声抽出\n      desc: 動画ファイルから音声トラックを抽出\n    extractFrames:\n      title: フレーム抽出\n      desc: 動画から画像やサムネイルを抽出\n    audioConvert:\n      title: 音声変換\n      desc: 音声形式間で変換 (MP3, AAC, FLAC, WAV)\n  operationComplete: 操作が正常に完了しました！\npdfTools:\n  title: PDFツール\n  tools:\n    merge:\n      title: PDF結合\n      desc: 複数のPDFファイルを1つに結合\n    imagesToPdf:\n      title: 画像からPDF\n      desc: 画像を1つのPDFドキュメントに変換\n    deletePages:\n      title: ページ削除\n      desc: PDFから特定のページを削除\n    removeWatermark:\n      title: 透かし削除\n      desc: PDFから透かしを削除を試みる\n    md2pdf:\n      title: MarkdownからPDF\n      desc: Markdownファイルを美しいPDFに変換\n      inputFile: Markdownファイル\n      selectFile: Markdownファイルを選択\n      dropHint: Markdownファイルをここにドロップ、またはクリックして選択\n      dropError: このファイルはファイルピッカーをご使用ください\n      invalidFile: Markdownファイルをドロップしてください (.md, .markdown, .txt)\n      change: 変更\n      theme: テーマ\n      themeLight: ライト (GitHubスタイル)\n      themeDark: ダーク\n      pageSize: ページサイズ\n      output: 出力\n      convert: PDFに変換\n      success: MarkdownをPDFに変換しました！\n      clickToReveal: 下をクリックしてファイルを表示\n      convertAnother: 別のファイルを変換\ncommon:\n  loading: 読み込み中...\n  error: エラー\n  success: 成功\n  cancel: キャンセル\n  save: 保存\n  delete: 削除\n  add: 追加\n  edit: 編集\n  close: 閉じる\n  confirm: 確認\n  \"yes\": はい\n  \"no\": いいえ\n"
  },
  {
    "path": "tauri/src/i18n/locales/kr.yml",
    "content": "nav:\n  download: 다운로드\n  mediaTools: 미디어 도구\n  pdfTools: PDF 도구\n  settings: 설정\n  expandMenu: 메뉴 펼치기\n  vgetDesktop: VGet 데스크톱\nhome:\n  title: 다운로드\n  urlPlaceholder: 여기에 비디오 URL을 붙여넣기...\n  download: 다운로드\n  extracting: 추출 중...\n  noFormats: 다운로드 가능한 형식을 찾을 수 없습니다\n  downloadStarted: 다운로드가 시작되었습니다\n  supportsHint: Twitter/X, Bilibili, 샤오홍슈, YouTube, Apple Podcasts 및 직접 URL 지원\n  downloads: 다운로드 목록\n  clearCompleted: 완료 항목 지우기\n  openFolder: 폴더 열기\n  failedToOpenFolder: 폴더를 열 수 없습니다\n  noDownloadsYet: 아직 다운로드가 없습니다. 위에 URL을 붙여넣어 시작하세요.\n  startingDownload: 다운로드 시작 중...\n  failedToCancel: 다운로드 취소 실패\n  bulkDownloadTitle: 일괄 다운로드\n  bulkDownloadHint: URL이 포함된 .txt 파일을 드롭하거나 클릭하여 선택\n  dropTxtFile: URL이 포함된 .txt 파일을 드롭해 주세요\n  dropMdHint: Markdown 파일은 PDF 도구 → Markdown을 PDF로 이동하세요\n  noUrlsInFile: 파일에서 유효한 URL을 찾을 수 없습니다\n  foundUrls: \"{{count}}개의 URL을 찾았습니다\"\n  processingBulk: 처리 중 {{current}}/{{total}}\n  failedToReadFile: 파일 읽기 실패\n  dockerNotRunning: YouTube 다운로드에는 vget-server가 필요합니다. Docker 컨테이너를 실행하거나 서버를 시작하세요.\n  dockerAuthRequired: Docker 서버에 인증이 필요합니다. 설정 → 사이트 → Docker 서버에서 JWT 토큰을 추가하세요.\n  youtubeDownloadStarted: Docker 서버를 통해 YouTube 다운로드가 시작되었습니다\n  downloadComplete: 다운로드 완료!\nsettings:\n  back: 뒤로\n  save: 저장\n  saving: 저장 중...\n  unsavedChanges: 저장되지 않은 변경사항\n  loading: 설정 로드 중...\n  loadFailed: 설정을 로드하지 못했습니다\n  sections:\n    general: 일반\n    sites: 사이트\n    about: 정보\n  general:\n    downloads: 다운로드\n    downloadsDesc: 다운로드 환경설정 구성\n    downloadLocation: 다운로드 위치\n    downloadLocationHint: 다운로드한 파일이 저장될 위치\n    selectDirectory: 다운로드 디렉토리 선택\n    defaultFormat: 기본 형식\n    selectFormat: 형식 선택\n    bestAvailable: 최상\n    defaultQuality: 기본 화질\n    selectQuality: 화질 선택\n    qualityHint: 여러 옵션이 있을 때 선호하는 비디오 화질\n    language: 언어\n    languageDesc: 앱 표시 언어\n    selectLanguage: 언어 선택\n    theme: 테마\n    themeDesc: 선호하는 외관 선택\n    selectTheme: 테마 선택\n    light: 라이트\n    dark: 다크\n    system: 시스템\n  sites:\n    twitter:\n      title: Twitter / X\n      desc: NSFW 또는 보호된 콘텐츠 다운로드에 필요\n      authToken: 인증 토큰\n      tokenPlaceholder: auth_token 쿠키 값을 입력하세요\n      tokenHint: Twitter/X 로그인 후 브라우저 쿠키에서 찾으세요\n    bilibili:\n      title: Bilibili\n      loggedInAs: \"{{username}}(으)로 로그인됨\"\n      loggedIn: 로그인됨\n      desc: 고화질 다운로드 및 회원 전용 콘텐츠에 필요\n      logout: 로그아웃\n      checkingStatus: 로그인 상태 확인 중...\n      qrCode: QR 코드\n      cookie: 쿠키\n      scanWithApp: Bilibili 앱으로 스캔\n      confirmLogin: 휴대폰에서 로그인 확인\n      qrExpired: QR 코드가 만료되었습니다\n      loginSuccess: 로그인 성공!\n      refreshQR: QR 코드 새로고침\n      waiting: 대기 중...\n      failedToGenerateQR: QR 코드 생성 실패\n      cookieInstructions: \"쿠키 얻는 방법:\"\n      step1: bilibili.com을 열고 로그인\n      step2: F12를 눌러 개발자 도구 열기\n      step3: Application 탭 → Cookies로 이동\n      step4: 아래 값을 복사\n      fillOneField: 최소 하나의 필드를 입력하세요\n      failedToSave: 쿠키 저장 실패\n      pasteSessdata: SESSDATA 값 붙여넣기\n      pasteBiliJct: bili_jct 값 붙여넣기\n      pasteDedeUserId: DedeUserID 값 붙여넣기\n    xiaohongshu:\n      title: 샤오홍슈\n      sessionSaved: 세션 쿠키가 저장되었습니다\n      loggedInAs: \"{{username}}(으)로 로그인됨\"\n      desc: 비디오 및 이미지 다운로드에 필요\n      loginInstructions: \"아래를 클릭하여 로그인 창 열기:\"\n      scanWithApp: 샤오홍슈 앱으로 QR 스캔\n      orLoginPhone: 또는 전화번호로 로그인\n      closeWhenDone: 완료되면 창 닫기\n      openLoginWindow: 로그인 창 열기\n      opening: 여는 중...\n      failedToOpenLogin: 로그인 창을 열 수 없습니다\n    logoutSuccess: 성공적으로 로그아웃되었습니다\n    logoutFailed: 로그아웃 실패\n    loginSuccess: 로그인 성공!\n    welcome: 환영합니다, {{username}}님!\n    docker:\n      title: Docker 서버 (YouTube)\n      desc: YouTube 다운로드를 위한 vget-server를 구성합니다. Docker 컨테이너 또는 로컬에서 vget-server를 실행하세요.\n      serverUrl: 서버 URL\n      serverUrlHint: \"vget-server의 URL (기본값: http://localhost:8080)\"\n      jwtToken: JWT 토큰 (선택사항)\n      jwtPlaceholder: 서버가 인증을 요구하는 경우 JWT 토큰을 붙여넣으세요\n      jwtHint: \"서버에 api_key가 구성된 경우에만 필요합니다. 토큰 획득: POST /api/auth/token\"\n      testConnection: 연결 테스트\n      connectionSuccess: Docker 서버에 연결되었습니다!\n      connectionFailed: Docker 서버 연결 실패\n      saved: Docker 서버 설정이 저장되었습니다\n      notRunningHint: \"Docker 서버가 실행되고 있지 않습니다. 시작 명령: docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\"\n  about:\n    title: VGet 정보\n    desc: 버전 및 업데이트 정보\n    version: 버전\n    checkForUpdates: 업데이트 확인\n    checking: 확인 중...\n    updateTo: v{{version}}으로 업데이트\n    downloading: 다운로드 중...\n    latestVersion: 최신 버전입니다\n    links: 링크\n    githubRepo: GitHub 저장소\n    reportIssue: 문제 보고\nmediaTools:\n  title: 미디어 도구\n  tools:\n    convert:\n      title: 비디오 변환\n      desc: 비디오 형식 간 변환 (MP4, WebM, MKV, MOV)\n    compress:\n      title: 비디오 압축\n      desc: 품질을 유지하면서 파일 크기 줄이기\n    trim:\n      title: 비디오 자르기\n      desc: 시작 및 종료 시간으로 클립 자르기\n    extractAudio:\n      title: 오디오 추출\n      desc: 비디오 파일에서 오디오 트랙 추출\n    extractFrames:\n      title: 프레임 추출\n      desc: 비디오에서 이미지 또는 썸네일 추출\n    audioConvert:\n      title: 오디오 변환\n      desc: 오디오 형식 간 변환 (MP3, AAC, FLAC, WAV)\n  operationComplete: 작업이 성공적으로 완료되었습니다!\npdfTools:\n  title: PDF 도구\n  tools:\n    merge:\n      title: PDF 병합\n      desc: 여러 PDF 파일을 하나로 병합\n    imagesToPdf:\n      title: 이미지를 PDF로\n      desc: 이미지를 단일 PDF 문서로 변환\n    deletePages:\n      title: 페이지 삭제\n      desc: PDF에서 특정 페이지 삭제\n    removeWatermark:\n      title: 워터마크 제거\n      desc: PDF에서 워터마크 제거 시도\n    md2pdf:\n      title: Markdown을 PDF로\n      desc: Markdown 파일을 아름다운 PDF로 변환\n      inputFile: Markdown 파일\n      selectFile: Markdown 파일 선택\n      dropHint: Markdown 파일을 여기에 드롭하거나 클릭하여 선택\n      dropError: 이 파일은 파일 선택기를 사용해 주세요\n      invalidFile: Markdown 파일을 드롭해 주세요 (.md, .markdown, .txt)\n      change: 변경\n      theme: 테마\n      themeLight: 라이트 (GitHub 스타일)\n      themeDark: 다크\n      pageSize: 페이지 크기\n      output: 출력\n      convert: PDF로 변환\n      success: Markdown이 PDF로 변환되었습니다!\n      clickToReveal: 아래를 클릭하여 파일 표시\n      convertAnother: 다른 파일 변환\ncommon:\n  loading: 로딩 중...\n  error: 오류\n  success: 성공\n  cancel: 취소\n  save: 저장\n  delete: 삭제\n  add: 추가\n  edit: 편집\n  close: 닫기\n  confirm: 확인\n  \"yes\": 예\n  \"no\": 아니오\n"
  },
  {
    "path": "tauri/src/i18n/locales/zh.yml",
    "content": "nav:\n  download: 下载\n  mediaTools: 媒体工具\n  pdfTools: PDF工具\n  settings: 设置\n  expandMenu: 展开菜单\n  vgetDesktop: VGet 桌面版\nhome:\n  title: 下载\n  urlPlaceholder: 在此粘贴视频链接...\n  download: 下载\n  extracting: 解析中...\n  noFormats: 未找到可下载的格式\n  downloadStarted: 下载已开始\n  supportsHint: 支持 Twitter/X、Bilibili、小红书、YouTube、Apple Podcasts 及直接链接\n  downloads: 下载列表\n  clearCompleted: 清除已完成\n  openFolder: 打开文件夹\n  failedToOpenFolder: 无法打开文件夹\n  noDownloadsYet: 暂无下载任务。在上方粘贴链接开始下载。\n  startingDownload: 开始下载...\n  failedToCancel: 取消下载失败\n  bulkDownloadTitle: 批量下载\n  bulkDownloadHint: 拖放包含URL的.txt文件，或点击选择\n  dropTxtFile: 请拖放包含URL的.txt文件\n  dropMdHint: Markdown文件请前往 PDF工具 → Markdown转PDF\n  noUrlsInFile: 文件中未找到有效的URL\n  foundUrls: 找到 {{count}} 个URL\n  processingBulk: 正在处理 {{current}}/{{total}}\n  failedToReadFile: 读取文件失败\n  dockerNotRunning: YouTube下载需要vget服务器。请运行Docker容器或启动服务器。\n  dockerAuthRequired: Docker服务器需要认证。请前往 设置 → 站点 → Docker服务器 添加JWT令牌。\n  youtubeDownloadStarted: YouTube下载已通过Docker服务器启动\n  downloadComplete: 下载完成！\nsettings:\n  back: 返回\n  save: 保存\n  saving: 保存中...\n  unsavedChanges: 有未保存的更改\n  loading: 加载设置中...\n  loadFailed: 加载设置失败\n  sections:\n    general: 通用\n    sites: 站点\n    about: 关于\n  general:\n    downloads: 下载\n    downloadsDesc: 配置下载偏好设置\n    downloadLocation: 下载位置\n    downloadLocationHint: 下载文件的保存位置\n    selectDirectory: 选择下载目录\n    defaultFormat: 默认格式\n    selectFormat: 选择格式\n    bestAvailable: 最佳可用\n    defaultQuality: 默认画质\n    selectQuality: 选择画质\n    qualityHint: 当有多个选项时的首选视频画质\n    language: 语言\n    languageDesc: 应用显示语言\n    selectLanguage: 选择语言\n    theme: 主题\n    themeDesc: 选择您偏好的外观\n    selectTheme: 选择主题\n    light: 浅色\n    dark: 深色\n    system: 跟随系统\n  sites:\n    twitter:\n      title: Twitter / X\n      desc: 下载NSFW或受保护内容时需要\n      authToken: 认证令牌\n      tokenPlaceholder: 输入您的 auth_token cookie 值\n      tokenHint: 登录 Twitter/X 后在浏览器 cookie 中获取\n    bilibili:\n      title: 哔哩哔哩\n      loggedInAs: 已登录为 {{username}}\n      loggedIn: 已登录\n      desc: 下载高画质或会员内容时需要\n      logout: 退出登录\n      checkingStatus: 检查登录状态...\n      qrCode: 二维码\n      cookie: Cookie\n      scanWithApp: 使用哔哩哔哩App扫码\n      confirmLogin: 在手机上确认登录\n      qrExpired: 二维码已过期\n      loginSuccess: 登录成功！\n      refreshQR: 刷新二维码\n      waiting: 等待中...\n      failedToGenerateQR: 生成二维码失败\n      cookieInstructions: 如何获取 Cookie：\n      step1: 打开 bilibili.com 并登录\n      step2: 按 F12 打开开发者工具\n      step3: 前往 Application 标签 → Cookies\n      step4: 复制以下值\n      fillOneField: 请至少填写一个字段\n      failedToSave: 保存 Cookie 失败\n      pasteSessdata: 粘贴 SESSDATA 值\n      pasteBiliJct: 粘贴 bili_jct 值\n      pasteDedeUserId: 粘贴 DedeUserID 值\n    xiaohongshu:\n      title: 小红书\n      sessionSaved: 会话 Cookie 已保存\n      loggedInAs: 已登录为 {{username}}\n      desc: 下载视频和图片时需要\n      loginInstructions: 点击下方打开登录窗口：\n      scanWithApp: 使用小红书App扫码\n      orLoginPhone: 或使用手机号登录\n      closeWhenDone: 完成后关闭窗口\n      openLoginWindow: 打开登录窗口\n      opening: 打开中...\n      failedToOpenLogin: 无法打开登录窗口\n    logoutSuccess: 已成功退出登录\n    logoutFailed: 退出登录失败\n    loginSuccess: 登录成功！\n    welcome: 欢迎，{{username}}！\n    docker:\n      title: Docker 服务器 (YouTube)\n      desc: 配置 vget-server 以下载 YouTube 视频。运行 Docker 容器或本地启动 vget-server。\n      serverUrl: 服务器地址\n      serverUrlHint: vget-server 的地址（默认：http://localhost:8080）\n      jwtToken: JWT 令牌（可选）\n      jwtPlaceholder: 如服务器需要认证，请粘贴 JWT 令牌\n      jwtHint: 仅当服务器配置了 api_key 时需要。获取令牌：POST /api/auth/token\n      testConnection: 测试连接\n      connectionSuccess: 已连接到 Docker 服务器！\n      connectionFailed: 无法连接到 Docker 服务器\n      saved: Docker 服务器设置已保存\n      notRunningHint: Docker 服务器未运行。启动命令：docker run -p 8080:8080 ghcr.io/guiyumin/vget:latest\n  about:\n    title: 关于 VGet\n    desc: 版本和更新信息\n    version: 版本\n    checkForUpdates: 检查更新\n    checking: 检查中...\n    updateTo: 更新到 v{{version}}\n    downloading: 下载中...\n    latestVersion: 您已是最新版本\n    links: 链接\n    githubRepo: GitHub 仓库\n    reportIssue: 报告问题\nmediaTools:\n  title: 媒体工具\n  tools:\n    convert:\n      title: 转换视频\n      desc: 在视频格式之间转换 (MP4, WebM, MKV, MOV)\n    compress:\n      title: 压缩视频\n      desc: 在保持质量的同时减小文件大小\n    trim:\n      title: 裁剪视频\n      desc: 通过起止时间从视频中剪切片段\n    extractAudio:\n      title: 提取音频\n      desc: 从视频文件中提取音轨\n    extractFrames:\n      title: 提取帧\n      desc: 从视频中提取图片或缩略图\n    audioConvert:\n      title: 转换音频\n      desc: 在音频格式之间转换 (MP3, AAC, FLAC, WAV)\n  operationComplete: 操作成功完成！\npdfTools:\n  title: PDF工具\n  tools:\n    merge:\n      title: 合并PDF\n      desc: 将多个PDF文件合并为一个\n    imagesToPdf:\n      title: 图片转PDF\n      desc: 将图片转换为单个PDF文档\n    deletePages:\n      title: 删除页面\n      desc: 从PDF中删除指定页面\n    removeWatermark:\n      title: 去除水印\n      desc: 尝试从PDF中去除水印\n    md2pdf:\n      title: Markdown转PDF\n      desc: 将Markdown文件转换为精美的PDF文档\n      inputFile: Markdown文件\n      selectFile: 选择Markdown文件\n      dropHint: 拖放Markdown文件到此处，或点击选择\n      dropError: 请使用文件选择器选择此文件\n      invalidFile: 请拖放Markdown文件 (.md, .markdown, .txt)\n      change: 更换\n      theme: 主题\n      themeLight: 浅色 (GitHub风格)\n      themeDark: 深色\n      pageSize: 页面大小\n      output: 输出\n      convert: 转换为PDF\n      success: Markdown已成功转换为PDF！\n      clickToReveal: 点击下方在文件夹中显示\n      convertAnother: 转换其他文件\ncommon:\n  loading: 加载中...\n  error: 错误\n  success: 成功\n  cancel: 取消\n  save: 保存\n  delete: 删除\n  add: 添加\n  edit: 编辑\n  close: 关闭\n  confirm: 确认\n  \"yes\": 是\n  \"no\": 否\n"
  },
  {
    "path": "tauri/src/index.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n@plugin \"@tailwindcss/typography\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme {\n  --color-background: hsl(0 0% 100%);\n  --color-foreground: hsl(222.2 84% 4.9%);\n  --color-card: hsl(0 0% 100%);\n  --color-card-foreground: hsl(222.2 84% 4.9%);\n  --color-popover: hsl(0 0% 100%);\n  --color-popover-foreground: hsl(222.2 84% 4.9%);\n  --color-primary: hsl(222.2 47.4% 11.2%);\n  --color-primary-foreground: hsl(210 40% 98%);\n  --color-secondary: hsl(210 40% 96.1%);\n  --color-secondary-foreground: hsl(222.2 47.4% 11.2%);\n  --color-muted: hsl(210 40% 96.1%);\n  --color-muted-foreground: hsl(215.4 16.3% 46.9%);\n  --color-accent: hsl(210 40% 96.1%);\n  --color-accent-foreground: hsl(222.2 47.4% 11.2%);\n  --color-destructive: hsl(0 84.2% 60.2%);\n  --color-destructive-foreground: hsl(210 40% 98%);\n  --color-border: hsl(214.3 31.8% 91.4%);\n  --color-input: hsl(214.3 31.8% 91.4%);\n  --color-ring: hsl(222.2 84% 4.9%);\n  --radius: 0.5rem;\n}\n\n.dark {\n  --color-background: hsl(222.2 84% 4.9%);\n  --color-foreground: hsl(210 40% 98%);\n  --color-card: hsl(222.2 84% 4.9%);\n  --color-card-foreground: hsl(210 40% 98%);\n  --color-popover: hsl(222.2 84% 4.9%);\n  --color-popover-foreground: hsl(210 40% 98%);\n  --color-primary: hsl(210 40% 98%);\n  --color-primary-foreground: hsl(222.2 47.4% 11.2%);\n  --color-secondary: hsl(217.2 32.6% 17.5%);\n  --color-secondary-foreground: hsl(210 40% 98%);\n  --color-muted: hsl(217.2 32.6% 17.5%);\n  --color-muted-foreground: hsl(215 20.2% 65.1%);\n  --color-accent: hsl(217.2 32.6% 17.5%);\n  --color-accent-foreground: hsl(210 40% 98%);\n  --color-destructive: hsl(0 62.8% 30.6%);\n  --color-destructive-foreground: hsl(210 40% 98%);\n  --color-border: hsl(217.2 32.6% 17.5%);\n  --color-input: hsl(217.2 32.6% 17.5%);\n  --color-ring: hsl(212.7 26.8% 83.9%);\n}\n\n* {\n  border-color: var(--color-border);\n}\n\nbody {\n  background-color: var(--color-background);\n  color: var(--color-foreground);\n  font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n    sans-serif;\n}\n\n:root {\n  --sidebar: hsl(0 0% 98%);\n  --sidebar-foreground: hsl(240 5.3% 26.1%);\n  --sidebar-primary: hsl(240 5.9% 10%);\n  --sidebar-primary-foreground: hsl(0 0% 98%);\n  --sidebar-accent: hsl(240 4.8% 95.9%);\n  --sidebar-accent-foreground: hsl(240 5.9% 10%);\n  --sidebar-border: hsl(220 13% 91%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n}\n\n.dark {\n  --sidebar: hsl(240 5.9% 10%);\n  --sidebar-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-primary: hsl(224.3 76.3% 48%);\n  --sidebar-primary-foreground: hsl(0 0% 100%);\n  --sidebar-accent: hsl(240 3.7% 15.9%);\n  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-border: hsl(240 3.7% 15.9%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n}\n\n@theme inline {\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n/* Thin scrollbar */\n::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\n::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--color-border);\n  border-radius: 3px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: var(--color-muted-foreground);\n}\n"
  },
  {
    "path": "tauri/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "tauri/src/main.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { RouterProvider, createRouter } from \"@tanstack/react-router\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport \"./index.css\";\nimport { routeTree } from \"./routeTree.gen\";\nimport './i18n';\nimport { changeLanguage } from './i18n';\n\n// Theme management\nlet currentTheme = \"light\";\nconst mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\nfunction applyTheme(theme: string) {\n  currentTheme = theme;\n  const root = document.documentElement;\n\n  if (theme === \"dark\") {\n    root.classList.add(\"dark\");\n  } else if (theme === \"system\") {\n    root.classList.toggle(\"dark\", mediaQuery.matches);\n  } else {\n    root.classList.remove(\"dark\");\n  }\n}\n\n// Listen for system theme changes\nmediaQuery.addEventListener(\"change\", (e) => {\n  if (currentTheme === \"system\") {\n    document.documentElement.classList.toggle(\"dark\", e.matches);\n  }\n});\n\n// Apply theme and language on startup from config\ninvoke<{ theme: string; language: string }>(\"get_config\")\n  .then((config) => {\n    applyTheme(config.theme || \"light\");\n    changeLanguage(config.language || \"en\");\n  })\n  .catch(() => {\n    // Config not available yet, defaults applied\n  });\n\n// Export for use in settings\n(window as any).__applyTheme = applyTheme;\n(window as any).__changeLanguage = changeLanguage;\n\nconst router = createRouter({ routeTree });\n\ndeclare module \"@tanstack/react-router\" {\n  interface Register {\n    router: typeof router;\n  }\n}\n\nReactDOM.createRoot(document.getElementById(\"root\") as HTMLElement).render(\n  <React.StrictMode>\n    <RouterProvider router={router} />\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "tauri/src/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\nimport { Route as rootRouteImport } from './routes/__root'\nimport { Route as SettingsRouteImport } from './routes/settings'\nimport { Route as PdfToolsRouteImport } from './routes/pdf-tools'\nimport { Route as MediaToolsRouteImport } from './routes/media-tools'\nimport { Route as IndexRouteImport } from './routes/index'\n\nconst SettingsRoute = SettingsRouteImport.update({\n  id: '/settings',\n  path: '/settings',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst PdfToolsRoute = PdfToolsRouteImport.update({\n  id: '/pdf-tools',\n  path: '/pdf-tools',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst MediaToolsRoute = MediaToolsRouteImport.update({\n  id: '/media-tools',\n  path: '/media-tools',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst IndexRoute = IndexRouteImport.update({\n  id: '/',\n  path: '/',\n  getParentRoute: () => rootRouteImport,\n} as any)\n\nexport interface FileRoutesByFullPath {\n  '/': typeof IndexRoute\n  '/media-tools': typeof MediaToolsRoute\n  '/pdf-tools': typeof PdfToolsRoute\n  '/settings': typeof SettingsRoute\n}\nexport interface FileRoutesByTo {\n  '/': typeof IndexRoute\n  '/media-tools': typeof MediaToolsRoute\n  '/pdf-tools': typeof PdfToolsRoute\n  '/settings': typeof SettingsRoute\n}\nexport interface FileRoutesById {\n  __root__: typeof rootRouteImport\n  '/': typeof IndexRoute\n  '/media-tools': typeof MediaToolsRoute\n  '/pdf-tools': typeof PdfToolsRoute\n  '/settings': typeof SettingsRoute\n}\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath\n  fullPaths: '/' | '/media-tools' | '/pdf-tools' | '/settings'\n  fileRoutesByTo: FileRoutesByTo\n  to: '/' | '/media-tools' | '/pdf-tools' | '/settings'\n  id: '__root__' | '/' | '/media-tools' | '/pdf-tools' | '/settings'\n  fileRoutesById: FileRoutesById\n}\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute\n  MediaToolsRoute: typeof MediaToolsRoute\n  PdfToolsRoute: typeof PdfToolsRoute\n  SettingsRoute: typeof SettingsRoute\n}\n\ndeclare module '@tanstack/react-router' {\n  interface FileRoutesByPath {\n    '/settings': {\n      id: '/settings'\n      path: '/settings'\n      fullPath: '/settings'\n      preLoaderRoute: typeof SettingsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/pdf-tools': {\n      id: '/pdf-tools'\n      path: '/pdf-tools'\n      fullPath: '/pdf-tools'\n      preLoaderRoute: typeof PdfToolsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/media-tools': {\n      id: '/media-tools'\n      path: '/media-tools'\n      fullPath: '/media-tools'\n      preLoaderRoute: typeof MediaToolsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/': {\n      id: '/'\n      path: '/'\n      fullPath: '/'\n      preLoaderRoute: typeof IndexRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n  }\n}\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  MediaToolsRoute: MediaToolsRoute,\n  PdfToolsRoute: PdfToolsRoute,\n  SettingsRoute: SettingsRoute,\n}\nexport const routeTree = rootRouteImport\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>()\n"
  },
  {
    "path": "tauri/src/routes/__root.tsx",
    "content": "import { createRootRoute, Outlet } from \"@tanstack/react-router\";\nimport { useState } from \"react\";\nimport { Toaster } from \"sonner\";\nimport { AppSidebar } from \"@/components/AppSidebar\";\n\nfunction RootLayout() {\n  const [sidebarCollapsed, setSidebarCollapsed] = useState(true);\n\n  return (\n    <div className=\"flex h-screen bg-background\">\n      <AppSidebar\n        collapsed={sidebarCollapsed}\n        onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}\n      />\n      <main className=\"flex-1 overflow-auto\">\n        <Outlet />\n      </main>\n      <Toaster position=\"top-center\" richColors />\n    </div>\n  );\n}\n\nexport const Route = createRootRoute({\n  component: RootLayout,\n});\n"
  },
  {
    "path": "tauri/src/routes/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { HomePage } from \"@/components/home/HomePage\";\n\nexport const Route = createFileRoute(\"/\")({\n  component: HomePage,\n});\n"
  },
  {
    "path": "tauri/src/routes/media-tools.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { MediaToolsPage } from \"@/components/media-tools/MediaToolsPage\";\n\nexport const Route = createFileRoute(\"/media-tools\")({\n  component: MediaToolsPage,\n});\n"
  },
  {
    "path": "tauri/src/routes/pdf-tools.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { PDFToolsPage } from \"@/components/pdf-tools/PDFToolsPage\";\n\nexport const Route = createFileRoute(\"/pdf-tools\")({\n  component: PDFToolsPage,\n});\n"
  },
  {
    "path": "tauri/src/routes/settings.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { SettingsPage } from \"@/components/settings\";\n\nexport const Route = createFileRoute(\"/settings\")({\n  component: SettingsPage,\n});\n"
  },
  {
    "path": "tauri/src/services/dockerApi.ts",
    "content": "/**\n * Docker API Service\n * Communicates with vget-server running in Docker for YouTube downloads\n */\n\nconst DEFAULT_DOCKER_SERVER_URL = \"http://localhost:8080\";\nconst JWT_STORAGE_KEY = \"docker_server_jwt\";\nconst JWT_EXPIRY_KEY = \"docker_server_jwt_expiry\";\n\nexport interface DockerServerConfig {\n  url: string;\n  apiKey?: string;\n}\n\nexport interface DockerHealthResponse {\n  code: number;\n  data: {\n    status: string;\n    version: string;\n  };\n  message: string;\n}\n\nexport interface DockerAuthStatusResponse {\n  code: number;\n  data: {\n    api_key_configured: boolean;\n  };\n  message: string;\n}\n\nexport interface DockerTokenResponse {\n  code: number;\n  data: {\n    jwt: string;\n  } | null;\n  message: string;\n}\n\nexport interface DockerDownloadJob {\n  id: string;\n  url: string;\n  status: \"queued\" | \"downloading\" | \"completed\" | \"failed\" | \"cancelled\";\n  progress: number;\n  downloaded: number;\n  total: number;\n  filename: string;\n  error: string | null;\n}\n\nexport interface DockerDownloadResponse {\n  code: number;\n  data: {\n    id: string;\n    status: string;\n  };\n  message: string;\n}\n\nexport interface DockerJobStatusResponse {\n  code: number;\n  data: DockerDownloadJob;\n  message: string;\n}\n\n/**\n * Check if a URL is a YouTube URL\n */\nexport function isYouTubeUrl(url: string): boolean {\n  try {\n    const parsed = new URL(url);\n    const host = parsed.hostname.toLowerCase();\n    return (\n      host === \"youtube.com\" ||\n      host === \"www.youtube.com\" ||\n      host === \"m.youtube.com\" ||\n      host === \"youtu.be\" ||\n      host === \"www.youtu.be\" ||\n      host === \"music.youtube.com\"\n    );\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Get the Docker server URL from localStorage or use default\n */\nexport function getDockerServerUrl(): string {\n  const stored = localStorage.getItem(\"docker_server_url\");\n  return stored || DEFAULT_DOCKER_SERVER_URL;\n}\n\n/**\n * Set the Docker server URL\n */\nexport function setDockerServerUrl(url: string): void {\n  localStorage.setItem(\"docker_server_url\", url);\n  // Clear cached JWT when URL changes\n  localStorage.removeItem(JWT_STORAGE_KEY);\n  localStorage.removeItem(JWT_EXPIRY_KEY);\n}\n\n/**\n * Get the stored JWT token\n */\nfunction getStoredJwt(): string | null {\n  const jwt = localStorage.getItem(JWT_STORAGE_KEY);\n  const expiry = localStorage.getItem(JWT_EXPIRY_KEY);\n\n  if (!jwt || !expiry) return null;\n\n  // Check if token is expired (with 1 hour buffer)\n  const expiryTime = parseInt(expiry, 10);\n  if (Date.now() > expiryTime - 3600000) {\n    // Token expired or expiring soon\n    localStorage.removeItem(JWT_STORAGE_KEY);\n    localStorage.removeItem(JWT_EXPIRY_KEY);\n    return null;\n  }\n\n  return jwt;\n}\n\n/**\n * Store JWT token\n */\nfunction storeJwt(jwt: string): void {\n  localStorage.setItem(JWT_STORAGE_KEY, jwt);\n  // API tokens are valid for 1 year, but we'll refresh more frequently\n  // Set expiry to 30 days from now\n  const expiry = Date.now() + 30 * 24 * 60 * 60 * 1000;\n  localStorage.setItem(JWT_EXPIRY_KEY, expiry.toString());\n}\n\n/**\n * Get the manually configured JWT token (for settings UI)\n */\nexport function getDockerJwtToken(): string {\n  return localStorage.getItem(JWT_STORAGE_KEY) || \"\";\n}\n\n/**\n * Set JWT token manually (from settings UI)\n */\nexport function setDockerJwtToken(token: string): void {\n  if (token) {\n    storeJwt(token);\n  } else {\n    localStorage.removeItem(JWT_STORAGE_KEY);\n    localStorage.removeItem(JWT_EXPIRY_KEY);\n  }\n}\n\n/**\n * Check if the Docker server requires authentication\n */\nexport async function checkAuthRequired(): Promise<boolean> {\n  try {\n    const baseUrl = getDockerServerUrl();\n    const response = await fetch(`${baseUrl}/api/auth/status`, {\n      method: \"GET\",\n      headers: { \"Content-Type\": \"application/json\" },\n    });\n\n    if (!response.ok) return false;\n\n    const data: DockerAuthStatusResponse = await response.json();\n    return data.code === 200 && data.data.api_key_configured;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Generate a new JWT token from the server\n */\nexport async function generateJwtToken(): Promise<string | null> {\n  try {\n    const baseUrl = getDockerServerUrl();\n    const response = await fetch(`${baseUrl}/api/auth/token`, {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({}),\n    });\n\n    if (!response.ok) return null;\n\n    const data: DockerTokenResponse = await response.json();\n    if (data.code === 201 && data.data?.jwt) {\n      storeJwt(data.data.jwt);\n      return data.data.jwt;\n    }\n    return null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Get authorization headers for API requests\n */\nfunction getAuthHeaders(): Record<string, string> {\n  const headers: Record<string, string> = {\n    \"Content-Type\": \"application/json\",\n  };\n\n  // Use stored JWT token if available\n  const jwt = getStoredJwt();\n  if (jwt) {\n    headers[\"Authorization\"] = `Bearer ${jwt}`;\n  }\n\n  return headers;\n}\n\n/**\n * Check if the Docker server is running and healthy\n */\nexport async function checkDockerHealth(): Promise<boolean> {\n  try {\n    const baseUrl = getDockerServerUrl();\n    const response = await fetch(`${baseUrl}/api/health`, {\n      method: \"GET\",\n      headers: { \"Content-Type\": \"application/json\" },\n    });\n\n    if (!response.ok) return false;\n\n    const data: DockerHealthResponse = await response.json();\n    return data.code === 200 && data.data.status === \"ok\";\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Start a download via the Docker server\n */\nexport async function startDockerDownload(\n  url: string,\n  filename?: string\n): Promise<DockerDownloadResponse> {\n  const baseUrl = getDockerServerUrl();\n  const response = await fetch(`${baseUrl}/api/download`, {\n    method: \"POST\",\n    headers: getAuthHeaders(),\n    body: JSON.stringify({ url, filename }),\n  });\n\n  if (!response.ok) {\n    if (response.status === 401) {\n      throw new Error(\"Authentication required. Please configure JWT token in Settings → Sites → Docker Server.\");\n    }\n    const errorText = await response.text();\n    throw new Error(`Docker server error: ${errorText}`);\n  }\n\n  return response.json();\n}\n\n/**\n * Get the status of a download job\n */\nexport async function getDockerJobStatus(\n  jobId: string\n): Promise<DockerJobStatusResponse> {\n  const baseUrl = getDockerServerUrl();\n  const response = await fetch(`${baseUrl}/api/status/${jobId}`, {\n    method: \"GET\",\n    headers: getAuthHeaders(),\n  });\n\n  if (!response.ok) {\n    throw new Error(`Failed to get job status: ${response.statusText}`);\n  }\n\n  return response.json();\n}\n\n/**\n * Get all download jobs from the Docker server\n */\nexport async function getDockerJobs(): Promise<DockerDownloadJob[]> {\n  const baseUrl = getDockerServerUrl();\n  const response = await fetch(`${baseUrl}/api/jobs`, {\n    method: \"GET\",\n    headers: getAuthHeaders(),\n  });\n\n  if (!response.ok) {\n    throw new Error(`Failed to get jobs: ${response.statusText}`);\n  }\n\n  const data = await response.json();\n  return data.data.jobs || [];\n}\n\n/**\n * Cancel a download job on the Docker server\n */\nexport async function cancelDockerJob(jobId: string): Promise<void> {\n  const baseUrl = getDockerServerUrl();\n  const response = await fetch(`${baseUrl}/api/jobs/${jobId}`, {\n    method: \"DELETE\",\n    headers: getAuthHeaders(),\n  });\n\n  if (!response.ok) {\n    throw new Error(`Failed to cancel job: ${response.statusText}`);\n  }\n}\n"
  },
  {
    "path": "tauri/src/stores/auth.ts",
    "content": "import { create } from \"zustand\";\nimport { invoke } from \"@tauri-apps/api/core\";\n\nexport type AuthStatus = \"logged_out\" | \"checking\" | \"logged_in\";\n\nexport interface SiteAuthStatus {\n  status: AuthStatus;\n  username?: string;\n  avatar?: string;\n}\n\nexport interface QRSession {\n  url: string;\n  qrcode_key: string;\n}\n\nexport interface QRPollResult {\n  status: number;\n  status_text: string;\n  username?: string;\n}\n\n// QR Status codes from Bilibili API\nexport const QR_WAITING = 86101;\nexport const QR_SCANNED = 86090;\nexport const QR_EXPIRED = 86038;\nexport const QR_CONFIRMED = 0;\n\ninterface AuthState {\n  // Sidebar visibility\n  isOpen: boolean;\n  activeTab: \"bilibili\" | \"xiaohongshu\";\n\n  // Site-specific auth status\n  bilibili: SiteAuthStatus;\n  xiaohongshu: SiteAuthStatus;\n\n  // Actions\n  open: (tab?: \"bilibili\" | \"xiaohongshu\") => void;\n  close: () => void;\n  setTab: (tab: \"bilibili\" | \"xiaohongshu\") => void;\n  checkAuthStatus: () => Promise<void>;\n  setBilibiliStatus: (status: SiteAuthStatus) => void;\n  setXiaohongshuStatus: (status: SiteAuthStatus) => void;\n  logout: (site: \"bilibili\" | \"xiaohongshu\") => Promise<void>;\n}\n\nexport const useAuthStore = create<AuthState>((set, get) => ({\n  isOpen: false,\n  activeTab: \"bilibili\",\n\n  bilibili: { status: \"logged_out\" },\n  xiaohongshu: { status: \"logged_out\" },\n\n  open: (tab) => {\n    set({ isOpen: true });\n    if (tab) {\n      set({ activeTab: tab });\n    }\n    // Check auth status when opening\n    get().checkAuthStatus();\n  },\n\n  close: () => set({ isOpen: false }),\n\n  setTab: (tab) => set({ activeTab: tab }),\n\n  checkAuthStatus: async () => {\n    // Check Bilibili status\n    set((state) => ({\n      bilibili: { ...state.bilibili, status: \"checking\" },\n      xiaohongshu: { ...state.xiaohongshu, status: \"checking\" },\n    }));\n\n    try {\n      const bilibiliStatus = await invoke<SiteAuthStatus>(\"bilibili_check_status\");\n      set({ bilibili: bilibiliStatus });\n    } catch {\n      set({ bilibili: { status: \"logged_out\" } });\n    }\n\n    try {\n      const xhsStatus = await invoke<SiteAuthStatus>(\"xhs_check_status\");\n      set({ xiaohongshu: xhsStatus });\n    } catch {\n      set({ xiaohongshu: { status: \"logged_out\" } });\n    }\n  },\n\n  setBilibiliStatus: (status) => set({ bilibili: status }),\n\n  setXiaohongshuStatus: (status) => set({ xiaohongshu: status }),\n\n  logout: async (site) => {\n    if (site === \"bilibili\") {\n      await invoke(\"bilibili_logout\");\n      set({ bilibili: { status: \"logged_out\" } });\n    } else {\n      await invoke(\"xhs_logout\");\n      set({ xiaohongshu: { status: \"logged_out\" } });\n    }\n  },\n}));\n\n// Helper functions for Bilibili QR login\nexport async function generateBilibiliQR(): Promise<QRSession> {\n  return invoke<QRSession>(\"bilibili_qr_generate\");\n}\n\nexport async function pollBilibiliQR(qrcodeKey: string): Promise<QRPollResult> {\n  return invoke<QRPollResult>(\"bilibili_qr_poll\", { qrcodeKey });\n}\n\nexport async function saveBilibiliCookie(cookie: string): Promise<void> {\n  return invoke(\"bilibili_save_cookie\", { cookie });\n}\n\n// Helper for XHS login window\nexport async function openXhsLoginWindow(): Promise<void> {\n  return invoke(\"xhs_open_login_window\");\n}\n"
  },
  {
    "path": "tauri/src/stores/downloads.ts",
    "content": "import { create } from \"zustand\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { listen } from \"@tauri-apps/api/event\";\n\nexport type DownloadStatus =\n  | \"pending\"\n  | \"downloading\"\n  | \"completed\"\n  | \"failed\"\n  | \"cancelled\";\n\nexport interface DownloadProgress {\n  job_id: string;\n  downloaded: number;\n  total: number | null;\n  speed: number;\n  percent: number;\n}\n\nexport interface Download {\n  id: string;\n  url: string;\n  title: string;\n  outputPath: string;\n  status: DownloadStatus;\n  progress: DownloadProgress | null;\n  error: string | null;\n}\n\ninterface DownloadsState {\n  downloads: Download[];\n  addDownload: (download: Download) => void;\n  updateDownload: (id: string, updates: Partial<Download>) => void;\n  removeDownload: (id: string) => void;\n  clearCompleted: () => void;\n}\n\nexport const useDownloadsStore = create<DownloadsState>((set) => ({\n  downloads: [],\n\n  addDownload: (download) =>\n    set((state) => ({\n      downloads: [download, ...state.downloads],\n    })),\n\n  updateDownload: (id, updates) =>\n    set((state) => ({\n      downloads: state.downloads.map((d) =>\n        d.id === id ? { ...d, ...updates } : d\n      ),\n    })),\n\n  removeDownload: (id) =>\n    set((state) => ({\n      downloads: state.downloads.filter((d) => d.id !== id),\n    })),\n\n  clearCompleted: () =>\n    set((state) => ({\n      downloads: state.downloads.filter(\n        (d) => d.status !== \"completed\" && d.status !== \"failed\"\n      ),\n    })),\n}));\n\n// Listen to download events from Rust\nlet listenersInitialized = false;\n\nexport async function setupDownloadListeners() {\n  // Prevent duplicate listeners\n  if (listenersInitialized) return;\n  listenersInitialized = true;\n\n  await listen<DownloadProgress>(\"download-progress\", (event) => {\n    const progress = event.payload;\n    useDownloadsStore.getState().updateDownload(progress.job_id, {\n      progress,\n      status: \"downloading\",\n    });\n  });\n\n  await listen<{ jobId: string; outputPath: string }>(\n    \"download-complete\",\n    (event) => {\n      useDownloadsStore.getState().updateDownload(event.payload.jobId, {\n        status: \"completed\",\n        progress: null,\n      });\n    }\n  );\n\n  await listen<{ jobId: string; error: string }>(\"download-error\", (event) => {\n    const { jobId, error } = event.payload;\n    useDownloadsStore.getState().updateDownload(jobId, {\n      status: error.includes(\"cancelled\") ? \"cancelled\" : \"failed\",\n      error,\n      progress: null,\n    });\n  });\n}\n\nexport async function startDownload(\n  url: string,\n  title: string,\n  outputPath: string,\n  headers?: Record<string, string>,\n  audioUrl?: string\n): Promise<string> {\n  const jobId = await invoke<string>(\"start_download\", {\n    url,\n    outputPath,\n    formatId: null,\n    headers: headers || null,\n    audioUrl: audioUrl || null,\n  });\n\n  useDownloadsStore.getState().addDownload({\n    id: jobId,\n    url,\n    title,\n    outputPath,\n    status: \"pending\",\n    progress: null,\n    error: null,\n  });\n\n  return jobId;\n}\n\nexport async function cancelDownload(jobId: string): Promise<void> {\n  await invoke(\"cancel_download\", { jobId });\n}\n"
  },
  {
    "path": "tauri/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ndeclare module '*.yml?raw' {\n  const content: string;\n  export default content;\n}\n\ndeclare module '*.yaml?raw' {\n  const content: string;\n  export default content;\n}\n"
  },
  {
    "path": "tauri/src-tauri/Cargo.toml",
    "content": "[package]\nname = \"vget-desktop\"\nversion = \"0.1.0\"\ndescription = \"vget Desktop - Media Downloader\"\nauthors = [\"guiyumin\"]\nedition = \"2021\"\n\n[lib]\nname = \"vget_desktop_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"rlib\"]\n\n[build-dependencies]\ntauri-build = { version = \"2\", features = [] }\n\n[dependencies]\ntauri = { version = \"2\", features = [] }\ntauri-plugin-opener = \"2\"\ntauri-plugin-dialog = \"2\"\ntauri-plugin-updater = \"2\"\ntauri-plugin-process = \"2\"\n\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nserde_yaml = \"0.9\"\n\ntokio = { version = \"1\", features = [\"rt-multi-thread\", \"sync\", \"time\", \"fs\"] }\nreqwest = { version = \"0.12\", features = [\"json\", \"cookies\", \"gzip\", \"stream\"] }\nfutures = \"0.3\"\n\ndirs = \"6\"\nuuid = { version = \"1\", features = [\"v4\", \"serde\"] }\nchrono = { version = \"0.4\", features = [\"serde\"] }\nurl = \"2\"\nurlencoding = \"2\"\nregex = \"1\"\nthiserror = \"1\"\nmd5 = \"0.7\"\nffmpeg-sidecar = \"2\"\n\n# PDF processing\nlopdf = \"0.34\"\nimage = \"0.25\"\nprintpdf = \"0.8\"\n\n# Markdown to PDF\npulldown-cmark = \"0.12\"\nheadless_chrome = \"1.0\"\nsyntect = \"5\"\nbase64 = \"0.22\"\n"
  },
  {
    "path": "tauri/src-tauri/binaries/.gitkeep",
    "content": "# FFmpeg Sidecar Binaries\n\nPlace FFmpeg binaries here with target-specific names:\n\n- macOS Intel: `ffmpeg-x86_64-apple-darwin`\n- macOS ARM: `ffmpeg-aarch64-apple-darwin`\n- Windows: `ffmpeg-x86_64-pc-windows-msvc.exe`\n- Linux: `ffmpeg-x86_64-unknown-linux-gnu`\n\nDownload from: https://ffmpeg.org/download.html\nOr use ffmpeg-sidecar download: `ffmpeg_sidecar::download::auto_download()`\n"
  },
  {
    "path": "tauri/src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "tauri/src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"Capability for the main window\",\n  \"windows\": [\"main\"],\n  \"permissions\": [\n    \"core:default\",\n    \"core:event:allow-listen\",\n    \"core:event:allow-emit\",\n    \"opener:default\",\n    {\n      \"identifier\": \"opener:allow-open-path\",\n      \"allow\": [\n        { \"path\": \"$DOWNLOAD/**\" },\n        { \"path\": \"$HOME/**\" }\n      ]\n    },\n    \"dialog:default\",\n    \"updater:default\",\n    \"process:default\"\n  ]\n}\n"
  },
  {
    "path": "tauri/src-tauri/gen/schemas/acl-manifests.json",
    "content": "{\"core\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default core plugins set.\",\"permissions\":[\"core:path:default\",\"core:event:default\",\"core:window:default\",\"core:webview:default\",\"core:app:default\",\"core:image:default\",\"core:resources:default\",\"core:menu:default\",\"core:tray:default\"]},\"permissions\":{},\"permission_sets\":{},\"global_scope_schema\":null},\"core:app\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-version\",\"allow-name\",\"allow-tauri-version\",\"allow-identifier\",\"allow-bundle-type\",\"allow-register-listener\",\"allow-remove-listener\"]},\"permissions\":{\"allow-app-hide\":{\"identifier\":\"allow-app-hide\",\"description\":\"Enables the app_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"app_hide\"],\"deny\":[]}},\"allow-app-show\":{\"identifier\":\"allow-app-show\",\"description\":\"Enables the app_show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"app_show\"],\"deny\":[]}},\"allow-bundle-type\":{\"identifier\":\"allow-bundle-type\",\"description\":\"Enables the bundle_type command without any pre-configured scope.\",\"commands\":{\"allow\":[\"bundle_type\"],\"deny\":[]}},\"allow-default-window-icon\":{\"identifier\":\"allow-default-window-icon\",\"description\":\"Enables the default_window_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"default_window_icon\"],\"deny\":[]}},\"allow-fetch-data-store-identifiers\":{\"identifier\":\"allow-fetch-data-store-identifiers\",\"description\":\"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\"commands\":{\"allow\":[\"fetch_data_store_identifiers\"],\"deny\":[]}},\"allow-identifier\":{\"identifier\":\"allow-identifier\",\"description\":\"Enables the identifier command without any pre-configured scope.\",\"commands\":{\"allow\":[\"identifier\"],\"deny\":[]}},\"allow-name\":{\"identifier\":\"allow-name\",\"description\":\"Enables the name command without any pre-configured scope.\",\"commands\":{\"allow\":[\"name\"],\"deny\":[]}},\"allow-register-listener\":{\"identifier\":\"allow-register-listener\",\"description\":\"Enables the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[\"register_listener\"],\"deny\":[]}},\"allow-remove-data-store\":{\"identifier\":\"allow-remove-data-store\",\"description\":\"Enables the remove_data_store command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_data_store\"],\"deny\":[]}},\"allow-remove-listener\":{\"identifier\":\"allow-remove-listener\",\"description\":\"Enables the remove_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_listener\"],\"deny\":[]}},\"allow-set-app-theme\":{\"identifier\":\"allow-set-app-theme\",\"description\":\"Enables the set_app_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_app_theme\"],\"deny\":[]}},\"allow-set-dock-visibility\":{\"identifier\":\"allow-set-dock-visibility\",\"description\":\"Enables the set_dock_visibility command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_dock_visibility\"],\"deny\":[]}},\"allow-tauri-version\":{\"identifier\":\"allow-tauri-version\",\"description\":\"Enables the tauri_version command without any pre-configured scope.\",\"commands\":{\"allow\":[\"tauri_version\"],\"deny\":[]}},\"allow-version\":{\"identifier\":\"allow-version\",\"description\":\"Enables the version command without any pre-configured scope.\",\"commands\":{\"allow\":[\"version\"],\"deny\":[]}},\"deny-app-hide\":{\"identifier\":\"deny-app-hide\",\"description\":\"Denies the app_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"app_hide\"]}},\"deny-app-show\":{\"identifier\":\"deny-app-show\",\"description\":\"Denies the app_show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"app_show\"]}},\"deny-bundle-type\":{\"identifier\":\"deny-bundle-type\",\"description\":\"Denies the bundle_type command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"bundle_type\"]}},\"deny-default-window-icon\":{\"identifier\":\"deny-default-window-icon\",\"description\":\"Denies the default_window_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"default_window_icon\"]}},\"deny-fetch-data-store-identifiers\":{\"identifier\":\"deny-fetch-data-store-identifiers\",\"description\":\"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"fetch_data_store_identifiers\"]}},\"deny-identifier\":{\"identifier\":\"deny-identifier\",\"description\":\"Denies the identifier command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"identifier\"]}},\"deny-name\":{\"identifier\":\"deny-name\",\"description\":\"Denies the name command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"name\"]}},\"deny-register-listener\":{\"identifier\":\"deny-register-listener\",\"description\":\"Denies the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"register_listener\"]}},\"deny-remove-data-store\":{\"identifier\":\"deny-remove-data-store\",\"description\":\"Denies the remove_data_store command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_data_store\"]}},\"deny-remove-listener\":{\"identifier\":\"deny-remove-listener\",\"description\":\"Denies the remove_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_listener\"]}},\"deny-set-app-theme\":{\"identifier\":\"deny-set-app-theme\",\"description\":\"Denies the set_app_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_app_theme\"]}},\"deny-set-dock-visibility\":{\"identifier\":\"deny-set-dock-visibility\",\"description\":\"Denies the set_dock_visibility command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_dock_visibility\"]}},\"deny-tauri-version\":{\"identifier\":\"deny-tauri-version\",\"description\":\"Denies the tauri_version command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"tauri_version\"]}},\"deny-version\":{\"identifier\":\"deny-version\",\"description\":\"Denies the version command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"version\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:event\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-listen\",\"allow-unlisten\",\"allow-emit\",\"allow-emit-to\"]},\"permissions\":{\"allow-emit\":{\"identifier\":\"allow-emit\",\"description\":\"Enables the emit command without any pre-configured scope.\",\"commands\":{\"allow\":[\"emit\"],\"deny\":[]}},\"allow-emit-to\":{\"identifier\":\"allow-emit-to\",\"description\":\"Enables the emit_to command without any pre-configured scope.\",\"commands\":{\"allow\":[\"emit_to\"],\"deny\":[]}},\"allow-listen\":{\"identifier\":\"allow-listen\",\"description\":\"Enables the listen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"listen\"],\"deny\":[]}},\"allow-unlisten\":{\"identifier\":\"allow-unlisten\",\"description\":\"Enables the unlisten command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unlisten\"],\"deny\":[]}},\"deny-emit\":{\"identifier\":\"deny-emit\",\"description\":\"Denies the emit command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"emit\"]}},\"deny-emit-to\":{\"identifier\":\"deny-emit-to\",\"description\":\"Denies the emit_to command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"emit_to\"]}},\"deny-listen\":{\"identifier\":\"deny-listen\",\"description\":\"Denies the listen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"listen\"]}},\"deny-unlisten\":{\"identifier\":\"deny-unlisten\",\"description\":\"Denies the unlisten command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unlisten\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:image\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-from-bytes\",\"allow-from-path\",\"allow-rgba\",\"allow-size\"]},\"permissions\":{\"allow-from-bytes\":{\"identifier\":\"allow-from-bytes\",\"description\":\"Enables the from_bytes command without any pre-configured scope.\",\"commands\":{\"allow\":[\"from_bytes\"],\"deny\":[]}},\"allow-from-path\":{\"identifier\":\"allow-from-path\",\"description\":\"Enables the from_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"from_path\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-rgba\":{\"identifier\":\"allow-rgba\",\"description\":\"Enables the rgba command without any pre-configured scope.\",\"commands\":{\"allow\":[\"rgba\"],\"deny\":[]}},\"allow-size\":{\"identifier\":\"allow-size\",\"description\":\"Enables the size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"size\"],\"deny\":[]}},\"deny-from-bytes\":{\"identifier\":\"deny-from-bytes\",\"description\":\"Denies the from_bytes command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"from_bytes\"]}},\"deny-from-path\":{\"identifier\":\"deny-from-path\",\"description\":\"Denies the from_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"from_path\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-rgba\":{\"identifier\":\"deny-rgba\",\"description\":\"Denies the rgba command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"rgba\"]}},\"deny-size\":{\"identifier\":\"deny-size\",\"description\":\"Denies the size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"size\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:menu\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-append\",\"allow-prepend\",\"allow-insert\",\"allow-remove\",\"allow-remove-at\",\"allow-items\",\"allow-get\",\"allow-popup\",\"allow-create-default\",\"allow-set-as-app-menu\",\"allow-set-as-window-menu\",\"allow-text\",\"allow-set-text\",\"allow-is-enabled\",\"allow-set-enabled\",\"allow-set-accelerator\",\"allow-set-as-windows-menu-for-nsapp\",\"allow-set-as-help-menu-for-nsapp\",\"allow-is-checked\",\"allow-set-checked\",\"allow-set-icon\"]},\"permissions\":{\"allow-append\":{\"identifier\":\"allow-append\",\"description\":\"Enables the append command without any pre-configured scope.\",\"commands\":{\"allow\":[\"append\"],\"deny\":[]}},\"allow-create-default\":{\"identifier\":\"allow-create-default\",\"description\":\"Enables the create_default command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_default\"],\"deny\":[]}},\"allow-get\":{\"identifier\":\"allow-get\",\"description\":\"Enables the get command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get\"],\"deny\":[]}},\"allow-insert\":{\"identifier\":\"allow-insert\",\"description\":\"Enables the insert command without any pre-configured scope.\",\"commands\":{\"allow\":[\"insert\"],\"deny\":[]}},\"allow-is-checked\":{\"identifier\":\"allow-is-checked\",\"description\":\"Enables the is_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_checked\"],\"deny\":[]}},\"allow-is-enabled\":{\"identifier\":\"allow-is-enabled\",\"description\":\"Enables the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_enabled\"],\"deny\":[]}},\"allow-items\":{\"identifier\":\"allow-items\",\"description\":\"Enables the items command without any pre-configured scope.\",\"commands\":{\"allow\":[\"items\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-popup\":{\"identifier\":\"allow-popup\",\"description\":\"Enables the popup command without any pre-configured scope.\",\"commands\":{\"allow\":[\"popup\"],\"deny\":[]}},\"allow-prepend\":{\"identifier\":\"allow-prepend\",\"description\":\"Enables the prepend command without any pre-configured scope.\",\"commands\":{\"allow\":[\"prepend\"],\"deny\":[]}},\"allow-remove\":{\"identifier\":\"allow-remove\",\"description\":\"Enables the remove command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove\"],\"deny\":[]}},\"allow-remove-at\":{\"identifier\":\"allow-remove-at\",\"description\":\"Enables the remove_at command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_at\"],\"deny\":[]}},\"allow-set-accelerator\":{\"identifier\":\"allow-set-accelerator\",\"description\":\"Enables the set_accelerator command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_accelerator\"],\"deny\":[]}},\"allow-set-as-app-menu\":{\"identifier\":\"allow-set-as-app-menu\",\"description\":\"Enables the set_as_app_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_app_menu\"],\"deny\":[]}},\"allow-set-as-help-menu-for-nsapp\":{\"identifier\":\"allow-set-as-help-menu-for-nsapp\",\"description\":\"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_help_menu_for_nsapp\"],\"deny\":[]}},\"allow-set-as-window-menu\":{\"identifier\":\"allow-set-as-window-menu\",\"description\":\"Enables the set_as_window_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_window_menu\"],\"deny\":[]}},\"allow-set-as-windows-menu-for-nsapp\":{\"identifier\":\"allow-set-as-windows-menu-for-nsapp\",\"description\":\"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_windows_menu_for_nsapp\"],\"deny\":[]}},\"allow-set-checked\":{\"identifier\":\"allow-set-checked\",\"description\":\"Enables the set_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_checked\"],\"deny\":[]}},\"allow-set-enabled\":{\"identifier\":\"allow-set-enabled\",\"description\":\"Enables the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_enabled\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-text\":{\"identifier\":\"allow-set-text\",\"description\":\"Enables the set_text command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_text\"],\"deny\":[]}},\"allow-text\":{\"identifier\":\"allow-text\",\"description\":\"Enables the text command without any pre-configured scope.\",\"commands\":{\"allow\":[\"text\"],\"deny\":[]}},\"deny-append\":{\"identifier\":\"deny-append\",\"description\":\"Denies the append command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"append\"]}},\"deny-create-default\":{\"identifier\":\"deny-create-default\",\"description\":\"Denies the create_default command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_default\"]}},\"deny-get\":{\"identifier\":\"deny-get\",\"description\":\"Denies the get command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get\"]}},\"deny-insert\":{\"identifier\":\"deny-insert\",\"description\":\"Denies the insert command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"insert\"]}},\"deny-is-checked\":{\"identifier\":\"deny-is-checked\",\"description\":\"Denies the is_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_checked\"]}},\"deny-is-enabled\":{\"identifier\":\"deny-is-enabled\",\"description\":\"Denies the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_enabled\"]}},\"deny-items\":{\"identifier\":\"deny-items\",\"description\":\"Denies the items command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"items\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-popup\":{\"identifier\":\"deny-popup\",\"description\":\"Denies the popup command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"popup\"]}},\"deny-prepend\":{\"identifier\":\"deny-prepend\",\"description\":\"Denies the prepend command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"prepend\"]}},\"deny-remove\":{\"identifier\":\"deny-remove\",\"description\":\"Denies the remove command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove\"]}},\"deny-remove-at\":{\"identifier\":\"deny-remove-at\",\"description\":\"Denies the remove_at command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_at\"]}},\"deny-set-accelerator\":{\"identifier\":\"deny-set-accelerator\",\"description\":\"Denies the set_accelerator command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_accelerator\"]}},\"deny-set-as-app-menu\":{\"identifier\":\"deny-set-as-app-menu\",\"description\":\"Denies the set_as_app_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_app_menu\"]}},\"deny-set-as-help-menu-for-nsapp\":{\"identifier\":\"deny-set-as-help-menu-for-nsapp\",\"description\":\"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_help_menu_for_nsapp\"]}},\"deny-set-as-window-menu\":{\"identifier\":\"deny-set-as-window-menu\",\"description\":\"Denies the set_as_window_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_window_menu\"]}},\"deny-set-as-windows-menu-for-nsapp\":{\"identifier\":\"deny-set-as-windows-menu-for-nsapp\",\"description\":\"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_windows_menu_for_nsapp\"]}},\"deny-set-checked\":{\"identifier\":\"deny-set-checked\",\"description\":\"Denies the set_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_checked\"]}},\"deny-set-enabled\":{\"identifier\":\"deny-set-enabled\",\"description\":\"Denies the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_enabled\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-text\":{\"identifier\":\"deny-set-text\",\"description\":\"Denies the set_text command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_text\"]}},\"deny-text\":{\"identifier\":\"deny-text\",\"description\":\"Denies the text command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"text\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:path\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-resolve-directory\",\"allow-resolve\",\"allow-normalize\",\"allow-join\",\"allow-dirname\",\"allow-extname\",\"allow-basename\",\"allow-is-absolute\"]},\"permissions\":{\"allow-basename\":{\"identifier\":\"allow-basename\",\"description\":\"Enables the basename command without any pre-configured scope.\",\"commands\":{\"allow\":[\"basename\"],\"deny\":[]}},\"allow-dirname\":{\"identifier\":\"allow-dirname\",\"description\":\"Enables the dirname command without any pre-configured scope.\",\"commands\":{\"allow\":[\"dirname\"],\"deny\":[]}},\"allow-extname\":{\"identifier\":\"allow-extname\",\"description\":\"Enables the extname command without any pre-configured scope.\",\"commands\":{\"allow\":[\"extname\"],\"deny\":[]}},\"allow-is-absolute\":{\"identifier\":\"allow-is-absolute\",\"description\":\"Enables the is_absolute command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_absolute\"],\"deny\":[]}},\"allow-join\":{\"identifier\":\"allow-join\",\"description\":\"Enables the join command without any pre-configured scope.\",\"commands\":{\"allow\":[\"join\"],\"deny\":[]}},\"allow-normalize\":{\"identifier\":\"allow-normalize\",\"description\":\"Enables the normalize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"normalize\"],\"deny\":[]}},\"allow-resolve\":{\"identifier\":\"allow-resolve\",\"description\":\"Enables the resolve command without any pre-configured scope.\",\"commands\":{\"allow\":[\"resolve\"],\"deny\":[]}},\"allow-resolve-directory\":{\"identifier\":\"allow-resolve-directory\",\"description\":\"Enables the resolve_directory command without any pre-configured scope.\",\"commands\":{\"allow\":[\"resolve_directory\"],\"deny\":[]}},\"deny-basename\":{\"identifier\":\"deny-basename\",\"description\":\"Denies the basename command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"basename\"]}},\"deny-dirname\":{\"identifier\":\"deny-dirname\",\"description\":\"Denies the dirname command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"dirname\"]}},\"deny-extname\":{\"identifier\":\"deny-extname\",\"description\":\"Denies the extname command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"extname\"]}},\"deny-is-absolute\":{\"identifier\":\"deny-is-absolute\",\"description\":\"Denies the is_absolute command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_absolute\"]}},\"deny-join\":{\"identifier\":\"deny-join\",\"description\":\"Denies the join command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"join\"]}},\"deny-normalize\":{\"identifier\":\"deny-normalize\",\"description\":\"Denies the normalize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"normalize\"]}},\"deny-resolve\":{\"identifier\":\"deny-resolve\",\"description\":\"Denies the resolve command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"resolve\"]}},\"deny-resolve-directory\":{\"identifier\":\"deny-resolve-directory\",\"description\":\"Denies the resolve_directory command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"resolve_directory\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:resources\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-close\"]},\"permissions\":{\"allow-close\":{\"identifier\":\"allow-close\",\"description\":\"Enables the close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"close\"],\"deny\":[]}},\"deny-close\":{\"identifier\":\"deny-close\",\"description\":\"Denies the close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"close\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:tray\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-get-by-id\",\"allow-remove-by-id\",\"allow-set-icon\",\"allow-set-menu\",\"allow-set-tooltip\",\"allow-set-title\",\"allow-set-visible\",\"allow-set-temp-dir-path\",\"allow-set-icon-as-template\",\"allow-set-show-menu-on-left-click\"]},\"permissions\":{\"allow-get-by-id\":{\"identifier\":\"allow-get-by-id\",\"description\":\"Enables the get_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_by_id\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-remove-by-id\":{\"identifier\":\"allow-remove-by-id\",\"description\":\"Enables the remove_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_by_id\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-icon-as-template\":{\"identifier\":\"allow-set-icon-as-template\",\"description\":\"Enables the set_icon_as_template command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon_as_template\"],\"deny\":[]}},\"allow-set-menu\":{\"identifier\":\"allow-set-menu\",\"description\":\"Enables the set_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_menu\"],\"deny\":[]}},\"allow-set-show-menu-on-left-click\":{\"identifier\":\"allow-set-show-menu-on-left-click\",\"description\":\"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_show_menu_on_left_click\"],\"deny\":[]}},\"allow-set-temp-dir-path\":{\"identifier\":\"allow-set-temp-dir-path\",\"description\":\"Enables the set_temp_dir_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_temp_dir_path\"],\"deny\":[]}},\"allow-set-title\":{\"identifier\":\"allow-set-title\",\"description\":\"Enables the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title\"],\"deny\":[]}},\"allow-set-tooltip\":{\"identifier\":\"allow-set-tooltip\",\"description\":\"Enables the set_tooltip command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_tooltip\"],\"deny\":[]}},\"allow-set-visible\":{\"identifier\":\"allow-set-visible\",\"description\":\"Enables the set_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_visible\"],\"deny\":[]}},\"deny-get-by-id\":{\"identifier\":\"deny-get-by-id\",\"description\":\"Denies the get_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_by_id\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-remove-by-id\":{\"identifier\":\"deny-remove-by-id\",\"description\":\"Denies the remove_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_by_id\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-icon-as-template\":{\"identifier\":\"deny-set-icon-as-template\",\"description\":\"Denies the set_icon_as_template command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon_as_template\"]}},\"deny-set-menu\":{\"identifier\":\"deny-set-menu\",\"description\":\"Denies the set_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_menu\"]}},\"deny-set-show-menu-on-left-click\":{\"identifier\":\"deny-set-show-menu-on-left-click\",\"description\":\"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_show_menu_on_left_click\"]}},\"deny-set-temp-dir-path\":{\"identifier\":\"deny-set-temp-dir-path\",\"description\":\"Denies the set_temp_dir_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_temp_dir_path\"]}},\"deny-set-title\":{\"identifier\":\"deny-set-title\",\"description\":\"Denies the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title\"]}},\"deny-set-tooltip\":{\"identifier\":\"deny-set-tooltip\",\"description\":\"Denies the set_tooltip command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_tooltip\"]}},\"deny-set-visible\":{\"identifier\":\"deny-set-visible\",\"description\":\"Denies the set_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_visible\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:webview\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-get-all-webviews\",\"allow-webview-position\",\"allow-webview-size\",\"allow-internal-toggle-devtools\"]},\"permissions\":{\"allow-clear-all-browsing-data\":{\"identifier\":\"allow-clear-all-browsing-data\",\"description\":\"Enables the clear_all_browsing_data command without any pre-configured scope.\",\"commands\":{\"allow\":[\"clear_all_browsing_data\"],\"deny\":[]}},\"allow-create-webview\":{\"identifier\":\"allow-create-webview\",\"description\":\"Enables the create_webview command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_webview\"],\"deny\":[]}},\"allow-create-webview-window\":{\"identifier\":\"allow-create-webview-window\",\"description\":\"Enables the create_webview_window command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_webview_window\"],\"deny\":[]}},\"allow-get-all-webviews\":{\"identifier\":\"allow-get-all-webviews\",\"description\":\"Enables the get_all_webviews command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_all_webviews\"],\"deny\":[]}},\"allow-internal-toggle-devtools\":{\"identifier\":\"allow-internal-toggle-devtools\",\"description\":\"Enables the internal_toggle_devtools command without any pre-configured scope.\",\"commands\":{\"allow\":[\"internal_toggle_devtools\"],\"deny\":[]}},\"allow-print\":{\"identifier\":\"allow-print\",\"description\":\"Enables the print command without any pre-configured scope.\",\"commands\":{\"allow\":[\"print\"],\"deny\":[]}},\"allow-reparent\":{\"identifier\":\"allow-reparent\",\"description\":\"Enables the reparent command without any pre-configured scope.\",\"commands\":{\"allow\":[\"reparent\"],\"deny\":[]}},\"allow-set-webview-auto-resize\":{\"identifier\":\"allow-set-webview-auto-resize\",\"description\":\"Enables the set_webview_auto_resize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_auto_resize\"],\"deny\":[]}},\"allow-set-webview-background-color\":{\"identifier\":\"allow-set-webview-background-color\",\"description\":\"Enables the set_webview_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_background_color\"],\"deny\":[]}},\"allow-set-webview-focus\":{\"identifier\":\"allow-set-webview-focus\",\"description\":\"Enables the set_webview_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_focus\"],\"deny\":[]}},\"allow-set-webview-position\":{\"identifier\":\"allow-set-webview-position\",\"description\":\"Enables the set_webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_position\"],\"deny\":[]}},\"allow-set-webview-size\":{\"identifier\":\"allow-set-webview-size\",\"description\":\"Enables the set_webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_size\"],\"deny\":[]}},\"allow-set-webview-zoom\":{\"identifier\":\"allow-set-webview-zoom\",\"description\":\"Enables the set_webview_zoom command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_zoom\"],\"deny\":[]}},\"allow-webview-close\":{\"identifier\":\"allow-webview-close\",\"description\":\"Enables the webview_close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_close\"],\"deny\":[]}},\"allow-webview-hide\":{\"identifier\":\"allow-webview-hide\",\"description\":\"Enables the webview_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_hide\"],\"deny\":[]}},\"allow-webview-position\":{\"identifier\":\"allow-webview-position\",\"description\":\"Enables the webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_position\"],\"deny\":[]}},\"allow-webview-show\":{\"identifier\":\"allow-webview-show\",\"description\":\"Enables the webview_show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_show\"],\"deny\":[]}},\"allow-webview-size\":{\"identifier\":\"allow-webview-size\",\"description\":\"Enables the webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_size\"],\"deny\":[]}},\"deny-clear-all-browsing-data\":{\"identifier\":\"deny-clear-all-browsing-data\",\"description\":\"Denies the clear_all_browsing_data command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"clear_all_browsing_data\"]}},\"deny-create-webview\":{\"identifier\":\"deny-create-webview\",\"description\":\"Denies the create_webview command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_webview\"]}},\"deny-create-webview-window\":{\"identifier\":\"deny-create-webview-window\",\"description\":\"Denies the create_webview_window command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_webview_window\"]}},\"deny-get-all-webviews\":{\"identifier\":\"deny-get-all-webviews\",\"description\":\"Denies the get_all_webviews command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_all_webviews\"]}},\"deny-internal-toggle-devtools\":{\"identifier\":\"deny-internal-toggle-devtools\",\"description\":\"Denies the internal_toggle_devtools command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"internal_toggle_devtools\"]}},\"deny-print\":{\"identifier\":\"deny-print\",\"description\":\"Denies the print command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"print\"]}},\"deny-reparent\":{\"identifier\":\"deny-reparent\",\"description\":\"Denies the reparent command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"reparent\"]}},\"deny-set-webview-auto-resize\":{\"identifier\":\"deny-set-webview-auto-resize\",\"description\":\"Denies the set_webview_auto_resize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_auto_resize\"]}},\"deny-set-webview-background-color\":{\"identifier\":\"deny-set-webview-background-color\",\"description\":\"Denies the set_webview_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_background_color\"]}},\"deny-set-webview-focus\":{\"identifier\":\"deny-set-webview-focus\",\"description\":\"Denies the set_webview_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_focus\"]}},\"deny-set-webview-position\":{\"identifier\":\"deny-set-webview-position\",\"description\":\"Denies the set_webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_position\"]}},\"deny-set-webview-size\":{\"identifier\":\"deny-set-webview-size\",\"description\":\"Denies the set_webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_size\"]}},\"deny-set-webview-zoom\":{\"identifier\":\"deny-set-webview-zoom\",\"description\":\"Denies the set_webview_zoom command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_zoom\"]}},\"deny-webview-close\":{\"identifier\":\"deny-webview-close\",\"description\":\"Denies the webview_close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_close\"]}},\"deny-webview-hide\":{\"identifier\":\"deny-webview-hide\",\"description\":\"Denies the webview_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_hide\"]}},\"deny-webview-position\":{\"identifier\":\"deny-webview-position\",\"description\":\"Denies the webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_position\"]}},\"deny-webview-show\":{\"identifier\":\"deny-webview-show\",\"description\":\"Denies the webview_show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_show\"]}},\"deny-webview-size\":{\"identifier\":\"deny-webview-size\",\"description\":\"Denies the webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_size\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:window\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-get-all-windows\",\"allow-scale-factor\",\"allow-inner-position\",\"allow-outer-position\",\"allow-inner-size\",\"allow-outer-size\",\"allow-is-fullscreen\",\"allow-is-minimized\",\"allow-is-maximized\",\"allow-is-focused\",\"allow-is-decorated\",\"allow-is-resizable\",\"allow-is-maximizable\",\"allow-is-minimizable\",\"allow-is-closable\",\"allow-is-visible\",\"allow-is-enabled\",\"allow-title\",\"allow-current-monitor\",\"allow-primary-monitor\",\"allow-monitor-from-point\",\"allow-available-monitors\",\"allow-cursor-position\",\"allow-theme\",\"allow-is-always-on-top\",\"allow-internal-toggle-maximize\"]},\"permissions\":{\"allow-available-monitors\":{\"identifier\":\"allow-available-monitors\",\"description\":\"Enables the available_monitors command without any pre-configured scope.\",\"commands\":{\"allow\":[\"available_monitors\"],\"deny\":[]}},\"allow-center\":{\"identifier\":\"allow-center\",\"description\":\"Enables the center command without any pre-configured scope.\",\"commands\":{\"allow\":[\"center\"],\"deny\":[]}},\"allow-close\":{\"identifier\":\"allow-close\",\"description\":\"Enables the close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"close\"],\"deny\":[]}},\"allow-create\":{\"identifier\":\"allow-create\",\"description\":\"Enables the create command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create\"],\"deny\":[]}},\"allow-current-monitor\":{\"identifier\":\"allow-current-monitor\",\"description\":\"Enables the current_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"current_monitor\"],\"deny\":[]}},\"allow-cursor-position\":{\"identifier\":\"allow-cursor-position\",\"description\":\"Enables the cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"cursor_position\"],\"deny\":[]}},\"allow-destroy\":{\"identifier\":\"allow-destroy\",\"description\":\"Enables the destroy command without any pre-configured scope.\",\"commands\":{\"allow\":[\"destroy\"],\"deny\":[]}},\"allow-get-all-windows\":{\"identifier\":\"allow-get-all-windows\",\"description\":\"Enables the get_all_windows command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_all_windows\"],\"deny\":[]}},\"allow-hide\":{\"identifier\":\"allow-hide\",\"description\":\"Enables the hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"hide\"],\"deny\":[]}},\"allow-inner-position\":{\"identifier\":\"allow-inner-position\",\"description\":\"Enables the inner_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"inner_position\"],\"deny\":[]}},\"allow-inner-size\":{\"identifier\":\"allow-inner-size\",\"description\":\"Enables the inner_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"inner_size\"],\"deny\":[]}},\"allow-internal-toggle-maximize\":{\"identifier\":\"allow-internal-toggle-maximize\",\"description\":\"Enables the internal_toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"internal_toggle_maximize\"],\"deny\":[]}},\"allow-is-always-on-top\":{\"identifier\":\"allow-is-always-on-top\",\"description\":\"Enables the is_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_always_on_top\"],\"deny\":[]}},\"allow-is-closable\":{\"identifier\":\"allow-is-closable\",\"description\":\"Enables the is_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_closable\"],\"deny\":[]}},\"allow-is-decorated\":{\"identifier\":\"allow-is-decorated\",\"description\":\"Enables the is_decorated command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_decorated\"],\"deny\":[]}},\"allow-is-enabled\":{\"identifier\":\"allow-is-enabled\",\"description\":\"Enables the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_enabled\"],\"deny\":[]}},\"allow-is-focused\":{\"identifier\":\"allow-is-focused\",\"description\":\"Enables the is_focused command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_focused\"],\"deny\":[]}},\"allow-is-fullscreen\":{\"identifier\":\"allow-is-fullscreen\",\"description\":\"Enables the is_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_fullscreen\"],\"deny\":[]}},\"allow-is-maximizable\":{\"identifier\":\"allow-is-maximizable\",\"description\":\"Enables the is_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_maximizable\"],\"deny\":[]}},\"allow-is-maximized\":{\"identifier\":\"allow-is-maximized\",\"description\":\"Enables the is_maximized command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_maximized\"],\"deny\":[]}},\"allow-is-minimizable\":{\"identifier\":\"allow-is-minimizable\",\"description\":\"Enables the is_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_minimizable\"],\"deny\":[]}},\"allow-is-minimized\":{\"identifier\":\"allow-is-minimized\",\"description\":\"Enables the is_minimized command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_minimized\"],\"deny\":[]}},\"allow-is-resizable\":{\"identifier\":\"allow-is-resizable\",\"description\":\"Enables the is_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_resizable\"],\"deny\":[]}},\"allow-is-visible\":{\"identifier\":\"allow-is-visible\",\"description\":\"Enables the is_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_visible\"],\"deny\":[]}},\"allow-maximize\":{\"identifier\":\"allow-maximize\",\"description\":\"Enables the maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"maximize\"],\"deny\":[]}},\"allow-minimize\":{\"identifier\":\"allow-minimize\",\"description\":\"Enables the minimize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"minimize\"],\"deny\":[]}},\"allow-monitor-from-point\":{\"identifier\":\"allow-monitor-from-point\",\"description\":\"Enables the monitor_from_point command without any pre-configured scope.\",\"commands\":{\"allow\":[\"monitor_from_point\"],\"deny\":[]}},\"allow-outer-position\":{\"identifier\":\"allow-outer-position\",\"description\":\"Enables the outer_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"outer_position\"],\"deny\":[]}},\"allow-outer-size\":{\"identifier\":\"allow-outer-size\",\"description\":\"Enables the outer_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"outer_size\"],\"deny\":[]}},\"allow-primary-monitor\":{\"identifier\":\"allow-primary-monitor\",\"description\":\"Enables the primary_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"primary_monitor\"],\"deny\":[]}},\"allow-request-user-attention\":{\"identifier\":\"allow-request-user-attention\",\"description\":\"Enables the request_user_attention command without any pre-configured scope.\",\"commands\":{\"allow\":[\"request_user_attention\"],\"deny\":[]}},\"allow-scale-factor\":{\"identifier\":\"allow-scale-factor\",\"description\":\"Enables the scale_factor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"scale_factor\"],\"deny\":[]}},\"allow-set-always-on-bottom\":{\"identifier\":\"allow-set-always-on-bottom\",\"description\":\"Enables the set_always_on_bottom command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_always_on_bottom\"],\"deny\":[]}},\"allow-set-always-on-top\":{\"identifier\":\"allow-set-always-on-top\",\"description\":\"Enables the set_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_always_on_top\"],\"deny\":[]}},\"allow-set-background-color\":{\"identifier\":\"allow-set-background-color\",\"description\":\"Enables the set_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_background_color\"],\"deny\":[]}},\"allow-set-badge-count\":{\"identifier\":\"allow-set-badge-count\",\"description\":\"Enables the set_badge_count command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_badge_count\"],\"deny\":[]}},\"allow-set-badge-label\":{\"identifier\":\"allow-set-badge-label\",\"description\":\"Enables the set_badge_label command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_badge_label\"],\"deny\":[]}},\"allow-set-closable\":{\"identifier\":\"allow-set-closable\",\"description\":\"Enables the set_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_closable\"],\"deny\":[]}},\"allow-set-content-protected\":{\"identifier\":\"allow-set-content-protected\",\"description\":\"Enables the set_content_protected command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_content_protected\"],\"deny\":[]}},\"allow-set-cursor-grab\":{\"identifier\":\"allow-set-cursor-grab\",\"description\":\"Enables the set_cursor_grab command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_grab\"],\"deny\":[]}},\"allow-set-cursor-icon\":{\"identifier\":\"allow-set-cursor-icon\",\"description\":\"Enables the set_cursor_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_icon\"],\"deny\":[]}},\"allow-set-cursor-position\":{\"identifier\":\"allow-set-cursor-position\",\"description\":\"Enables the set_cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_position\"],\"deny\":[]}},\"allow-set-cursor-visible\":{\"identifier\":\"allow-set-cursor-visible\",\"description\":\"Enables the set_cursor_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_visible\"],\"deny\":[]}},\"allow-set-decorations\":{\"identifier\":\"allow-set-decorations\",\"description\":\"Enables the set_decorations command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_decorations\"],\"deny\":[]}},\"allow-set-effects\":{\"identifier\":\"allow-set-effects\",\"description\":\"Enables the set_effects command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_effects\"],\"deny\":[]}},\"allow-set-enabled\":{\"identifier\":\"allow-set-enabled\",\"description\":\"Enables the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_enabled\"],\"deny\":[]}},\"allow-set-focus\":{\"identifier\":\"allow-set-focus\",\"description\":\"Enables the set_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_focus\"],\"deny\":[]}},\"allow-set-focusable\":{\"identifier\":\"allow-set-focusable\",\"description\":\"Enables the set_focusable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_focusable\"],\"deny\":[]}},\"allow-set-fullscreen\":{\"identifier\":\"allow-set-fullscreen\",\"description\":\"Enables the set_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_fullscreen\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-ignore-cursor-events\":{\"identifier\":\"allow-set-ignore-cursor-events\",\"description\":\"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_ignore_cursor_events\"],\"deny\":[]}},\"allow-set-max-size\":{\"identifier\":\"allow-set-max-size\",\"description\":\"Enables the set_max_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_max_size\"],\"deny\":[]}},\"allow-set-maximizable\":{\"identifier\":\"allow-set-maximizable\",\"description\":\"Enables the set_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_maximizable\"],\"deny\":[]}},\"allow-set-min-size\":{\"identifier\":\"allow-set-min-size\",\"description\":\"Enables the set_min_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_min_size\"],\"deny\":[]}},\"allow-set-minimizable\":{\"identifier\":\"allow-set-minimizable\",\"description\":\"Enables the set_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_minimizable\"],\"deny\":[]}},\"allow-set-overlay-icon\":{\"identifier\":\"allow-set-overlay-icon\",\"description\":\"Enables the set_overlay_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_overlay_icon\"],\"deny\":[]}},\"allow-set-position\":{\"identifier\":\"allow-set-position\",\"description\":\"Enables the set_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_position\"],\"deny\":[]}},\"allow-set-progress-bar\":{\"identifier\":\"allow-set-progress-bar\",\"description\":\"Enables the set_progress_bar command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_progress_bar\"],\"deny\":[]}},\"allow-set-resizable\":{\"identifier\":\"allow-set-resizable\",\"description\":\"Enables the set_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_resizable\"],\"deny\":[]}},\"allow-set-shadow\":{\"identifier\":\"allow-set-shadow\",\"description\":\"Enables the set_shadow command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_shadow\"],\"deny\":[]}},\"allow-set-simple-fullscreen\":{\"identifier\":\"allow-set-simple-fullscreen\",\"description\":\"Enables the set_simple_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_simple_fullscreen\"],\"deny\":[]}},\"allow-set-size\":{\"identifier\":\"allow-set-size\",\"description\":\"Enables the set_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_size\"],\"deny\":[]}},\"allow-set-size-constraints\":{\"identifier\":\"allow-set-size-constraints\",\"description\":\"Enables the set_size_constraints command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_size_constraints\"],\"deny\":[]}},\"allow-set-skip-taskbar\":{\"identifier\":\"allow-set-skip-taskbar\",\"description\":\"Enables the set_skip_taskbar command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_skip_taskbar\"],\"deny\":[]}},\"allow-set-theme\":{\"identifier\":\"allow-set-theme\",\"description\":\"Enables the set_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_theme\"],\"deny\":[]}},\"allow-set-title\":{\"identifier\":\"allow-set-title\",\"description\":\"Enables the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title\"],\"deny\":[]}},\"allow-set-title-bar-style\":{\"identifier\":\"allow-set-title-bar-style\",\"description\":\"Enables the set_title_bar_style command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title_bar_style\"],\"deny\":[]}},\"allow-set-visible-on-all-workspaces\":{\"identifier\":\"allow-set-visible-on-all-workspaces\",\"description\":\"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_visible_on_all_workspaces\"],\"deny\":[]}},\"allow-show\":{\"identifier\":\"allow-show\",\"description\":\"Enables the show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"show\"],\"deny\":[]}},\"allow-start-dragging\":{\"identifier\":\"allow-start-dragging\",\"description\":\"Enables the start_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[\"start_dragging\"],\"deny\":[]}},\"allow-start-resize-dragging\":{\"identifier\":\"allow-start-resize-dragging\",\"description\":\"Enables the start_resize_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[\"start_resize_dragging\"],\"deny\":[]}},\"allow-theme\":{\"identifier\":\"allow-theme\",\"description\":\"Enables the theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"theme\"],\"deny\":[]}},\"allow-title\":{\"identifier\":\"allow-title\",\"description\":\"Enables the title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"title\"],\"deny\":[]}},\"allow-toggle-maximize\":{\"identifier\":\"allow-toggle-maximize\",\"description\":\"Enables the toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"toggle_maximize\"],\"deny\":[]}},\"allow-unmaximize\":{\"identifier\":\"allow-unmaximize\",\"description\":\"Enables the unmaximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unmaximize\"],\"deny\":[]}},\"allow-unminimize\":{\"identifier\":\"allow-unminimize\",\"description\":\"Enables the unminimize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unminimize\"],\"deny\":[]}},\"deny-available-monitors\":{\"identifier\":\"deny-available-monitors\",\"description\":\"Denies the available_monitors command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"available_monitors\"]}},\"deny-center\":{\"identifier\":\"deny-center\",\"description\":\"Denies the center command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"center\"]}},\"deny-close\":{\"identifier\":\"deny-close\",\"description\":\"Denies the close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"close\"]}},\"deny-create\":{\"identifier\":\"deny-create\",\"description\":\"Denies the create command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create\"]}},\"deny-current-monitor\":{\"identifier\":\"deny-current-monitor\",\"description\":\"Denies the current_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"current_monitor\"]}},\"deny-cursor-position\":{\"identifier\":\"deny-cursor-position\",\"description\":\"Denies the cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"cursor_position\"]}},\"deny-destroy\":{\"identifier\":\"deny-destroy\",\"description\":\"Denies the destroy command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"destroy\"]}},\"deny-get-all-windows\":{\"identifier\":\"deny-get-all-windows\",\"description\":\"Denies the get_all_windows command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_all_windows\"]}},\"deny-hide\":{\"identifier\":\"deny-hide\",\"description\":\"Denies the hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"hide\"]}},\"deny-inner-position\":{\"identifier\":\"deny-inner-position\",\"description\":\"Denies the inner_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"inner_position\"]}},\"deny-inner-size\":{\"identifier\":\"deny-inner-size\",\"description\":\"Denies the inner_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"inner_size\"]}},\"deny-internal-toggle-maximize\":{\"identifier\":\"deny-internal-toggle-maximize\",\"description\":\"Denies the internal_toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"internal_toggle_maximize\"]}},\"deny-is-always-on-top\":{\"identifier\":\"deny-is-always-on-top\",\"description\":\"Denies the is_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_always_on_top\"]}},\"deny-is-closable\":{\"identifier\":\"deny-is-closable\",\"description\":\"Denies the is_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_closable\"]}},\"deny-is-decorated\":{\"identifier\":\"deny-is-decorated\",\"description\":\"Denies the is_decorated command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_decorated\"]}},\"deny-is-enabled\":{\"identifier\":\"deny-is-enabled\",\"description\":\"Denies the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_enabled\"]}},\"deny-is-focused\":{\"identifier\":\"deny-is-focused\",\"description\":\"Denies the is_focused command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_focused\"]}},\"deny-is-fullscreen\":{\"identifier\":\"deny-is-fullscreen\",\"description\":\"Denies the is_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_fullscreen\"]}},\"deny-is-maximizable\":{\"identifier\":\"deny-is-maximizable\",\"description\":\"Denies the is_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_maximizable\"]}},\"deny-is-maximized\":{\"identifier\":\"deny-is-maximized\",\"description\":\"Denies the is_maximized command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_maximized\"]}},\"deny-is-minimizable\":{\"identifier\":\"deny-is-minimizable\",\"description\":\"Denies the is_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_minimizable\"]}},\"deny-is-minimized\":{\"identifier\":\"deny-is-minimized\",\"description\":\"Denies the is_minimized command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_minimized\"]}},\"deny-is-resizable\":{\"identifier\":\"deny-is-resizable\",\"description\":\"Denies the is_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_resizable\"]}},\"deny-is-visible\":{\"identifier\":\"deny-is-visible\",\"description\":\"Denies the is_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_visible\"]}},\"deny-maximize\":{\"identifier\":\"deny-maximize\",\"description\":\"Denies the maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"maximize\"]}},\"deny-minimize\":{\"identifier\":\"deny-minimize\",\"description\":\"Denies the minimize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"minimize\"]}},\"deny-monitor-from-point\":{\"identifier\":\"deny-monitor-from-point\",\"description\":\"Denies the monitor_from_point command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"monitor_from_point\"]}},\"deny-outer-position\":{\"identifier\":\"deny-outer-position\",\"description\":\"Denies the outer_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"outer_position\"]}},\"deny-outer-size\":{\"identifier\":\"deny-outer-size\",\"description\":\"Denies the outer_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"outer_size\"]}},\"deny-primary-monitor\":{\"identifier\":\"deny-primary-monitor\",\"description\":\"Denies the primary_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"primary_monitor\"]}},\"deny-request-user-attention\":{\"identifier\":\"deny-request-user-attention\",\"description\":\"Denies the request_user_attention command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"request_user_attention\"]}},\"deny-scale-factor\":{\"identifier\":\"deny-scale-factor\",\"description\":\"Denies the scale_factor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"scale_factor\"]}},\"deny-set-always-on-bottom\":{\"identifier\":\"deny-set-always-on-bottom\",\"description\":\"Denies the set_always_on_bottom command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_always_on_bottom\"]}},\"deny-set-always-on-top\":{\"identifier\":\"deny-set-always-on-top\",\"description\":\"Denies the set_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_always_on_top\"]}},\"deny-set-background-color\":{\"identifier\":\"deny-set-background-color\",\"description\":\"Denies the set_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_background_color\"]}},\"deny-set-badge-count\":{\"identifier\":\"deny-set-badge-count\",\"description\":\"Denies the set_badge_count command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_badge_count\"]}},\"deny-set-badge-label\":{\"identifier\":\"deny-set-badge-label\",\"description\":\"Denies the set_badge_label command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_badge_label\"]}},\"deny-set-closable\":{\"identifier\":\"deny-set-closable\",\"description\":\"Denies the set_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_closable\"]}},\"deny-set-content-protected\":{\"identifier\":\"deny-set-content-protected\",\"description\":\"Denies the set_content_protected command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_content_protected\"]}},\"deny-set-cursor-grab\":{\"identifier\":\"deny-set-cursor-grab\",\"description\":\"Denies the set_cursor_grab command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_grab\"]}},\"deny-set-cursor-icon\":{\"identifier\":\"deny-set-cursor-icon\",\"description\":\"Denies the set_cursor_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_icon\"]}},\"deny-set-cursor-position\":{\"identifier\":\"deny-set-cursor-position\",\"description\":\"Denies the set_cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_position\"]}},\"deny-set-cursor-visible\":{\"identifier\":\"deny-set-cursor-visible\",\"description\":\"Denies the set_cursor_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_visible\"]}},\"deny-set-decorations\":{\"identifier\":\"deny-set-decorations\",\"description\":\"Denies the set_decorations command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_decorations\"]}},\"deny-set-effects\":{\"identifier\":\"deny-set-effects\",\"description\":\"Denies the set_effects command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_effects\"]}},\"deny-set-enabled\":{\"identifier\":\"deny-set-enabled\",\"description\":\"Denies the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_enabled\"]}},\"deny-set-focus\":{\"identifier\":\"deny-set-focus\",\"description\":\"Denies the set_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_focus\"]}},\"deny-set-focusable\":{\"identifier\":\"deny-set-focusable\",\"description\":\"Denies the set_focusable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_focusable\"]}},\"deny-set-fullscreen\":{\"identifier\":\"deny-set-fullscreen\",\"description\":\"Denies the set_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_fullscreen\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-ignore-cursor-events\":{\"identifier\":\"deny-set-ignore-cursor-events\",\"description\":\"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_ignore_cursor_events\"]}},\"deny-set-max-size\":{\"identifier\":\"deny-set-max-size\",\"description\":\"Denies the set_max_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_max_size\"]}},\"deny-set-maximizable\":{\"identifier\":\"deny-set-maximizable\",\"description\":\"Denies the set_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_maximizable\"]}},\"deny-set-min-size\":{\"identifier\":\"deny-set-min-size\",\"description\":\"Denies the set_min_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_min_size\"]}},\"deny-set-minimizable\":{\"identifier\":\"deny-set-minimizable\",\"description\":\"Denies the set_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_minimizable\"]}},\"deny-set-overlay-icon\":{\"identifier\":\"deny-set-overlay-icon\",\"description\":\"Denies the set_overlay_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_overlay_icon\"]}},\"deny-set-position\":{\"identifier\":\"deny-set-position\",\"description\":\"Denies the set_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_position\"]}},\"deny-set-progress-bar\":{\"identifier\":\"deny-set-progress-bar\",\"description\":\"Denies the set_progress_bar command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_progress_bar\"]}},\"deny-set-resizable\":{\"identifier\":\"deny-set-resizable\",\"description\":\"Denies the set_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_resizable\"]}},\"deny-set-shadow\":{\"identifier\":\"deny-set-shadow\",\"description\":\"Denies the set_shadow command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_shadow\"]}},\"deny-set-simple-fullscreen\":{\"identifier\":\"deny-set-simple-fullscreen\",\"description\":\"Denies the set_simple_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_simple_fullscreen\"]}},\"deny-set-size\":{\"identifier\":\"deny-set-size\",\"description\":\"Denies the set_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_size\"]}},\"deny-set-size-constraints\":{\"identifier\":\"deny-set-size-constraints\",\"description\":\"Denies the set_size_constraints command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_size_constraints\"]}},\"deny-set-skip-taskbar\":{\"identifier\":\"deny-set-skip-taskbar\",\"description\":\"Denies the set_skip_taskbar command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_skip_taskbar\"]}},\"deny-set-theme\":{\"identifier\":\"deny-set-theme\",\"description\":\"Denies the set_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_theme\"]}},\"deny-set-title\":{\"identifier\":\"deny-set-title\",\"description\":\"Denies the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title\"]}},\"deny-set-title-bar-style\":{\"identifier\":\"deny-set-title-bar-style\",\"description\":\"Denies the set_title_bar_style command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title_bar_style\"]}},\"deny-set-visible-on-all-workspaces\":{\"identifier\":\"deny-set-visible-on-all-workspaces\",\"description\":\"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_visible_on_all_workspaces\"]}},\"deny-show\":{\"identifier\":\"deny-show\",\"description\":\"Denies the show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"show\"]}},\"deny-start-dragging\":{\"identifier\":\"deny-start-dragging\",\"description\":\"Denies the start_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"start_dragging\"]}},\"deny-start-resize-dragging\":{\"identifier\":\"deny-start-resize-dragging\",\"description\":\"Denies the start_resize_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"start_resize_dragging\"]}},\"deny-theme\":{\"identifier\":\"deny-theme\",\"description\":\"Denies the theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"theme\"]}},\"deny-title\":{\"identifier\":\"deny-title\",\"description\":\"Denies the title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"title\"]}},\"deny-toggle-maximize\":{\"identifier\":\"deny-toggle-maximize\",\"description\":\"Denies the toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"toggle_maximize\"]}},\"deny-unmaximize\":{\"identifier\":\"deny-unmaximize\",\"description\":\"Denies the unmaximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unmaximize\"]}},\"deny-unminimize\":{\"identifier\":\"deny-unminimize\",\"description\":\"Denies the unminimize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unminimize\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"dialog\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set configures the types of dialogs\\navailable from the dialog plugin.\\n\\n#### Granted Permissions\\n\\nAll dialog types are enabled.\\n\\n\\n\",\"permissions\":[\"allow-ask\",\"allow-confirm\",\"allow-message\",\"allow-save\",\"allow-open\"]},\"permissions\":{\"allow-ask\":{\"identifier\":\"allow-ask\",\"description\":\"Enables the ask command without any pre-configured scope.\",\"commands\":{\"allow\":[\"ask\"],\"deny\":[]}},\"allow-confirm\":{\"identifier\":\"allow-confirm\",\"description\":\"Enables the confirm command without any pre-configured scope.\",\"commands\":{\"allow\":[\"confirm\"],\"deny\":[]}},\"allow-message\":{\"identifier\":\"allow-message\",\"description\":\"Enables the message command without any pre-configured scope.\",\"commands\":{\"allow\":[\"message\"],\"deny\":[]}},\"allow-open\":{\"identifier\":\"allow-open\",\"description\":\"Enables the open command without any pre-configured scope.\",\"commands\":{\"allow\":[\"open\"],\"deny\":[]}},\"allow-save\":{\"identifier\":\"allow-save\",\"description\":\"Enables the save command without any pre-configured scope.\",\"commands\":{\"allow\":[\"save\"],\"deny\":[]}},\"deny-ask\":{\"identifier\":\"deny-ask\",\"description\":\"Denies the ask command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"ask\"]}},\"deny-confirm\":{\"identifier\":\"deny-confirm\",\"description\":\"Denies the confirm command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"confirm\"]}},\"deny-message\":{\"identifier\":\"deny-message\",\"description\":\"Denies the message command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"message\"]}},\"deny-open\":{\"identifier\":\"deny-open\",\"description\":\"Denies the open command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"open\"]}},\"deny-save\":{\"identifier\":\"deny-save\",\"description\":\"Denies the save command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"save\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"opener\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\",\"permissions\":[\"allow-open-url\",\"allow-reveal-item-in-dir\",\"allow-default-urls\"]},\"permissions\":{\"allow-default-urls\":{\"identifier\":\"allow-default-urls\",\"description\":\"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\"commands\":{\"allow\":[],\"deny\":[]},\"scope\":{\"allow\":[{\"url\":\"mailto:*\"},{\"url\":\"tel:*\"},{\"url\":\"http://*\"},{\"url\":\"https://*\"}]}},\"allow-open-path\":{\"identifier\":\"allow-open-path\",\"description\":\"Enables the open_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"open_path\"],\"deny\":[]}},\"allow-open-url\":{\"identifier\":\"allow-open-url\",\"description\":\"Enables the open_url command without any pre-configured scope.\",\"commands\":{\"allow\":[\"open_url\"],\"deny\":[]}},\"allow-reveal-item-in-dir\":{\"identifier\":\"allow-reveal-item-in-dir\",\"description\":\"Enables the reveal_item_in_dir command without any pre-configured scope.\",\"commands\":{\"allow\":[\"reveal_item_in_dir\"],\"deny\":[]}},\"deny-open-path\":{\"identifier\":\"deny-open-path\",\"description\":\"Denies the open_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"open_path\"]}},\"deny-open-url\":{\"identifier\":\"deny-open-url\",\"description\":\"Denies the open_url command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"open_url\"]}},\"deny-reveal-item-in-dir\":{\"identifier\":\"deny-reveal-item-in-dir\",\"description\":\"Denies the reveal_item_in_dir command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"reveal_item_in_dir\"]}}},\"permission_sets\":{},\"global_scope_schema\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"anyOf\":[{\"properties\":{\"app\":{\"allOf\":[{\"$ref\":\"#/definitions/Application\"}],\"description\":\"An application to open this url with, for example: firefox.\"},\"url\":{\"description\":\"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\"type\":\"string\"}},\"required\":[\"url\"],\"type\":\"object\"},{\"properties\":{\"app\":{\"allOf\":[{\"$ref\":\"#/definitions/Application\"}],\"description\":\"An application to open this path with, for example: xdg-open.\"},\"path\":{\"description\":\"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\"type\":\"string\"}},\"required\":[\"path\"],\"type\":\"object\"}],\"definitions\":{\"Application\":{\"anyOf\":[{\"description\":\"Open in default application.\",\"type\":\"null\"},{\"description\":\"If true, allow open with any application.\",\"type\":\"boolean\"},{\"description\":\"Allow specific application to open with.\",\"type\":\"string\"}],\"description\":\"Opener scope application.\"}},\"description\":\"Opener scope entry.\",\"title\":\"OpenerScopeEntry\"}},\"process\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set configures which\\nprocess features are by default exposed.\\n\\n#### Granted Permissions\\n\\nThis enables to quit via `allow-exit` and restart via `allow-restart`\\nthe application.\\n\",\"permissions\":[\"allow-exit\",\"allow-restart\"]},\"permissions\":{\"allow-exit\":{\"identifier\":\"allow-exit\",\"description\":\"Enables the exit command without any pre-configured scope.\",\"commands\":{\"allow\":[\"exit\"],\"deny\":[]}},\"allow-restart\":{\"identifier\":\"allow-restart\",\"description\":\"Enables the restart command without any pre-configured scope.\",\"commands\":{\"allow\":[\"restart\"],\"deny\":[]}},\"deny-exit\":{\"identifier\":\"deny-exit\",\"description\":\"Denies the exit command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"exit\"]}},\"deny-restart\":{\"identifier\":\"deny-restart\",\"description\":\"Denies the restart command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"restart\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"updater\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\",\"permissions\":[\"allow-check\",\"allow-download\",\"allow-install\",\"allow-download-and-install\"]},\"permissions\":{\"allow-check\":{\"identifier\":\"allow-check\",\"description\":\"Enables the check command without any pre-configured scope.\",\"commands\":{\"allow\":[\"check\"],\"deny\":[]}},\"allow-download\":{\"identifier\":\"allow-download\",\"description\":\"Enables the download command without any pre-configured scope.\",\"commands\":{\"allow\":[\"download\"],\"deny\":[]}},\"allow-download-and-install\":{\"identifier\":\"allow-download-and-install\",\"description\":\"Enables the download_and_install command without any pre-configured scope.\",\"commands\":{\"allow\":[\"download_and_install\"],\"deny\":[]}},\"allow-install\":{\"identifier\":\"allow-install\",\"description\":\"Enables the install command without any pre-configured scope.\",\"commands\":{\"allow\":[\"install\"],\"deny\":[]}},\"deny-check\":{\"identifier\":\"deny-check\",\"description\":\"Denies the check command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"check\"]}},\"deny-download\":{\"identifier\":\"deny-download\",\"description\":\"Denies the download command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"download\"]}},\"deny-download-and-install\":{\"identifier\":\"deny-download-and-install\",\"description\":\"Denies the download_and_install command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"download_and_install\"]}},\"deny-install\":{\"identifier\":\"deny-install\",\"description\":\"Denies the install command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"install\"]}}},\"permission_sets\":{},\"global_scope_schema\":null}}"
  },
  {
    "path": "tauri/src-tauri/gen/schemas/capabilities.json",
    "content": "{\"default\":{\"identifier\":\"default\",\"description\":\"Capability for the main window\",\"local\":true,\"windows\":[\"main\"],\"permissions\":[\"core:default\",\"core:event:allow-listen\",\"core:event:allow-emit\",\"opener:default\",{\"identifier\":\"opener:allow-open-path\",\"allow\":[{\"path\":\"$DOWNLOAD/**\"},{\"path\":\"$HOME/**\"}]},\"dialog:default\",\"updater:default\",\"process:default\"]}}"
  },
  {
    "path": "tauri/src-tauri/gen/schemas/desktop-schema.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"CapabilityFile\",\n  \"description\": \"Capability formats accepted in a capability file.\",\n  \"anyOf\": [\n    {\n      \"description\": \"A single capability.\",\n      \"allOf\": [\n        {\n          \"$ref\": \"#/definitions/Capability\"\n        }\n      ]\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Capability\"\n      }\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"capabilities\"\n      ],\n      \"properties\": {\n        \"capabilities\": {\n          \"description\": \"The list of capabilities.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Capability\"\n          }\n        }\n      }\n    }\n  ],\n  \"definitions\": {\n    \"Capability\": {\n      \"description\": \"A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\\n\\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\\n\\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\\n\\n## Example\\n\\n```json { \\\"identifier\\\": \\\"main-user-files-write\\\", \\\"description\\\": \\\"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\\\", \\\"windows\\\": [ \\\"main\\\" ], \\\"permissions\\\": [ \\\"core:default\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] }, ], \\\"platforms\\\": [\\\"macOS\\\",\\\"windows\\\"] } ```\",\n      \"type\": \"object\",\n      \"required\": [\n        \"identifier\",\n        \"permissions\"\n      ],\n      \"properties\": {\n        \"identifier\": {\n          \"description\": \"Identifier of the capability.\\n\\n## Example\\n\\n`main-user-files-write`\",\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"description\": \"Description of what the capability is intended to allow on associated windows.\\n\\nIt should contain a description of what the grouped permissions should allow.\\n\\n## Example\\n\\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\",\n          \"default\": \"\",\n          \"type\": \"string\"\n        },\n        \"remote\": {\n          \"description\": \"Configure remote URLs that can use the capability permissions.\\n\\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\\n\\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\\n\\n## Example\\n\\n```json { \\\"urls\\\": [\\\"https://*.mydomain.dev\\\"] } ```\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/CapabilityRemote\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"local\": {\n          \"description\": \"Whether this capability is enabled for local app URLs or not. Defaults to `true`.\",\n          \"default\": true,\n          \"type\": \"boolean\"\n        },\n        \"windows\": {\n          \"description\": \"List of windows that are affected by this capability. Can be a glob pattern.\\n\\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\\n\\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\\n\\n## Example\\n\\n`[\\\"main\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"webviews\": {\n          \"description\": \"List of webviews that are affected by this capability. Can be a glob pattern.\\n\\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\\n\\n## Example\\n\\n`[\\\"sub-webview-one\\\", \\\"sub-webview-two\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"permissions\": {\n          \"description\": \"List of permissions attached to this capability.\\n\\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\\n\\n## Example\\n\\n```json [ \\\"core:default\\\", \\\"shell:allow-open\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] } ] ```\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/PermissionEntry\"\n          },\n          \"uniqueItems\": true\n        },\n        \"platforms\": {\n          \"description\": \"Limit which target platforms this capability applies to.\\n\\nBy default all platforms are targeted.\\n\\n## Example\\n\\n`[\\\"macOS\\\",\\\"windows\\\"]`\",\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"$ref\": \"#/definitions/Target\"\n          }\n        }\n      }\n    },\n    \"CapabilityRemote\": {\n      \"description\": \"Configuration for remote URLs that are associated with the capability.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"urls\"\n      ],\n      \"properties\": {\n        \"urls\": {\n          \"description\": \"Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\\n\\n## Examples\\n\\n- \\\"https://*.mydomain.dev\\\": allows subdomains of mydomain.dev - \\\"https://mydomain.dev/api/*\\\": allows any subpath of mydomain.dev/api\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    },\n    \"PermissionEntry\": {\n      \"description\": \"An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Reference a permission or permission set by identifier.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Identifier\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Reference a permission or permission set by identifier and extends its scope.\",\n          \"type\": \"object\",\n          \"allOf\": [\n            {\n              \"if\": {\n                \"properties\": {\n                  \"identifier\": {\n                    \"anyOf\": [\n                      {\n                        \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:default\",\n                        \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n                      },\n                      {\n                        \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-default-urls\",\n                        \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-path\",\n                        \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-url\",\n                        \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-path\",\n                        \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-url\",\n                        \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n                      }\n                    ]\n                  }\n                }\n              },\n              \"then\": {\n                \"properties\": {\n                  \"allow\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  },\n                  \"deny\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  }\n                }\n              },\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                }\n              }\n            },\n            {\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                },\n                \"allow\": {\n                  \"description\": \"Data that defines what is allowed by the scope.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                },\n                \"deny\": {\n                  \"description\": \"Data that defines what is denied by the scope. This should be prioritized by validation logic.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                }\n              }\n            }\n          ],\n          \"required\": [\n            \"identifier\"\n          ]\n        }\n      ]\n    },\n    \"Identifier\": {\n      \"description\": \"Permission identifier\",\n      \"oneOf\": [\n        {\n          \"description\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\",\n          \"type\": \"string\",\n          \"const\": \"core:default\",\n          \"markdownDescription\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\",\n          \"type\": \"string\",\n          \"const\": \"core:app:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\"\n        },\n        {\n          \"description\": \"Enables the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-hide\",\n          \"markdownDescription\": \"Enables the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-show\",\n          \"markdownDescription\": \"Enables the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-bundle-type\",\n          \"markdownDescription\": \"Enables the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-default-window-icon\",\n          \"markdownDescription\": \"Enables the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-identifier\",\n          \"markdownDescription\": \"Enables the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-name\",\n          \"markdownDescription\": \"Enables the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-data-store\",\n          \"markdownDescription\": \"Enables the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-listener\",\n          \"markdownDescription\": \"Enables the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-app-theme\",\n          \"markdownDescription\": \"Enables the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-dock-visibility\",\n          \"markdownDescription\": \"Enables the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-tauri-version\",\n          \"markdownDescription\": \"Enables the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-version\",\n          \"markdownDescription\": \"Enables the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-hide\",\n          \"markdownDescription\": \"Denies the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-show\",\n          \"markdownDescription\": \"Denies the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-bundle-type\",\n          \"markdownDescription\": \"Denies the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-default-window-icon\",\n          \"markdownDescription\": \"Denies the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-identifier\",\n          \"markdownDescription\": \"Denies the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-name\",\n          \"markdownDescription\": \"Denies the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-data-store\",\n          \"markdownDescription\": \"Denies the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-listener\",\n          \"markdownDescription\": \"Denies the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-app-theme\",\n          \"markdownDescription\": \"Denies the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-dock-visibility\",\n          \"markdownDescription\": \"Denies the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-tauri-version\",\n          \"markdownDescription\": \"Denies the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-version\",\n          \"markdownDescription\": \"Denies the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\",\n          \"type\": \"string\",\n          \"const\": \"core:event:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\"\n        },\n        {\n          \"description\": \"Enables the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit\",\n          \"markdownDescription\": \"Enables the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit-to\",\n          \"markdownDescription\": \"Enables the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-listen\",\n          \"markdownDescription\": \"Enables the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-unlisten\",\n          \"markdownDescription\": \"Enables the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit\",\n          \"markdownDescription\": \"Denies the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit-to\",\n          \"markdownDescription\": \"Denies the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-listen\",\n          \"markdownDescription\": \"Denies the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-unlisten\",\n          \"markdownDescription\": \"Denies the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\",\n          \"type\": \"string\",\n          \"const\": \"core:image:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\"\n        },\n        {\n          \"description\": \"Enables the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-bytes\",\n          \"markdownDescription\": \"Enables the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-path\",\n          \"markdownDescription\": \"Enables the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-rgba\",\n          \"markdownDescription\": \"Enables the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-size\",\n          \"markdownDescription\": \"Enables the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-bytes\",\n          \"markdownDescription\": \"Denies the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-path\",\n          \"markdownDescription\": \"Denies the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-rgba\",\n          \"markdownDescription\": \"Denies the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-size\",\n          \"markdownDescription\": \"Denies the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\"\n        },\n        {\n          \"description\": \"Enables the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-append\",\n          \"markdownDescription\": \"Enables the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-create-default\",\n          \"markdownDescription\": \"Enables the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-get\",\n          \"markdownDescription\": \"Enables the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-insert\",\n          \"markdownDescription\": \"Enables the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-checked\",\n          \"markdownDescription\": \"Enables the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-items\",\n          \"markdownDescription\": \"Enables the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-popup\",\n          \"markdownDescription\": \"Enables the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-prepend\",\n          \"markdownDescription\": \"Enables the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove\",\n          \"markdownDescription\": \"Enables the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove-at\",\n          \"markdownDescription\": \"Enables the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-accelerator\",\n          \"markdownDescription\": \"Enables the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-app-menu\",\n          \"markdownDescription\": \"Enables the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-window-menu\",\n          \"markdownDescription\": \"Enables the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-checked\",\n          \"markdownDescription\": \"Enables the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-text\",\n          \"markdownDescription\": \"Enables the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-text\",\n          \"markdownDescription\": \"Enables the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-append\",\n          \"markdownDescription\": \"Denies the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-create-default\",\n          \"markdownDescription\": \"Denies the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-get\",\n          \"markdownDescription\": \"Denies the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-insert\",\n          \"markdownDescription\": \"Denies the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-checked\",\n          \"markdownDescription\": \"Denies the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-items\",\n          \"markdownDescription\": \"Denies the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-popup\",\n          \"markdownDescription\": \"Denies the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-prepend\",\n          \"markdownDescription\": \"Denies the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove\",\n          \"markdownDescription\": \"Denies the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove-at\",\n          \"markdownDescription\": \"Denies the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-accelerator\",\n          \"markdownDescription\": \"Denies the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-app-menu\",\n          \"markdownDescription\": \"Denies the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-window-menu\",\n          \"markdownDescription\": \"Denies the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-checked\",\n          \"markdownDescription\": \"Denies the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-text\",\n          \"markdownDescription\": \"Denies the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-text\",\n          \"markdownDescription\": \"Denies the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\",\n          \"type\": \"string\",\n          \"const\": \"core:path:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\"\n        },\n        {\n          \"description\": \"Enables the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-basename\",\n          \"markdownDescription\": \"Enables the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-dirname\",\n          \"markdownDescription\": \"Enables the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-extname\",\n          \"markdownDescription\": \"Enables the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-is-absolute\",\n          \"markdownDescription\": \"Enables the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-join\",\n          \"markdownDescription\": \"Enables the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-normalize\",\n          \"markdownDescription\": \"Enables the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve\",\n          \"markdownDescription\": \"Enables the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve-directory\",\n          \"markdownDescription\": \"Enables the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-basename\",\n          \"markdownDescription\": \"Denies the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-dirname\",\n          \"markdownDescription\": \"Denies the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-extname\",\n          \"markdownDescription\": \"Denies the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-is-absolute\",\n          \"markdownDescription\": \"Denies the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-join\",\n          \"markdownDescription\": \"Denies the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-normalize\",\n          \"markdownDescription\": \"Denies the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve\",\n          \"markdownDescription\": \"Denies the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve-directory\",\n          \"markdownDescription\": \"Denies the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\"\n        },\n        {\n          \"description\": \"Enables the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-get-by-id\",\n          \"markdownDescription\": \"Enables the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-remove-by-id\",\n          \"markdownDescription\": \"Enables the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon-as-template\",\n          \"markdownDescription\": \"Enables the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-menu\",\n          \"markdownDescription\": \"Enables the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-temp-dir-path\",\n          \"markdownDescription\": \"Enables the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-tooltip\",\n          \"markdownDescription\": \"Enables the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-visible\",\n          \"markdownDescription\": \"Enables the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-get-by-id\",\n          \"markdownDescription\": \"Denies the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-remove-by-id\",\n          \"markdownDescription\": \"Denies the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon-as-template\",\n          \"markdownDescription\": \"Denies the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-menu\",\n          \"markdownDescription\": \"Denies the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-temp-dir-path\",\n          \"markdownDescription\": \"Denies the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-tooltip\",\n          \"markdownDescription\": \"Denies the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-visible\",\n          \"markdownDescription\": \"Denies the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\"\n        },\n        {\n          \"description\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-clear-all-browsing-data\",\n          \"markdownDescription\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview\",\n          \"markdownDescription\": \"Enables the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview-window\",\n          \"markdownDescription\": \"Enables the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-get-all-webviews\",\n          \"markdownDescription\": \"Enables the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-internal-toggle-devtools\",\n          \"markdownDescription\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-print\",\n          \"markdownDescription\": \"Enables the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-reparent\",\n          \"markdownDescription\": \"Enables the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-auto-resize\",\n          \"markdownDescription\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-background-color\",\n          \"markdownDescription\": \"Enables the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-focus\",\n          \"markdownDescription\": \"Enables the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-position\",\n          \"markdownDescription\": \"Enables the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-size\",\n          \"markdownDescription\": \"Enables the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-zoom\",\n          \"markdownDescription\": \"Enables the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-close\",\n          \"markdownDescription\": \"Enables the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-hide\",\n          \"markdownDescription\": \"Enables the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-position\",\n          \"markdownDescription\": \"Enables the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-show\",\n          \"markdownDescription\": \"Enables the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-size\",\n          \"markdownDescription\": \"Enables the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-clear-all-browsing-data\",\n          \"markdownDescription\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview\",\n          \"markdownDescription\": \"Denies the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview-window\",\n          \"markdownDescription\": \"Denies the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-get-all-webviews\",\n          \"markdownDescription\": \"Denies the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-internal-toggle-devtools\",\n          \"markdownDescription\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-print\",\n          \"markdownDescription\": \"Denies the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-reparent\",\n          \"markdownDescription\": \"Denies the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-auto-resize\",\n          \"markdownDescription\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-background-color\",\n          \"markdownDescription\": \"Denies the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-focus\",\n          \"markdownDescription\": \"Denies the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-position\",\n          \"markdownDescription\": \"Denies the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-size\",\n          \"markdownDescription\": \"Denies the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-zoom\",\n          \"markdownDescription\": \"Denies the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-close\",\n          \"markdownDescription\": \"Denies the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-hide\",\n          \"markdownDescription\": \"Denies the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-position\",\n          \"markdownDescription\": \"Denies the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-show\",\n          \"markdownDescription\": \"Denies the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-size\",\n          \"markdownDescription\": \"Denies the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\",\n          \"type\": \"string\",\n          \"const\": \"core:window:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\"\n        },\n        {\n          \"description\": \"Enables the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-available-monitors\",\n          \"markdownDescription\": \"Enables the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-center\",\n          \"markdownDescription\": \"Enables the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-create\",\n          \"markdownDescription\": \"Enables the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-current-monitor\",\n          \"markdownDescription\": \"Enables the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-cursor-position\",\n          \"markdownDescription\": \"Enables the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-destroy\",\n          \"markdownDescription\": \"Enables the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-get-all-windows\",\n          \"markdownDescription\": \"Enables the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-hide\",\n          \"markdownDescription\": \"Enables the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-position\",\n          \"markdownDescription\": \"Enables the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-size\",\n          \"markdownDescription\": \"Enables the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-internal-toggle-maximize\",\n          \"markdownDescription\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-always-on-top\",\n          \"markdownDescription\": \"Enables the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-closable\",\n          \"markdownDescription\": \"Enables the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-decorated\",\n          \"markdownDescription\": \"Enables the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-focused\",\n          \"markdownDescription\": \"Enables the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-fullscreen\",\n          \"markdownDescription\": \"Enables the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximizable\",\n          \"markdownDescription\": \"Enables the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximized\",\n          \"markdownDescription\": \"Enables the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimizable\",\n          \"markdownDescription\": \"Enables the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimized\",\n          \"markdownDescription\": \"Enables the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-resizable\",\n          \"markdownDescription\": \"Enables the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-visible\",\n          \"markdownDescription\": \"Enables the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-maximize\",\n          \"markdownDescription\": \"Enables the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-minimize\",\n          \"markdownDescription\": \"Enables the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-monitor-from-point\",\n          \"markdownDescription\": \"Enables the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-position\",\n          \"markdownDescription\": \"Enables the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-size\",\n          \"markdownDescription\": \"Enables the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-primary-monitor\",\n          \"markdownDescription\": \"Enables the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-request-user-attention\",\n          \"markdownDescription\": \"Enables the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-scale-factor\",\n          \"markdownDescription\": \"Enables the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-bottom\",\n          \"markdownDescription\": \"Enables the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-top\",\n          \"markdownDescription\": \"Enables the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-background-color\",\n          \"markdownDescription\": \"Enables the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-count\",\n          \"markdownDescription\": \"Enables the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-label\",\n          \"markdownDescription\": \"Enables the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-closable\",\n          \"markdownDescription\": \"Enables the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-content-protected\",\n          \"markdownDescription\": \"Enables the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-grab\",\n          \"markdownDescription\": \"Enables the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-icon\",\n          \"markdownDescription\": \"Enables the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-position\",\n          \"markdownDescription\": \"Enables the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-visible\",\n          \"markdownDescription\": \"Enables the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-decorations\",\n          \"markdownDescription\": \"Enables the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-effects\",\n          \"markdownDescription\": \"Enables the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focus\",\n          \"markdownDescription\": \"Enables the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focusable\",\n          \"markdownDescription\": \"Enables the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-fullscreen\",\n          \"markdownDescription\": \"Enables the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-max-size\",\n          \"markdownDescription\": \"Enables the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-maximizable\",\n          \"markdownDescription\": \"Enables the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-min-size\",\n          \"markdownDescription\": \"Enables the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-minimizable\",\n          \"markdownDescription\": \"Enables the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-overlay-icon\",\n          \"markdownDescription\": \"Enables the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-position\",\n          \"markdownDescription\": \"Enables the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-progress-bar\",\n          \"markdownDescription\": \"Enables the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-resizable\",\n          \"markdownDescription\": \"Enables the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-shadow\",\n          \"markdownDescription\": \"Enables the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-simple-fullscreen\",\n          \"markdownDescription\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size\",\n          \"markdownDescription\": \"Enables the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size-constraints\",\n          \"markdownDescription\": \"Enables the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-skip-taskbar\",\n          \"markdownDescription\": \"Enables the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-theme\",\n          \"markdownDescription\": \"Enables the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title-bar-style\",\n          \"markdownDescription\": \"Enables the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-dragging\",\n          \"markdownDescription\": \"Enables the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-resize-dragging\",\n          \"markdownDescription\": \"Enables the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-theme\",\n          \"markdownDescription\": \"Enables the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-title\",\n          \"markdownDescription\": \"Enables the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-toggle-maximize\",\n          \"markdownDescription\": \"Enables the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unmaximize\",\n          \"markdownDescription\": \"Enables the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unminimize\",\n          \"markdownDescription\": \"Enables the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-available-monitors\",\n          \"markdownDescription\": \"Denies the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-center\",\n          \"markdownDescription\": \"Denies the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-create\",\n          \"markdownDescription\": \"Denies the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-current-monitor\",\n          \"markdownDescription\": \"Denies the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-cursor-position\",\n          \"markdownDescription\": \"Denies the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-destroy\",\n          \"markdownDescription\": \"Denies the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-get-all-windows\",\n          \"markdownDescription\": \"Denies the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-hide\",\n          \"markdownDescription\": \"Denies the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-position\",\n          \"markdownDescription\": \"Denies the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-size\",\n          \"markdownDescription\": \"Denies the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-internal-toggle-maximize\",\n          \"markdownDescription\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-always-on-top\",\n          \"markdownDescription\": \"Denies the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-closable\",\n          \"markdownDescription\": \"Denies the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-decorated\",\n          \"markdownDescription\": \"Denies the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-focused\",\n          \"markdownDescription\": \"Denies the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-fullscreen\",\n          \"markdownDescription\": \"Denies the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximizable\",\n          \"markdownDescription\": \"Denies the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximized\",\n          \"markdownDescription\": \"Denies the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimizable\",\n          \"markdownDescription\": \"Denies the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimized\",\n          \"markdownDescription\": \"Denies the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-resizable\",\n          \"markdownDescription\": \"Denies the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-visible\",\n          \"markdownDescription\": \"Denies the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-maximize\",\n          \"markdownDescription\": \"Denies the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-minimize\",\n          \"markdownDescription\": \"Denies the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-monitor-from-point\",\n          \"markdownDescription\": \"Denies the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-position\",\n          \"markdownDescription\": \"Denies the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-size\",\n          \"markdownDescription\": \"Denies the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-primary-monitor\",\n          \"markdownDescription\": \"Denies the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-request-user-attention\",\n          \"markdownDescription\": \"Denies the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-scale-factor\",\n          \"markdownDescription\": \"Denies the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-bottom\",\n          \"markdownDescription\": \"Denies the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-top\",\n          \"markdownDescription\": \"Denies the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-background-color\",\n          \"markdownDescription\": \"Denies the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-count\",\n          \"markdownDescription\": \"Denies the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-label\",\n          \"markdownDescription\": \"Denies the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-closable\",\n          \"markdownDescription\": \"Denies the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-content-protected\",\n          \"markdownDescription\": \"Denies the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-grab\",\n          \"markdownDescription\": \"Denies the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-icon\",\n          \"markdownDescription\": \"Denies the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-position\",\n          \"markdownDescription\": \"Denies the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-visible\",\n          \"markdownDescription\": \"Denies the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-decorations\",\n          \"markdownDescription\": \"Denies the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-effects\",\n          \"markdownDescription\": \"Denies the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focus\",\n          \"markdownDescription\": \"Denies the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focusable\",\n          \"markdownDescription\": \"Denies the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-fullscreen\",\n          \"markdownDescription\": \"Denies the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-max-size\",\n          \"markdownDescription\": \"Denies the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-maximizable\",\n          \"markdownDescription\": \"Denies the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-min-size\",\n          \"markdownDescription\": \"Denies the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-minimizable\",\n          \"markdownDescription\": \"Denies the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-overlay-icon\",\n          \"markdownDescription\": \"Denies the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-position\",\n          \"markdownDescription\": \"Denies the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-progress-bar\",\n          \"markdownDescription\": \"Denies the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-resizable\",\n          \"markdownDescription\": \"Denies the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-shadow\",\n          \"markdownDescription\": \"Denies the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-simple-fullscreen\",\n          \"markdownDescription\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size\",\n          \"markdownDescription\": \"Denies the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size-constraints\",\n          \"markdownDescription\": \"Denies the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-skip-taskbar\",\n          \"markdownDescription\": \"Denies the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-theme\",\n          \"markdownDescription\": \"Denies the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title-bar-style\",\n          \"markdownDescription\": \"Denies the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-dragging\",\n          \"markdownDescription\": \"Denies the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-resize-dragging\",\n          \"markdownDescription\": \"Denies the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-theme\",\n          \"markdownDescription\": \"Denies the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-title\",\n          \"markdownDescription\": \"Denies the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-toggle-maximize\",\n          \"markdownDescription\": \"Denies the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unmaximize\",\n          \"markdownDescription\": \"Denies the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unminimize\",\n          \"markdownDescription\": \"Denies the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures the types of dialogs\\navailable from the dialog plugin.\\n\\n#### Granted Permissions\\n\\nAll dialog types are enabled.\\n\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-ask`\\n- `allow-confirm`\\n- `allow-message`\\n- `allow-save`\\n- `allow-open`\",\n          \"type\": \"string\",\n          \"const\": \"dialog:default\",\n          \"markdownDescription\": \"This permission set configures the types of dialogs\\navailable from the dialog plugin.\\n\\n#### Granted Permissions\\n\\nAll dialog types are enabled.\\n\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-ask`\\n- `allow-confirm`\\n- `allow-message`\\n- `allow-save`\\n- `allow-open`\"\n        },\n        {\n          \"description\": \"Enables the ask command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-ask\",\n          \"markdownDescription\": \"Enables the ask command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the confirm command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-confirm\",\n          \"markdownDescription\": \"Enables the confirm command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the message command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-message\",\n          \"markdownDescription\": \"Enables the message command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-open\",\n          \"markdownDescription\": \"Enables the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the save command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-save\",\n          \"markdownDescription\": \"Enables the save command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the ask command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-ask\",\n          \"markdownDescription\": \"Denies the ask command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the confirm command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-confirm\",\n          \"markdownDescription\": \"Denies the confirm command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the message command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-message\",\n          \"markdownDescription\": \"Denies the message command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-open\",\n          \"markdownDescription\": \"Denies the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the save command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-save\",\n          \"markdownDescription\": \"Denies the save command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n          \"type\": \"string\",\n          \"const\": \"opener:default\",\n          \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n        },\n        {\n          \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-default-urls\",\n          \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n        },\n        {\n          \"description\": \"Enables the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-path\",\n          \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-url\",\n          \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-reveal-item-in-dir\",\n          \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-path\",\n          \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-url\",\n          \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-reveal-item-in-dir\",\n          \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which\\nprocess features are by default exposed.\\n\\n#### Granted Permissions\\n\\nThis enables to quit via `allow-exit` and restart via `allow-restart`\\nthe application.\\n\\n#### This default permission set includes:\\n\\n- `allow-exit`\\n- `allow-restart`\",\n          \"type\": \"string\",\n          \"const\": \"process:default\",\n          \"markdownDescription\": \"This permission set configures which\\nprocess features are by default exposed.\\n\\n#### Granted Permissions\\n\\nThis enables to quit via `allow-exit` and restart via `allow-restart`\\nthe application.\\n\\n#### This default permission set includes:\\n\\n- `allow-exit`\\n- `allow-restart`\"\n        },\n        {\n          \"description\": \"Enables the exit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:allow-exit\",\n          \"markdownDescription\": \"Enables the exit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the restart command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:allow-restart\",\n          \"markdownDescription\": \"Enables the restart command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the exit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:deny-exit\",\n          \"markdownDescription\": \"Denies the exit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the restart command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:deny-restart\",\n          \"markdownDescription\": \"Denies the restart command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\",\n          \"type\": \"string\",\n          \"const\": \"updater:default\",\n          \"markdownDescription\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\"\n        },\n        {\n          \"description\": \"Enables the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-check\",\n          \"markdownDescription\": \"Enables the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download\",\n          \"markdownDescription\": \"Enables the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download-and-install\",\n          \"markdownDescription\": \"Enables the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-install\",\n          \"markdownDescription\": \"Enables the install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-check\",\n          \"markdownDescription\": \"Denies the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download\",\n          \"markdownDescription\": \"Denies the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download-and-install\",\n          \"markdownDescription\": \"Denies the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-install\",\n          \"markdownDescription\": \"Denies the install command without any pre-configured scope.\"\n        }\n      ]\n    },\n    \"Value\": {\n      \"description\": \"All supported ACL values.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents a null JSON value.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"Represents a [`bool`].\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Represents a valid ACL [`Number`].\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Number\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Represents a [`String`].\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"Represents a list of other [`Value`]s.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        },\n        {\n          \"description\": \"Represents a map of [`String`] keys to [`Value`]s.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        }\n      ]\n    },\n    \"Number\": {\n      \"description\": \"A valid ACL number.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents an [`i64`].\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        {\n          \"description\": \"Represents a [`f64`].\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      ]\n    },\n    \"Target\": {\n      \"description\": \"Platform target.\",\n      \"oneOf\": [\n        {\n          \"description\": \"MacOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"macOS\"\n          ]\n        },\n        {\n          \"description\": \"Windows.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"windows\"\n          ]\n        },\n        {\n          \"description\": \"Linux.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"linux\"\n          ]\n        },\n        {\n          \"description\": \"Android.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"android\"\n          ]\n        },\n        {\n          \"description\": \"iOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iOS\"\n          ]\n        }\n      ]\n    },\n    \"Application\": {\n      \"description\": \"Opener scope application.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Open in default application.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"If true, allow open with any application.\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Allow specific application to open with.\",\n          \"type\": \"string\"\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "tauri/src-tauri/gen/schemas/macOS-schema.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"CapabilityFile\",\n  \"description\": \"Capability formats accepted in a capability file.\",\n  \"anyOf\": [\n    {\n      \"description\": \"A single capability.\",\n      \"allOf\": [\n        {\n          \"$ref\": \"#/definitions/Capability\"\n        }\n      ]\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Capability\"\n      }\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"capabilities\"\n      ],\n      \"properties\": {\n        \"capabilities\": {\n          \"description\": \"The list of capabilities.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Capability\"\n          }\n        }\n      }\n    }\n  ],\n  \"definitions\": {\n    \"Capability\": {\n      \"description\": \"A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\\n\\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\\n\\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\\n\\n## Example\\n\\n```json { \\\"identifier\\\": \\\"main-user-files-write\\\", \\\"description\\\": \\\"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\\\", \\\"windows\\\": [ \\\"main\\\" ], \\\"permissions\\\": [ \\\"core:default\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] }, ], \\\"platforms\\\": [\\\"macOS\\\",\\\"windows\\\"] } ```\",\n      \"type\": \"object\",\n      \"required\": [\n        \"identifier\",\n        \"permissions\"\n      ],\n      \"properties\": {\n        \"identifier\": {\n          \"description\": \"Identifier of the capability.\\n\\n## Example\\n\\n`main-user-files-write`\",\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"description\": \"Description of what the capability is intended to allow on associated windows.\\n\\nIt should contain a description of what the grouped permissions should allow.\\n\\n## Example\\n\\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\",\n          \"default\": \"\",\n          \"type\": \"string\"\n        },\n        \"remote\": {\n          \"description\": \"Configure remote URLs that can use the capability permissions.\\n\\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\\n\\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\\n\\n## Example\\n\\n```json { \\\"urls\\\": [\\\"https://*.mydomain.dev\\\"] } ```\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/CapabilityRemote\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"local\": {\n          \"description\": \"Whether this capability is enabled for local app URLs or not. Defaults to `true`.\",\n          \"default\": true,\n          \"type\": \"boolean\"\n        },\n        \"windows\": {\n          \"description\": \"List of windows that are affected by this capability. Can be a glob pattern.\\n\\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\\n\\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\\n\\n## Example\\n\\n`[\\\"main\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"webviews\": {\n          \"description\": \"List of webviews that are affected by this capability. Can be a glob pattern.\\n\\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\\n\\n## Example\\n\\n`[\\\"sub-webview-one\\\", \\\"sub-webview-two\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"permissions\": {\n          \"description\": \"List of permissions attached to this capability.\\n\\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\\n\\n## Example\\n\\n```json [ \\\"core:default\\\", \\\"shell:allow-open\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] } ] ```\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/PermissionEntry\"\n          },\n          \"uniqueItems\": true\n        },\n        \"platforms\": {\n          \"description\": \"Limit which target platforms this capability applies to.\\n\\nBy default all platforms are targeted.\\n\\n## Example\\n\\n`[\\\"macOS\\\",\\\"windows\\\"]`\",\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"$ref\": \"#/definitions/Target\"\n          }\n        }\n      }\n    },\n    \"CapabilityRemote\": {\n      \"description\": \"Configuration for remote URLs that are associated with the capability.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"urls\"\n      ],\n      \"properties\": {\n        \"urls\": {\n          \"description\": \"Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\\n\\n## Examples\\n\\n- \\\"https://*.mydomain.dev\\\": allows subdomains of mydomain.dev - \\\"https://mydomain.dev/api/*\\\": allows any subpath of mydomain.dev/api\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    },\n    \"PermissionEntry\": {\n      \"description\": \"An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Reference a permission or permission set by identifier.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Identifier\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Reference a permission or permission set by identifier and extends its scope.\",\n          \"type\": \"object\",\n          \"allOf\": [\n            {\n              \"if\": {\n                \"properties\": {\n                  \"identifier\": {\n                    \"anyOf\": [\n                      {\n                        \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:default\",\n                        \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n                      },\n                      {\n                        \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-default-urls\",\n                        \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-path\",\n                        \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-open-url\",\n                        \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:allow-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_path command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-path\",\n                        \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the open_url command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-open-url\",\n                        \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n                      },\n                      {\n                        \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n                        \"type\": \"string\",\n                        \"const\": \"opener:deny-reveal-item-in-dir\",\n                        \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n                      }\n                    ]\n                  }\n                }\n              },\n              \"then\": {\n                \"properties\": {\n                  \"allow\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  },\n                  \"deny\": {\n                    \"items\": {\n                      \"title\": \"OpenerScopeEntry\",\n                      \"description\": \"Opener scope entry.\",\n                      \"anyOf\": [\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"url\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this url with, for example: firefox.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"url\": {\n                              \"description\": \"A URL that can be opened by the webview when using the Opener APIs.\\n\\nWildcards can be used following the UNIX glob pattern.\\n\\nExamples:\\n\\n- \\\"https://*\\\" : allows all HTTPS origin\\n\\n- \\\"https://*.github.com/tauri-apps/tauri\\\": allows any subdomain of \\\"github.com\\\" with the \\\"tauri-apps/api\\\" path\\n\\n- \\\"https://myapi.service.com/users/*\\\": allows access to any URLs that begins with \\\"https://myapi.service.com/users/\\\"\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        },\n                        {\n                          \"type\": \"object\",\n                          \"required\": [\n                            \"path\"\n                          ],\n                          \"properties\": {\n                            \"app\": {\n                              \"description\": \"An application to open this path with, for example: xdg-open.\",\n                              \"allOf\": [\n                                {\n                                  \"$ref\": \"#/definitions/Application\"\n                                }\n                              ]\n                            },\n                            \"path\": {\n                              \"description\": \"A path that can be opened by the webview when using the Opener APIs.\\n\\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.\",\n                              \"type\": \"string\"\n                            }\n                          }\n                        }\n                      ]\n                    }\n                  }\n                }\n              },\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                }\n              }\n            },\n            {\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                },\n                \"allow\": {\n                  \"description\": \"Data that defines what is allowed by the scope.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                },\n                \"deny\": {\n                  \"description\": \"Data that defines what is denied by the scope. This should be prioritized by validation logic.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                }\n              }\n            }\n          ],\n          \"required\": [\n            \"identifier\"\n          ]\n        }\n      ]\n    },\n    \"Identifier\": {\n      \"description\": \"Permission identifier\",\n      \"oneOf\": [\n        {\n          \"description\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\",\n          \"type\": \"string\",\n          \"const\": \"core:default\",\n          \"markdownDescription\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\",\n          \"type\": \"string\",\n          \"const\": \"core:app:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\"\n        },\n        {\n          \"description\": \"Enables the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-hide\",\n          \"markdownDescription\": \"Enables the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-show\",\n          \"markdownDescription\": \"Enables the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-bundle-type\",\n          \"markdownDescription\": \"Enables the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-default-window-icon\",\n          \"markdownDescription\": \"Enables the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-identifier\",\n          \"markdownDescription\": \"Enables the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-name\",\n          \"markdownDescription\": \"Enables the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-data-store\",\n          \"markdownDescription\": \"Enables the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-listener\",\n          \"markdownDescription\": \"Enables the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-app-theme\",\n          \"markdownDescription\": \"Enables the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-dock-visibility\",\n          \"markdownDescription\": \"Enables the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-tauri-version\",\n          \"markdownDescription\": \"Enables the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-version\",\n          \"markdownDescription\": \"Enables the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-hide\",\n          \"markdownDescription\": \"Denies the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-show\",\n          \"markdownDescription\": \"Denies the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-bundle-type\",\n          \"markdownDescription\": \"Denies the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-default-window-icon\",\n          \"markdownDescription\": \"Denies the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-identifier\",\n          \"markdownDescription\": \"Denies the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-name\",\n          \"markdownDescription\": \"Denies the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-data-store\",\n          \"markdownDescription\": \"Denies the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-listener\",\n          \"markdownDescription\": \"Denies the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-app-theme\",\n          \"markdownDescription\": \"Denies the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-dock-visibility\",\n          \"markdownDescription\": \"Denies the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-tauri-version\",\n          \"markdownDescription\": \"Denies the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-version\",\n          \"markdownDescription\": \"Denies the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\",\n          \"type\": \"string\",\n          \"const\": \"core:event:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\"\n        },\n        {\n          \"description\": \"Enables the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit\",\n          \"markdownDescription\": \"Enables the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit-to\",\n          \"markdownDescription\": \"Enables the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-listen\",\n          \"markdownDescription\": \"Enables the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-unlisten\",\n          \"markdownDescription\": \"Enables the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit\",\n          \"markdownDescription\": \"Denies the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit-to\",\n          \"markdownDescription\": \"Denies the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-listen\",\n          \"markdownDescription\": \"Denies the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-unlisten\",\n          \"markdownDescription\": \"Denies the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\",\n          \"type\": \"string\",\n          \"const\": \"core:image:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\"\n        },\n        {\n          \"description\": \"Enables the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-bytes\",\n          \"markdownDescription\": \"Enables the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-path\",\n          \"markdownDescription\": \"Enables the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-rgba\",\n          \"markdownDescription\": \"Enables the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-size\",\n          \"markdownDescription\": \"Enables the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-bytes\",\n          \"markdownDescription\": \"Denies the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-path\",\n          \"markdownDescription\": \"Denies the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-rgba\",\n          \"markdownDescription\": \"Denies the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-size\",\n          \"markdownDescription\": \"Denies the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\"\n        },\n        {\n          \"description\": \"Enables the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-append\",\n          \"markdownDescription\": \"Enables the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-create-default\",\n          \"markdownDescription\": \"Enables the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-get\",\n          \"markdownDescription\": \"Enables the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-insert\",\n          \"markdownDescription\": \"Enables the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-checked\",\n          \"markdownDescription\": \"Enables the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-items\",\n          \"markdownDescription\": \"Enables the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-popup\",\n          \"markdownDescription\": \"Enables the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-prepend\",\n          \"markdownDescription\": \"Enables the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove\",\n          \"markdownDescription\": \"Enables the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove-at\",\n          \"markdownDescription\": \"Enables the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-accelerator\",\n          \"markdownDescription\": \"Enables the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-app-menu\",\n          \"markdownDescription\": \"Enables the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-window-menu\",\n          \"markdownDescription\": \"Enables the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-checked\",\n          \"markdownDescription\": \"Enables the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-text\",\n          \"markdownDescription\": \"Enables the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-text\",\n          \"markdownDescription\": \"Enables the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-append\",\n          \"markdownDescription\": \"Denies the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-create-default\",\n          \"markdownDescription\": \"Denies the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-get\",\n          \"markdownDescription\": \"Denies the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-insert\",\n          \"markdownDescription\": \"Denies the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-checked\",\n          \"markdownDescription\": \"Denies the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-items\",\n          \"markdownDescription\": \"Denies the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-popup\",\n          \"markdownDescription\": \"Denies the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-prepend\",\n          \"markdownDescription\": \"Denies the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove\",\n          \"markdownDescription\": \"Denies the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove-at\",\n          \"markdownDescription\": \"Denies the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-accelerator\",\n          \"markdownDescription\": \"Denies the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-app-menu\",\n          \"markdownDescription\": \"Denies the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-window-menu\",\n          \"markdownDescription\": \"Denies the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-checked\",\n          \"markdownDescription\": \"Denies the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-text\",\n          \"markdownDescription\": \"Denies the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-text\",\n          \"markdownDescription\": \"Denies the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\",\n          \"type\": \"string\",\n          \"const\": \"core:path:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\"\n        },\n        {\n          \"description\": \"Enables the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-basename\",\n          \"markdownDescription\": \"Enables the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-dirname\",\n          \"markdownDescription\": \"Enables the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-extname\",\n          \"markdownDescription\": \"Enables the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-is-absolute\",\n          \"markdownDescription\": \"Enables the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-join\",\n          \"markdownDescription\": \"Enables the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-normalize\",\n          \"markdownDescription\": \"Enables the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve\",\n          \"markdownDescription\": \"Enables the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve-directory\",\n          \"markdownDescription\": \"Enables the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-basename\",\n          \"markdownDescription\": \"Denies the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-dirname\",\n          \"markdownDescription\": \"Denies the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-extname\",\n          \"markdownDescription\": \"Denies the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-is-absolute\",\n          \"markdownDescription\": \"Denies the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-join\",\n          \"markdownDescription\": \"Denies the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-normalize\",\n          \"markdownDescription\": \"Denies the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve\",\n          \"markdownDescription\": \"Denies the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve-directory\",\n          \"markdownDescription\": \"Denies the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\"\n        },\n        {\n          \"description\": \"Enables the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-get-by-id\",\n          \"markdownDescription\": \"Enables the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-remove-by-id\",\n          \"markdownDescription\": \"Enables the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon-as-template\",\n          \"markdownDescription\": \"Enables the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-menu\",\n          \"markdownDescription\": \"Enables the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-temp-dir-path\",\n          \"markdownDescription\": \"Enables the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-tooltip\",\n          \"markdownDescription\": \"Enables the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-visible\",\n          \"markdownDescription\": \"Enables the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-get-by-id\",\n          \"markdownDescription\": \"Denies the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-remove-by-id\",\n          \"markdownDescription\": \"Denies the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon-as-template\",\n          \"markdownDescription\": \"Denies the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-menu\",\n          \"markdownDescription\": \"Denies the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-temp-dir-path\",\n          \"markdownDescription\": \"Denies the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-tooltip\",\n          \"markdownDescription\": \"Denies the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-visible\",\n          \"markdownDescription\": \"Denies the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\"\n        },\n        {\n          \"description\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-clear-all-browsing-data\",\n          \"markdownDescription\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview\",\n          \"markdownDescription\": \"Enables the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview-window\",\n          \"markdownDescription\": \"Enables the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-get-all-webviews\",\n          \"markdownDescription\": \"Enables the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-internal-toggle-devtools\",\n          \"markdownDescription\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-print\",\n          \"markdownDescription\": \"Enables the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-reparent\",\n          \"markdownDescription\": \"Enables the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-auto-resize\",\n          \"markdownDescription\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-background-color\",\n          \"markdownDescription\": \"Enables the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-focus\",\n          \"markdownDescription\": \"Enables the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-position\",\n          \"markdownDescription\": \"Enables the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-size\",\n          \"markdownDescription\": \"Enables the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-zoom\",\n          \"markdownDescription\": \"Enables the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-close\",\n          \"markdownDescription\": \"Enables the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-hide\",\n          \"markdownDescription\": \"Enables the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-position\",\n          \"markdownDescription\": \"Enables the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-show\",\n          \"markdownDescription\": \"Enables the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-size\",\n          \"markdownDescription\": \"Enables the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-clear-all-browsing-data\",\n          \"markdownDescription\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview\",\n          \"markdownDescription\": \"Denies the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview-window\",\n          \"markdownDescription\": \"Denies the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-get-all-webviews\",\n          \"markdownDescription\": \"Denies the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-internal-toggle-devtools\",\n          \"markdownDescription\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-print\",\n          \"markdownDescription\": \"Denies the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-reparent\",\n          \"markdownDescription\": \"Denies the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-auto-resize\",\n          \"markdownDescription\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-background-color\",\n          \"markdownDescription\": \"Denies the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-focus\",\n          \"markdownDescription\": \"Denies the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-position\",\n          \"markdownDescription\": \"Denies the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-size\",\n          \"markdownDescription\": \"Denies the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-zoom\",\n          \"markdownDescription\": \"Denies the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-close\",\n          \"markdownDescription\": \"Denies the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-hide\",\n          \"markdownDescription\": \"Denies the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-position\",\n          \"markdownDescription\": \"Denies the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-show\",\n          \"markdownDescription\": \"Denies the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-size\",\n          \"markdownDescription\": \"Denies the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\",\n          \"type\": \"string\",\n          \"const\": \"core:window:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\"\n        },\n        {\n          \"description\": \"Enables the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-available-monitors\",\n          \"markdownDescription\": \"Enables the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-center\",\n          \"markdownDescription\": \"Enables the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-create\",\n          \"markdownDescription\": \"Enables the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-current-monitor\",\n          \"markdownDescription\": \"Enables the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-cursor-position\",\n          \"markdownDescription\": \"Enables the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-destroy\",\n          \"markdownDescription\": \"Enables the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-get-all-windows\",\n          \"markdownDescription\": \"Enables the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-hide\",\n          \"markdownDescription\": \"Enables the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-position\",\n          \"markdownDescription\": \"Enables the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-size\",\n          \"markdownDescription\": \"Enables the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-internal-toggle-maximize\",\n          \"markdownDescription\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-always-on-top\",\n          \"markdownDescription\": \"Enables the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-closable\",\n          \"markdownDescription\": \"Enables the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-decorated\",\n          \"markdownDescription\": \"Enables the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-focused\",\n          \"markdownDescription\": \"Enables the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-fullscreen\",\n          \"markdownDescription\": \"Enables the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximizable\",\n          \"markdownDescription\": \"Enables the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximized\",\n          \"markdownDescription\": \"Enables the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimizable\",\n          \"markdownDescription\": \"Enables the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimized\",\n          \"markdownDescription\": \"Enables the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-resizable\",\n          \"markdownDescription\": \"Enables the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-visible\",\n          \"markdownDescription\": \"Enables the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-maximize\",\n          \"markdownDescription\": \"Enables the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-minimize\",\n          \"markdownDescription\": \"Enables the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-monitor-from-point\",\n          \"markdownDescription\": \"Enables the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-position\",\n          \"markdownDescription\": \"Enables the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-size\",\n          \"markdownDescription\": \"Enables the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-primary-monitor\",\n          \"markdownDescription\": \"Enables the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-request-user-attention\",\n          \"markdownDescription\": \"Enables the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-scale-factor\",\n          \"markdownDescription\": \"Enables the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-bottom\",\n          \"markdownDescription\": \"Enables the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-top\",\n          \"markdownDescription\": \"Enables the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-background-color\",\n          \"markdownDescription\": \"Enables the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-count\",\n          \"markdownDescription\": \"Enables the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-label\",\n          \"markdownDescription\": \"Enables the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-closable\",\n          \"markdownDescription\": \"Enables the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-content-protected\",\n          \"markdownDescription\": \"Enables the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-grab\",\n          \"markdownDescription\": \"Enables the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-icon\",\n          \"markdownDescription\": \"Enables the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-position\",\n          \"markdownDescription\": \"Enables the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-visible\",\n          \"markdownDescription\": \"Enables the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-decorations\",\n          \"markdownDescription\": \"Enables the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-effects\",\n          \"markdownDescription\": \"Enables the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focus\",\n          \"markdownDescription\": \"Enables the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focusable\",\n          \"markdownDescription\": \"Enables the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-fullscreen\",\n          \"markdownDescription\": \"Enables the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-max-size\",\n          \"markdownDescription\": \"Enables the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-maximizable\",\n          \"markdownDescription\": \"Enables the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-min-size\",\n          \"markdownDescription\": \"Enables the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-minimizable\",\n          \"markdownDescription\": \"Enables the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-overlay-icon\",\n          \"markdownDescription\": \"Enables the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-position\",\n          \"markdownDescription\": \"Enables the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-progress-bar\",\n          \"markdownDescription\": \"Enables the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-resizable\",\n          \"markdownDescription\": \"Enables the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-shadow\",\n          \"markdownDescription\": \"Enables the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-simple-fullscreen\",\n          \"markdownDescription\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size\",\n          \"markdownDescription\": \"Enables the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size-constraints\",\n          \"markdownDescription\": \"Enables the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-skip-taskbar\",\n          \"markdownDescription\": \"Enables the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-theme\",\n          \"markdownDescription\": \"Enables the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title-bar-style\",\n          \"markdownDescription\": \"Enables the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-dragging\",\n          \"markdownDescription\": \"Enables the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-resize-dragging\",\n          \"markdownDescription\": \"Enables the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-theme\",\n          \"markdownDescription\": \"Enables the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-title\",\n          \"markdownDescription\": \"Enables the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-toggle-maximize\",\n          \"markdownDescription\": \"Enables the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unmaximize\",\n          \"markdownDescription\": \"Enables the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unminimize\",\n          \"markdownDescription\": \"Enables the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-available-monitors\",\n          \"markdownDescription\": \"Denies the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-center\",\n          \"markdownDescription\": \"Denies the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-create\",\n          \"markdownDescription\": \"Denies the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-current-monitor\",\n          \"markdownDescription\": \"Denies the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-cursor-position\",\n          \"markdownDescription\": \"Denies the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-destroy\",\n          \"markdownDescription\": \"Denies the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-get-all-windows\",\n          \"markdownDescription\": \"Denies the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-hide\",\n          \"markdownDescription\": \"Denies the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-position\",\n          \"markdownDescription\": \"Denies the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-size\",\n          \"markdownDescription\": \"Denies the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-internal-toggle-maximize\",\n          \"markdownDescription\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-always-on-top\",\n          \"markdownDescription\": \"Denies the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-closable\",\n          \"markdownDescription\": \"Denies the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-decorated\",\n          \"markdownDescription\": \"Denies the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-focused\",\n          \"markdownDescription\": \"Denies the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-fullscreen\",\n          \"markdownDescription\": \"Denies the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximizable\",\n          \"markdownDescription\": \"Denies the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximized\",\n          \"markdownDescription\": \"Denies the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimizable\",\n          \"markdownDescription\": \"Denies the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimized\",\n          \"markdownDescription\": \"Denies the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-resizable\",\n          \"markdownDescription\": \"Denies the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-visible\",\n          \"markdownDescription\": \"Denies the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-maximize\",\n          \"markdownDescription\": \"Denies the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-minimize\",\n          \"markdownDescription\": \"Denies the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-monitor-from-point\",\n          \"markdownDescription\": \"Denies the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-position\",\n          \"markdownDescription\": \"Denies the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-size\",\n          \"markdownDescription\": \"Denies the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-primary-monitor\",\n          \"markdownDescription\": \"Denies the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-request-user-attention\",\n          \"markdownDescription\": \"Denies the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-scale-factor\",\n          \"markdownDescription\": \"Denies the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-bottom\",\n          \"markdownDescription\": \"Denies the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-top\",\n          \"markdownDescription\": \"Denies the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-background-color\",\n          \"markdownDescription\": \"Denies the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-count\",\n          \"markdownDescription\": \"Denies the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-label\",\n          \"markdownDescription\": \"Denies the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-closable\",\n          \"markdownDescription\": \"Denies the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-content-protected\",\n          \"markdownDescription\": \"Denies the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-grab\",\n          \"markdownDescription\": \"Denies the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-icon\",\n          \"markdownDescription\": \"Denies the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-position\",\n          \"markdownDescription\": \"Denies the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-visible\",\n          \"markdownDescription\": \"Denies the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-decorations\",\n          \"markdownDescription\": \"Denies the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-effects\",\n          \"markdownDescription\": \"Denies the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focus\",\n          \"markdownDescription\": \"Denies the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focusable\",\n          \"markdownDescription\": \"Denies the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-fullscreen\",\n          \"markdownDescription\": \"Denies the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-max-size\",\n          \"markdownDescription\": \"Denies the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-maximizable\",\n          \"markdownDescription\": \"Denies the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-min-size\",\n          \"markdownDescription\": \"Denies the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-minimizable\",\n          \"markdownDescription\": \"Denies the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-overlay-icon\",\n          \"markdownDescription\": \"Denies the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-position\",\n          \"markdownDescription\": \"Denies the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-progress-bar\",\n          \"markdownDescription\": \"Denies the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-resizable\",\n          \"markdownDescription\": \"Denies the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-shadow\",\n          \"markdownDescription\": \"Denies the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-simple-fullscreen\",\n          \"markdownDescription\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size\",\n          \"markdownDescription\": \"Denies the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size-constraints\",\n          \"markdownDescription\": \"Denies the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-skip-taskbar\",\n          \"markdownDescription\": \"Denies the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-theme\",\n          \"markdownDescription\": \"Denies the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title-bar-style\",\n          \"markdownDescription\": \"Denies the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-dragging\",\n          \"markdownDescription\": \"Denies the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-resize-dragging\",\n          \"markdownDescription\": \"Denies the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-theme\",\n          \"markdownDescription\": \"Denies the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-title\",\n          \"markdownDescription\": \"Denies the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-toggle-maximize\",\n          \"markdownDescription\": \"Denies the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unmaximize\",\n          \"markdownDescription\": \"Denies the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unminimize\",\n          \"markdownDescription\": \"Denies the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures the types of dialogs\\navailable from the dialog plugin.\\n\\n#### Granted Permissions\\n\\nAll dialog types are enabled.\\n\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-ask`\\n- `allow-confirm`\\n- `allow-message`\\n- `allow-save`\\n- `allow-open`\",\n          \"type\": \"string\",\n          \"const\": \"dialog:default\",\n          \"markdownDescription\": \"This permission set configures the types of dialogs\\navailable from the dialog plugin.\\n\\n#### Granted Permissions\\n\\nAll dialog types are enabled.\\n\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-ask`\\n- `allow-confirm`\\n- `allow-message`\\n- `allow-save`\\n- `allow-open`\"\n        },\n        {\n          \"description\": \"Enables the ask command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-ask\",\n          \"markdownDescription\": \"Enables the ask command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the confirm command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-confirm\",\n          \"markdownDescription\": \"Enables the confirm command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the message command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-message\",\n          \"markdownDescription\": \"Enables the message command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-open\",\n          \"markdownDescription\": \"Enables the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the save command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:allow-save\",\n          \"markdownDescription\": \"Enables the save command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the ask command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-ask\",\n          \"markdownDescription\": \"Denies the ask command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the confirm command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-confirm\",\n          \"markdownDescription\": \"Denies the confirm command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the message command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-message\",\n          \"markdownDescription\": \"Denies the message command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-open\",\n          \"markdownDescription\": \"Denies the open command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the save command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"dialog:deny-save\",\n          \"markdownDescription\": \"Denies the save command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\",\n          \"type\": \"string\",\n          \"const\": \"opener:default\",\n          \"markdownDescription\": \"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\\nas well as reveal file in directories using default file explorer\\n#### This default permission set includes:\\n\\n- `allow-open-url`\\n- `allow-reveal-item-in-dir`\\n- `allow-default-urls`\"\n        },\n        {\n          \"description\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-default-urls\",\n          \"markdownDescription\": \"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.\"\n        },\n        {\n          \"description\": \"Enables the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-path\",\n          \"markdownDescription\": \"Enables the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-open-url\",\n          \"markdownDescription\": \"Enables the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:allow-reveal-item-in-dir\",\n          \"markdownDescription\": \"Enables the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-path\",\n          \"markdownDescription\": \"Denies the open_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the open_url command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-open-url\",\n          \"markdownDescription\": \"Denies the open_url command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"opener:deny-reveal-item-in-dir\",\n          \"markdownDescription\": \"Denies the reveal_item_in_dir command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which\\nprocess features are by default exposed.\\n\\n#### Granted Permissions\\n\\nThis enables to quit via `allow-exit` and restart via `allow-restart`\\nthe application.\\n\\n#### This default permission set includes:\\n\\n- `allow-exit`\\n- `allow-restart`\",\n          \"type\": \"string\",\n          \"const\": \"process:default\",\n          \"markdownDescription\": \"This permission set configures which\\nprocess features are by default exposed.\\n\\n#### Granted Permissions\\n\\nThis enables to quit via `allow-exit` and restart via `allow-restart`\\nthe application.\\n\\n#### This default permission set includes:\\n\\n- `allow-exit`\\n- `allow-restart`\"\n        },\n        {\n          \"description\": \"Enables the exit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:allow-exit\",\n          \"markdownDescription\": \"Enables the exit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the restart command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:allow-restart\",\n          \"markdownDescription\": \"Enables the restart command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the exit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:deny-exit\",\n          \"markdownDescription\": \"Denies the exit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the restart command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"process:deny-restart\",\n          \"markdownDescription\": \"Denies the restart command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\",\n          \"type\": \"string\",\n          \"const\": \"updater:default\",\n          \"markdownDescription\": \"This permission set configures which kind of\\nupdater functions are exposed to the frontend.\\n\\n#### Granted Permissions\\n\\nThe full workflow from checking for updates to installing them\\nis enabled.\\n\\n\\n#### This default permission set includes:\\n\\n- `allow-check`\\n- `allow-download`\\n- `allow-install`\\n- `allow-download-and-install`\"\n        },\n        {\n          \"description\": \"Enables the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-check\",\n          \"markdownDescription\": \"Enables the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download\",\n          \"markdownDescription\": \"Enables the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-download-and-install\",\n          \"markdownDescription\": \"Enables the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:allow-install\",\n          \"markdownDescription\": \"Enables the install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the check command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-check\",\n          \"markdownDescription\": \"Denies the check command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download\",\n          \"markdownDescription\": \"Denies the download command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the download_and_install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-download-and-install\",\n          \"markdownDescription\": \"Denies the download_and_install command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the install command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"updater:deny-install\",\n          \"markdownDescription\": \"Denies the install command without any pre-configured scope.\"\n        }\n      ]\n    },\n    \"Value\": {\n      \"description\": \"All supported ACL values.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents a null JSON value.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"Represents a [`bool`].\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Represents a valid ACL [`Number`].\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Number\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Represents a [`String`].\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"Represents a list of other [`Value`]s.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        },\n        {\n          \"description\": \"Represents a map of [`String`] keys to [`Value`]s.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        }\n      ]\n    },\n    \"Number\": {\n      \"description\": \"A valid ACL number.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents an [`i64`].\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        {\n          \"description\": \"Represents a [`f64`].\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      ]\n    },\n    \"Target\": {\n      \"description\": \"Platform target.\",\n      \"oneOf\": [\n        {\n          \"description\": \"MacOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"macOS\"\n          ]\n        },\n        {\n          \"description\": \"Windows.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"windows\"\n          ]\n        },\n        {\n          \"description\": \"Linux.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"linux\"\n          ]\n        },\n        {\n          \"description\": \"Android.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"android\"\n          ]\n        },\n        {\n          \"description\": \"iOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iOS\"\n          ]\n        }\n      ]\n    },\n    \"Application\": {\n      \"description\": \"Opener scope application.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Open in default application.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"If true, allow open with any application.\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Allow specific application to open with.\",\n          \"type\": \"string\"\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "tauri/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n  <background android:drawable=\"@color/ic_launcher_background\"/>\n</adaptive-icon>"
  },
  {
    "path": "tauri/src-tauri/icons/android/values/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n  <color name=\"ic_launcher_background\">#fff</color>\n</resources>"
  },
  {
    "path": "tauri/src-tauri/rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"1.91.1\"\n"
  },
  {
    "path": "tauri/src-tauri/src/auth.rs",
    "content": "use crate::config::{get_config, save_config, BilibiliConfig};\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::path::PathBuf;\nuse tauri::Manager;\n\n// ============ TYPES ============\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SiteAuthStatus {\n    pub status: String, // \"logged_out\", \"checking\", \"logged_in\"\n    pub username: Option<String>,\n    pub avatar: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct QRSession {\n    pub url: String,\n    pub qrcode_key: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct QRPollResult {\n    pub status: i32,\n    pub status_text: String,\n    pub username: Option<String>,\n}\n\n// ============ HELPER FUNCTIONS ============\n\nfn config_dir() -> PathBuf {\n    dirs::home_dir()\n        .unwrap_or_else(|| PathBuf::from(\".\"))\n        .join(\".config\")\n        .join(\"vget\")\n}\n\nfn xhs_cookies_path() -> PathBuf {\n    config_dir().join(\"xhs_cookies.json\")\n}\n\n// ============ BILIBILI AUTH ============\n\n#[tauri::command]\npub async fn bilibili_check_status() -> Result<SiteAuthStatus, String> {\n    let config = tauri::async_runtime::spawn_blocking(|| {\n        get_config().map_err(|e| e.to_string())\n    })\n    .await\n    .map_err(|e| e.to_string())??;\n\n    let cookie = config.bilibili.cookie.unwrap_or_default();\n    if cookie.is_empty() {\n        return Ok(SiteAuthStatus {\n            status: \"logged_out\".to_string(),\n            username: None,\n            avatar: None,\n        });\n    }\n\n    // Try to get user info from Bilibili API\n    match fetch_bilibili_user_info(&cookie).await {\n        Ok((username, avatar)) => Ok(SiteAuthStatus {\n            status: \"logged_in\".to_string(),\n            username: Some(username),\n            avatar,\n        }),\n        Err(_) => {\n            // Cookie might be invalid, but we still have it\n            Ok(SiteAuthStatus {\n                status: \"logged_in\".to_string(),\n                username: None,\n                avatar: None,\n            })\n        }\n    }\n}\n\nasync fn fetch_bilibili_user_info(cookie: &str) -> Result<(String, Option<String>), String> {\n    let client = reqwest::Client::new();\n    let resp = client\n        .get(\"https://api.bilibili.com/x/web-interface/nav\")\n        .header(\"Cookie\", cookie)\n        .header(\n            \"User-Agent\",\n            \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\",\n        )\n        .send()\n        .await\n        .map_err(|e| e.to_string())?;\n\n    let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;\n\n    if json[\"code\"].as_i64() == Some(0) {\n        let data = &json[\"data\"];\n        let username = data[\"uname\"].as_str().unwrap_or(\"User\").to_string();\n        let avatar = data[\"face\"].as_str().map(|s| s.to_string());\n        Ok((username, avatar))\n    } else {\n        Err(\"Not logged in\".to_string())\n    }\n}\n\n#[tauri::command]\npub async fn bilibili_qr_generate() -> Result<QRSession, String> {\n    let client = reqwest::Client::new();\n    let resp = client\n        .get(\"https://passport.bilibili.com/x/passport-login/web/qrcode/generate\")\n        .header(\n            \"User-Agent\",\n            \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\",\n        )\n        .send()\n        .await\n        .map_err(|e| e.to_string())?;\n\n    let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;\n\n    if json[\"code\"].as_i64() != Some(0) {\n        return Err(json[\"message\"]\n            .as_str()\n            .unwrap_or(\"Failed to generate QR code\")\n            .to_string());\n    }\n\n    let data = &json[\"data\"];\n    Ok(QRSession {\n        url: data[\"url\"].as_str().unwrap_or(\"\").to_string(),\n        qrcode_key: data[\"qrcode_key\"].as_str().unwrap_or(\"\").to_string(),\n    })\n}\n\n#[tauri::command]\npub async fn bilibili_qr_poll(qrcode_key: String) -> Result<QRPollResult, String> {\n    let client = reqwest::Client::new();\n    let resp = client\n        .get(\"https://passport.bilibili.com/x/passport-login/web/qrcode/poll\")\n        .query(&[(\"qrcode_key\", &qrcode_key)])\n        .header(\n            \"User-Agent\",\n            \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\",\n        )\n        .send()\n        .await\n        .map_err(|e| e.to_string())?;\n\n    // Get cookies from response before parsing JSON\n    let cookies: Vec<String> = resp\n        .headers()\n        .get_all(\"set-cookie\")\n        .iter()\n        .filter_map(|v| v.to_str().ok())\n        .map(|s| s.to_string())\n        .collect();\n\n    let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;\n\n    if json[\"code\"].as_i64() != Some(0) {\n        return Err(json[\"message\"]\n            .as_str()\n            .unwrap_or(\"Failed to poll QR code\")\n            .to_string());\n    }\n\n    let data = &json[\"data\"];\n    let status = data[\"code\"].as_i64().unwrap_or(-1) as i32;\n\n    // Status codes:\n    // 86101 - waiting for scan\n    // 86090 - scanned, waiting for confirmation\n    // 86038 - expired\n    // 0 - confirmed/success\n\n    let mut username = None;\n\n    // If login confirmed, save cookies\n    if status == 0 && !cookies.is_empty() {\n        let cookie_str = extract_bilibili_cookies(&cookies);\n        if !cookie_str.is_empty() {\n            // Save cookie to config\n            if let Ok(mut config) = get_config() {\n                config.bilibili = BilibiliConfig {\n                    cookie: Some(cookie_str.clone()),\n                };\n                let _ = save_config(&config);\n            }\n\n            // Try to get username\n            if let Ok((name, _)) = fetch_bilibili_user_info(&cookie_str).await {\n                username = Some(name);\n            }\n        }\n    }\n\n    let status_text = match status {\n        86101 => \"Waiting for scan\",\n        86090 => \"Scanned, confirm on phone\",\n        86038 => \"QR code expired\",\n        0 => \"Login successful\",\n        _ => \"Unknown status\",\n    };\n\n    Ok(QRPollResult {\n        status,\n        status_text: status_text.to_string(),\n        username,\n    })\n}\n\nfn extract_bilibili_cookies(set_cookie_headers: &[String]) -> String {\n    let mut sessdata = String::new();\n    let mut bili_jct = String::new();\n    let mut dede_user_id = String::new();\n\n    for cookie in set_cookie_headers {\n        let parts: Vec<&str> = cookie.split(';').collect();\n        if let Some(kv) = parts.first() {\n            let kv_parts: Vec<&str> = kv.splitn(2, '=').collect();\n            if kv_parts.len() == 2 {\n                let key = kv_parts[0].trim();\n                let value = kv_parts[1].trim();\n                match key {\n                    \"SESSDATA\" => sessdata = value.to_string(),\n                    \"bili_jct\" => bili_jct = value.to_string(),\n                    \"DedeUserID\" => dede_user_id = value.to_string(),\n                    _ => {}\n                }\n            }\n        }\n    }\n\n    let mut parts = Vec::new();\n    if !sessdata.is_empty() {\n        parts.push(format!(\"SESSDATA={}\", sessdata));\n    }\n    if !bili_jct.is_empty() {\n        parts.push(format!(\"bili_jct={}\", bili_jct));\n    }\n    if !dede_user_id.is_empty() {\n        parts.push(format!(\"DedeUserID={}\", dede_user_id));\n    }\n    parts.join(\"; \")\n}\n\n#[tauri::command]\npub async fn bilibili_save_cookie(cookie: String) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        let mut config = get_config().map_err(|e| e.to_string())?;\n        config.bilibili = BilibiliConfig {\n            cookie: if cookie.is_empty() {\n                None\n            } else {\n                Some(cookie)\n            },\n        };\n        save_config(&config).map_err(|e| e.to_string())\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\npub async fn bilibili_logout() -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(|| {\n        let mut config = get_config().map_err(|e| e.to_string())?;\n        config.bilibili = BilibiliConfig { cookie: None };\n        save_config(&config).map_err(|e| e.to_string())\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n\n// ============ XIAOHONGSHU AUTH ============\n\n#[tauri::command]\npub async fn xhs_check_status() -> Result<SiteAuthStatus, String> {\n    let cookies_path = xhs_cookies_path();\n\n    if !cookies_path.exists() {\n        return Ok(SiteAuthStatus {\n            status: \"logged_out\".to_string(),\n            username: None,\n            avatar: None,\n        });\n    }\n\n    // Check if cookies file has content\n    match fs::read_to_string(&cookies_path) {\n        Ok(content) => {\n            if content.trim().is_empty() || content == \"[]\" {\n                return Ok(SiteAuthStatus {\n                    status: \"logged_out\".to_string(),\n                    username: None,\n                    avatar: None,\n                });\n            }\n            // Has cookies, assume logged in\n            Ok(SiteAuthStatus {\n                status: \"logged_in\".to_string(),\n                username: None, // XHS doesn't easily expose username\n                avatar: None,\n            })\n        }\n        Err(_) => Ok(SiteAuthStatus {\n            status: \"logged_out\".to_string(),\n            username: None,\n            avatar: None,\n        }),\n    }\n}\n\n#[tauri::command]\npub async fn xhs_logout() -> Result<(), String> {\n    let cookies_path = xhs_cookies_path();\n\n    if cookies_path.exists() {\n        fs::remove_file(&cookies_path).map_err(|e| e.to_string())?;\n    }\n\n    // Also clear browser user data if exists\n    let browser_data = config_dir().join(\"browser\");\n    if browser_data.exists() {\n        // Only remove XHS-related data, not the entire browser folder\n        // This is safer than removing everything\n        let _ = fs::remove_dir_all(browser_data.join(\"Default\").join(\"Cookies\"));\n    }\n\n    Ok(())\n}\n\n#[tauri::command]\npub async fn xhs_open_login_window(app: tauri::AppHandle) -> Result<(), String> {\n    use tauri::WebviewUrl;\n    use tauri::WebviewWindowBuilder;\n\n    // Check if window already exists\n    if let Some(window) = app.get_webview_window(\"xhs-login\") {\n        window.set_focus().map_err(|e| e.to_string())?;\n        return Ok(());\n    }\n\n    // Create new webview window\n    let window = WebviewWindowBuilder::new(\n        &app,\n        \"xhs-login\",\n        WebviewUrl::External(\"https://www.xiaohongshu.com/explore\".parse().unwrap()),\n    )\n    .title(\"Login to Xiaohongshu\")\n    .inner_size(450.0, 700.0)\n    .center()\n    .build()\n    .map_err(|e| e.to_string())?;\n\n    // Monitor for login by checking cookies periodically\n    let app_handle = app.clone();\n    let window_label = window.label().to_string();\n\n    tauri::async_runtime::spawn(async move {\n        use tokio::time::{sleep, Duration};\n\n        loop {\n            sleep(Duration::from_secs(3)).await;\n\n            // Check if window still exists\n            if app_handle.get_webview_window(&window_label).is_none() {\n                break;\n            }\n\n            // Try to get cookies from the webview\n            // Note: This is a simplified check - in production you'd use more robust cookie extraction\n            if let Some(win) = app_handle.get_webview_window(&window_label) {\n                // Execute JS to check if user is logged in\n                let result = win.eval(\n                    r#\"\n                    (function() {\n                        // Check if user menu or profile element exists (indicates logged in)\n                        const loggedInIndicator = document.querySelector('.user-info, .login-btn[style*=\"display: none\"], .user-avatar');\n                        if (loggedInIndicator) {\n                            window.__VGET_LOGGED_IN__ = true;\n                        }\n                        // Also check localStorage for user data\n                        const userData = localStorage.getItem('user') || localStorage.getItem('userInfo');\n                        if (userData) {\n                            window.__VGET_LOGGED_IN__ = true;\n                        }\n                    })();\n                    \"#\n                );\n\n                if result.is_ok() {\n                    // Give it a moment for the JS to execute\n                    sleep(Duration::from_millis(500)).await;\n\n                    // For now, just keep window open and let user close it manually\n                    // A more robust solution would use tauri's webview cookie API when available\n                }\n            }\n        }\n    });\n\n    Ok(())\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/config.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::fs;\nuse std::path::PathBuf;\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct WebDAVServer {\n    pub url: String,\n    pub username: String,\n    pub password: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct TwitterConfig {\n    #[serde(default)]\n    pub auth_token: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ServerConfig {\n    #[serde(default = \"default_max_concurrent\")]\n    pub max_concurrent: u32,\n}\n\nfn default_max_concurrent() -> u32 {\n    10\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct BilibiliConfig {\n    #[serde(default)]\n    pub cookie: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct Kuaidi100Config {\n    #[serde(default)]\n    pub customer: Option<String>,\n    #[serde(default)]\n    pub key: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ExpressConfig {\n    #[serde(default)]\n    pub kuaidi100: Option<Kuaidi100Config>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Config {\n    #[serde(default = \"default_language\")]\n    pub language: String,\n    #[serde(default = \"default_output_dir\")]\n    pub output_dir: String,\n    #[serde(default = \"default_format\")]\n    pub format: String,\n    #[serde(default = \"default_quality\")]\n    pub quality: String,\n    #[serde(default = \"default_theme\")]\n    pub theme: String,\n    #[serde(default, rename = \"webdavServers\")]\n    pub webdav_servers: HashMap<String, WebDAVServer>,\n    #[serde(default)]\n    pub twitter: TwitterConfig,\n    #[serde(default)]\n    pub server: ServerConfig,\n    #[serde(default)]\n    pub express: ExpressConfig,\n    #[serde(default)]\n    pub bilibili: BilibiliConfig,\n}\n\nfn default_language() -> String {\n    \"en\".to_string()\n}\n\nfn default_output_dir() -> String {\n    dirs::download_dir()\n        .map(|p| p.join(\"vget\").to_string_lossy().to_string())\n        .unwrap_or_else(|| \"~/Downloads/vget\".to_string())\n}\n\nfn default_format() -> String {\n    \"mp4\".to_string()\n}\n\nfn default_quality() -> String {\n    \"best\".to_string()\n}\n\nfn default_theme() -> String {\n    \"light\".to_string()\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            language: default_language(),\n            output_dir: default_output_dir(),\n            format: default_format(),\n            quality: default_quality(),\n            theme: default_theme(),\n            webdav_servers: HashMap::new(),\n            twitter: TwitterConfig::default(),\n            server: ServerConfig::default(),\n            express: ExpressConfig::default(),\n            bilibili: BilibiliConfig::default(),\n        }\n    }\n}\n\nfn config_dir() -> PathBuf {\n    // Share config with CLI: ~/.config/vget/\n    // Don't use dirs::config_dir() as it returns ~/Library/Application Support/ on macOS\n    dirs::home_dir()\n        .unwrap_or_else(|| PathBuf::from(\".\"))\n        .join(\".config\")\n        .join(\"vget\")\n}\n\nfn config_path() -> PathBuf {\n    config_dir().join(\"config.yml\")\n}\n\npub fn get_config() -> Result<Config, Box<dyn std::error::Error>> {\n    let path = config_path();\n    if path.exists() {\n        let contents = fs::read_to_string(&path)?;\n        let config: Config = serde_yaml::from_str(&contents)?;\n        Ok(config)\n    } else {\n        Ok(Config::default())\n    }\n}\n\npub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> {\n    let path = config_path();\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent)?;\n    }\n    let contents = serde_yaml::to_string(config)?;\n    fs::write(path, contents)?;\n    Ok(())\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/downloader/mod.rs",
    "content": "mod simple;\n\npub use simple::*;\n\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DownloadProgress {\n    pub job_id: String,\n    pub downloaded: u64,\n    pub total: Option<u64>,\n    pub speed: u64,\n    pub percent: f64,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n#[serde(rename_all = \"lowercase\")]\npub enum DownloadStatus {\n    Pending,\n    Downloading,\n    Completed,\n    Failed,\n    Cancelled,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DownloadJob {\n    pub id: String,\n    pub url: String,\n    pub output_path: String,\n    pub status: DownloadStatus,\n    pub progress: Option<DownloadProgress>,\n    pub error: Option<String>,\n}\n\n/// Global download manager to track active downloads\npub struct DownloadManager {\n    jobs: Arc<RwLock<HashMap<String, DownloadJob>>>,\n    cancellation_tokens: Arc<RwLock<HashMap<String, tokio::sync::watch::Sender<bool>>>>,\n}\n\nimpl DownloadManager {\n    pub fn new() -> Self {\n        Self {\n            jobs: Arc::new(RwLock::new(HashMap::new())),\n            cancellation_tokens: Arc::new(RwLock::new(HashMap::new())),\n        }\n    }\n\n    pub async fn add_job(&self, job: DownloadJob) -> tokio::sync::watch::Receiver<bool> {\n        let (tx, rx) = tokio::sync::watch::channel(false);\n        let job_id = job.id.clone();\n\n        self.jobs.write().await.insert(job_id.clone(), job);\n        self.cancellation_tokens.write().await.insert(job_id, tx);\n\n        rx\n    }\n\n    pub async fn update_job(&self, job_id: &str, status: DownloadStatus, progress: Option<DownloadProgress>, error: Option<String>) {\n        if let Some(job) = self.jobs.write().await.get_mut(job_id) {\n            job.status = status;\n            job.progress = progress;\n            job.error = error;\n        }\n    }\n\n    pub async fn cancel_job(&self, job_id: &str) -> Result<(), String> {\n        if let Some(tx) = self.cancellation_tokens.read().await.get(job_id) {\n            tx.send(true).map_err(|e| e.to_string())?;\n        }\n        self.update_job(job_id, DownloadStatus::Cancelled, None, None).await;\n        Ok(())\n    }\n\n    pub async fn get_job(&self, job_id: &str) -> Option<DownloadJob> {\n        self.jobs.read().await.get(job_id).cloned()\n    }\n\n    #[allow(dead_code)]\n    pub async fn remove_job(&self, job_id: &str) {\n        self.jobs.write().await.remove(job_id);\n        self.cancellation_tokens.write().await.remove(job_id);\n    }\n}\n\nimpl Default for DownloadManager {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/downloader/simple.rs",
    "content": "use super::{DownloadProgress, DownloadStatus};\nuse futures::StreamExt;\nuse reqwest::Client;\nuse std::collections::HashMap;\nuse std::path::Path;\nuse std::time::Instant;\nuse tauri::{Emitter, Window};\nuse tokio::fs::File;\nuse tokio::io::AsyncWriteExt;\nuse tokio::sync::watch::Receiver;\n\npub struct SimpleDownloader {\n    client: Client,\n}\n\nimpl SimpleDownloader {\n    pub fn new() -> Self {\n        Self {\n            client: Client::builder()\n                .user_agent(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n                .build()\n                .unwrap_or_default(),\n        }\n    }\n\n    pub async fn download(\n        &self,\n        job_id: &str,\n        url: &str,\n        output_path: &str,\n        window: &Window,\n        cancel_rx: Receiver<bool>,\n        headers: Option<HashMap<String, String>>,\n    ) -> Result<(), String> {\n        // Ensure parent directory exists\n        if let Some(parent) = Path::new(output_path).parent() {\n            tokio::fs::create_dir_all(parent)\n                .await\n                .map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n        }\n\n        // Start download with optional headers\n        let mut request = self.client.get(url);\n\n        if let Some(hdrs) = headers {\n            for (key, value) in hdrs {\n                request = request.header(&key, &value);\n            }\n        }\n\n        let response = request\n            .send()\n            .await\n            .map_err(|e| format!(\"Failed to fetch: {}\", e))?;\n\n        if !response.status().is_success() {\n            return Err(format!(\"HTTP error: {}\", response.status()));\n        }\n\n        let total = response.content_length();\n        let mut downloaded: u64 = 0;\n        let mut last_emit = Instant::now();\n        let mut last_downloaded: u64 = 0;\n\n        // Create file\n        let mut file = File::create(output_path)\n            .await\n            .map_err(|e| format!(\"Failed to create file: {}\", e))?;\n\n        // Stream download\n        let mut stream = response.bytes_stream();\n\n        while let Some(chunk_result) = stream.next().await {\n            // Check for cancellation\n            if *cancel_rx.borrow() {\n                drop(file);\n                let _ = tokio::fs::remove_file(output_path).await;\n                return Err(\"Download cancelled\".to_string());\n            }\n\n            let chunk = chunk_result.map_err(|e| format!(\"Stream error: {}\", e))?;\n            downloaded += chunk.len() as u64;\n\n            file.write_all(&chunk)\n                .await\n                .map_err(|e| format!(\"Write error: {}\", e))?;\n\n            // Emit progress every 100ms\n            if last_emit.elapsed().as_millis() >= 100 {\n                let elapsed = last_emit.elapsed().as_secs_f64();\n                let speed = if elapsed > 0.0 {\n                    ((downloaded - last_downloaded) as f64 / elapsed) as u64\n                } else {\n                    0\n                };\n\n                let percent = total.map(|t| (downloaded as f64 / t as f64) * 100.0).unwrap_or(0.0);\n\n                let progress = DownloadProgress {\n                    job_id: job_id.to_string(),\n                    downloaded,\n                    total,\n                    speed,\n                    percent,\n                };\n\n                let _ = window.emit(\"download-progress\", &progress);\n\n                last_emit = Instant::now();\n                last_downloaded = downloaded;\n            }\n        }\n\n        file.flush()\n            .await\n            .map_err(|e| format!(\"Flush error: {}\", e))?;\n\n        // Emit completion\n        let progress = DownloadProgress {\n            job_id: job_id.to_string(),\n            downloaded,\n            total,\n            speed: 0,\n            percent: 100.0,\n        };\n\n        let _ = window.emit(\"download-progress\", &progress);\n        let _ = window.emit(\n            \"download-complete\",\n            serde_json::json!({\n                \"jobId\": job_id,\n                \"status\": DownloadStatus::Completed,\n                \"outputPath\": output_path,\n            }),\n        );\n\n        Ok(())\n    }\n\n    /// Download video and audio separately, then merge with ffmpeg\n    pub async fn download_and_merge(\n        &self,\n        job_id: &str,\n        video_url: &str,\n        audio_url: &str,\n        output_path: &str,\n        window: &Window,\n        cancel_rx: Receiver<bool>,\n        headers: Option<HashMap<String, String>>,\n    ) -> Result<(), String> {\n        use crate::ffmpeg::merge_video_audio;\n\n        // Create temp file paths\n        let output = Path::new(output_path);\n        let parent = output.parent().unwrap_or(Path::new(\".\"));\n        let stem = output.file_stem().and_then(|s| s.to_str()).unwrap_or(\"video\");\n\n        let video_temp = parent.join(format!(\".{}_video.m4s\", stem));\n        let audio_temp = parent.join(format!(\".{}_audio.m4s\", stem));\n\n        let video_temp_str = video_temp.to_string_lossy().to_string();\n        let audio_temp_str = audio_temp.to_string_lossy().to_string();\n\n        // Download video (0-45% of progress)\n        eprintln!(\"[download] Downloading video from: {}\", video_url);\n        self.download_file_with_progress(\n            video_url,\n            &video_temp_str,\n            headers.clone(),\n            job_id,\n            window,\n            0.0,  // start percent\n            45.0, // end percent\n            \"downloading video\",\n        )\n        .await\n        .map_err(|e| {\n            eprintln!(\"[download] Video download failed: {}\", e);\n            e\n        })?;\n        eprintln!(\"[download] Video downloaded to: {}\", video_temp_str);\n\n        // Check cancellation\n        if *cancel_rx.borrow() {\n            let _ = tokio::fs::remove_file(&video_temp).await;\n            return Err(\"Download cancelled\".to_string());\n        }\n\n        // Download audio (45-90% of progress)\n        eprintln!(\"[download] Downloading audio from: {}\", audio_url);\n        self.download_file_with_progress(\n            audio_url,\n            &audio_temp_str,\n            headers,\n            job_id,\n            window,\n            45.0, // start percent\n            90.0, // end percent\n            \"downloading audio\",\n        )\n        .await\n        .map_err(|e| {\n            eprintln!(\"[download] Audio download failed: {}\", e);\n            // Clean up video temp file\n            let _ = std::fs::remove_file(&video_temp);\n            e\n        })?;\n        eprintln!(\"[download] Audio downloaded to: {}\", audio_temp_str);\n\n        // Check cancellation\n        if *cancel_rx.borrow() {\n            let _ = tokio::fs::remove_file(&video_temp).await;\n            let _ = tokio::fs::remove_file(&audio_temp).await;\n            return Err(\"Download cancelled\".to_string());\n        }\n\n        // Merge with ffmpeg\n        let _ = window.emit(\n            \"download-progress\",\n            serde_json::json!({\n                \"job_id\": job_id,\n                \"downloaded\": 0,\n                \"total\": null,\n                \"speed\": 0,\n                \"percent\": 90.0,\n                \"stage\": \"merging video and audio\"\n            }),\n        );\n\n        eprintln!(\"[download] Merging video and audio to: {}\", output_path);\n        merge_video_audio(&video_temp_str, &audio_temp_str, output_path, true)\n            .await\n            .map_err(|e| {\n                eprintln!(\"[download] Merge failed: {}\", e);\n                format!(\"Failed to merge: {}\", e)\n            })?;\n        eprintln!(\"[download] Merge completed successfully\");\n\n        // Emit completion\n        let _ = window.emit(\n            \"download-progress\",\n            serde_json::json!({\n                \"job_id\": job_id,\n                \"downloaded\": 0,\n                \"total\": null,\n                \"speed\": 0,\n                \"percent\": 100.0,\n                \"stage\": \"completed\"\n            }),\n        );\n\n        let _ = window.emit(\n            \"download-complete\",\n            serde_json::json!({\n                \"jobId\": job_id,\n                \"status\": \"completed\",\n                \"outputPath\": output_path,\n            }),\n        );\n\n        Ok(())\n    }\n\n    /// Download a file with streaming and progress reporting\n    async fn download_file_with_progress(\n        &self,\n        url: &str,\n        output_path: &str,\n        headers: Option<HashMap<String, String>>,\n        job_id: &str,\n        window: &Window,\n        start_percent: f64,\n        end_percent: f64,\n        _stage: &str,\n    ) -> Result<(), String> {\n        // Ensure parent directory exists\n        if let Some(parent) = Path::new(output_path).parent() {\n            tokio::fs::create_dir_all(parent)\n                .await\n                .map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n        }\n\n        let mut request = self.client.get(url);\n\n        if let Some(hdrs) = headers {\n            for (key, value) in hdrs {\n                request = request.header(&key, &value);\n            }\n        }\n\n        let response = request\n            .send()\n            .await\n            .map_err(|e| format!(\"Failed to fetch {}: {}\", url, e))?;\n\n        if !response.status().is_success() {\n            return Err(format!(\"HTTP error for {}: {}\", url, response.status()));\n        }\n\n        let total = response.content_length();\n        let mut downloaded: u64 = 0;\n        let mut last_emit = Instant::now();\n        let mut last_downloaded: u64 = 0;\n        let percent_range = end_percent - start_percent;\n\n        // Stream download to file\n        let mut file = File::create(output_path)\n            .await\n            .map_err(|e| format!(\"Failed to create file: {}\", e))?;\n\n        let mut stream = response.bytes_stream();\n\n        while let Some(chunk_result) = stream.next().await {\n            let chunk = chunk_result.map_err(|e| format!(\"Stream error: {}\", e))?;\n            downloaded += chunk.len() as u64;\n\n            file.write_all(&chunk)\n                .await\n                .map_err(|e| format!(\"Write error: {}\", e))?;\n\n            // Emit progress every 100ms\n            if last_emit.elapsed().as_millis() >= 100 {\n                let elapsed = last_emit.elapsed().as_secs_f64();\n                let speed = if elapsed > 0.0 {\n                    ((downloaded - last_downloaded) as f64 / elapsed) as u64\n                } else {\n                    0\n                };\n\n                // Calculate percent within the allocated range\n                let file_percent = total\n                    .map(|t| (downloaded as f64 / t as f64) * 100.0)\n                    .unwrap_or(50.0);\n                let overall_percent = start_percent + (file_percent / 100.0) * percent_range;\n\n                let progress = DownloadProgress {\n                    job_id: job_id.to_string(),\n                    downloaded,\n                    total,\n                    speed,\n                    percent: overall_percent,\n                };\n\n                let _ = window.emit(\"download-progress\", &progress);\n\n                last_emit = Instant::now();\n                last_downloaded = downloaded;\n            }\n        }\n\n        file.flush()\n            .await\n            .map_err(|e| format!(\"Flush error: {}\", e))?;\n\n        Ok(())\n    }\n}\n\nimpl Default for SimpleDownloader {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/extractor/bilibili.rs",
    "content": "use super::types::*;\nuse regex::Regex;\nuse reqwest::header::{HeaderMap, HeaderValue, COOKIE, REFERER, USER_AGENT};\nuse serde::Deserialize;\nuse std::collections::{BTreeMap, HashMap};\nuse std::sync::LazyLock;\nuse url::Url;\n\nuse crate::config::get_config;\n\n// BV/AV conversion constants (from https://github.com/Colerar/abv)\nconst XOR_CODE: i64 = 23442827791579;\nconst MASK_CODE: i64 = (1 << 51) - 1;\nconst MAX_AID: i64 = MASK_CODE + 1;\nconst BASE: i64 = 58;\nconst BV_LEN: usize = 9;\n\nconst ALPHABET: &[u8] = b\"FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf\";\n\n// Mixin key encoding table for WBI signing\nconst MIXIN_KEY_ENC_TAB: [usize; 32] = [\n    46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,\n    28, 14, 39, 12, 38, 41, 13,\n];\n\n// Quality definitions\nfn quality_map(id: i32) -> Option<&'static str> {\n    match id {\n        127 => Some(\"8K\"),\n        126 => Some(\"Dolby Vision\"),\n        125 => Some(\"HDR\"),\n        120 => Some(\"4K\"),\n        116 => Some(\"1080P60\"),\n        112 => Some(\"1080P+\"),\n        80 => Some(\"1080P\"),\n        74 => Some(\"720P60\"),\n        64 => Some(\"720P\"),\n        32 => Some(\"480P\"),\n        16 => Some(\"360P\"),\n        _ => None,\n    }\n}\n\nfn codec_name(codec_id: i32) -> &'static str {\n    match codec_id {\n        7 => \"AVC\",\n        12 => \"HEVC\",\n        13 => \"AV1\",\n        _ => \"Unknown\",\n    }\n}\n\nstatic VIDEO_REGEX: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"bilibili\\.com/video/(BV[\\w]+|av\\d+)\").unwrap());\n\nstatic SHORT_REGEX: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"b23\\.tv/(BV[\\w]+|av\\d+|\\w+)\").unwrap());\n\nstatic BV_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"(?i)^BV1[\\w]{9}$\").unwrap());\n\nstatic AV_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"(?i)^av(\\d+)$\").unwrap());\n\n// Build reverse alphabet lookup\nfn rev_alphabet() -> [i64; 256] {\n    let mut rev = [0i64; 256];\n    for (i, &c) in ALPHABET.iter().enumerate() {\n        rev[c as usize] = i as i64;\n    }\n    rev\n}\n\n/// Convert BV ID to AV number\npub fn bv_to_av(bvid: &str) -> Result<i64, String> {\n    let rev = rev_alphabet();\n\n    // Remove \"BV1\" or \"BV\" prefix if present\n    let bvid = if bvid.to_uppercase().starts_with(\"BV1\") {\n        &bvid[3..]\n    } else if bvid.to_uppercase().starts_with(\"BV\") {\n        &bvid[2..]\n    } else {\n        bvid\n    };\n\n    if bvid.len() != BV_LEN {\n        return Err(format!(\n            \"invalid BV ID length: expected {}, got {}\",\n            BV_LEN,\n            bvid.len()\n        ));\n    }\n\n    let mut bv: Vec<u8> = bvid.bytes().collect();\n\n    // Swap positions\n    bv.swap(0, 6);\n    bv.swap(1, 4);\n\n    let mut avid: i64 = 0;\n    for b in bv.iter() {\n        avid = avid * BASE + rev[*b as usize];\n    }\n\n    Ok((avid & MASK_CODE) ^ XOR_CODE)\n}\n\n/// Convert AV number to BV ID\npub fn av_to_bv(avid: i64) -> Result<String, String> {\n    if avid < 1 {\n        return Err(format!(\"AV {} is smaller than 1\", avid));\n    }\n    if avid >= MAX_AID {\n        return Err(format!(\"AV {} is bigger than {}\", avid, MAX_AID));\n    }\n\n    let mut bvid = vec![0u8; BV_LEN];\n    let mut tmp = (MAX_AID | avid) ^ XOR_CODE;\n\n    for i in (0..BV_LEN).rev() {\n        if tmp == 0 {\n            break;\n        }\n        bvid[i] = ALPHABET[(tmp % BASE) as usize];\n        tmp /= BASE;\n    }\n\n    // Swap positions\n    bvid.swap(0, 6);\n    bvid.swap(1, 4);\n\n    Ok(format!(\"BV1{}\", String::from_utf8(bvid).unwrap()))\n}\n\npub struct BilibiliExtractor {\n    client: reqwest::Client,\n    cookie: Option<String>,\n    wbi_key: Option<String>,\n}\n\nimpl BilibiliExtractor {\n    pub fn new() -> Self {\n        let cookie = get_config()\n            .ok()\n            .and_then(|c| c.bilibili.cookie);\n\n        Self {\n            client: reqwest::Client::builder()\n                .timeout(std::time::Duration::from_secs(30))\n                .redirect(reqwest::redirect::Policy::none())\n                .build()\n                .unwrap(),\n            cookie,\n            wbi_key: None,\n        }\n    }\n\n    /// Check if URL is a Bilibili video URL\n    pub fn matches(url: &Url) -> bool {\n        let url_str = url.as_str();\n        VIDEO_REGEX.is_match(url_str) || SHORT_REGEX.is_match(url_str)\n    }\n\n    /// Extract media info from Bilibili URL\n    pub async fn extract(url_str: &str) -> Result<MediaInfo, ExtractError> {\n        let mut extractor = Self::new();\n        extractor.do_extract(url_str).await\n    }\n\n    async fn do_extract(&mut self, url_str: &str) -> Result<MediaInfo, ExtractError> {\n        // Resolve video ID\n        let (aid, bvid) = self.resolve_video_id(url_str).await?;\n\n        // Fetch WBI keys (non-fatal if fails)\n        if let Err(e) = self.fetch_wbi_keys().await {\n            eprintln!(\"Warning: failed to get WBI keys: {}\", e);\n        }\n\n        // Fetch video info\n        let video_info = self.fetch_video_info(aid).await?;\n\n        // Get first page CID\n        let cid = video_info\n            .pages\n            .first()\n            .map(|p| p.cid)\n            .ok_or_else(|| ExtractError::Parse(\"no video pages found\".into()))?;\n\n        // Fetch play URL to get stream info\n        let streams = self.fetch_play_url(aid, cid).await?;\n\n        // Build formats from streams\n        let formats = self.build_formats(&streams);\n\n        if formats.is_empty() {\n            return Err(ExtractError::NotAvailable);\n        }\n\n        Ok(MediaInfo {\n            id: bvid,\n            title: video_info.title,\n            uploader: Some(video_info.owner.name),\n            thumbnail: Some(video_info.pic),\n            duration: Some(video_info.duration as u64),\n            media_type: MediaType::Video,\n            formats,\n        })\n    }\n\n    async fn resolve_video_id(&self, url_str: &str) -> Result<(i64, String), ExtractError> {\n        // Handle short URLs\n        let resolved_url = if url_str.contains(\"b23.tv\") {\n            self.resolve_short_url(url_str).await?\n        } else {\n            url_str.to_string()\n        };\n\n        // Extract video ID from URL\n        if let Some(caps) = VIDEO_REGEX.captures(&resolved_url) {\n            let id = caps.get(1).unwrap().as_str();\n\n            if BV_REGEX.is_match(id) {\n                let aid = bv_to_av(id).map_err(|e| ExtractError::Parse(e))?;\n                return Ok((aid, id.to_string()));\n            } else if let Some(av_caps) = AV_REGEX.captures(id) {\n                let aid: i64 = av_caps\n                    .get(1)\n                    .unwrap()\n                    .as_str()\n                    .parse()\n                    .map_err(|_| ExtractError::Parse(\"invalid AV number\".into()))?;\n                let bvid = av_to_bv(aid).map_err(|e| ExtractError::Parse(e))?;\n                return Ok((aid, bvid));\n            }\n        }\n\n        Err(ExtractError::Parse(format!(\n            \"could not extract video ID from URL: {}\",\n            url_str\n        )))\n    }\n\n    async fn resolve_short_url(&self, short_url: &str) -> Result<String, ExtractError> {\n        let resp = self\n            .client\n            .head(short_url)\n            .header(USER_AGENT, user_agent())\n            .send()\n            .await?;\n\n        if resp.status().is_redirection() {\n            if let Some(location) = resp.headers().get(\"location\") {\n                return Ok(location.to_str().unwrap_or(short_url).to_string());\n            }\n        }\n\n        Ok(short_url.to_string())\n    }\n\n    async fn fetch_wbi_keys(&mut self) -> Result<(), ExtractError> {\n        let api = \"https://api.bilibili.com/x/web-interface/nav\";\n\n        let resp = self\n            .client\n            .get(api)\n            .headers(self.build_headers())\n            .send()\n            .await?;\n\n        let data: NavResponse = resp.json().await?;\n\n        // Extract keys from URLs\n        let img_key = extract_key_from_url(&data.data.wbi_img.img_url);\n        let sub_key = extract_key_from_url(&data.data.wbi_img.sub_url);\n\n        // Generate mixin key\n        self.wbi_key = Some(get_mixin_key(&format!(\"{}{}\", img_key, sub_key)));\n\n        Ok(())\n    }\n\n    async fn fetch_video_info(&self, aid: i64) -> Result<VideoInfo, ExtractError> {\n        let api = format!(\n            \"https://api.bilibili.com/x/web-interface/view?aid={}\",\n            aid\n        );\n\n        let resp = self\n            .client\n            .get(&api)\n            .headers(self.build_headers())\n            .send()\n            .await?;\n\n        let data: VideoInfoResponse = resp.json().await?;\n\n        if data.code != 0 {\n            return Err(ExtractError::Parse(format!(\n                \"API error: {} (code: {})\",\n                data.message, data.code\n            )));\n        }\n\n        Ok(data.data)\n    }\n\n    async fn fetch_play_url(&self, aid: i64, cid: i64) -> Result<DashInfo, ExtractError> {\n        let mut params = BTreeMap::new();\n        params.insert(\"avid\", aid.to_string());\n        params.insert(\"cid\", cid.to_string());\n        params.insert(\"fnval\", \"4048\".to_string()); // DASH + HDR + Dolby + 8K + AV1\n        params.insert(\"fnver\", \"0\".to_string());\n        params.insert(\"fourk\", \"1\".to_string());\n        params.insert(\"qn\", \"127\".to_string()); // Request highest quality\n\n        // Sign with WBI if available\n        let query = self.wbi_sign(&mut params);\n\n        let api = format!(\n            \"https://api.bilibili.com/x/player/wbi/playurl?{}\",\n            query\n        );\n\n        let resp = self\n            .client\n            .get(&api)\n            .headers(self.build_headers())\n            .send()\n            .await?;\n\n        let data: PlayUrlResponse = resp.json().await?;\n\n        if data.code != 0 {\n            return Err(ExtractError::Parse(format!(\n                \"API error: {} (code: {})\",\n                data.message, data.code\n            )));\n        }\n\n        data.data\n            .dash\n            .ok_or_else(|| ExtractError::Parse(\"no DASH streams available\".into()))\n    }\n\n    fn wbi_sign(&self, params: &mut BTreeMap<&str, String>) -> String {\n        let wbi_key = match &self.wbi_key {\n            Some(k) => k,\n            None => return build_query(params),\n        };\n\n        // Add timestamp\n        let wts = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .unwrap()\n            .as_secs();\n        params.insert(\"wts\", wts.to_string());\n\n        // Build query string (sorted by key)\n        let mut query_parts: Vec<String> = Vec::new();\n        for (k, v) in params.iter() {\n            let filtered_v = filter_wbi_value(v);\n            query_parts.push(format!(\n                \"{}={}\",\n                k,\n                urlencoding::encode(&filtered_v)\n            ));\n        }\n        let query_str = query_parts.join(\"&\");\n\n        // Calculate signature\n        let digest = md5::compute(format!(\"{}{}\", query_str, wbi_key));\n        let signature = format!(\"{:x}\", digest);\n\n        format!(\"{}&w_rid={}\", query_str, signature)\n    }\n\n    fn build_formats(&self, streams: &DashInfo) -> Vec<Format> {\n        let mut formats = Vec::new();\n\n        // Build headers for Bilibili CDN\n        let mut headers = HashMap::new();\n        headers.insert(\"Referer\".to_string(), \"https://www.bilibili.com/\".to_string());\n        headers.insert(\"User-Agent\".to_string(), user_agent().to_string());\n\n        // Find best audio stream\n        let best_audio_url = streams\n            .audio\n            .as_ref()\n            .and_then(|audios| {\n                audios\n                    .iter()\n                    .max_by_key(|a| a.bandwidth)\n                    .map(|a| a.base_url.clone())\n            });\n\n        // Build video formats\n        for video in &streams.video {\n            let quality = quality_map(video.id)\n                .map(|q| q.to_string())\n                .unwrap_or_else(|| format!(\"{}p\", video.height));\n\n            let codec = codec_name(video.codecid);\n\n            formats.push(Format {\n                id: format!(\"{}_{}\", video.id, video.codecid),\n                url: video.base_url.clone(),\n                ext: \"mp4\".to_string(),\n                quality: Some(format!(\"{} [{}]\", quality, codec)),\n                width: Some(video.width as u32),\n                height: Some(video.height as u32),\n                filesize: None,\n                audio_url: best_audio_url.clone(),\n                headers: headers.clone(),\n            });\n        }\n\n        // Sort by height (highest first), then by bitrate\n        formats.sort_by(|a, b| {\n            let height_a = a.height.unwrap_or(0);\n            let height_b = b.height.unwrap_or(0);\n            if height_a != height_b {\n                height_b.cmp(&height_a)\n            } else {\n                // Compare by id (higher quality id = better)\n                b.id.cmp(&a.id)\n            }\n        });\n\n        formats\n    }\n\n    fn build_headers(&self) -> HeaderMap {\n        let mut headers = HeaderMap::new();\n        headers.insert(USER_AGENT, HeaderValue::from_static(user_agent()));\n        headers.insert(\n            REFERER,\n            HeaderValue::from_static(\"https://www.bilibili.com/\"),\n        );\n        headers.insert(\n            \"Accept\",\n            HeaderValue::from_static(\"application/json\"),\n        );\n\n        if let Some(cookie) = &self.cookie {\n            if let Ok(value) = HeaderValue::from_str(cookie) {\n                headers.insert(COOKIE, value);\n            }\n        }\n\n        headers\n    }\n}\n\n// Helper functions\n\nfn user_agent() -> &'static str {\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n}\n\nfn extract_key_from_url(url: &str) -> String {\n    // URL like: https://i0.hdslb.com/bfs/wbi/xxx.png\n    // Extract xxx (without extension)\n    url.rsplit('/')\n        .next()\n        .and_then(|filename| filename.rsplit_once('.').map(|(name, _)| name.to_string()))\n        .unwrap_or_default()\n}\n\nfn get_mixin_key(orig: &str) -> String {\n    let bytes: Vec<u8> = orig.bytes().collect();\n    MIXIN_KEY_ENC_TAB\n        .iter()\n        .filter_map(|&idx| bytes.get(idx).copied())\n        .map(|b| b as char)\n        .collect()\n}\n\nfn filter_wbi_value(s: &str) -> String {\n    // Remove !'()*\n    s.chars()\n        .filter(|&c| c != '!' && c != '\\'' && c != '(' && c != ')' && c != '*')\n        .collect()\n}\n\nfn build_query(params: &BTreeMap<&str, String>) -> String {\n    params\n        .iter()\n        .map(|(k, v)| format!(\"{}={}\", k, urlencoding::encode(v)))\n        .collect::<Vec<_>>()\n        .join(\"&\")\n}\n\n// Response structs\n\n#[derive(Debug, Deserialize)]\nstruct NavResponse {\n    data: NavData,\n}\n\n#[derive(Debug, Deserialize)]\nstruct NavData {\n    wbi_img: WbiImg,\n}\n\n#[derive(Debug, Deserialize)]\nstruct WbiImg {\n    img_url: String,\n    sub_url: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct VideoInfoResponse {\n    code: i32,\n    message: String,\n    data: VideoInfo,\n}\n\n#[derive(Debug, Deserialize)]\nstruct VideoInfo {\n    title: String,\n    #[serde(default)]\n    pic: String,\n    duration: i64,\n    owner: Owner,\n    pages: Vec<Page>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct Owner {\n    name: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct Page {\n    cid: i64,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PlayUrlResponse {\n    code: i32,\n    message: String,\n    data: PlayUrlData,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PlayUrlData {\n    dash: Option<DashInfo>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct DashInfo {\n    video: Vec<VideoStream>,\n    audio: Option<Vec<AudioStream>>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct VideoStream {\n    id: i32,\n    #[serde(rename = \"baseUrl\")]\n    base_url: String,\n    #[allow(dead_code)]\n    bandwidth: i64,\n    width: i32,\n    height: i32,\n    codecid: i32,\n}\n\n#[derive(Debug, Deserialize)]\nstruct AudioStream {\n    #[serde(rename = \"baseUrl\")]\n    base_url: String,\n    #[allow(dead_code)]\n    bandwidth: i64,\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/extractor/direct.rs",
    "content": "use super::types::*;\nuse std::collections::HashMap;\nuse url::Url;\n\nconst VIDEO_EXTENSIONS: &[&str] = &[\"mp4\", \"mkv\", \"webm\", \"avi\", \"mov\", \"flv\", \"m3u8\", \"ts\"];\nconst AUDIO_EXTENSIONS: &[&str] = &[\"mp3\", \"m4a\", \"aac\", \"flac\", \"wav\", \"ogg\", \"opus\"];\nconst IMAGE_EXTENSIONS: &[&str] = &[\"jpg\", \"jpeg\", \"png\", \"gif\", \"webp\", \"bmp\", \"svg\"];\n\npub struct DirectExtractor;\n\nimpl DirectExtractor {\n    /// Check if URL is a direct file link\n    pub fn matches(url: &Url) -> bool {\n        let path = url.path().to_lowercase();\n        VIDEO_EXTENSIONS.iter().any(|ext| path.ends_with(&format!(\".{}\", ext)))\n            || AUDIO_EXTENSIONS.iter().any(|ext| path.ends_with(&format!(\".{}\", ext)))\n            || IMAGE_EXTENSIONS.iter().any(|ext| path.ends_with(&format!(\".{}\", ext)))\n    }\n\n    /// Extract media info from direct URL\n    pub async fn extract(url: &str) -> Result<MediaInfo, ExtractError> {\n        let parsed = Url::parse(url).map_err(|_| ExtractError::InvalidUrl(url.to_string()))?;\n        let path = parsed.path();\n\n        // Get filename from path\n        let filename = path\n            .rsplit('/')\n            .next()\n            .unwrap_or(\"video\")\n            .to_string();\n\n        // Determine extension and media type\n        let ext = path\n            .rsplit('.')\n            .next()\n            .unwrap_or(\"mp4\")\n            .to_lowercase();\n\n        let media_type = if VIDEO_EXTENSIONS.contains(&ext.as_str()) {\n            MediaType::Video\n        } else if AUDIO_EXTENSIONS.contains(&ext.as_str()) {\n            MediaType::Audio\n        } else {\n            MediaType::Image\n        };\n\n        // Try HEAD request to get file size\n        let client = reqwest::Client::new();\n        let filesize = client\n            .head(url)\n            .send()\n            .await\n            .ok()\n            .and_then(|resp| {\n                resp.headers()\n                    .get(\"content-length\")\n                    .and_then(|v| v.to_str().ok())\n                    .and_then(|v| v.parse().ok())\n            });\n\n        Ok(MediaInfo {\n            id: filename.clone(),\n            title: filename,\n            uploader: parsed.host_str().map(|s| s.to_string()),\n            thumbnail: None,\n            duration: None,\n            media_type,\n            formats: vec![Format {\n                id: \"direct\".to_string(),\n                url: url.to_string(),\n                ext,\n                quality: None,\n                width: None,\n                height: None,\n                filesize,\n                audio_url: None,\n                headers: HashMap::new(),\n            }],\n        })\n    }\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/extractor/mod.rs",
    "content": "mod bilibili;\nmod direct;\nmod twitter;\nmod types;\n\npub use types::*;\n\nuse crate::config::get_config;\nuse url::Url;\n\n/// Extract media information from a URL\npub async fn extract_media(url_str: &str) -> Result<MediaInfo, ExtractError> {\n    let url = Url::parse(url_str).map_err(|_| ExtractError::InvalidUrl(url_str.to_string()))?;\n\n    // Check for Twitter/X URLs\n    if twitter::TwitterExtractor::matches(&url) {\n        // Load auth token from config\n        let auth_token = get_config()\n            .ok()\n            .and_then(|c| c.twitter.auth_token);\n        return twitter::TwitterExtractor::extract(url_str, auth_token).await;\n    }\n\n    // Check for Bilibili URLs\n    if bilibili::BilibiliExtractor::matches(&url) {\n        return bilibili::BilibiliExtractor::extract(url_str).await;\n    }\n\n    // Check for direct file URLs\n    if direct::DirectExtractor::matches(&url) {\n        return direct::DirectExtractor::extract(url_str).await;\n    }\n\n    // TODO: Add more extractors here\n    // - Xiaoyuzhou\n    // - etc.\n\n    Err(ExtractError::NoExtractor(url_str.to_string()))\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/extractor/twitter.rs",
    "content": "use super::types::*;\nuse regex::Regex;\nuse reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, COOKIE, USER_AGENT};\nuse serde::Deserialize;\nuse std::collections::HashMap;\nuse std::sync::LazyLock;\nuse url::Url;\n\nconst BEARER_TOKEN: &str = \"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs=1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA\";\nconst GUEST_TOKEN_URL: &str = \"https://api.x.com/1.1/guest/activate.json\";\nconst GRAPHQL_URL: &str = \"https://x.com/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId\";\nconst SYNDICATION_URL: &str = \"https://cdn.syndication.twimg.com/tweet-result\";\n\nstatic URL_REGEX: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"(?:twitter\\.com|x\\.com)/(?:[^/]+)/status/(\\d+)\").unwrap());\n\nstatic RESOLUTION_REGEX: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"/(\\d+)x(\\d+)/\").unwrap());\n\npub struct TwitterExtractor {\n    client: reqwest::Client,\n    auth_token: Option<String>,\n    csrf_token: Option<String>,\n}\n\nimpl TwitterExtractor {\n    pub fn new(auth_token: Option<String>) -> Self {\n        Self {\n            client: reqwest::Client::builder()\n                .timeout(std::time::Duration::from_secs(30))\n                .build()\n                .unwrap(),\n            auth_token,\n            csrf_token: None,\n        }\n    }\n\n    /// Check if URL is a Twitter/X status URL\n    pub fn matches(url: &Url) -> bool {\n        let host = url.host_str().unwrap_or(\"\");\n        if ![\"twitter.com\", \"x.com\", \"mobile.twitter.com\", \"mobile.x.com\"].contains(&host) {\n            return false;\n        }\n        URL_REGEX.is_match(url.as_str())\n    }\n\n    /// Extract media info from Twitter URL\n    pub async fn extract(url_str: &str, auth_token: Option<String>) -> Result<MediaInfo, ExtractError> {\n        let mut extractor = Self::new(auth_token);\n        extractor.do_extract(url_str).await\n    }\n\n    async fn do_extract(&mut self, url_str: &str) -> Result<MediaInfo, ExtractError> {\n        // Extract tweet ID\n        let caps = URL_REGEX\n            .captures(url_str)\n            .ok_or_else(|| ExtractError::Parse(\"Could not extract tweet ID from URL\".into()))?;\n        let tweet_id = caps.get(1).unwrap().as_str();\n\n        // If authenticated, use GraphQL with auth\n        if self.auth_token.is_some() {\n            return self.fetch_from_graphql_auth(tweet_id).await;\n        }\n\n        // Try syndication API first (simpler, works for public tweets)\n        if let Ok(info) = self.fetch_from_syndication(tweet_id).await {\n            return Ok(info);\n        }\n\n        // Fallback to GraphQL with guest token\n        let guest_token = self.fetch_guest_token().await?;\n        self.fetch_from_graphql(tweet_id, &guest_token).await\n    }\n\n    async fn fetch_guest_token(&self) -> Result<String, ExtractError> {\n        let resp = self\n            .client\n            .post(GUEST_TOKEN_URL)\n            .header(AUTHORIZATION, format!(\"Bearer {}\", BEARER_TOKEN))\n            .send()\n            .await?;\n\n        if !resp.status().is_success() {\n            return Err(ExtractError::Parse(format!(\n                \"Guest token request failed: {}\",\n                resp.status()\n            )));\n        }\n\n        #[derive(Deserialize)]\n        struct GuestTokenResponse {\n            guest_token: String,\n        }\n\n        let data: GuestTokenResponse = resp.json().await?;\n        Ok(data.guest_token)\n    }\n\n    async fn fetch_from_syndication(&self, tweet_id: &str) -> Result<MediaInfo, ExtractError> {\n        let url = format!(\"{}?id={}&token=x\", SYNDICATION_URL, tweet_id);\n\n        let resp = self\n            .client\n            .get(&url)\n            .header(USER_AGENT, \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n            .header(\"Accept\", \"application/json\")\n            .send()\n            .await?;\n\n        if !resp.status().is_success() {\n            return Err(ExtractError::Parse(format!(\n                \"Syndication request failed: {}\",\n                resp.status()\n            )));\n        }\n\n        let data: SyndicationResponse = resp.json().await?;\n        self.parse_syndication_response(&data, tweet_id)\n    }\n\n    async fn fetch_from_graphql(\n        &self,\n        tweet_id: &str,\n        guest_token: &str,\n    ) -> Result<MediaInfo, ExtractError> {\n        let (variables, features) = build_graphql_params(tweet_id);\n        let url = format!(\n            \"{}?variables={}&features={}\",\n            GRAPHQL_URL,\n            urlencoding::encode(&variables),\n            urlencoding::encode(&features)\n        );\n\n        let resp = self\n            .client\n            .get(&url)\n            .header(AUTHORIZATION, format!(\"Bearer {}\", BEARER_TOKEN))\n            .header(\"x-guest-token\", guest_token)\n            .header(CONTENT_TYPE, \"application/json\")\n            .header(USER_AGENT, \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n            .send()\n            .await?;\n\n        if !resp.status().is_success() {\n            return Err(ExtractError::Parse(format!(\n                \"GraphQL request failed: {}\",\n                resp.status()\n            )));\n        }\n\n        let body = resp.text().await?;\n        self.parse_graphql_response(&body, tweet_id)\n    }\n\n    async fn fetch_csrf_token(&mut self) -> Result<(), ExtractError> {\n        let auth_token = self.auth_token.as_ref().ok_or(ExtractError::AuthRequired)?;\n\n        let resp = self\n            .client\n            .get(\"https://x.com\")\n            .header(USER_AGENT, \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n            .header(COOKIE, format!(\"auth_token={}\", auth_token))\n            .send()\n            .await?;\n\n        // Extract ct0 cookie from response\n        for cookie in resp.cookies() {\n            if cookie.name() == \"ct0\" {\n                self.csrf_token = Some(cookie.value().to_string());\n                return Ok(());\n            }\n        }\n\n        Err(ExtractError::Parse(\"Could not obtain CSRF token\".into()))\n    }\n\n    async fn fetch_from_graphql_auth(&mut self, tweet_id: &str) -> Result<MediaInfo, ExtractError> {\n        // Fetch CSRF token if needed\n        if self.csrf_token.is_none() {\n            self.fetch_csrf_token().await?;\n        }\n\n        let csrf_token = self.csrf_token.as_ref().unwrap();\n        let auth_token = self.auth_token.as_ref().unwrap();\n\n        let (variables, features) = build_graphql_params(tweet_id);\n        let url = format!(\n            \"{}?variables={}&features={}\",\n            GRAPHQL_URL,\n            urlencoding::encode(&variables),\n            urlencoding::encode(&features)\n        );\n\n        let mut headers = HeaderMap::new();\n        headers.insert(AUTHORIZATION, HeaderValue::from_str(&format!(\"Bearer {}\", BEARER_TOKEN)).unwrap());\n        headers.insert(CONTENT_TYPE, HeaderValue::from_static(\"application/json\"));\n        headers.insert(USER_AGENT, HeaderValue::from_static(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\"));\n        headers.insert(\"x-twitter-auth-type\", HeaderValue::from_static(\"OAuth2Session\"));\n        headers.insert(\"x-twitter-client-language\", HeaderValue::from_static(\"en\"));\n        headers.insert(\"x-twitter-active-user\", HeaderValue::from_static(\"yes\"));\n        headers.insert(\"x-csrf-token\", HeaderValue::from_str(csrf_token).unwrap());\n        headers.insert(\n            COOKIE,\n            HeaderValue::from_str(&format!(\"auth_token={}; ct0={}\", auth_token, csrf_token)).unwrap(),\n        );\n\n        let resp = self.client.get(&url).headers(headers).send().await?;\n\n        if !resp.status().is_success() {\n            return Err(ExtractError::Parse(format!(\n                \"GraphQL auth request failed: {}\",\n                resp.status()\n            )));\n        }\n\n        let body = resp.text().await?;\n        self.parse_graphql_response(&body, tweet_id)\n    }\n\n    fn parse_syndication_response(\n        &self,\n        data: &SyndicationResponse,\n        tweet_id: &str,\n    ) -> Result<MediaInfo, ExtractError> {\n        let title = truncate_text(&data.text, 100);\n        let uploader = data.user.as_ref().map(|u| u.screen_name.clone());\n\n        let mut formats = Vec::new();\n\n        // Process media_details\n        if let Some(media_details) = &data.media_details {\n            for media in media_details {\n                match media.r#type.as_str() {\n                    \"video\" | \"animated_gif\" => {\n                        if let Some(video_info) = &media.video_info {\n                            for variant in &video_info.variants {\n                                if variant.content_type != \"video/mp4\" {\n                                    continue;\n                                }\n\n                                let (width, height) = extract_resolution(&variant.url);\n                                let quality = if height > 0 {\n                                    Some(format!(\"{}p\", height))\n                                } else {\n                                    estimate_quality(variant.bitrate)\n                                };\n\n                                formats.push(Format {\n                                    id: format!(\"mp4_{}\", variant.bitrate.unwrap_or(0)),\n                                    url: variant.url.clone(),\n                                    ext: \"mp4\".into(),\n                                    quality,\n                                    width: if width > 0 { Some(width) } else { None },\n                                    height: if height > 0 { Some(height) } else { None },\n                                    filesize: None,\n                                    audio_url: None,\n                                    headers: HashMap::new(),\n                                });\n                            }\n                        }\n                    }\n                    \"photo\" => {\n                        let image_url = get_high_quality_image_url(&media.media_url_https);\n                        let ext = get_image_extension(&media.media_url_https);\n                        formats.push(Format {\n                            id: \"photo\".into(),\n                            url: image_url,\n                            ext,\n                            quality: Some(\"orig\".into()),\n                            width: media.original_info_width,\n                            height: media.original_info_height,\n                            filesize: None,\n                            audio_url: None,\n                            headers: HashMap::new(),\n                        });\n                    }\n                    _ => {}\n                }\n            }\n        }\n\n        // Also check video field for single video tweets\n        if formats.is_empty() {\n            if let Some(video) = &data.video {\n                for variant in &video.variants {\n                    if variant.r#type != \"video/mp4\" {\n                        continue;\n                    }\n                    if let Some(src) = &variant.src {\n                        let (width, height) = extract_resolution(src);\n                        formats.push(Format {\n                            id: \"mp4_direct\".into(),\n                            url: src.clone(),\n                            ext: \"mp4\".into(),\n                            quality: if height > 0 { Some(format!(\"{}p\", height)) } else { None },\n                            width: if width > 0 { Some(width) } else { None },\n                            height: if height > 0 { Some(height) } else { None },\n                            filesize: None,\n                            audio_url: None,\n                            headers: HashMap::new(),\n                        });\n                    }\n                }\n            }\n        }\n\n        if formats.is_empty() {\n            return Err(ExtractError::NotAvailable);\n        }\n\n        // Sort by bitrate/quality (highest first)\n        formats.sort_by(|a, b| {\n            let height_a = a.height.unwrap_or(0);\n            let height_b = b.height.unwrap_or(0);\n            height_b.cmp(&height_a)\n        });\n\n        // Determine media type\n        let media_type = if formats.iter().any(|f| f.ext == \"mp4\") {\n            MediaType::Video\n        } else {\n            MediaType::Image\n        };\n\n        Ok(MediaInfo {\n            id: tweet_id.to_string(),\n            title,\n            uploader,\n            thumbnail: None,\n            duration: None,\n            media_type,\n            formats,\n        })\n    }\n\n    fn parse_graphql_response(\n        &self,\n        body: &str,\n        tweet_id: &str,\n    ) -> Result<MediaInfo, ExtractError> {\n        let resp: GraphQLResponse =\n            serde_json::from_str(body).map_err(|e| ExtractError::Parse(e.to_string()))?;\n\n        let result = resp\n            .data\n            .tweet_result\n            .result\n            .ok_or(ExtractError::NotAvailable)?;\n\n        // Handle different result types\n        match result.typename.as_str() {\n            \"TweetTombstone\" => return Err(ExtractError::NotAvailable),\n            \"TweetUnavailable\" => {\n                return match result.reason.as_deref() {\n                    Some(\"NsfwLoggedOut\") => Err(ExtractError::AuthRequired),\n                    Some(\"Protected\") => Err(ExtractError::AuthRequired),\n                    _ => Err(ExtractError::NotAvailable),\n                };\n            }\n            _ => {}\n        }\n\n        // Get legacy data (may be nested in tweet field)\n        let legacy = result\n            .legacy\n            .as_ref()\n            .or_else(|| result.tweet.as_ref().and_then(|t| t.legacy.as_ref()))\n            .ok_or_else(|| ExtractError::Parse(\"Could not find tweet data\".into()))?;\n\n        let title = truncate_text(&legacy.full_text, 100);\n        let uploader = result\n            .core\n            .as_ref()\n            .and_then(|c| c.user_results.result.as_ref())\n            .map(|u| u.legacy.screen_name.clone());\n\n        let extended_entities = legacy\n            .extended_entities\n            .as_ref()\n            .ok_or(ExtractError::NotAvailable)?;\n\n        let mut formats = Vec::new();\n        let mut duration: Option<u64> = None;\n\n        for media in &extended_entities.media {\n            match media.r#type.as_str() {\n                \"video\" | \"animated_gif\" => {\n                    if let Some(video_info) = &media.video_info {\n                        if duration.is_none() && video_info.duration_millis > 0 {\n                            duration = Some((video_info.duration_millis / 1000) as u64);\n                        }\n\n                        for variant in &video_info.variants {\n                            if variant.content_type != \"video/mp4\" {\n                                continue;\n                            }\n\n                            let (width, height) = extract_resolution(&variant.url);\n                            let quality = if height > 0 {\n                                Some(format!(\"{}p\", height))\n                            } else {\n                                estimate_quality(variant.bitrate)\n                            };\n\n                            formats.push(Format {\n                                id: format!(\"mp4_{}\", variant.bitrate.unwrap_or(0)),\n                                url: variant.url.clone(),\n                                ext: \"mp4\".into(),\n                                quality,\n                                width: if width > 0 { Some(width) } else { None },\n                                height: if height > 0 { Some(height) } else { None },\n                                filesize: None,\n                                audio_url: None,\n                                headers: HashMap::new(),\n                            });\n                        }\n                    }\n                }\n                \"photo\" => {\n                    let image_url = get_high_quality_image_url(&media.media_url_https);\n                    let ext = get_image_extension(&media.media_url_https);\n                    formats.push(Format {\n                        id: \"photo\".into(),\n                        url: image_url,\n                        ext,\n                        quality: Some(\"orig\".into()),\n                        width: media.original_info.as_ref().map(|i| i.width),\n                        height: media.original_info.as_ref().map(|i| i.height),\n                        filesize: None,\n                        audio_url: None,\n                        headers: HashMap::new(),\n                    });\n                }\n                _ => {}\n            }\n        }\n\n        if formats.is_empty() {\n            return Err(ExtractError::NotAvailable);\n        }\n\n        // Sort by quality (highest first)\n        formats.sort_by(|a, b| {\n            let height_a = a.height.unwrap_or(0);\n            let height_b = b.height.unwrap_or(0);\n            height_b.cmp(&height_a)\n        });\n\n        let media_type = if formats.iter().any(|f| f.ext == \"mp4\") {\n            MediaType::Video\n        } else {\n            MediaType::Image\n        };\n\n        Ok(MediaInfo {\n            id: tweet_id.to_string(),\n            title,\n            uploader,\n            thumbnail: None,\n            duration,\n            media_type,\n            formats,\n        })\n    }\n}\n\n// ============ Helper functions ============\n\nfn build_graphql_params(tweet_id: &str) -> (String, String) {\n    let variables = serde_json::json!({\n        \"tweetId\": tweet_id,\n        \"withCommunity\": false,\n        \"includePromotedContent\": false,\n        \"withVoice\": false\n    });\n\n    let features = serde_json::json!({\n        \"creator_subscriptions_tweet_preview_api_enabled\": true,\n        \"tweetypie_unmention_optimization_enabled\": true,\n        \"responsive_web_edit_tweet_api_enabled\": true,\n        \"graphql_is_translatable_rweb_tweet_is_translatable_enabled\": true,\n        \"view_counts_everywhere_api_enabled\": true,\n        \"longform_notetweets_consumption_enabled\": true,\n        \"responsive_web_twitter_article_tweet_consumption_enabled\": false,\n        \"tweet_awards_web_tipping_enabled\": false,\n        \"freedom_of_speech_not_reach_fetch_enabled\": true,\n        \"standardized_nudges_misinfo\": true,\n        \"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled\": true,\n        \"longform_notetweets_rich_text_read_enabled\": true,\n        \"longform_notetweets_inline_media_enabled\": true,\n        \"responsive_web_graphql_exclude_directive_enabled\": true,\n        \"verified_phone_label_enabled\": false,\n        \"responsive_web_media_download_video_enabled\": false,\n        \"responsive_web_graphql_skip_user_profile_image_extensions_enabled\": false,\n        \"responsive_web_graphql_timeline_navigation_enabled\": true,\n        \"responsive_web_enhance_cards_enabled\": false\n    });\n\n    (variables.to_string(), features.to_string())\n}\n\nfn truncate_text(s: &str, max_len: usize) -> String {\n    let s = s.replace('\\n', \" \");\n    let chars: Vec<char> = s.chars().collect();\n    if chars.len() <= max_len {\n        s\n    } else {\n        format!(\"{}...\", chars[..max_len - 3].iter().collect::<String>())\n    }\n}\n\nfn extract_resolution(url: &str) -> (u32, u32) {\n    if let Some(caps) = RESOLUTION_REGEX.captures(url) {\n        let width = caps.get(1).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);\n        let height = caps.get(2).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);\n        (width, height)\n    } else {\n        (0, 0)\n    }\n}\n\nfn estimate_quality(bitrate: Option<i64>) -> Option<String> {\n    bitrate.map(|b| {\n        if b >= 2_000_000 {\n            \"1080p\".into()\n        } else if b >= 1_000_000 {\n            \"720p\".into()\n        } else if b >= 500_000 {\n            \"480p\".into()\n        } else {\n            \"360p\".into()\n        }\n    })\n}\n\nfn get_high_quality_image_url(image_url: &str) -> String {\n    let base_url = image_url.split('?').next().unwrap_or(image_url);\n    let format = if base_url.contains(\".png\") {\n        \"png\"\n    } else if base_url.contains(\".webp\") {\n        \"webp\"\n    } else {\n        \"jpg\"\n    };\n    format!(\"{}?format={}&name=orig\", base_url, format)\n}\n\nfn get_image_extension(image_url: &str) -> String {\n    let base_url = image_url.split('?').next().unwrap_or(image_url);\n    if base_url.ends_with(\".png\") {\n        \"png\".into()\n    } else if base_url.ends_with(\".webp\") {\n        \"webp\".into()\n    } else if base_url.ends_with(\".gif\") {\n        \"gif\".into()\n    } else {\n        \"jpg\".into()\n    }\n}\n\n// ============ Response structs ============\n\n#[derive(Debug, Deserialize)]\nstruct SyndicationResponse {\n    #[serde(default)]\n    text: String,\n    user: Option<SyndicationUser>,\n    #[serde(rename = \"mediaDetails\")]\n    media_details: Option<Vec<SyndicationMedia>>,\n    video: Option<SyndicationVideo>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SyndicationUser {\n    screen_name: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SyndicationMedia {\n    r#type: String,\n    #[serde(default)]\n    media_url_https: String,\n    #[serde(rename = \"original_info_width\")]\n    original_info_width: Option<u32>,\n    #[serde(rename = \"original_info_height\")]\n    original_info_height: Option<u32>,\n    video_info: Option<SyndicationVideoInfo>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SyndicationVideoInfo {\n    variants: Vec<SyndicationVariant>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SyndicationVariant {\n    #[serde(default)]\n    bitrate: Option<i64>,\n    content_type: String,\n    url: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SyndicationVideo {\n    variants: Vec<SyndicationVideoVariant>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SyndicationVideoVariant {\n    r#type: String,\n    src: Option<String>,\n}\n\n// GraphQL response structs\n#[derive(Debug, Deserialize)]\nstruct GraphQLResponse {\n    data: GraphQLData,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLData {\n    #[serde(rename = \"tweetResult\")]\n    tweet_result: GraphQLTweetResult,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLTweetResult {\n    result: Option<GraphQLResult>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLResult {\n    #[serde(rename = \"__typename\")]\n    typename: String,\n    legacy: Option<GraphQLLegacy>,\n    core: Option<GraphQLCore>,\n    tweet: Option<Box<GraphQLResult>>,\n    reason: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLCore {\n    user_results: GraphQLUserResults,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLUserResults {\n    result: Option<GraphQLUser>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLUser {\n    legacy: GraphQLUserLegacy,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLUserLegacy {\n    screen_name: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLLegacy {\n    full_text: String,\n    extended_entities: Option<GraphQLExtendedEntities>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLExtendedEntities {\n    media: Vec<GraphQLMedia>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLMedia {\n    r#type: String,\n    #[serde(default)]\n    media_url_https: String,\n    original_info: Option<GraphQLOriginalInfo>,\n    video_info: Option<GraphQLVideoInfo>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLOriginalInfo {\n    width: u32,\n    height: u32,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLVideoInfo {\n    #[serde(default)]\n    duration_millis: i64,\n    variants: Vec<GraphQLVariant>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct GraphQLVariant {\n    #[serde(default)]\n    bitrate: Option<i64>,\n    content_type: String,\n    url: String,\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/extractor/types.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum ExtractError {\n    #[error(\"Invalid URL: {0}\")]\n    InvalidUrl(String),\n    #[error(\"No extractor found for URL: {0}\")]\n    NoExtractor(String),\n    #[error(\"Network error: {0}\")]\n    Network(#[from] reqwest::Error),\n    #[error(\"Parse error: {0}\")]\n    Parse(String),\n    #[error(\"Authentication required\")]\n    AuthRequired,\n    #[error(\"Content not available\")]\n    NotAvailable,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum MediaType {\n    Video,\n    Audio,\n    Image,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Format {\n    pub id: String,\n    pub url: String,\n    pub ext: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub quality: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub width: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub height: Option<u32>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub filesize: Option<u64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub audio_url: Option<String>,\n    #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n    pub headers: HashMap<String, String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MediaInfo {\n    pub id: String,\n    pub title: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub uploader: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub thumbnail: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub duration: Option<u64>,\n    pub media_type: MediaType,\n    pub formats: Vec<Format>,\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/ffmpeg.rs",
    "content": "use ffmpeg_sidecar::command::FfmpegCommand;\nuse ffmpeg_sidecar::event::{FfmpegEvent, LogLevel};\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\nuse std::process::Command;\n\n/// Parse ffmpeg time string (HH:MM:SS.microseconds) to seconds\nfn parse_time_to_secs(time_str: &str) -> Option<f32> {\n    let parts: Vec<&str> = time_str.split(':').collect();\n    if parts.len() == 3 {\n        let hours: f32 = parts[0].parse().ok()?;\n        let minutes: f32 = parts[1].parse().ok()?;\n        let seconds: f32 = parts[2].parse().ok()?;\n        Some(hours * 3600.0 + minutes * 60.0 + seconds)\n    } else {\n        // Try parsing as plain seconds\n        time_str.parse().ok()\n    }\n}\n\n/// Merge separate video and audio files into a single output file.\n/// Uses stream copy (-c copy) for fast merging without re-encoding.\npub async fn merge_video_audio(\n    video_path: &str,\n    audio_path: &str,\n    output_path: &str,\n    delete_originals: bool,\n) -> Result<(), String> {\n    // Validate input files exist\n    if !Path::new(video_path).exists() {\n        return Err(format!(\"Video file not found: {}\", video_path));\n    }\n    if !Path::new(audio_path).exists() {\n        return Err(format!(\"Audio file not found: {}\", audio_path));\n    }\n\n    // Create output directory if needed\n    if let Some(parent) = Path::new(output_path).parent() {\n        std::fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n    }\n\n    // Run ffmpeg merge command\n    // -i video_path -i audio_path -c copy -map 0:v -map 1:a output_path\n    tokio::task::spawn_blocking({\n        let video_path = video_path.to_string();\n        let audio_path = audio_path.to_string();\n        let output_path = output_path.to_string();\n\n        move || {\n            let mut cmd = FfmpegCommand::new();\n            cmd.args([\"-y\"]) // Overwrite output\n                .input(&video_path)\n                .input(&audio_path)\n                .args([\"-map\", \"0:v\"]) // Video from first input\n                .args([\"-map\", \"1:a\"]) // Audio from second input\n                .args([\"-c\", \"copy\"]) // Stream copy, no re-encoding\n                .output(&output_path);\n\n            let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn ffmpeg: {}\", e))?;\n\n            // Collect events and check for errors\n            let mut error_msg: Option<String> = None;\n\n            for event in child.iter().expect(\"Failed to iterate ffmpeg events\") {\n                match event {\n                    FfmpegEvent::Log(LogLevel::Error, msg) => {\n                        eprintln!(\"[ffmpeg error] {}\", msg);\n                        error_msg = Some(msg);\n                    }\n                    FfmpegEvent::Log(LogLevel::Warning, msg) => {\n                        eprintln!(\"[ffmpeg warning] {}\", msg);\n                    }\n                    FfmpegEvent::Progress(progress) => {\n                        // Could emit progress events here if needed\n                        let _ = progress;\n                    }\n                    FfmpegEvent::Done => {\n                        break;\n                    }\n                    _ => {}\n                }\n            }\n\n            // Check if output file was created\n            if !Path::new(&output_path).exists() {\n                return Err(error_msg.unwrap_or_else(|| \"FFmpeg failed to create output file\".to_string()));\n            }\n\n            Ok(())\n        }\n    })\n    .await\n    .map_err(|e| format!(\"Task join error: {}\", e))??;\n\n    // Delete original files if requested\n    if delete_originals {\n        if let Err(e) = std::fs::remove_file(video_path) {\n            eprintln!(\"[ffmpeg] Warning: could not remove video file: {}\", e);\n        }\n        if let Err(e) = std::fs::remove_file(audio_path) {\n            eprintln!(\"[ffmpeg] Warning: could not remove audio file: {}\", e);\n        }\n    }\n\n    Ok(())\n}\n\n// ============ MEDIA INFO ============\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MediaInfoResult {\n    pub filename: String,\n    pub format_name: String,\n    pub format_long_name: String,\n    pub duration: Option<f64>,\n    pub size: u64,\n    pub bit_rate: Option<u64>,\n    pub streams: Vec<StreamInfo>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct StreamInfo {\n    pub index: u32,\n    pub codec_type: String,\n    pub codec_name: String,\n    pub codec_long_name: Option<String>,\n    pub width: Option<u32>,\n    pub height: Option<u32>,\n    pub sample_rate: Option<String>,\n    pub channels: Option<u32>,\n    pub bit_rate: Option<String>,\n    pub duration: Option<String>,\n}\n\n/// Get media file information using ffprobe\npub async fn get_media_info(input_path: &str) -> Result<MediaInfoResult, String> {\n    if !Path::new(input_path).exists() {\n        return Err(format!(\"File not found: {}\", input_path));\n    }\n\n    let input = input_path.to_string();\n\n    tokio::task::spawn_blocking(move || {\n        let ffprobe_path = ffmpeg_sidecar::ffprobe::ffprobe_path();\n\n        let output = Command::new(ffprobe_path)\n            .args([\n                \"-v\", \"quiet\",\n                \"-print_format\", \"json\",\n                \"-show_format\",\n                \"-show_streams\",\n                &input,\n            ])\n            .output()\n            .map_err(|e| format!(\"Failed to run ffprobe: {}\", e))?;\n\n        if !output.status.success() {\n            return Err(\"ffprobe failed to analyze file\".to_string());\n        }\n\n        let json_str = String::from_utf8_lossy(&output.stdout);\n        let probe: serde_json::Value = serde_json::from_str(&json_str)\n            .map_err(|e| format!(\"Failed to parse ffprobe output: {}\", e))?;\n\n        let format = probe.get(\"format\").ok_or(\"No format info found\")?;\n        let streams_arr = probe.get(\"streams\").and_then(|s| s.as_array());\n\n        let mut streams = Vec::new();\n        if let Some(arr) = streams_arr {\n            for s in arr {\n                streams.push(StreamInfo {\n                    index: s.get(\"index\").and_then(|v| v.as_u64()).unwrap_or(0) as u32,\n                    codec_type: s.get(\"codec_type\").and_then(|v| v.as_str()).unwrap_or(\"unknown\").to_string(),\n                    codec_name: s.get(\"codec_name\").and_then(|v| v.as_str()).unwrap_or(\"unknown\").to_string(),\n                    codec_long_name: s.get(\"codec_long_name\").and_then(|v| v.as_str()).map(|s| s.to_string()),\n                    width: s.get(\"width\").and_then(|v| v.as_u64()).map(|v| v as u32),\n                    height: s.get(\"height\").and_then(|v| v.as_u64()).map(|v| v as u32),\n                    sample_rate: s.get(\"sample_rate\").and_then(|v| v.as_str()).map(|s| s.to_string()),\n                    channels: s.get(\"channels\").and_then(|v| v.as_u64()).map(|v| v as u32),\n                    bit_rate: s.get(\"bit_rate\").and_then(|v| v.as_str()).map(|s| s.to_string()),\n                    duration: s.get(\"duration\").and_then(|v| v.as_str()).map(|s| s.to_string()),\n                });\n            }\n        }\n\n        Ok(MediaInfoResult {\n            filename: format.get(\"filename\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string(),\n            format_name: format.get(\"format_name\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string(),\n            format_long_name: format.get(\"format_long_name\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string(),\n            duration: format.get(\"duration\").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()),\n            size: format.get(\"size\").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()).unwrap_or(0),\n            bit_rate: format.get(\"bit_rate\").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()),\n            streams,\n        })\n    })\n    .await\n    .map_err(|e| format!(\"Task join error: {}\", e))?\n}\n\n// ============ VIDEO CONVERT ============\n\n/// Convert video to a different format\npub fn convert_video_sync(\n    input_path: &str,\n    output_path: &str,\n    progress_callback: impl Fn(f32) + Send + 'static,\n) -> Result<(), String> {\n    if !Path::new(input_path).exists() {\n        return Err(format!(\"Input file not found: {}\", input_path));\n    }\n\n    // Create output directory if needed\n    if let Some(parent) = Path::new(output_path).parent() {\n        std::fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n    }\n\n    let mut cmd = FfmpegCommand::new();\n    cmd.args([\"-y\"])\n        .input(input_path)\n        .args([\"-c:v\", \"libx264\"])\n        .args([\"-c:a\", \"aac\"])\n        .args([\"-preset\", \"medium\"])\n        .output(output_path);\n\n    let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn ffmpeg: {}\", e))?;\n    let mut error_msg: Option<String> = None;\n\n    for event in child.iter().expect(\"Failed to iterate ffmpeg events\") {\n        match event {\n            FfmpegEvent::Progress(progress) => {\n                // Progress time is a string like \"00:00:05.123456\"\n                if let Some(secs) = parse_time_to_secs(&progress.time) {\n                    progress_callback(secs);\n                }\n            }\n            FfmpegEvent::Log(LogLevel::Error, msg) => {\n                eprintln!(\"[ffmpeg error] {}\", msg);\n                error_msg = Some(msg);\n            }\n            FfmpegEvent::Done => break,\n            _ => {}\n        }\n    }\n\n    if !Path::new(output_path).exists() {\n        return Err(error_msg.unwrap_or_else(|| \"FFmpeg failed to create output file\".to_string()));\n    }\n\n    Ok(())\n}\n\n// ============ VIDEO COMPRESS ============\n\n/// Compress video to reduce file size\npub fn compress_video_sync(\n    input_path: &str,\n    output_path: &str,\n    crf: u8, // 18-28 typically, higher = more compression\n    progress_callback: impl Fn(f32) + Send + 'static,\n) -> Result<(), String> {\n    if !Path::new(input_path).exists() {\n        return Err(format!(\"Input file not found: {}\", input_path));\n    }\n\n    if let Some(parent) = Path::new(output_path).parent() {\n        std::fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n    }\n\n    let crf_str = crf.to_string();\n    let mut cmd = FfmpegCommand::new();\n    cmd.args([\"-y\"])\n        .input(input_path)\n        .args([\"-c:v\", \"libx264\"])\n        .args([\"-crf\", &crf_str])\n        .args([\"-preset\", \"medium\"])\n        .args([\"-c:a\", \"aac\"])\n        .args([\"-b:a\", \"128k\"])\n        .output(output_path);\n\n    let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn ffmpeg: {}\", e))?;\n    let mut error_msg: Option<String> = None;\n\n    for event in child.iter().expect(\"Failed to iterate ffmpeg events\") {\n        match event {\n            FfmpegEvent::Progress(progress) => {\n                if let Some(secs) = parse_time_to_secs(&progress.time) {\n                    progress_callback(secs);\n                }\n            }\n            FfmpegEvent::Log(LogLevel::Error, msg) => {\n                eprintln!(\"[ffmpeg error] {}\", msg);\n                error_msg = Some(msg);\n            }\n            FfmpegEvent::Done => break,\n            _ => {}\n        }\n    }\n\n    if !Path::new(output_path).exists() {\n        return Err(error_msg.unwrap_or_else(|| \"FFmpeg failed to create output file\".to_string()));\n    }\n\n    Ok(())\n}\n\n// ============ VIDEO TRIM ============\n\n/// Trim video to specified time range\npub fn trim_video_sync(\n    input_path: &str,\n    output_path: &str,\n    start_time: &str, // Format: \"HH:MM:SS\" or \"SS\"\n    end_time: &str,\n    progress_callback: impl Fn(f32) + Send + 'static,\n) -> Result<(), String> {\n    if !Path::new(input_path).exists() {\n        return Err(format!(\"Input file not found: {}\", input_path));\n    }\n\n    if let Some(parent) = Path::new(output_path).parent() {\n        std::fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n    }\n\n    let mut cmd = FfmpegCommand::new();\n    cmd.args([\"-y\"])\n        .args([\"-ss\", start_time])\n        .args([\"-to\", end_time])\n        .input(input_path)\n        .args([\"-c\", \"copy\"]) // Stream copy for fast trimming\n        .output(output_path);\n\n    let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn ffmpeg: {}\", e))?;\n    let mut error_msg: Option<String> = None;\n\n    for event in child.iter().expect(\"Failed to iterate ffmpeg events\") {\n        match event {\n            FfmpegEvent::Progress(progress) => {\n                if let Some(secs) = parse_time_to_secs(&progress.time) {\n                    progress_callback(secs);\n                }\n            }\n            FfmpegEvent::Log(LogLevel::Error, msg) => {\n                eprintln!(\"[ffmpeg error] {}\", msg);\n                error_msg = Some(msg);\n            }\n            FfmpegEvent::Done => break,\n            _ => {}\n        }\n    }\n\n    if !Path::new(output_path).exists() {\n        return Err(error_msg.unwrap_or_else(|| \"FFmpeg failed to create output file\".to_string()));\n    }\n\n    Ok(())\n}\n\n// ============ EXTRACT AUDIO ============\n\n/// Extract audio from video file\npub fn extract_audio_sync(\n    input_path: &str,\n    output_path: &str,\n    format: &str, // mp3, aac, flac, wav\n    progress_callback: impl Fn(f32) + Send + 'static,\n) -> Result<(), String> {\n    if !Path::new(input_path).exists() {\n        return Err(format!(\"Input file not found: {}\", input_path));\n    }\n\n    if let Some(parent) = Path::new(output_path).parent() {\n        std::fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n    }\n\n    let mut cmd = FfmpegCommand::new();\n    cmd.args([\"-y\"])\n        .input(input_path)\n        .args([\"-vn\"]); // No video\n\n    // Set codec based on format\n    match format {\n        \"mp3\" => {\n            cmd.args([\"-c:a\", \"libmp3lame\"]);\n            cmd.args([\"-b:a\", \"192k\"]);\n        }\n        \"aac\" => {\n            cmd.args([\"-c:a\", \"aac\"]);\n            cmd.args([\"-b:a\", \"192k\"]);\n        }\n        \"flac\" => {\n            cmd.args([\"-c:a\", \"flac\"]);\n        }\n        \"wav\" => {\n            cmd.args([\"-c:a\", \"pcm_s16le\"]);\n        }\n        _ => {\n            cmd.args([\"-c:a\", \"copy\"]); // Try to copy\n        }\n    }\n\n    cmd.output(output_path);\n\n    let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn ffmpeg: {}\", e))?;\n    let mut error_msg: Option<String> = None;\n\n    for event in child.iter().expect(\"Failed to iterate ffmpeg events\") {\n        match event {\n            FfmpegEvent::Progress(progress) => {\n                if let Some(secs) = parse_time_to_secs(&progress.time) {\n                    progress_callback(secs);\n                }\n            }\n            FfmpegEvent::Log(LogLevel::Error, msg) => {\n                eprintln!(\"[ffmpeg error] {}\", msg);\n                error_msg = Some(msg);\n            }\n            FfmpegEvent::Done => break,\n            _ => {}\n        }\n    }\n\n    if !Path::new(output_path).exists() {\n        return Err(error_msg.unwrap_or_else(|| \"FFmpeg failed to create output file\".to_string()));\n    }\n\n    Ok(())\n}\n\n// ============ EXTRACT FRAMES ============\n\n/// Extract frames from video as images\npub fn extract_frames_sync(\n    input_path: &str,\n    output_dir: &str,\n    fps: f32, // frames per second to extract (e.g., 1.0 = 1 frame per second)\n    progress_callback: impl Fn(f32) + Send + 'static,\n) -> Result<Vec<String>, String> {\n    if !Path::new(input_path).exists() {\n        return Err(format!(\"Input file not found: {}\", input_path));\n    }\n\n    std::fs::create_dir_all(output_dir)\n        .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n\n    let output_pattern = format!(\"{}/frame_%04d.jpg\", output_dir);\n    let fps_filter = format!(\"fps={}\", fps);\n\n    let mut cmd = FfmpegCommand::new();\n    cmd.args([\"-y\"])\n        .input(input_path)\n        .args([\"-vf\", &fps_filter])\n        .args([\"-q:v\", \"2\"]) // High quality JPEG\n        .output(&output_pattern);\n\n    let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn ffmpeg: {}\", e))?;\n    let mut error_msg: Option<String> = None;\n\n    for event in child.iter().expect(\"Failed to iterate ffmpeg events\") {\n        match event {\n            FfmpegEvent::Progress(progress) => {\n                if let Some(secs) = parse_time_to_secs(&progress.time) {\n                    progress_callback(secs);\n                }\n            }\n            FfmpegEvent::Log(LogLevel::Error, msg) => {\n                eprintln!(\"[ffmpeg error] {}\", msg);\n                error_msg = Some(msg);\n            }\n            FfmpegEvent::Done => break,\n            _ => {}\n        }\n    }\n\n    // Collect output files\n    let mut frames = Vec::new();\n    if let Ok(entries) = std::fs::read_dir(output_dir) {\n        for entry in entries.flatten() {\n            let path = entry.path();\n            if path.extension().map(|e| e == \"jpg\").unwrap_or(false) {\n                frames.push(path.to_string_lossy().to_string());\n            }\n        }\n    }\n\n    if frames.is_empty() && error_msg.is_some() {\n        return Err(error_msg.unwrap());\n    }\n\n    frames.sort();\n    Ok(frames)\n}\n\n// ============ AUDIO CONVERT ============\n\n/// Convert audio to a different format\npub fn convert_audio_sync(\n    input_path: &str,\n    output_path: &str,\n    format: &str, // mp3, aac, flac, wav\n    bitrate: Option<&str>, // e.g., \"192k\"\n    progress_callback: impl Fn(f32) + Send + 'static,\n) -> Result<(), String> {\n    if !Path::new(input_path).exists() {\n        return Err(format!(\"Input file not found: {}\", input_path));\n    }\n\n    if let Some(parent) = Path::new(output_path).parent() {\n        std::fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n    }\n\n    let mut cmd = FfmpegCommand::new();\n    cmd.args([\"-y\"])\n        .input(input_path);\n\n    // Set codec based on format\n    match format {\n        \"mp3\" => {\n            cmd.args([\"-c:a\", \"libmp3lame\"]);\n            if let Some(br) = bitrate {\n                cmd.args([\"-b:a\", br]);\n            } else {\n                cmd.args([\"-b:a\", \"192k\"]);\n            }\n        }\n        \"aac\" => {\n            cmd.args([\"-c:a\", \"aac\"]);\n            if let Some(br) = bitrate {\n                cmd.args([\"-b:a\", br]);\n            } else {\n                cmd.args([\"-b:a\", \"192k\"]);\n            }\n        }\n        \"flac\" => {\n            cmd.args([\"-c:a\", \"flac\"]);\n        }\n        \"wav\" => {\n            cmd.args([\"-c:a\", \"pcm_s16le\"]);\n        }\n        \"ogg\" => {\n            cmd.args([\"-c:a\", \"libvorbis\"]);\n            if let Some(br) = bitrate {\n                cmd.args([\"-b:a\", br]);\n            }\n        }\n        _ => {\n            return Err(format!(\"Unsupported format: {}\", format));\n        }\n    }\n\n    cmd.output(output_path);\n\n    let mut child = cmd.spawn().map_err(|e| format!(\"Failed to spawn ffmpeg: {}\", e))?;\n    let mut error_msg: Option<String> = None;\n\n    for event in child.iter().expect(\"Failed to iterate ffmpeg events\") {\n        match event {\n            FfmpegEvent::Progress(progress) => {\n                if let Some(secs) = parse_time_to_secs(&progress.time) {\n                    progress_callback(secs);\n                }\n            }\n            FfmpegEvent::Log(LogLevel::Error, msg) => {\n                eprintln!(\"[ffmpeg error] {}\", msg);\n                error_msg = Some(msg);\n            }\n            FfmpegEvent::Done => break,\n            _ => {}\n        }\n    }\n\n    if !Path::new(output_path).exists() {\n        return Err(error_msg.unwrap_or_else(|| \"FFmpeg failed to create output file\".to_string()));\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/lib.rs",
    "content": "mod auth;\nmod config;\nmod downloader;\nmod extractor;\nmod ffmpeg;\nmod md2pdf;\nmod pdf;\n\nuse auth::{\n    bilibili_check_status, bilibili_logout, bilibili_qr_generate, bilibili_qr_poll,\n    bilibili_save_cookie, xhs_check_status, xhs_logout, xhs_open_login_window,\n};\nuse config::{get_config as load_config, save_config as store_config, Config};\nuse downloader::{DownloadJob, DownloadManager, DownloadStatus, SimpleDownloader};\nuse extractor::{extract_media as do_extract, MediaInfo};\nuse ffmpeg::MediaInfoResult;\nuse std::sync::Arc;\nuse tauri::{Emitter, State};\n\n// ============ CONFIG COMMANDS ============\n\n#[tauri::command]\nasync fn get_config() -> Result<Config, String> {\n    tauri::async_runtime::spawn_blocking(|| {\n        load_config().map_err(|e| e.to_string())\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\nasync fn save_config(config: Config) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        store_config(&config).map_err(|e| e.to_string())\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n\n// ============ EXTRACTOR COMMANDS ============\n\n#[tauri::command]\nasync fn extract_media(url: String) -> Result<MediaInfo, String> {\n    do_extract(&url).await.map_err(|e| e.to_string())\n}\n\n// ============ FOLDER COMMANDS ============\n\n#[tauri::command]\nasync fn open_output_folder(path: String) -> Result<(), String> {\n    use std::path::Path;\n    use std::process::Command;\n\n    let path = Path::new(&path);\n\n    // Create directory if it doesn't exist\n    if !path.exists() {\n        std::fs::create_dir_all(path).map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n    }\n\n    // Open the folder using platform-specific command\n    #[cfg(target_os = \"macos\")]\n    {\n        Command::new(\"open\")\n            .arg(path)\n            .spawn()\n            .map_err(|e| format!(\"Failed to open folder: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        Command::new(\"explorer\")\n            .arg(path)\n            .spawn()\n            .map_err(|e| format!(\"Failed to open folder: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        Command::new(\"xdg-open\")\n            .arg(path)\n            .spawn()\n            .map_err(|e| format!(\"Failed to open folder: {}\", e))?;\n    }\n\n    Ok(())\n}\n\n// ============ DOWNLOAD COMMANDS ============\n\n#[tauri::command]\nasync fn start_download(\n    url: String,\n    output_path: String,\n    _format_id: Option<String>,\n    headers: Option<std::collections::HashMap<String, String>>,\n    audio_url: Option<String>,\n    window: tauri::Window,\n    download_manager: State<'_, Arc<DownloadManager>>,\n) -> Result<String, String> {\n    let job_id = uuid::Uuid::new_v4().to_string();\n\n    // Create job and get cancellation receiver\n    let job = DownloadJob {\n        id: job_id.clone(),\n        url: url.clone(),\n        output_path: output_path.clone(),\n        status: DownloadStatus::Pending,\n        progress: None,\n        error: None,\n    };\n\n    let cancel_rx = download_manager.add_job(job).await;\n\n    // Update status to downloading\n    download_manager\n        .update_job(&job_id, DownloadStatus::Downloading, None, None)\n        .await;\n\n    // Clone for async task\n    let dm = download_manager.inner().clone();\n    let jid = job_id.clone();\n\n    // Spawn download task\n    tauri::async_runtime::spawn(async move {\n        let downloader = SimpleDownloader::new();\n\n        let result = if let Some(audio) = audio_url {\n            // DASH stream: download video + audio separately, then merge\n            downloader\n                .download_and_merge(\n                    &jid,\n                    &url,\n                    &audio,\n                    &output_path,\n                    &window,\n                    cancel_rx,\n                    headers,\n                )\n                .await\n        } else {\n            // Simple download\n            downloader\n                .download(&jid, &url, &output_path, &window, cancel_rx, headers)\n                .await\n        };\n\n        match result {\n            Ok(()) => {\n                dm.update_job(&jid, DownloadStatus::Completed, None, None)\n                    .await;\n            }\n            Err(e) => {\n                if e.contains(\"cancelled\") {\n                    dm.update_job(&jid, DownloadStatus::Cancelled, None, Some(e.clone()))\n                        .await;\n                } else {\n                    dm.update_job(&jid, DownloadStatus::Failed, None, Some(e.clone()))\n                        .await;\n                }\n                let _ = window.emit(\n                    \"download-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e,\n                    }),\n                );\n            }\n        }\n    });\n\n    Ok(job_id)\n}\n\n#[tauri::command]\nasync fn cancel_download(\n    job_id: String,\n    download_manager: State<'_, Arc<DownloadManager>>,\n) -> Result<(), String> {\n    download_manager.cancel_job(&job_id).await\n}\n\n#[tauri::command]\nasync fn get_download_status(\n    job_id: String,\n    download_manager: State<'_, Arc<DownloadManager>>,\n) -> Result<Option<DownloadJob>, String> {\n    Ok(download_manager.get_job(&job_id).await)\n}\n\n// ============ FFMPEG MEDIA TOOLS ============\n\n#[tauri::command]\nasync fn ffmpeg_get_media_info(input_path: String) -> Result<MediaInfoResult, String> {\n    ffmpeg::get_media_info(&input_path).await\n}\n\n#[tauri::command]\nasync fn ffmpeg_convert_video(\n    input_path: String,\n    output_path: String,\n    window: tauri::Window,\n) -> Result<String, String> {\n    let job_id = uuid::Uuid::new_v4().to_string();\n    let jid = job_id.clone();\n\n    tauri::async_runtime::spawn(async move {\n        let result = tokio::task::spawn_blocking({\n            let input = input_path.clone();\n            let output = output_path.clone();\n            let jid = jid.clone();\n            let win = window.clone();\n\n            move || {\n                ffmpeg::convert_video_sync(&input, &output, move |progress| {\n                    let _ = win.emit(\n                        \"ffmpeg-progress\",\n                        serde_json::json!({\n                            \"jobId\": jid,\n                            \"progress\": progress,\n                        }),\n                    );\n                })\n            }\n        })\n        .await;\n\n        match result {\n            Ok(Ok(())) => {\n                let _ = window.emit(\n                    \"ffmpeg-complete\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"outputPath\": output_path,\n                    }),\n                );\n            }\n            Ok(Err(e)) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e,\n                    }),\n                );\n            }\n            Err(e) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e.to_string(),\n                    }),\n                );\n            }\n        }\n    });\n\n    Ok(job_id)\n}\n\n#[tauri::command]\nasync fn ffmpeg_compress_video(\n    input_path: String,\n    output_path: String,\n    quality: u8, // CRF value: 18 (high quality) to 28 (low quality/small size)\n    window: tauri::Window,\n) -> Result<String, String> {\n    let job_id = uuid::Uuid::new_v4().to_string();\n    let jid = job_id.clone();\n\n    tauri::async_runtime::spawn(async move {\n        let result = tokio::task::spawn_blocking({\n            let input = input_path.clone();\n            let output = output_path.clone();\n            let jid = jid.clone();\n            let win = window.clone();\n\n            move || {\n                ffmpeg::compress_video_sync(&input, &output, quality, move |progress| {\n                    let _ = win.emit(\n                        \"ffmpeg-progress\",\n                        serde_json::json!({\n                            \"jobId\": jid,\n                            \"progress\": progress,\n                        }),\n                    );\n                })\n            }\n        })\n        .await;\n\n        match result {\n            Ok(Ok(())) => {\n                let _ = window.emit(\n                    \"ffmpeg-complete\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"outputPath\": output_path,\n                    }),\n                );\n            }\n            Ok(Err(e)) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e,\n                    }),\n                );\n            }\n            Err(e) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e.to_string(),\n                    }),\n                );\n            }\n        }\n    });\n\n    Ok(job_id)\n}\n\n#[tauri::command]\nasync fn ffmpeg_trim_video(\n    input_path: String,\n    output_path: String,\n    start_time: String,\n    end_time: String,\n    window: tauri::Window,\n) -> Result<String, String> {\n    let job_id = uuid::Uuid::new_v4().to_string();\n    let jid = job_id.clone();\n\n    tauri::async_runtime::spawn(async move {\n        let result = tokio::task::spawn_blocking({\n            let input = input_path.clone();\n            let output = output_path.clone();\n            let start = start_time.clone();\n            let end = end_time.clone();\n            let jid = jid.clone();\n            let win = window.clone();\n\n            move || {\n                ffmpeg::trim_video_sync(&input, &output, &start, &end, move |progress| {\n                    let _ = win.emit(\n                        \"ffmpeg-progress\",\n                        serde_json::json!({\n                            \"jobId\": jid,\n                            \"progress\": progress,\n                        }),\n                    );\n                })\n            }\n        })\n        .await;\n\n        match result {\n            Ok(Ok(())) => {\n                let _ = window.emit(\n                    \"ffmpeg-complete\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"outputPath\": output_path,\n                    }),\n                );\n            }\n            Ok(Err(e)) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e,\n                    }),\n                );\n            }\n            Err(e) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e.to_string(),\n                    }),\n                );\n            }\n        }\n    });\n\n    Ok(job_id)\n}\n\n#[tauri::command]\nasync fn ffmpeg_extract_audio(\n    input_path: String,\n    output_path: String,\n    format: String, // mp3, aac, flac, wav\n    window: tauri::Window,\n) -> Result<String, String> {\n    let job_id = uuid::Uuid::new_v4().to_string();\n    let jid = job_id.clone();\n\n    tauri::async_runtime::spawn(async move {\n        let result = tokio::task::spawn_blocking({\n            let input = input_path.clone();\n            let output = output_path.clone();\n            let fmt = format.clone();\n            let jid = jid.clone();\n            let win = window.clone();\n\n            move || {\n                ffmpeg::extract_audio_sync(&input, &output, &fmt, move |progress| {\n                    let _ = win.emit(\n                        \"ffmpeg-progress\",\n                        serde_json::json!({\n                            \"jobId\": jid,\n                            \"progress\": progress,\n                        }),\n                    );\n                })\n            }\n        })\n        .await;\n\n        match result {\n            Ok(Ok(())) => {\n                let _ = window.emit(\n                    \"ffmpeg-complete\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"outputPath\": output_path,\n                    }),\n                );\n            }\n            Ok(Err(e)) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e,\n                    }),\n                );\n            }\n            Err(e) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e.to_string(),\n                    }),\n                );\n            }\n        }\n    });\n\n    Ok(job_id)\n}\n\n#[tauri::command]\nasync fn ffmpeg_extract_frames(\n    input_path: String,\n    output_dir: String,\n    fps: f32,\n    window: tauri::Window,\n) -> Result<String, String> {\n    let job_id = uuid::Uuid::new_v4().to_string();\n    let jid = job_id.clone();\n\n    tauri::async_runtime::spawn(async move {\n        let result = tokio::task::spawn_blocking({\n            let input = input_path.clone();\n            let output = output_dir.clone();\n            let jid = jid.clone();\n            let win = window.clone();\n\n            move || {\n                ffmpeg::extract_frames_sync(&input, &output, fps, move |progress| {\n                    let _ = win.emit(\n                        \"ffmpeg-progress\",\n                        serde_json::json!({\n                            \"jobId\": jid,\n                            \"progress\": progress,\n                        }),\n                    );\n                })\n            }\n        })\n        .await;\n\n        match result {\n            Ok(Ok(frames)) => {\n                let _ = window.emit(\n                    \"ffmpeg-complete\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"outputPath\": output_dir,\n                        \"frames\": frames,\n                    }),\n                );\n            }\n            Ok(Err(e)) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e,\n                    }),\n                );\n            }\n            Err(e) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e.to_string(),\n                    }),\n                );\n            }\n        }\n    });\n\n    Ok(job_id)\n}\n\n#[tauri::command]\nasync fn ffmpeg_convert_audio(\n    input_path: String,\n    output_path: String,\n    format: String,\n    bitrate: Option<String>,\n    window: tauri::Window,\n) -> Result<String, String> {\n    let job_id = uuid::Uuid::new_v4().to_string();\n    let jid = job_id.clone();\n\n    tauri::async_runtime::spawn(async move {\n        let result = tokio::task::spawn_blocking({\n            let input = input_path.clone();\n            let output = output_path.clone();\n            let fmt = format.clone();\n            let br = bitrate.clone();\n            let jid = jid.clone();\n            let win = window.clone();\n\n            move || {\n                ffmpeg::convert_audio_sync(&input, &output, &fmt, br.as_deref(), move |progress| {\n                    let _ = win.emit(\n                        \"ffmpeg-progress\",\n                        serde_json::json!({\n                            \"jobId\": jid,\n                            \"progress\": progress,\n                        }),\n                    );\n                })\n            }\n        })\n        .await;\n\n        match result {\n            Ok(Ok(())) => {\n                let _ = window.emit(\n                    \"ffmpeg-complete\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"outputPath\": output_path,\n                    }),\n                );\n            }\n            Ok(Err(e)) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e,\n                    }),\n                );\n            }\n            Err(e) => {\n                let _ = window.emit(\n                    \"ffmpeg-error\",\n                    serde_json::json!({\n                        \"jobId\": jid,\n                        \"error\": e.to_string(),\n                    }),\n                );\n            }\n        }\n    });\n\n    Ok(job_id)\n}\n\n// ============ PDF TOOLS ============\n\n#[tauri::command]\nasync fn pdf_get_info(input_path: String) -> Result<pdf::PdfInfo, String> {\n    tauri::async_runtime::spawn_blocking(move || pdf::get_pdf_info(&input_path))\n        .await\n        .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\nasync fn pdf_merge(input_paths: Vec<String>, output_path: String) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || pdf::merge_pdfs(&input_paths, &output_path))\n        .await\n        .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\nasync fn pdf_images_to_pdf(image_paths: Vec<String>, output_path: String) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || pdf::images_to_pdf(&image_paths, &output_path))\n        .await\n        .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\nasync fn pdf_delete_pages(\n    input_path: String,\n    output_path: String,\n    pages: Vec<u32>,\n) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        pdf::delete_pages(&input_path, &output_path, &pages)\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\nasync fn pdf_remove_watermark(\n    input_path: String,\n    output_path: String,\n) -> Result<pdf::WatermarkRemovalResult, String> {\n    tauri::async_runtime::spawn_blocking(move || pdf::remove_watermark(&input_path, &output_path))\n        .await\n        .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\nasync fn pdf_print(input_path: String) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || pdf::print_pdf(&input_path))\n        .await\n        .map_err(|e| e.to_string())?\n}\n\n#[tauri::command]\nasync fn pdf_open_external(input_path: String) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || pdf::open_pdf_external(&input_path))\n        .await\n        .map_err(|e| e.to_string())?\n}\n\n// ============ FILE UTILITIES ============\n\n#[tauri::command]\nasync fn read_text_file(path: String) -> Result<String, String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        std::fs::read_to_string(&path).map_err(|e| format!(\"Failed to read file: {}\", e))\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n\n// ============ MARKDOWN TO PDF ============\n\n#[tauri::command]\nasync fn md_to_pdf(\n    input_path: String,\n    output_path: String,\n    theme: String,\n    page_size: String,\n) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        md2pdf::convert_md_to_pdf(&input_path, &output_path, &theme, &page_size)\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n\n// ============ TAURI SETUP ============\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    tauri::Builder::default()\n        .plugin(tauri_plugin_opener::init())\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_updater::Builder::new().build())\n        .plugin(tauri_plugin_process::init())\n        .manage(Arc::new(DownloadManager::new()))\n        .invoke_handler(tauri::generate_handler![\n            // Config\n            get_config,\n            save_config,\n            // Extractor\n            extract_media,\n            // Folder\n            open_output_folder,\n            // Download\n            start_download,\n            cancel_download,\n            get_download_status,\n            // Auth - Bilibili\n            bilibili_check_status,\n            bilibili_qr_generate,\n            bilibili_qr_poll,\n            bilibili_save_cookie,\n            bilibili_logout,\n            // Auth - Xiaohongshu\n            xhs_check_status,\n            xhs_logout,\n            xhs_open_login_window,\n            // FFmpeg Media Tools\n            ffmpeg_get_media_info,\n            ffmpeg_convert_video,\n            ffmpeg_compress_video,\n            ffmpeg_trim_video,\n            ffmpeg_extract_audio,\n            ffmpeg_extract_frames,\n            ffmpeg_convert_audio,\n            // PDF Tools\n            pdf_get_info,\n            pdf_merge,\n            pdf_images_to_pdf,\n            pdf_delete_pages,\n            pdf_remove_watermark,\n            pdf_print,\n            pdf_open_external,\n            // File utilities\n            read_text_file,\n            // Markdown to PDF\n            md_to_pdf,\n        ])\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n    vget_desktop_lib::run()\n}\n"
  },
  {
    "path": "tauri/src-tauri/src/md2pdf.rs",
    "content": "use base64::{engine::general_purpose::STANDARD, Engine};\nuse headless_chrome::{types::PrintToPdfOptions, Browser, LaunchOptions};\nuse pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};\nuse std::fs;\nuse std::path::Path;\nuse syntect::highlighting::ThemeSet;\nuse syntect::html::highlighted_html_for_string;\nuse syntect::parsing::SyntaxSet;\n\n// Embed fonts at compile time\nstatic INTER_REGULAR: &[u8] = include_bytes!(\"../resources/fonts/Inter-Regular.woff2\");\nstatic INTER_MEDIUM: &[u8] = include_bytes!(\"../resources/fonts/Inter-Medium.woff2\");\nstatic INTER_SEMIBOLD: &[u8] = include_bytes!(\"../resources/fonts/Inter-SemiBold.woff2\");\nstatic INTER_BOLD: &[u8] = include_bytes!(\"../resources/fonts/Inter-Bold.woff2\");\nstatic INTER_EXTRABOLD: &[u8] = include_bytes!(\"../resources/fonts/Inter-ExtraBold.woff2\");\nstatic JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!(\"../resources/fonts/JetBrainsMono-Regular.woff2\");\nstatic JETBRAINS_MONO_MEDIUM: &[u8] = include_bytes!(\"../resources/fonts/JetBrainsMono-Medium.woff2\");\n\n/// Convert markdown file to PDF\npub fn convert_md_to_pdf(\n    input_path: &str,\n    output_path: &str,\n    theme: &str,\n    page_size: &str,\n) -> Result<(), String> {\n    // Read markdown file\n    let markdown = fs::read_to_string(input_path)\n        .map_err(|e| format!(\"Failed to read markdown file: {}\", e))?;\n\n    // Parse markdown to HTML with syntax highlighting\n    let html_content = markdown_to_html(&markdown, theme);\n\n    // Generate full HTML with styling\n    let full_html = generate_styled_html(&html_content, theme);\n\n    // Convert HTML to PDF using headless Chrome\n    html_to_pdf(&full_html, output_path, page_size)?;\n\n    Ok(())\n}\n\n/// Parse markdown to HTML using pulldown-cmark with syntax highlighting\nfn markdown_to_html(markdown: &str, theme: &str) -> String {\n    let mut options = Options::empty();\n    options.insert(Options::ENABLE_TABLES);\n    options.insert(Options::ENABLE_FOOTNOTES);\n    options.insert(Options::ENABLE_STRIKETHROUGH);\n    options.insert(Options::ENABLE_TASKLISTS);\n    options.insert(Options::ENABLE_HEADING_ATTRIBUTES);\n\n    let parser = Parser::new_ext(markdown, options);\n\n    // Load syntax highlighting\n    let ss = SyntaxSet::load_defaults_newlines();\n    let ts = ThemeSet::load_defaults();\n    let syntax_theme = if theme == \"dark\" {\n        &ts.themes[\"base16-ocean.dark\"]\n    } else {\n        &ts.themes[\"InspiredGitHub\"]\n    };\n\n    let mut html_output = String::new();\n    let mut in_code_block = false;\n    let mut in_table_head = false;\n    let mut code_lang = String::new();\n    let mut code_content = String::new();\n\n    for event in parser {\n        match event {\n            Event::Start(Tag::CodeBlock(kind)) => {\n                in_code_block = true;\n                code_content.clear();\n                code_lang = match kind {\n                    CodeBlockKind::Fenced(lang) => lang.to_string(),\n                    CodeBlockKind::Indented => String::new(),\n                };\n            }\n            Event::End(TagEnd::CodeBlock) => {\n                in_code_block = false;\n                // Try to find syntax for the language\n                let syntax = if !code_lang.is_empty() {\n                    ss.find_syntax_by_token(&code_lang)\n                } else {\n                    None\n                }\n                .unwrap_or_else(|| ss.find_syntax_plain_text());\n\n                // Generate highlighted HTML\n                match highlighted_html_for_string(&code_content, &ss, syntax, syntax_theme) {\n                    Ok(highlighted) => {\n                        html_output.push_str(&highlighted);\n                    }\n                    Err(_) => {\n                        // Fallback to plain code block\n                        html_output.push_str(\"<pre><code>\");\n                        html_output.push_str(&html_escape(&code_content));\n                        html_output.push_str(\"</code></pre>\\n\");\n                    }\n                }\n            }\n            Event::Text(text) if in_code_block => {\n                code_content.push_str(&text);\n            }\n            Event::Start(Tag::Table(alignments)) => {\n                html_output.push_str(\"<table>\\n\");\n                // Store alignments for later use (simplified - we just open the table)\n                let _ = alignments;\n            }\n            Event::End(TagEnd::Table) => {\n                html_output.push_str(\"</tbody>\\n</table>\\n\");\n            }\n            Event::Start(Tag::TableHead) => {\n                in_table_head = true;\n                html_output.push_str(\"<thead>\\n\");\n            }\n            Event::End(TagEnd::TableHead) => {\n                in_table_head = false;\n                html_output.push_str(\"</thead>\\n<tbody>\\n\");\n            }\n            Event::Start(Tag::TableRow) => {\n                html_output.push_str(\"<tr>\\n\");\n            }\n            Event::End(TagEnd::TableRow) => {\n                html_output.push_str(\"</tr>\\n\");\n            }\n            Event::Start(Tag::TableCell) => {\n                if in_table_head {\n                    html_output.push_str(\"<th>\");\n                } else {\n                    html_output.push_str(\"<td>\");\n                }\n            }\n            Event::End(TagEnd::TableCell) => {\n                if in_table_head {\n                    html_output.push_str(\"</th>\\n\");\n                } else {\n                    html_output.push_str(\"</td>\\n\");\n                }\n            }\n            Event::Start(Tag::Heading { level, .. }) => {\n                let level_num = heading_level_to_u8(level);\n                html_output.push_str(&format!(\"<h{}>\", level_num));\n            }\n            Event::End(TagEnd::Heading(level)) => {\n                let level_num = heading_level_to_u8(level);\n                html_output.push_str(&format!(\"</h{}>\\n\", level_num));\n            }\n            Event::Start(Tag::Paragraph) => {\n                html_output.push_str(\"<p>\");\n            }\n            Event::End(TagEnd::Paragraph) => {\n                html_output.push_str(\"</p>\\n\");\n            }\n            Event::Start(Tag::List(None)) => {\n                html_output.push_str(\"<ul>\\n\");\n            }\n            Event::Start(Tag::List(Some(start))) => {\n                html_output.push_str(&format!(\"<ol start=\\\"{}\\\">\\n\", start));\n            }\n            Event::End(TagEnd::List(ordered)) => {\n                if ordered {\n                    html_output.push_str(\"</ol>\\n\");\n                } else {\n                    html_output.push_str(\"</ul>\\n\");\n                }\n            }\n            Event::Start(Tag::Item) => {\n                html_output.push_str(\"<li>\");\n            }\n            Event::End(TagEnd::Item) => {\n                html_output.push_str(\"</li>\\n\");\n            }\n            Event::Start(Tag::BlockQuote(_)) => {\n                html_output.push_str(\"<blockquote>\\n\");\n            }\n            Event::End(TagEnd::BlockQuote(_)) => {\n                html_output.push_str(\"</blockquote>\\n\");\n            }\n            Event::Start(Tag::Emphasis) => {\n                html_output.push_str(\"<em>\");\n            }\n            Event::End(TagEnd::Emphasis) => {\n                html_output.push_str(\"</em>\");\n            }\n            Event::Start(Tag::Strong) => {\n                html_output.push_str(\"<strong>\");\n            }\n            Event::End(TagEnd::Strong) => {\n                html_output.push_str(\"</strong>\");\n            }\n            Event::Start(Tag::Strikethrough) => {\n                html_output.push_str(\"<del>\");\n            }\n            Event::End(TagEnd::Strikethrough) => {\n                html_output.push_str(\"</del>\");\n            }\n            Event::Start(Tag::Link { dest_url, title, .. }) => {\n                html_output.push_str(&format!(\n                    \"<a href=\\\"{}\\\" title=\\\"{}\\\">\",\n                    html_escape(&dest_url),\n                    html_escape(&title)\n                ));\n            }\n            Event::End(TagEnd::Link) => {\n                html_output.push_str(\"</a>\");\n            }\n            Event::Start(Tag::Image { dest_url, title, .. }) => {\n                html_output.push_str(&format!(\n                    \"<img src=\\\"{}\\\" alt=\\\"\",\n                    html_escape(&dest_url)\n                ));\n                // The alt text will come as a Text event\n                let _ = title;\n            }\n            Event::End(TagEnd::Image) => {\n                html_output.push_str(\"\\\" />\");\n            }\n            Event::Code(code) => {\n                html_output.push_str(\"<code>\");\n                html_output.push_str(&html_escape(&code));\n                html_output.push_str(\"</code>\");\n            }\n            Event::Text(text) => {\n                html_output.push_str(&html_escape(&text));\n            }\n            Event::SoftBreak => {\n                html_output.push('\\n');\n            }\n            Event::HardBreak => {\n                html_output.push_str(\"<br />\\n\");\n            }\n            Event::Rule => {\n                html_output.push_str(\"<hr />\\n\");\n            }\n            Event::TaskListMarker(checked) => {\n                if checked {\n                    html_output.push_str(\"<input type=\\\"checkbox\\\" checked disabled /> \");\n                } else {\n                    html_output.push_str(\"<input type=\\\"checkbox\\\" disabled /> \");\n                }\n            }\n            _ => {}\n        }\n    }\n\n    html_output\n}\n\n/// Escape HTML special characters\nfn html_escape(text: &str) -> String {\n    text.replace('&', \"&amp;\")\n        .replace('<', \"&lt;\")\n        .replace('>', \"&gt;\")\n        .replace('\"', \"&quot;\")\n        .replace('\\'', \"&#39;\")\n}\n\n/// Convert HeadingLevel enum to u8\nfn heading_level_to_u8(level: HeadingLevel) -> u8 {\n    match level {\n        HeadingLevel::H1 => 1,\n        HeadingLevel::H2 => 2,\n        HeadingLevel::H3 => 3,\n        HeadingLevel::H4 => 4,\n        HeadingLevel::H5 => 5,\n        HeadingLevel::H6 => 6,\n    }\n}\n\n/// Generate @font-face CSS rules with embedded base64 fonts\nfn generate_font_css() -> String {\n    format!(\n        r#\"\n/* Embedded Fonts */\n@font-face {{\n    font-family: 'Inter';\n    font-style: normal;\n    font-weight: 400;\n    font-display: swap;\n    src: url(data:font/woff2;base64,{}) format('woff2');\n}}\n@font-face {{\n    font-family: 'Inter';\n    font-style: normal;\n    font-weight: 500;\n    font-display: swap;\n    src: url(data:font/woff2;base64,{}) format('woff2');\n}}\n@font-face {{\n    font-family: 'Inter';\n    font-style: normal;\n    font-weight: 600;\n    font-display: swap;\n    src: url(data:font/woff2;base64,{}) format('woff2');\n}}\n@font-face {{\n    font-family: 'Inter';\n    font-style: normal;\n    font-weight: 700;\n    font-display: swap;\n    src: url(data:font/woff2;base64,{}) format('woff2');\n}}\n@font-face {{\n    font-family: 'Inter';\n    font-style: normal;\n    font-weight: 800;\n    font-display: swap;\n    src: url(data:font/woff2;base64,{}) format('woff2');\n}}\n@font-face {{\n    font-family: 'JetBrains Mono';\n    font-style: normal;\n    font-weight: 400;\n    font-display: swap;\n    src: url(data:font/woff2;base64,{}) format('woff2');\n}}\n@font-face {{\n    font-family: 'JetBrains Mono';\n    font-style: normal;\n    font-weight: 500;\n    font-display: swap;\n    src: url(data:font/woff2;base64,{}) format('woff2');\n}}\n\"#,\n        STANDARD.encode(INTER_REGULAR),\n        STANDARD.encode(INTER_MEDIUM),\n        STANDARD.encode(INTER_SEMIBOLD),\n        STANDARD.encode(INTER_BOLD),\n        STANDARD.encode(INTER_EXTRABOLD),\n        STANDARD.encode(JETBRAINS_MONO_REGULAR),\n        STANDARD.encode(JETBRAINS_MONO_MEDIUM),\n    )\n}\n\n/// Generate full HTML document with CSS styling\nfn generate_styled_html(content: &str, theme: &str) -> String {\n    let font_css = generate_font_css();\n    let theme_css = get_theme_css(theme);\n\n    format!(\n        r#\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"UTF-8\">\n    <style>\n{font_css}\n{theme_css}\n    </style>\n</head>\n<body>\n    <article class=\"markdown-body\">\n{content}\n    </article>\n</body>\n</html>\"#\n    )\n}\n\n/// Get CSS based on theme\nfn get_theme_css(theme: &str) -> &'static str {\n    match theme {\n        \"dark\" => DARK_THEME_CSS,\n        _ => LIGHT_THEME_CSS,\n    }\n}\n\n/// Convert HTML to PDF using headless Chrome\nfn html_to_pdf(html: &str, output_path: &str, page_size: &str) -> Result<(), String> {\n    // Write HTML to a temp file (more reliable than data URLs for large content)\n    let temp_dir = std::env::temp_dir();\n    let temp_html_path = temp_dir.join(format!(\"md2pdf_{}.html\", std::process::id()));\n    fs::write(&temp_html_path, html)\n        .map_err(|e| format!(\"Failed to write temp HTML file: {}\", e))?;\n\n    let browser = Browser::new(\n        LaunchOptions::default_builder()\n            .headless(true)\n            .sandbox(false)\n            .build()\n            .map_err(|e| format!(\"Failed to build launch options: {}\", e))?,\n    )\n    .map_err(|e| format!(\"Failed to launch browser: {}\", e))?;\n\n    let tab = browser\n        .new_tab()\n        .map_err(|e| format!(\"Failed to create new tab: {}\", e))?;\n\n    // Navigate to the temp HTML file\n    let file_url = format!(\"file://{}\", temp_html_path.display());\n\n    tab.navigate_to(&file_url)\n        .map_err(|e| format!(\"Failed to navigate: {}\", e))?;\n\n    tab.wait_until_navigated()\n        .map_err(|e| format!(\"Failed to wait for navigation: {}\", e))?;\n\n    // Wait for page to fully render (important for embedded fonts to load)\n    std::thread::sleep(std::time::Duration::from_millis(500));\n\n    // Get page dimensions based on page size (in inches)\n    let (paper_width, paper_height) = match page_size {\n        \"Letter\" => (8.5, 11.0),\n        _ => (8.27, 11.69), // A4 default\n    };\n\n    // Generate PDF with custom options\n    let options = PrintToPdfOptions {\n        landscape: Some(false),\n        display_header_footer: Some(false),\n        print_background: Some(true),\n        scale: Some(1.0),\n        paper_width: Some(paper_width),\n        paper_height: Some(paper_height),\n        margin_top: Some(0.5),\n        margin_bottom: Some(0.5),\n        margin_left: Some(0.5),\n        margin_right: Some(0.5),\n        prefer_css_page_size: Some(false),\n        ..Default::default()\n    };\n\n    let pdf_bytes = tab\n        .print_to_pdf(Some(options))\n        .map_err(|e| format!(\"Failed to generate PDF: {}\", e))?;\n\n    // Ensure output directory exists\n    if let Some(parent) = Path::new(output_path).parent() {\n        fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create output directory: {}\", e))?;\n    }\n\n    // Write PDF to file\n    fs::write(output_path, pdf_bytes).map_err(|e| format!(\"Failed to write PDF: {}\", e))?;\n\n    // Clean up temp file\n    let _ = fs::remove_file(&temp_html_path);\n\n    Ok(())\n}\n\n// Light theme CSS - Clean document styling\nconst LIGHT_THEME_CSS: &str = r#\"\n@page {\n    size: auto;\n}\n\n*, *::before, *::after {\n    box-sizing: border-box;\n}\n\nhtml {\n    font-size: 16px;\n    -webkit-print-color-adjust: exact;\n    print-color-adjust: exact;\n    text-rendering: optimizeLegibility;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n    font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif,\n        \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\",\n        \"Hiragino Kaku Gothic Pro\", \"Yu Gothic\",\n        \"Apple SD Gothic Neo\", \"Malgun Gothic\",\n        \"Apple Color Emoji\", \"Segoe UI Emoji\";\n    font-size: 1rem;\n    font-weight: 400;\n    line-height: 1.6;\n    color: #111827;\n    background-color: #ffffff;\n    margin: 0;\n    padding: 0;\n    word-wrap: break-word;\n    font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1;\n}\n\n.markdown-body {\n    max-width: 100%;\n    margin: 0 auto;\n    padding: 0;\n}\n\n/* ==================== Typography ==================== */\n\nh1, h2, h3, h4, h5, h6 {\n    font-weight: 600;\n    line-height: 1.35;\n    color: #111827;\n    margin-top: 1.8em;\n    margin-bottom: 0.6em;\n    page-break-after: avoid;\n    page-break-inside: avoid;\n}\n\nh1:first-child, h2:first-child, h3:first-child,\nh4:first-child, h5:first-child, h6:first-child {\n    margin-top: 0;\n}\n\nh1 {\n    font-size: 2.2rem;\n    padding-bottom: 0.3em;\n    border-bottom: 1px solid #e5e7eb;\n}\n\nh2 {\n    font-size: 1.6rem;\n    padding-bottom: 0.2em;\n    border-bottom: 1px solid #e5e7eb;\n}\n\nh3 {\n    font-size: 1.3rem;\n}\n\nh4 {\n    font-size: 1.1rem;\n}\n\nh5 {\n    font-size: 1rem;\n}\n\nh6 {\n    font-size: 0.95rem;\n    color: #4b5563;\n}\n\n/* Paragraphs */\np {\n    margin-top: 0;\n    margin-bottom: 1em;\n}\n\n/* Links */\na {\n    color: #0969da;\n    text-decoration: none;\n    border-bottom: 1px solid rgba(9, 105, 218, 0.2);\n}\n\na:hover {\n    border-bottom-color: rgba(9, 105, 218, 0.5);\n}\n\n/* ==================== Code ==================== */\n\ncode {\n    font-family: \"JetBrains Mono\", \"Fira Code\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace;\n    font-size: 0.9em;\n    font-weight: 500;\n    padding: 0.2em 0.4em;\n    background-color: #f6f8fa;\n    border-radius: 4px;\n    color: #24292f;\n    border: 1px solid #e5e7eb;\n}\n\n/* Code blocks - syntect generates pre with inline styles */\npre {\n    font-family: \"JetBrains Mono\", \"Fira Code\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace;\n    font-size: 0.88rem;\n    line-height: 1.6;\n    padding: 1em 1.2em;\n    overflow-x: auto;\n    background-color: #f6f8fa !important;\n    border-radius: 6px;\n    border: 1px solid #e5e7eb;\n    margin-top: 0;\n    margin-bottom: 1.2em;\n    page-break-inside: avoid;\n}\n\npre code {\n    font-size: inherit;\n    font-weight: 400;\n    padding: 0;\n    background: transparent !important;\n    border: none;\n    border-radius: 0;\n    color: inherit;\n    white-space: pre;\n}\n\n/* ==================== Blockquotes ==================== */\n\nblockquote {\n    margin: 0 0 1em 0;\n    padding: 0.2em 1em;\n    color: #4b5563;\n    border-left: 4px solid #d0d7de;\n}\n\nblockquote p {\n    margin-bottom: 0.6em;\n}\n\nblockquote p:last-child {\n    margin-bottom: 0;\n}\n\nblockquote code {\n    font-style: normal;\n}\n\n/* ==================== Lists ==================== */\n\nul, ol {\n    margin-top: 0;\n    margin-bottom: 1em;\n    padding-left: 2em;\n}\n\nul ul, ol ol, ul ol, ol ul {\n    margin-bottom: 0;\n    margin-top: 0.4em;\n}\n\nli {\n    margin-bottom: 0.35em;\n    line-height: 1.6;\n}\n\nli > p {\n    margin-bottom: 0.5em;\n}\n\nli > p:last-child {\n    margin-bottom: 0;\n}\n\n/* Task lists */\nli input[type=\"checkbox\"] {\n    margin-right: 0.5em;\n    margin-left: -0.1em;\n    vertical-align: middle;\n    position: relative;\n    top: -1px;\n    width: 14px;\n    height: 14px;\n    accent-color: #0969da;\n}\n\n/* ==================== Tables ==================== */\n\ntable {\n    border-collapse: collapse;\n    margin-top: 0;\n    margin-bottom: 1.2em;\n    width: 100%;\n    page-break-inside: avoid;\n}\n\nth, td {\n    padding: 0.6em 0.9em;\n    text-align: left;\n    border: 1px solid #d0d7de;\n}\n\nth {\n    font-weight: 600;\n    font-size: 0.9em;\n    color: #374151;\n    background-color: #f6f8fa;\n}\n\ntd {\n    color: #1f2937;\n}\n\ntbody tr:nth-child(even) {\n    background-color: #fbfcfe;\n}\n\n/* ==================== Other Elements ==================== */\n\nhr {\n    height: 0;\n    padding: 0;\n    margin: 1.8em 0;\n    border: 0;\n    border-top: 1px solid #e5e7eb;\n    background: transparent;\n}\n\n/* Images */\nimg {\n    max-width: 100%;\n    height: auto;\n    display: block;\n    margin: 1.2em auto;\n    border-radius: 6px;\n    border: 1px solid #e5e7eb;\n}\n\n/* Strikethrough */\ndel {\n    color: #6b7280;\n    text-decoration: line-through;\n}\n\n/* Strong and emphasis */\nstrong {\n    font-weight: 600;\n    color: #111827;\n}\n\nem {\n    font-style: italic;\n    color: #374151;\n}\n\n/* Definition lists */\ndt {\n    font-weight: 600;\n    margin-top: 1.1em;\n    color: #111827;\n}\n\ndd {\n    margin-left: 1.6em;\n    margin-bottom: 0.5em;\n    color: #4b5563;\n}\n\n/* Footnotes */\n.footnote-definition {\n    font-size: 0.9rem;\n    margin-top: 2em;\n    padding-top: 1em;\n    border-top: 1px solid #e5e7eb;\n    color: #4b5563;\n}\n\n/* Keyboard shortcut styling */\nkbd {\n    font-family: inherit;\n    font-size: 0.85em;\n    padding: 0.15em 0.35em;\n    background-color: #f3f4f6;\n    border: 1px solid #d1d5db;\n    border-radius: 4px;\n    color: #111827;\n}\n\n/* ==================== Print Optimizations ==================== */\n\n@media print {\n    html {\n        font-size: 15px;\n    }\n\n    body {\n        background: white;\n    }\n\n    .markdown-body {\n        padding: 0;\n    }\n\n    pre, blockquote, table, img, h1, h2, h3, h4, h5, h6 {\n        page-break-inside: avoid;\n    }\n\n    h1, h2, h3, h4, h5, h6 {\n        page-break-after: avoid;\n    }\n\n    p, li {\n        orphans: 3;\n        widows: 3;\n    }\n\n    table {\n        border: 1px solid #d0d7de;\n    }\n\n    img {\n        border: 1px solid #e5e7eb;\n    }\n\n    pre {\n        border: 1px solid #d0d7de;\n    }\n}\n\"#;\n\n// Dark theme CSS - Clean document styling\nconst DARK_THEME_CSS: &str = r#\"\n@page {\n    size: auto;\n}\n\n*, *::before, *::after {\n    box-sizing: border-box;\n}\n\nhtml {\n    font-size: 16px;\n    -webkit-print-color-adjust: exact;\n    print-color-adjust: exact;\n    text-rendering: optimizeLegibility;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n    font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif,\n        \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\",\n        \"Hiragino Kaku Gothic Pro\", \"Yu Gothic\",\n        \"Apple SD Gothic Neo\", \"Malgun Gothic\",\n        \"Apple Color Emoji\", \"Segoe UI Emoji\";\n    font-size: 1rem;\n    font-weight: 400;\n    line-height: 1.6;\n    color: #e2e8f0;\n    background-color: #0f172a;\n    margin: 0;\n    padding: 0;\n    word-wrap: break-word;\n    font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1;\n}\n\n.markdown-body {\n    max-width: 100%;\n    margin: 0 auto;\n    padding: 0;\n}\n\n/* ==================== Typography ==================== */\n\nh1, h2, h3, h4, h5, h6 {\n    font-weight: 600;\n    line-height: 1.35;\n    color: #f8fafc;\n    margin-top: 1.8em;\n    margin-bottom: 0.6em;\n    page-break-after: avoid;\n    page-break-inside: avoid;\n}\n\nh1:first-child, h2:first-child, h3:first-child,\nh4:first-child, h5:first-child, h6:first-child {\n    margin-top: 0;\n}\n\nh1 {\n    font-size: 2.2rem;\n    padding-bottom: 0.3em;\n    border-bottom: 1px solid #334155;\n}\n\nh2 {\n    font-size: 1.6rem;\n    padding-bottom: 0.2em;\n    border-bottom: 1px solid #334155;\n}\n\nh3 {\n    font-size: 1.3rem;\n}\n\nh4 {\n    font-size: 1.1rem;\n    color: #e2e8f0;\n}\n\nh5 {\n    font-size: 1rem;\n    color: #cbd5e1;\n}\n\nh6 {\n    font-size: 0.95rem;\n    color: #94a3b8;\n}\n\n/* Paragraphs */\np {\n    margin-top: 0;\n    margin-bottom: 1em;\n}\n\n/* Links */\na {\n    color: #60a5fa;\n    text-decoration: none;\n    border-bottom: 1px solid rgba(96, 165, 250, 0.25);\n}\n\na:hover {\n    border-bottom-color: rgba(96, 165, 250, 0.55);\n}\n\n/* ==================== Code ==================== */\n\ncode {\n    font-family: \"JetBrains Mono\", \"Fira Code\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace;\n    font-size: 0.9em;\n    font-weight: 500;\n    padding: 0.2em 0.4em;\n    background-color: #111827;\n    border-radius: 4px;\n    color: #e2e8f0;\n    border: 1px solid #334155;\n}\n\n/* Code blocks - syntect generates pre with inline styles */\npre {\n    font-family: \"JetBrains Mono\", \"Fira Code\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace;\n    font-size: 0.88rem;\n    line-height: 1.6;\n    padding: 1em 1.2em;\n    overflow-x: auto;\n    background-color: #111827 !important;\n    border-radius: 6px;\n    border: 1px solid #334155;\n    margin-top: 0;\n    margin-bottom: 1.2em;\n    page-break-inside: avoid;\n}\n\npre code {\n    font-size: inherit;\n    font-weight: 400;\n    padding: 0;\n    background: transparent !important;\n    border: none;\n    border-radius: 0;\n    color: inherit;\n    white-space: pre;\n}\n\n/* ==================== Blockquotes ==================== */\n\nblockquote {\n    margin: 0 0 1em 0;\n    padding: 0.2em 1em;\n    color: #94a3b8;\n    border-left: 4px solid #334155;\n}\n\nblockquote p {\n    margin-bottom: 0.6em;\n}\n\nblockquote p:last-child {\n    margin-bottom: 0;\n}\n\nblockquote code {\n    font-style: normal;\n}\n\n/* ==================== Lists ==================== */\n\nul, ol {\n    margin-top: 0;\n    margin-bottom: 1em;\n    padding-left: 2em;\n}\n\nul ul, ol ol, ul ol, ol ul {\n    margin-bottom: 0;\n    margin-top: 0.4em;\n}\n\nli {\n    margin-bottom: 0.35em;\n    line-height: 1.6;\n}\n\nli > p {\n    margin-bottom: 0.5em;\n}\n\nli > p:last-child {\n    margin-bottom: 0;\n}\n\n/* Task lists */\nli input[type=\"checkbox\"] {\n    margin-right: 0.5em;\n    margin-left: -0.1em;\n    vertical-align: middle;\n    position: relative;\n    top: -1px;\n    width: 14px;\n    height: 14px;\n    accent-color: #60a5fa;\n}\n\n/* ==================== Tables ==================== */\n\ntable {\n    border-collapse: collapse;\n    margin-top: 0;\n    margin-bottom: 1.2em;\n    width: 100%;\n    page-break-inside: avoid;\n}\n\nth, td {\n    padding: 0.6em 0.9em;\n    text-align: left;\n    border: 1px solid #334155;\n}\n\nth {\n    font-weight: 600;\n    font-size: 0.9em;\n    color: #cbd5e1;\n    background-color: #111827;\n}\n\ntd {\n    color: #e2e8f0;\n}\n\ntbody tr:nth-child(even) {\n    background-color: #0b1220;\n}\n\n/* ==================== Other Elements ==================== */\n\nhr {\n    height: 0;\n    padding: 0;\n    margin: 1.8em 0;\n    border: 0;\n    border-top: 1px solid #334155;\n    background: transparent;\n}\n\n/* Images */\nimg {\n    max-width: 100%;\n    height: auto;\n    display: block;\n    margin: 1.2em auto;\n    border-radius: 6px;\n    border: 1px solid #334155;\n}\n\n/* Strikethrough */\ndel {\n    color: #94a3b8;\n    text-decoration: line-through;\n}\n\n/* Strong and emphasis */\nstrong {\n    font-weight: 600;\n    color: #f8fafc;\n}\n\nem {\n    font-style: italic;\n    color: #cbd5e1;\n}\n\n/* Definition lists */\ndt {\n    font-weight: 600;\n    margin-top: 1.1em;\n    color: #f8fafc;\n}\n\ndd {\n    margin-left: 1.6em;\n    margin-bottom: 0.5em;\n    color: #94a3b8;\n}\n\n/* Footnotes */\n.footnote-definition {\n    font-size: 0.9rem;\n    margin-top: 2em;\n    padding-top: 1em;\n    border-top: 1px solid #334155;\n    color: #94a3b8;\n}\n\n/* Keyboard shortcut styling */\nkbd {\n    font-family: inherit;\n    font-size: 0.85em;\n    padding: 0.15em 0.35em;\n    background-color: #111827;\n    border: 1px solid #475569;\n    border-radius: 4px;\n    color: #e2e8f0;\n}\n\n/* ==================== Print Optimizations ==================== */\n\n@media print {\n    html {\n        font-size: 15px;\n    }\n\n    body {\n        background: #0f172a;\n    }\n\n    .markdown-body {\n        padding: 0;\n    }\n\n    pre, blockquote, table, img, h1, h2, h3, h4, h5, h6 {\n        page-break-inside: avoid;\n    }\n\n    h1, h2, h3, h4, h5, h6 {\n        page-break-after: avoid;\n    }\n\n    p, li {\n        orphans: 3;\n        widows: 3;\n    }\n\n    table {\n        border: 1px solid #334155;\n    }\n\n    img {\n        border: 1px solid #334155;\n    }\n\n    pre {\n        border: 1px solid #334155;\n    }\n}\n\"#;\n"
  },
  {
    "path": "tauri/src-tauri/src/pdf.rs",
    "content": "use lopdf::{Document, Object, ObjectId};\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct PdfInfo {\n    pub path: String,\n    pub pages: usize,\n    pub title: Option<String>,\n    pub author: Option<String>,\n}\n\n/// Get basic info about a PDF file\npub fn get_pdf_info(path: &str) -> Result<PdfInfo, String> {\n    let doc = Document::load(path).map_err(|e| format!(\"Failed to load PDF: {}\", e))?;\n\n    let pages = doc.get_pages().len();\n\n    // Try to get metadata from document info dictionary\n    let mut title = None;\n    let mut author = None;\n\n    if let Ok(info_dict) = doc.trailer.get(b\"Info\") {\n        if let Ok(info_ref) = info_dict.as_reference() {\n            if let Ok(info_obj) = doc.get_object(info_ref) {\n                if let Object::Dictionary(dict) = info_obj {\n                    if let Ok(t) = dict.get(b\"Title\") {\n                        if let Ok(s) = t.as_str() {\n                            title = Some(String::from_utf8_lossy(s).to_string());\n                        }\n                    }\n                    if let Ok(a) = dict.get(b\"Author\") {\n                        if let Ok(s) = a.as_str() {\n                            author = Some(String::from_utf8_lossy(s).to_string());\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    Ok(PdfInfo {\n        path: path.to_string(),\n        pages,\n        title,\n        author,\n    })\n}\n\n/// Merge multiple PDF files into one\npub fn merge_pdfs(input_paths: &[String], output_path: &str) -> Result<(), String> {\n    if input_paths.is_empty() {\n        return Err(\"No input files provided\".to_string());\n    }\n\n    if input_paths.len() == 1 {\n        // Just copy the file\n        std::fs::copy(&input_paths[0], output_path)\n            .map_err(|e| format!(\"Failed to copy PDF: {}\", e))?;\n        return Ok(());\n    }\n\n    // Load the first document as the base\n    let mut base_doc =\n        Document::load(&input_paths[0]).map_err(|e| format!(\"Failed to load first PDF: {}\", e))?;\n\n    // Merge subsequent documents\n    for (idx, path) in input_paths.iter().enumerate().skip(1) {\n        let doc = Document::load(path)\n            .map_err(|e| format!(\"Failed to load PDF {}: {}\", idx + 1, e))?;\n\n        // Get the max object id from base document\n        let mut max_id = base_doc.max_id;\n\n        // Map old object ids to new ones\n        let mut id_map: HashMap<ObjectId, ObjectId> = HashMap::new();\n\n        // Copy all objects from the source document with new IDs\n        for (old_id, object) in doc.objects.iter() {\n            max_id += 1;\n            let new_id = (max_id, 0);\n            id_map.insert(*old_id, new_id);\n            base_doc.objects.insert(new_id, object.clone());\n        }\n\n        // Update references in copied objects\n        for (_old_id, new_id) in id_map.iter() {\n            if let Some(obj) = base_doc.objects.get_mut(new_id) {\n                update_references(obj, &id_map);\n            }\n        }\n\n        // Get pages from the source document and add them to base\n        let src_pages = doc.get_pages();\n        for (_page_num, page_id) in src_pages {\n            if let Some(new_page_id) = id_map.get(&page_id) {\n                // Add the page to the base document's page tree\n                let catalog = base_doc.catalog().map_err(|e| e.to_string())?;\n                let pages_ref = catalog.get(b\"Pages\").map_err(|e| e.to_string())?;\n                let pages_id = pages_ref.as_reference().map_err(|e| e.to_string())?;\n\n                if let Ok(Object::Dictionary(ref mut pages_dict)) =\n                    base_doc.get_object_mut(pages_id)\n                {\n                    if let Ok(kids) = pages_dict.get_mut(b\"Kids\") {\n                        if let Object::Array(ref mut kids_array) = kids {\n                            kids_array.push(Object::Reference(*new_page_id));\n                        }\n                    }\n                    // Update page count\n                    if let Ok(count) = pages_dict.get_mut(b\"Count\") {\n                        if let Object::Integer(ref mut n) = count {\n                            *n += 1;\n                        }\n                    }\n                }\n            }\n        }\n\n        base_doc.max_id = max_id;\n    }\n\n    // Save the merged document\n    base_doc\n        .save(output_path)\n        .map_err(|e| format!(\"Failed to save merged PDF: {}\", e))?;\n\n    Ok(())\n}\n\n/// Update object references after copying\nfn update_references(obj: &mut Object, id_map: &HashMap<ObjectId, ObjectId>) {\n    match obj {\n        Object::Reference(ref mut id) => {\n            if let Some(new_id) = id_map.get(id) {\n                *id = *new_id;\n            }\n        }\n        Object::Array(arr) => {\n            for item in arr.iter_mut() {\n                update_references(item, id_map);\n            }\n        }\n        Object::Dictionary(dict) => {\n            for (_, value) in dict.iter_mut() {\n                update_references(value, id_map);\n            }\n        }\n        Object::Stream(stream) => {\n            for (_, value) in stream.dict.iter_mut() {\n                update_references(value, id_map);\n            }\n        }\n        _ => {}\n    }\n}\n\n/// Convert images to a single PDF using printpdf\npub fn images_to_pdf(image_paths: &[String], output_path: &str) -> Result<(), String> {\n    use printpdf::*;\n\n    if image_paths.is_empty() {\n        return Err(\"No image files provided\".to_string());\n    }\n\n    // Create a new PDF document\n    let mut doc = PdfDocument::new(\"Images to PDF\");\n    let mut warnings = Vec::new();\n\n    for image_path in image_paths.iter() {\n        // Read the image file\n        let image_bytes = std::fs::read(image_path)\n            .map_err(|e| format!(\"Failed to read image {}: {}\", image_path, e))?;\n\n        // Decode image to get dimensions using the image crate\n        let img = ::image::load_from_memory(&image_bytes)\n            .map_err(|e| format!(\"Failed to decode image {}: {}\", image_path, e))?;\n\n        let (img_width, img_height) = ::image::GenericImageView::dimensions(&img);\n\n        // Calculate page size (A4 max, scale if needed)\n        let max_width_mm: f32 = 210.0;\n        let max_height_mm: f32 = 297.0;\n\n        // Convert pixels to mm (assuming 96 DPI)\n        let dpi: f32 = 96.0;\n        let img_width_mm = (img_width as f32 / dpi) * 25.4;\n        let img_height_mm = (img_height as f32 / dpi) * 25.4;\n\n        // Scale to fit within A4 while maintaining aspect ratio\n        let scale = (max_width_mm / img_width_mm)\n            .min(max_height_mm / img_height_mm)\n            .min(1.0);\n        let final_width_mm = img_width_mm * scale;\n        let final_height_mm = img_height_mm * scale;\n\n        // Decode image for printpdf\n        let raw_image = RawImage::decode_from_bytes(&image_bytes, &mut warnings)\n            .map_err(|e| format!(\"Failed to decode image for PDF: {}\", e))?;\n\n        // Add image to document resources\n        let image_id = doc.add_image(&raw_image);\n\n        // Create page with image\n        let page = PdfPage::new(\n            Mm(final_width_mm),\n            Mm(final_height_mm),\n            vec![Op::UseXobject {\n                id: image_id.into(),\n                transform: XObjectTransform {\n                    translate_x: Some(Pt(0.0)),\n                    translate_y: Some(Pt(0.0)),\n                    scale_x: Some(scale),\n                    scale_y: Some(scale),\n                    ..Default::default()\n                },\n            }],\n        );\n\n        doc.pages.push(page);\n    }\n\n    // Save the PDF\n    let pdf_bytes = doc.save(&PdfSaveOptions::default(), &mut warnings);\n    std::fs::write(output_path, pdf_bytes)\n        .map_err(|e| format!(\"Failed to save PDF: {}\", e))?;\n\n    Ok(())\n}\n\n/// Result of watermark removal attempt\n#[derive(Debug, Serialize, Deserialize)]\npub struct WatermarkRemovalResult {\n    pub success: bool,\n    pub items_removed: usize,\n    pub message: String,\n}\n\n/// Attempt to remove watermarks from a PDF\n/// This works for overlay-type watermarks but not for embedded ones\npub fn remove_watermark(input_path: &str, output_path: &str) -> Result<WatermarkRemovalResult, String> {\n    let mut doc = Document::load(input_path)\n        .map_err(|e| format!(\"Failed to load PDF: {}\", e))?;\n\n    let mut items_removed = 0;\n\n    // Common watermark-related names to look for\n    let watermark_indicators: Vec<&[u8]> = vec![\n        b\"Watermark\",\n        b\"watermark\",\n        b\"WATERMARK\",\n        b\"WM\",\n        b\"wm\",\n        b\"Overlay\",\n        b\"overlay\",\n        b\"Background\",\n        b\"Draft\",\n        b\"DRAFT\",\n        b\"Confidential\",\n        b\"CONFIDENTIAL\",\n        b\"Sample\",\n        b\"SAMPLE\",\n        b\"Copy\",\n        b\"COPY\",\n    ];\n\n    // Collect object IDs that might be watermarks\n    let mut objects_to_remove: Vec<ObjectId> = Vec::new();\n\n    // Check all objects for watermark indicators\n    for (obj_id, obj) in doc.objects.iter() {\n        if let Object::Dictionary(dict) = obj {\n            // Check for watermark-named objects\n            if let Ok(name) = dict.get(b\"Name\") {\n                if let Object::Name(n) = name {\n                    for indicator in &watermark_indicators {\n                        if n.windows(indicator.len()).any(|w| w == *indicator) {\n                            objects_to_remove.push(*obj_id);\n                            break;\n                        }\n                    }\n                }\n            }\n\n            // Check Subtype for watermark\n            if let Ok(subtype) = dict.get(b\"Subtype\") {\n                if let Object::Name(n) = subtype {\n                    if n == b\"Watermark\" {\n                        objects_to_remove.push(*obj_id);\n                    }\n                }\n            }\n\n            // Check Type for watermark annotation\n            if let Ok(type_obj) = dict.get(b\"Type\") {\n                if let Object::Name(n) = type_obj {\n                    if n == b\"Annot\" {\n                        if let Ok(subtype) = dict.get(b\"Subtype\") {\n                            if let Object::Name(st) = subtype {\n                                if st == b\"Watermark\" {\n                                    objects_to_remove.push(*obj_id);\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        // Check streams for watermark content\n        if let Object::Stream(stream) = obj {\n            if let Ok(name) = stream.dict.get(b\"Name\") {\n                if let Object::Name(n) = name {\n                    for indicator in &watermark_indicators {\n                        if n.windows(indicator.len()).any(|w| w == *indicator) {\n                            objects_to_remove.push(*obj_id);\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // Remove identified watermark objects\n    for obj_id in &objects_to_remove {\n        doc.objects.remove(obj_id);\n        items_removed += 1;\n    }\n\n    // Also try to clean up page annotations that might be watermarks\n    let pages: Vec<ObjectId> = doc.get_pages().values().cloned().collect();\n\n    for page_id in pages {\n        // First, collect annotation info\n        let annots_to_process: Option<(Vec<Object>, Vec<ObjectId>)> = {\n            if let Ok(Object::Dictionary(page_dict)) = doc.get_object(page_id) {\n                if let Ok(annots) = page_dict.get(b\"Annots\") {\n                    if let Object::Array(annot_array) = annots {\n                        let mut watermark_annots = Vec::new();\n                        for annot_ref in annot_array {\n                            if let Object::Reference(annot_id) = annot_ref {\n                                if objects_to_remove.contains(annot_id) {\n                                    watermark_annots.push(*annot_id);\n                                } else if let Ok(annot_obj) = doc.get_object(*annot_id) {\n                                    if let Object::Dictionary(annot_dict) = annot_obj {\n                                        if let Ok(subtype) = annot_dict.get(b\"Subtype\") {\n                                            if let Object::Name(n) = subtype {\n                                                if n == b\"Watermark\" {\n                                                    watermark_annots.push(*annot_id);\n                                                }\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        if !watermark_annots.is_empty() {\n                            Some((annot_array.clone(), watermark_annots))\n                        } else {\n                            None\n                        }\n                    } else {\n                        None\n                    }\n                } else {\n                    None\n                }\n            } else {\n                None\n            }\n        };\n\n        // Now modify the page if needed\n        if let Some((annot_array, watermark_annots)) = annots_to_process {\n            let new_annots: Vec<Object> = annot_array\n                .into_iter()\n                .filter(|annot_ref| {\n                    if let Object::Reference(annot_id) = annot_ref {\n                        !watermark_annots.contains(annot_id)\n                    } else {\n                        true\n                    }\n                })\n                .collect();\n\n            items_removed += watermark_annots.len();\n\n            if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {\n                page_dict.set(b\"Annots\", Object::Array(new_annots));\n            }\n        }\n    }\n\n    // Save the modified document\n    doc.save(output_path)\n        .map_err(|e| format!(\"Failed to save PDF: {}\", e))?;\n\n    let message = if items_removed > 0 {\n        format!(\"Found and removed {} potential watermark element(s). Please check the output file.\", items_removed)\n    } else {\n        \"No obvious watermark elements were found. The watermark may be embedded in the page content, which cannot be easily removed.\".to_string()\n    };\n\n    Ok(WatermarkRemovalResult {\n        success: items_removed > 0,\n        items_removed,\n        message,\n    })\n}\n\n/// Delete specific pages from a PDF\npub fn delete_pages(input_path: &str, output_path: &str, pages_to_delete: &[u32]) -> Result<(), String> {\n    let mut doc = Document::load(input_path)\n        .map_err(|e| format!(\"Failed to load PDF: {}\", e))?;\n\n    let total_pages = doc.get_pages().len();\n\n    // Validate page numbers\n    for &page in pages_to_delete {\n        if page == 0 || page as usize > total_pages {\n            return Err(format!(\n                \"Invalid page number: {}. PDF has {} pages (1-indexed).\",\n                page, total_pages\n            ));\n        }\n    }\n\n    // Check we're not deleting all pages\n    if pages_to_delete.len() >= total_pages {\n        return Err(\"Cannot delete all pages from PDF\".to_string());\n    }\n\n    // Delete pages in reverse order to maintain correct indices\n    let mut sorted_pages: Vec<u32> = pages_to_delete.to_vec();\n    sorted_pages.sort_by(|a, b| b.cmp(a)); // Reverse sort\n\n    for page_num in sorted_pages {\n        doc.delete_pages(&[page_num]);\n    }\n\n    // Save the modified document\n    doc.save(output_path)\n        .map_err(|e| format!(\"Failed to save PDF: {}\", e))?;\n\n    Ok(())\n}\n\n/// Print a PDF file using the system printer\npub fn print_pdf(path: &str) -> Result<(), String> {\n    #[cfg(target_os = \"macos\")]\n    {\n        std::process::Command::new(\"lpr\")\n            .arg(path)\n            .status()\n            .map_err(|e| format!(\"Failed to print: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        std::process::Command::new(\"cmd\")\n            .args([\"/C\", \"print\", path])\n            .status()\n            .map_err(|e| format!(\"Failed to print: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        std::process::Command::new(\"lpr\")\n            .arg(path)\n            .status()\n            .map_err(|e| format!(\"Failed to print: {}\", e))?;\n    }\n\n    Ok(())\n}\n\n/// Open PDF with system default application\npub fn open_pdf_external(path: &str) -> Result<(), String> {\n    #[cfg(target_os = \"macos\")]\n    {\n        std::process::Command::new(\"open\")\n            .arg(path)\n            .status()\n            .map_err(|e| format!(\"Failed to open PDF: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        std::process::Command::new(\"cmd\")\n            .args([\"/C\", \"start\", \"\", path])\n            .status()\n            .map_err(|e| format!(\"Failed to open PDF: {}\", e))?;\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        std::process::Command::new(\"xdg-open\")\n            .arg(path)\n            .status()\n            .map_err(|e| format!(\"Failed to open PDF: {}\", e))?;\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_get_pdf_info() {\n        // This would require a test PDF file\n    }\n}\n"
  },
  {
    "path": "tauri/src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2\",\n  \"productName\": \"VGet\",\n  \"version\": \"0.1.0\",\n  \"identifier\": \"io.vget\",\n  \"build\": {\n    \"beforeDevCommand\": \"bun run dev\",\n    \"devUrl\": \"http://localhost:1420\",\n    \"beforeBuildCommand\": \"bun run build\",\n    \"frontendDist\": \"../dist\"\n  },\n  \"app\": {\n    \"windows\": [\n      {\n        \"label\": \"main\",\n        \"title\": \"VGet\",\n        \"width\": 900,\n        \"height\": 700,\n        \"minWidth\": 600,\n        \"minHeight\": 500,\n        \"center\": true,\n        \"dragDropEnabled\": true\n      }\n    ],\n    \"security\": {\n      \"csp\": null\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": \"all\",\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ],\n    \"resources\": [\n      \"resources/fonts/*\"\n    ],\n    \"externalBin\": [\n      \"binaries/ffmpeg\"\n    ],\n    \"macOS\": {\n      \"minimumSystemVersion\": \"10.15\"\n    }\n  },\n  \"plugins\": {\n    \"updater\": {\n      \"endpoints\": [\n        \"https://github.com/guiyumin/vget/releases/download/desktop-latest/latest.json\"\n      ],\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZDNDNDODAwMkU4NDQ1MUUKUldRZVJZUXVBTWhEYkdEa01weDNtOGphSUxmRkhoeFBLWnVuVFZvVkRROERTa055VXF5eHJTelQK\"\n    }\n  }\n}\n"
  },
  {
    "path": "tauri/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n\n    /* Paths */\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "tauri/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "tauri/vite.config.ts",
    "content": "import { fileURLToPath, URL } from \"url\";\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { tanstackRouter } from \"@tanstack/router-plugin/vite\";\nimport tailwindcss from \"@tailwindcss/vite\";\n\nconst host = process.env.TAURI_DEV_HOST;\n\n// https://vite.dev/config/\nexport default defineConfig(async () => ({\n  plugins: [\n    tanstackRouter({\n      target: \"react\",\n      autoCodeSplitting: true,\n    }),\n    react(),\n    tailwindcss(),\n  ],\n\n  resolve: {\n    alias: {\n      \"@\": fileURLToPath(new URL(\"./src\", import.meta.url)),\n    },\n  },\n\n  // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`\n  //\n  // 1. prevent Vite from obscuring rust errors\n  clearScreen: false,\n  // 2. tauri expects a fixed port, fail if that port is not available\n  server: {\n    port: 1420,\n    strictPort: true,\n    host: host || false,\n    hmr: host\n      ? {\n          protocol: \"ws\",\n          host,\n          port: 1421,\n        }\n      : undefined,\n    watch: {\n      // 3. tell Vite to ignore watching `src-tauri`\n      ignored: [\"**/src-tauri/**\"],\n    },\n  },\n}));\n"
  },
  {
    "path": "ui/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n\n"
  },
  {
    "path": "ui/README.md",
    "content": "# React + TypeScript + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.\n\nCurrently, two official plugins are available:\n\n- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh\n- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh\n\n## React Compiler\n\nThe React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).\n\n## Expanding the ESLint configuration\n\nIf you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:\n\n```js\nexport default defineConfig([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      // Other configs...\n\n      // Remove tseslint.configs.recommended and replace with this\n      tseslint.configs.recommendedTypeChecked,\n      // Alternatively, use this for stricter rules\n      tseslint.configs.strictTypeChecked,\n      // Optionally, add this for stylistic rules\n      tseslint.configs.stylisticTypeChecked,\n\n      // Other configs...\n    ],\n    languageOptions: {\n      parserOptions: {\n        project: ['./tsconfig.node.json', './tsconfig.app.json'],\n        tsconfigRootDir: import.meta.dirname,\n      },\n      // other options...\n    },\n  },\n])\n```\n\nYou can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:\n\n```js\n// eslint.config.js\nimport reactX from 'eslint-plugin-react-x'\nimport reactDom from 'eslint-plugin-react-dom'\n\nexport default defineConfig([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      // Other configs...\n      // Enable lint rules for React\n      reactX.configs['recommended-typescript'],\n      // Enable lint rules for React DOM\n      reactDom.configs.recommended,\n    ],\n    languageOptions: {\n      parserOptions: {\n        project: ['./tsconfig.node.json', './tsconfig.app.json'],\n        tsconfigRootDir: import.meta.dirname,\n      },\n      // other options...\n    },\n  },\n])\n```\n"
  },
  {
    "path": "ui/eslint.config.js",
    "content": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reactRefresh from 'eslint-plugin-react-refresh'\nimport tseslint from 'typescript-eslint'\nimport { defineConfig, globalIgnores } from 'eslint/config'\n\nexport default defineConfig([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      js.configs.recommended,\n      tseslint.configs.recommended,\n      reactHooks.configs.flat.recommended,\n      reactRefresh.configs.vite,\n    ],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser,\n    },\n  },\n])\n"
  },
  {
    "path": "ui/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\" />\n    <title>VGet Server UI</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "ui/package.json",
    "content": "{\n  \"name\": \"ui\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc -b && vite build\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@tailwindcss/vite\": \"^4.1.18\",\n    \"@tanstack/react-router\": \"^1.141.2\",\n    \"@tanstack/react-router-devtools\": \"^1.141.2\",\n    \"clsx\": \"^2.1.1\",\n    \"pretty-bytes\": \"^7.1.0\",\n    \"qrcode.react\": \"^4.2.0\",\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-icons\": \"^5.5.0\",\n    \"tailwindcss\": \"^4.1.18\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.1\",\n    \"@tanstack/router-plugin\": \"^1.141.2\",\n    \"@types/node\": \"^24.10.1\",\n    \"@types/react\": \"^19.2.5\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@vitejs/plugin-react\": \"^5.1.1\",\n    \"eslint\": \"^9.39.1\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.4.24\",\n    \"globals\": \"^16.5.0\",\n    \"typescript\": \"~5.9.3\",\n    \"typescript-eslint\": \"^8.46.4\",\n    \"vite\": \"^7.2.4\"\n  }\n}\n"
  },
  {
    "path": "ui/src/components/ConfigEditor.tsx",
    "content": "import { useState } from \"react\";\nimport { ConfigRow } from \"./ConfigRow\";\n\ninterface WebDAVServer {\n  url: string;\n  username: string;\n  password: string;\n}\n\ninterface UITranslations {\n  settings: string;\n  save: string;\n  cancel: string;\n  language: string;\n  format: string;\n  quality: string;\n  twitter_auth: string;\n  server_port: string;\n  max_concurrent: string;\n  api_key: string;\n  webdav_servers: string;\n  add: string;\n  delete: string;\n  name: string;\n  url: string;\n  username: string;\n  password: string;\n  no_webdav_servers: string;\n}\n\ninterface ConfigEditorProps {\n  isConnected: boolean;\n  t: UITranslations;\n  // Initial values from config\n  initialLang: string;\n  initialFormat: string;\n  initialQuality: string;\n  initialMaxConcurrent: number;\n  initialApiKey: string;\n  initialKuaidi100Key: string;\n  initialKuaidi100Customer: string;\n  initialTelegramTdataPath: string;\n  serverPort: number;\n  webdavServers: Record<string, WebDAVServer>;\n  // Callbacks\n  onSave: (values: ConfigValues) => Promise<void>;\n  onCancel: () => void;\n  onAddWebDAV: (\n    name: string,\n    url: string,\n    username: string,\n    password: string\n  ) => Promise<void>;\n  onDeleteWebDAV: (name: string) => Promise<void>;\n}\n\nexport interface ConfigValues {\n  language: string;\n  format: string;\n  quality: string;\n  twitterAuth: string;\n  maxConcurrent: string;\n  apiKey: string;\n  kuaidi100Key: string;\n  kuaidi100Customer: string;\n  telegramTdataPath: string;\n}\n\nexport function ConfigEditor({\n  isConnected,\n  t,\n  initialLang,\n  initialFormat,\n  initialQuality,\n  initialMaxConcurrent,\n  initialApiKey,\n  initialKuaidi100Key,\n  initialKuaidi100Customer,\n  initialTelegramTdataPath,\n  serverPort,\n  webdavServers,\n  onSave,\n  onCancel,\n  onAddWebDAV,\n  onDeleteWebDAV,\n}: ConfigEditorProps) {\n  const [savingConfig, setSavingConfig] = useState(false);\n\n  // Pending values (local state for editing)\n  const [pendingLang, setPendingLang] = useState(initialLang || \"en\");\n  const [pendingFormat, setPendingFormat] = useState(initialFormat || \"mp4\");\n  const [pendingQuality, setPendingQuality] = useState(\n    initialQuality || \"best\"\n  );\n  const [pendingTwitterAuth, setPendingTwitterAuth] = useState(\"\");\n  const [pendingMaxConcurrent, setPendingMaxConcurrent] = useState(\n    String(initialMaxConcurrent || 10)\n  );\n  const [pendingApiKey, setPendingApiKey] = useState(initialApiKey || \"\");\n  const [pendingKuaidi100Key, setPendingKuaidi100Key] = useState(\n    initialKuaidi100Key || \"\"\n  );\n  const [pendingKuaidi100Customer, setPendingKuaidi100Customer] = useState(\n    initialKuaidi100Customer || \"\"\n  );\n  const [pendingTelegramTdataPath, setPendingTelegramTdataPath] = useState(\n    initialTelegramTdataPath || \"\"\n  );\n\n  // WebDAV add form\n  const [newWebDAVName, setNewWebDAVName] = useState(\"\");\n  const [newWebDAVUrl, setNewWebDAVUrl] = useState(\"\");\n  const [newWebDAVUsername, setNewWebDAVUsername] = useState(\"\");\n  const [newWebDAVPassword, setNewWebDAVPassword] = useState(\"\");\n  const [addingWebDAV, setAddingWebDAV] = useState(false);\n\n  const handleSave = async () => {\n    setSavingConfig(true);\n    try {\n      await onSave({\n        language: pendingLang,\n        format: pendingFormat,\n        quality: pendingQuality,\n        twitterAuth: pendingTwitterAuth,\n        maxConcurrent: pendingMaxConcurrent,\n        apiKey: pendingApiKey,\n        kuaidi100Key: pendingKuaidi100Key,\n        kuaidi100Customer: pendingKuaidi100Customer,\n        telegramTdataPath: pendingTelegramTdataPath,\n      });\n    } finally {\n      setSavingConfig(false);\n    }\n  };\n\n  const handleCancel = () => {\n    // Reset to initial values\n    setPendingLang(initialLang || \"en\");\n    setPendingFormat(initialFormat || \"mp4\");\n    setPendingQuality(initialQuality || \"best\");\n    setPendingTwitterAuth(\"\");\n    setPendingMaxConcurrent(String(initialMaxConcurrent || 10));\n    setPendingApiKey(initialApiKey || \"\");\n    setPendingKuaidi100Key(initialKuaidi100Key || \"\");\n    setPendingKuaidi100Customer(initialKuaidi100Customer || \"\");\n    setPendingTelegramTdataPath(initialTelegramTdataPath || \"\");\n    // Reset WebDAV form\n    setNewWebDAVName(\"\");\n    setNewWebDAVUrl(\"\");\n    setNewWebDAVUsername(\"\");\n    setNewWebDAVPassword(\"\");\n    onCancel();\n  };\n\n  const handleAddWebDAV = async () => {\n    if (!newWebDAVName.trim() || !newWebDAVUrl.trim()) return;\n    setAddingWebDAV(true);\n    try {\n      await onAddWebDAV(\n        newWebDAVName.trim(),\n        newWebDAVUrl.trim(),\n        newWebDAVUsername,\n        newWebDAVPassword\n      );\n      setNewWebDAVName(\"\");\n      setNewWebDAVUrl(\"\");\n      setNewWebDAVUsername(\"\");\n      setNewWebDAVPassword(\"\");\n    } finally {\n      setAddingWebDAV(false);\n    }\n  };\n\n  const handleDeleteWebDAV = async (name: string) => {\n    await onDeleteWebDAV(name);\n  };\n\n  const inputBaseClass =\n    \"flex-1 px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm font-mono focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\";\n\n  return (\n    <div className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg p-3 sm:p-4 mb-4\">\n      <div className=\"flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-4\">\n        <h2 className=\"text-sm font-semibold text-zinc-900 dark:text-white\">\n          {t.settings}\n        </h2>\n        <div className=\"flex gap-2 self-end sm:self-auto\">\n          <button\n            className=\"px-3 py-1.5 rounded text-xs cursor-pointer transition-colors bg-transparent border border-zinc-300 dark:border-zinc-700 text-zinc-500 hover:border-zinc-500 hover:text-zinc-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed\"\n            onClick={handleCancel}\n            disabled={savingConfig}\n          >\n            {t.cancel}\n          </button>\n          <button\n            className=\"px-3 py-1.5 rounded text-xs cursor-pointer transition-colors bg-blue-500 border border-blue-500 text-white hover:bg-blue-600 hover:border-blue-600 disabled:opacity-50 disabled:cursor-not-allowed\"\n            onClick={handleSave}\n            disabled={!isConnected || savingConfig}\n          >\n            {savingConfig ? \"...\" : t.save}\n          </button>\n        </div>\n      </div>\n      <div className=\"flex flex-col gap-3\">\n        <ConfigRow\n          label={t.language}\n          value={pendingLang}\n          options={[\"en\", \"zh\", \"jp\", \"kr\", \"es\", \"fr\", \"de\"]}\n          disabled={!isConnected || savingConfig}\n          onChange={setPendingLang}\n        />\n        <ConfigRow\n          label={t.format}\n          value={pendingFormat}\n          options={[\"mp4\", \"webm\", \"best\"]}\n          disabled={!isConnected || savingConfig}\n          onChange={setPendingFormat}\n        />\n        <ConfigRow\n          label={t.quality}\n          value={pendingQuality}\n          options={[\"best\", \"1080p\", \"720p\", \"480p\"]}\n          disabled={!isConnected || savingConfig}\n          onChange={setPendingQuality}\n        />\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n          <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n            {t.twitter_auth}\n          </span>\n          <input\n            type=\"password\"\n            className={inputBaseClass}\n            placeholder=\"auth_token\"\n            value={pendingTwitterAuth}\n            onChange={(e) => setPendingTwitterAuth(e.target.value)}\n            disabled={!isConnected || savingConfig}\n          />\n        </div>\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n          <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n            {t.server_port}\n          </span>\n          <span className=\"flex-1 px-2 py-1.5 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded text-zinc-500 text-sm font-mono\">\n            {serverPort || 8080}\n          </span>\n        </div>\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n          <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n            {t.max_concurrent}\n          </span>\n          <input\n            type=\"number\"\n            className={`${inputBaseClass} w-20 flex-none`}\n            value={pendingMaxConcurrent}\n            onChange={(e) => setPendingMaxConcurrent(e.target.value)}\n            disabled={!isConnected || savingConfig}\n            min=\"1\"\n            max=\"50\"\n          />\n        </div>\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n          <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n            {t.api_key}\n          </span>\n          <input\n            type=\"password\"\n            className={inputBaseClass}\n            placeholder=\"(optional)\"\n            value={pendingApiKey}\n            onChange={(e) => setPendingApiKey(e.target.value)}\n            disabled={!isConnected || savingConfig}\n          />\n        </div>\n\n        {/* Kuaidi100 Section */}\n        <div className=\"text-sm font-semibold text-zinc-900 dark:text-white mt-4 mb-2 pt-3 border-t border-zinc-300 dark:border-zinc-700\">\n          Kuaidi100 (快递查询)\n        </div>\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n          <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n            API Key\n          </span>\n          <input\n            type=\"password\"\n            className={inputBaseClass}\n            placeholder=\"(optional)\"\n            value={pendingKuaidi100Key}\n            onChange={(e) => setPendingKuaidi100Key(e.target.value)}\n            disabled={!isConnected || savingConfig}\n          />\n        </div>\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n          <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n            Customer ID\n          </span>\n          <input\n            type=\"text\"\n            className={inputBaseClass}\n            placeholder=\"(optional)\"\n            value={pendingKuaidi100Customer}\n            onChange={(e) => setPendingKuaidi100Customer(e.target.value)}\n            disabled={!isConnected || savingConfig}\n          />\n        </div>\n      </div>\n\n      {/* Telegram Section */}\n      <div className=\"text-sm font-semibold text-zinc-900 dark:text-white mt-4 mb-2 pt-3 border-t border-zinc-300 dark:border-zinc-700\">\n        Telegram\n      </div>\n      <div className=\"flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n        <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n          TData Path\n        </span>\n        <input\n          type=\"text\"\n          className={inputBaseClass}\n          placeholder=\"Custom Telegram Desktop tdata directory path\"\n          value={pendingTelegramTdataPath}\n          onChange={(e) => setPendingTelegramTdataPath(e.target.value)}\n          disabled={!isConnected || savingConfig}\n        />\n      </div>\n\n      {/* WebDAV Servers Section */}\n      <div className=\"mt-4 pt-4 border-t border-zinc-300 dark:border-zinc-700\">\n        <div className=\"text-sm font-semibold text-zinc-900 dark:text-white mb-3\">\n          {t.webdav_servers}\n        </div>\n        {Object.keys(webdavServers).length === 0 ? (\n          <div className=\"text-zinc-500 dark:text-zinc-600 text-sm py-2\">\n            {t.no_webdav_servers}\n          </div>\n        ) : (\n          <div className=\"flex flex-col gap-2 mb-3\">\n            {Object.entries(webdavServers).map(([name, server]) => (\n              <div\n                key={name}\n                className=\"flex items-center justify-between px-3 py-2 bg-zinc-100 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded\"\n              >\n                <div className=\"flex flex-col gap-0.5\">\n                  <span className=\"text-sm font-medium text-zinc-900 dark:text-white\">\n                    {name}\n                  </span>\n                  <span className=\"text-xs text-zinc-500 font-mono\">\n                    {server.url}\n                  </span>\n                </div>\n                <button\n                  className=\"px-2 py-1 border border-red-500 rounded bg-transparent text-red-500 text-xs cursor-pointer hover:bg-red-500 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n                  onClick={() => handleDeleteWebDAV(name)}\n                  disabled={!isConnected}\n                >\n                  {t.delete}\n                </button>\n              </div>\n            ))}\n          </div>\n        )}\n        <div className=\"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 mt-2\">\n          <input\n            type=\"text\"\n            className=\"px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n            placeholder={t.name}\n            value={newWebDAVName}\n            onChange={(e) => setNewWebDAVName(e.target.value)}\n            disabled={!isConnected || addingWebDAV}\n          />\n          <input\n            type=\"text\"\n            className=\"sm:col-span-2 md:col-span-1 px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n            placeholder={t.url}\n            value={newWebDAVUrl}\n            onChange={(e) => setNewWebDAVUrl(e.target.value)}\n            disabled={!isConnected || addingWebDAV}\n          />\n          <input\n            type=\"text\"\n            className=\"px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n            placeholder={t.username}\n            value={newWebDAVUsername}\n            onChange={(e) => setNewWebDAVUsername(e.target.value)}\n            disabled={!isConnected || addingWebDAV}\n          />\n          <input\n            type=\"password\"\n            className=\"px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n            placeholder={t.password}\n            value={newWebDAVPassword}\n            onChange={(e) => setNewWebDAVPassword(e.target.value)}\n            disabled={!isConnected || addingWebDAV}\n          />\n        </div>\n        <button\n          className=\"mt-2 w-full sm:w-auto px-3 py-1.5 border border-blue-500 rounded bg-blue-500 text-white text-sm cursor-pointer hover:bg-blue-600 hover:border-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n          onClick={handleAddWebDAV}\n          disabled={\n            !isConnected ||\n            addingWebDAV ||\n            !newWebDAVName.trim() ||\n            !newWebDAVUrl.trim()\n          }\n        >\n          {addingWebDAV ? \"...\" : t.add}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/ConfigRow.tsx",
    "content": "interface ConfigRowProps {\n  label: string;\n  value: string;\n  options: string[];\n  disabled: boolean;\n  onChange: (value: string) => void;\n}\n\nexport function ConfigRow({\n  label,\n  value,\n  options,\n  disabled,\n  onChange,\n}: ConfigRowProps) {\n  return (\n    <div className=\"ConfigRow flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3\">\n      <span className=\"sm:min-w-25 text-sm text-zinc-700 dark:text-zinc-200\">\n        {label}\n      </span>\n      <select\n        className=\"flex-1 px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm cursor-pointer focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        disabled={disabled}\n      >\n        {options.map((opt) => (\n          <option key={opt} value={opt}>\n            {opt}\n          </option>\n        ))}\n      </select>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/DownloadJobCard.tsx",
    "content": "import clsx from \"clsx\";\nimport { useRef, useEffect, useState } from \"react\";\nimport prettyBytes from \"pretty-bytes\";\nimport type { Job, JobStatus } from \"../utils/apis\";\nimport type { UITranslations } from \"../utils/translations\";\n\ninterface DownloadJobCardProps {\n  job: Job;\n  onCancel: () => void;\n  onClear: () => void;\n  t: UITranslations;\n}\n\nexport function DownloadJobCard({\n  job,\n  onCancel,\n  onClear,\n  t,\n}: DownloadJobCardProps) {\n  const canCancel = job.status === \"queued\" || job.status === \"downloading\";\n  const canClear =\n    job.status === \"completed\" ||\n    job.status === \"failed\" ||\n    job.status === \"cancelled\";\n\n  // Track download speed with exponential moving average for smoothing\n  const prevDownloaded = useRef<number>(0);\n  const prevTime = useRef<number>(0);\n  const smoothedSpeed = useRef<number>(0);\n  const [speed, setSpeed] = useState<number>(0);\n\n  useEffect(() => {\n    if (job.status === \"downloading\") {\n      const now = Date.now();\n      // Initialize on first update\n      if (prevTime.current === 0) {\n        prevTime.current = now;\n        prevDownloaded.current = job.downloaded;\n        return;\n      }\n\n      const timeDelta = (now - prevTime.current) / 1000; // seconds\n      const bytesDelta = job.downloaded - prevDownloaded.current;\n\n      if (timeDelta > 0 && bytesDelta >= 0) {\n        const instantSpeed = bytesDelta / timeDelta;\n        // Exponential moving average: higher alpha = more weight on recent data\n        const alpha = 0.7;\n        smoothedSpeed.current =\n          alpha * instantSpeed + (1 - alpha) * smoothedSpeed.current;\n        setSpeed(smoothedSpeed.current);\n      }\n\n      prevDownloaded.current = job.downloaded;\n      prevTime.current = now;\n    } else {\n      // Reset when not downloading\n      prevDownloaded.current = 0;\n      prevTime.current = 0;\n      smoothedSpeed.current = 0;\n      setSpeed(0);\n    }\n  }, [job.downloaded, job.status]);\n\n  const statusText: Record<JobStatus, string> = {\n    queued: t.queued,\n    downloading: t.downloading,\n    completed: t.completed,\n    failed: t.failed,\n    cancelled: t.cancelled,\n  };\n\n  const statusStyles: Record<JobStatus, string> = {\n    queued: \"bg-zinc-300 dark:bg-zinc-700 text-zinc-500\",\n    downloading:\n      \"bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400\",\n    completed:\n      \"bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-500\",\n    failed: \"bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-500\",\n    cancelled: \"bg-zinc-300 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-600\",\n  };\n\n  return (\n    <div className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg p-3 sm:p-4\">\n      <div className=\"flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 mb-2\">\n        <code className=\"text-xs text-zinc-400 dark:text-zinc-600 truncate\">\n          {job.id}\n        </code>\n        <div className=\"flex items-center gap-2 flex-wrap\">\n          <span\n            className={clsx(\n              \"inline-block px-2 py-1 rounded text-[0.65rem] sm:text-[0.7rem] font-medium uppercase\",\n              statusStyles[job.status]\n            )}\n          >\n            {statusText[job.status]}\n          </span>\n          {canCancel && (\n            <button\n              className=\"px-2 py-1 border border-zinc-300 dark:border-zinc-700 rounded bg-transparent text-zinc-500 dark:text-zinc-600 text-[0.65rem] sm:text-[0.7rem] cursor-pointer hover:border-red-500 hover:text-red-500 transition-colors\"\n              onClick={onCancel}\n            >\n              {t.cancel}\n            </button>\n          )}\n          {canClear && (\n            <button\n              className=\"px-2 py-1 border border-zinc-300 dark:border-zinc-700 rounded bg-transparent text-zinc-500 text-[0.65rem] sm:text-[0.7rem] cursor-pointer hover:border-red-500 hover:text-red-500 transition-colors\"\n              onClick={onClear}\n            >\n              {t.clear_history}\n            </button>\n          )}\n        </div>\n      </div>\n      <p className=\"text-sm text-zinc-700 dark:text-zinc-200 break-all mb-2\">\n        {job.url}\n      </p>\n      {job.filename && (\n        <p className=\"text-xs text-zinc-400 dark:text-zinc-600 mb-2 overflow-hidden text-ellipsis whitespace-nowrap\">\n          {job.filename}\n        </p>\n      )}\n      {job.status === \"downloading\" && (\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mt-3\">\n          <div className=\"flex-1 h-1 bg-zinc-300 dark:bg-zinc-700 rounded overflow-hidden\">\n            <div\n              className={clsx(\n                \"h-full bg-blue-500 transition-all duration-300\",\n                job.total <= 0 && \"animate-indeterminate\"\n              )}\n              style={{ width: job.total > 0 ? `${job.progress}%` : \"100%\" }}\n            />\n          </div>\n          <div className=\"flex justify-between sm:justify-end gap-3\">\n            <span className=\"text-xs text-zinc-400 dark:text-zinc-600 sm:min-w-18 text-left sm:text-right\">\n              {job.total > 0\n                ? `${job.progress.toFixed(1)}%`\n                : prettyBytes(job.downloaded)}\n            </span>\n            <span className=\"text-xs text-zinc-500 dark:text-zinc-500 sm:min-w-20 text-right\">\n              {prettyBytes(speed, { bits: false }) + \"/s\"}\n            </span>\n          </div>\n        </div>\n      )}\n      {job.status === \"failed\" && job.error && (\n        <div className=\"mt-2 p-2 bg-red-100 dark:bg-red-900/30 rounded text-xs text-red-700 dark:text-red-300\">\n          {job.error}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/Kuaidi100.tsx",
    "content": "import { useState } from \"react\";\nimport clsx from \"clsx\";\n\ninterface TrackingRecord {\n  time: string;\n  ftime: string;\n  context: string;\n  status: string;\n}\n\ninterface TrackingResult {\n  message: string;\n  state: string;\n  status: string;\n  condition: string;\n  ischeck: string;\n  com: string;\n  nu: string;\n  data: TrackingRecord[];\n}\n\ninterface Kuaidi100Props {\n  isConnected: boolean;\n}\n\nasync function queryKuaidi100(\n  trackingNumber: string,\n  courier: string\n): Promise<{ code: number; data: TrackingResult; message: string }> {\n  const res = await fetch(\"/kuaidi100\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ tracking_number: trackingNumber, courier }),\n  });\n  return res.json();\n}\n\nexport function Kuaidi100({ isConnected }: Kuaidi100Props) {\n  const [trackingNumber, setTrackingNumber] = useState(\"\");\n  const [trackingCourier, setTrackingCourier] = useState(\"auto\");\n  const [trackingResult, setTrackingResult] = useState<TrackingResult | null>(\n    null\n  );\n  const [trackingError, setTrackingError] = useState(\"\");\n  const [isTracking, setIsTracking] = useState(false);\n\n  const handleTrack = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!trackingNumber.trim() || isTracking) return;\n\n    setIsTracking(true);\n    setTrackingError(\"\");\n    setTrackingResult(null);\n\n    try {\n      const res = await queryKuaidi100(trackingNumber.trim(), trackingCourier);\n      if (res.code === 200) {\n        setTrackingResult(res.data);\n      } else {\n        setTrackingError(res.message || \"查询失败\");\n      }\n    } catch {\n      setTrackingError(\"网络错误\");\n    } finally {\n      setIsTracking(false);\n    }\n  };\n\n  const getStateStyle = (state: string) => {\n    switch (state) {\n      case \"3\":\n        return \"bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-500\";\n      case \"0\":\n      case \"5\":\n        return \"bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400\";\n      case \"1\":\n        return \"bg-zinc-300 dark:bg-zinc-700 text-zinc-500\";\n      case \"2\":\n      case \"4\":\n      case \"6\":\n        return \"bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400\";\n      default:\n        return \"bg-zinc-300 dark:bg-zinc-700 text-zinc-500\";\n    }\n  };\n\n  return (\n    <section className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg mb-6 overflow-hidden\">\n      <div className=\"flex justify-between items-center px-4 py-3 cursor-pointer transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-950\">\n        <h2 className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200\">\n          📦 快递查询 (快递100 API)\n        </h2>\n      </div>\n      <div className=\"p-4 border-t border-zinc-300 dark:border-zinc-700\">\n        <form className=\"flex gap-3 flex-wrap\" onSubmit={handleTrack}>\n          <input\n            type=\"text\"\n            value={trackingNumber}\n            onChange={(e) => setTrackingNumber(e.target.value)}\n            placeholder=\"输入快递单号...\"\n            disabled={!isConnected || isTracking}\n            className=\"flex-1 min-w-50 px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-sm focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n          />\n          <select\n            value={trackingCourier}\n            onChange={(e) => setTrackingCourier(e.target.value)}\n            disabled={!isConnected || isTracking}\n            className=\"px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-sm cursor-pointer focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            <option value=\"auto\">自动识别</option>\n            <option value=\"shunfeng\">顺丰速运</option>\n            <option value=\"yuantong\">圆通速递</option>\n            <option value=\"zhongtong\">中通快递</option>\n            <option value=\"yunda\">韵达快递</option>\n            <option value=\"shentong\">申通快递</option>\n            <option value=\"jtexpress\">极兔速递</option>\n            <option value=\"jd\">京东物流</option>\n            <option value=\"ems\">EMS</option>\n            <option value=\"youzhengguonei\">邮政快递</option>\n            <option value=\"debangwuliu\">德邦物流</option>\n            <option value=\"huitongkuaidi\">百世快递</option>\n          </select>\n          <button\n            type=\"submit\"\n            disabled={!isConnected || !trackingNumber.trim() || isTracking}\n            className=\"px-4 py-2 border-none rounded-md bg-blue-500 text-white text-sm font-medium cursor-pointer whitespace-nowrap hover:bg-blue-600 disabled:bg-zinc-300 dark:disabled:bg-zinc-700 disabled:cursor-not-allowed transition-colors\"\n          >\n            {isTracking ? \"查询中...\" : \"查询\"}\n          </button>\n        </form>\n\n        {trackingError && (\n          <div className=\"mt-3 px-3 py-2 bg-red-100 dark:bg-red-900/30 rounded-md text-sm text-red-700 dark:text-red-300\">\n            {trackingError}\n          </div>\n        )}\n\n        {trackingResult && (\n          <div className=\"mt-4\">\n            <div className=\"flex items-center gap-3 p-3 bg-zinc-100 dark:bg-zinc-950 rounded-md mb-3\">\n              <span className=\"font-mono text-sm text-zinc-700 dark:text-zinc-200\">\n                {trackingResult.nu}\n              </span>\n              <span\n                className={clsx(\n                  \"text-xs font-medium px-2 py-1 rounded uppercase\",\n                  getStateStyle(trackingResult.state)\n                )}\n              >\n                {trackingResult.state === \"3\"\n                  ? \"✓ 已签收\"\n                  : trackingResult.state === \"0\"\n                    ? \"运输中\"\n                    : trackingResult.state === \"1\"\n                      ? \"已揽收\"\n                      : trackingResult.state === \"2\"\n                        ? \"疑难件\"\n                        : trackingResult.state === \"4\"\n                          ? \"已退签\"\n                          : trackingResult.state === \"5\"\n                            ? \"派送中\"\n                            : trackingResult.state === \"6\"\n                              ? \"退回中\"\n                              : \"未知\"}\n              </span>\n            </div>\n            <div className=\"flex flex-col gap-2 max-h-75 overflow-y-auto\">\n              {trackingResult.data?.map((record, idx) => (\n                <div\n                  key={idx}\n                  className=\"px-3 py-2 bg-zinc-100 dark:bg-zinc-950 rounded border-l-3 border-l-blue-500\"\n                >\n                  <div className=\"text-xs text-zinc-500 dark:text-zinc-600 mb-1\">\n                    {record.ftime || record.time}\n                  </div>\n                  <div className=\"text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed\">\n                    {record.context}\n                  </div>\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/Layout.tsx",
    "content": "import { useState } from \"react\";\nimport { Outlet } from \"@tanstack/react-router\";\nimport clsx from \"clsx\";\nimport { CiLight, CiDark } from \"react-icons/ci\";\nimport { FaBars, FaTimes } from \"react-icons/fa\";\nimport { Sidebar } from \"./Sidebar\";\nimport { useApp } from \"../context/AppContext\";\nimport logo from \"../assets/logo.png\";\n\nexport function Layout() {\n  const { health, isConnected, darkMode, setDarkMode, configLang } = useApp();\n  const [sidebarOpen, setSidebarOpen] = useState(false);\n\n  return (\n    <div className=\"flex w-full h-screen md:max-w-4xl bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white transition-colors\">\n      {/* Mobile sidebar overlay */}\n      {sidebarOpen && (\n        <div\n          className=\"fixed inset-0 bg-black/50 z-40 md:hidden\"\n          onClick={() => setSidebarOpen(false)}\n        />\n      )}\n\n      {/* Sidebar - hidden on mobile by default, shown when sidebarOpen */}\n      <div\n        className={clsx(\n          \"fixed md:relative z-50 md:z-auto h-full transition-transform duration-300 md:transition-none\",\n          sidebarOpen ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\"\n        )}\n      >\n        <Sidebar lang={configLang} onClose={() => setSidebarOpen(false)} />\n      </div>\n\n      <div className=\"flex-1 flex flex-col overflow-hidden\">\n        <header className=\"flex justify-between items-center px-4 md:px-6 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] bg-white dark:bg-zinc-900 border-b border-zinc-300 dark:border-zinc-700\">\n          <div className=\"flex items-center gap-3\">\n            {/* Mobile menu button */}\n            <button\n              className=\"md:hidden bg-transparent border border-zinc-300 dark:border-zinc-700 rounded-md p-2 cursor-pointer text-base leading-none transition-colors hover:border-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800\"\n              onClick={() => setSidebarOpen(!sidebarOpen)}\n              aria-label=\"Toggle menu\"\n            >\n              {sidebarOpen ? <FaTimes /> : <FaBars />}\n            </button>\n            <img\n              src={logo}\n              alt=\"vget\"\n              className={clsx(\n                \"w-8 h-8 object-contain transition-all\",\n                !isConnected && \"grayscale opacity-50\"\n              )}\n            />\n            <h1 className=\"text-lg md:text-xl font-bold bg-linear-to-br from-amber-400 to-orange-500 bg-clip-text text-transparent\">\n              VGet Server\n            </h1>\n          </div>\n          <div className=\"flex items-center gap-2 md:gap-3\">\n            <button\n              className=\"bg-transparent border border-zinc-300 dark:border-zinc-700 rounded-md px-2 py-1.5 cursor-pointer text-base leading-none transition-colors hover:border-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800\"\n              onClick={() => setDarkMode(!darkMode)}\n              title={darkMode ? \"Switch to light mode\" : \"Switch to dark mode\"}\n            >\n              {darkMode ? <CiLight /> : <CiDark />}\n            </button>\n            <span className=\"text-zinc-400 dark:text-zinc-600 text-xs md:text-sm px-2 py-1 bg-zinc-100 dark:bg-zinc-800 rounded\">\n              {health?.version || \"...\"}\n            </span>\n          </div>\n        </header>\n\n        <main className=\"flex-1 overflow-auto p-4 md:p-6\">\n          <Outlet />\n        </main>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/Sidebar.tsx",
    "content": "import { Link, useLocation } from \"@tanstack/react-router\";\nimport clsx from \"clsx\";\n\nimport {\n  FaDownload,\n  FaGear,\n  FaTruck,\n  FaLayerGroup,\n  FaMagnet,\n  FaCloud,\n  FaPodcast,\n  FaB,\n  FaKey,\n  FaXmark,\n  FaClockRotateLeft,\n} from \"react-icons/fa6\";\nimport { useApp } from \"../context/AppContext\";\n\ninterface SidebarProps {\n  lang: string;\n  onClose?: () => void;\n}\n\ninterface NavItem {\n  to?: string;\n  icon: React.ReactNode;\n  label: string;\n  show?: boolean;\n  children?: NavItem[];\n}\n\nexport function Sidebar({ lang, onClose }: SidebarProps) {\n  const location = useLocation();\n  const { t } = useApp();\n\n  const navItems: NavItem[] = [\n    {\n      to: \"/\",\n      icon: <FaDownload />,\n      label: t.download,\n      show: true,\n    },\n    {\n      to: \"/bulk\",\n      icon: <FaLayerGroup />,\n      label: t.bulk_download,\n      show: true,\n    },\n    {\n      to: \"/history\",\n      icon: <FaClockRotateLeft />,\n      label: t.history,\n      show: true,\n    },\n    {\n      to: \"/bilibili\",\n      icon: <FaB />,\n      label: \"哔哩哔哩\",\n      show: lang === \"zh\",\n    },\n\n    {\n      to: \"/podcast\",\n      icon: <FaPodcast />,\n      label: t.podcast,\n      show: true,\n    },\n    {\n      to: \"/webdav\",\n      icon: <FaCloud />,\n      label: t.webdav_browser,\n      show: true,\n    },\n    {\n      to: \"/torrent\",\n      icon: <FaMagnet />,\n      label: t.torrent,\n      show: true,\n    },\n    {\n      to: \"/kuaidi100\",\n      icon: <FaTruck />,\n      label: \"快递查询\",\n      show: lang === \"zh\",\n    },\n    {\n      to: \"/token\",\n      icon: <FaKey />,\n      label: \"API Token\",\n      show: true,\n    },\n    {\n      to: \"/config\",\n      icon: <FaGear />,\n      label: t.settings,\n      show: true,\n    },\n  ];\n\n  const visibleItems = navItems.filter((item) => item.show !== false);\n\n  const renderNavItem = (item: NavItem, isChild = false) => {\n    const hasChildren = item.children && item.children.length > 0;\n    const visibleChildren =\n      item.children?.filter((c) => c.show !== false) ?? [];\n\n    // Check if this item or any child is active\n    const isActive = item.to\n      ? item.to === \"/\"\n        ? location.pathname === \"/\"\n        : location.pathname.startsWith(item.to)\n      : false;\n    const hasActiveChild = visibleChildren.some(\n      (child) => child.to && location.pathname.startsWith(child.to)\n    );\n\n    if (hasChildren) {\n      // Always expanded section (non-collapsible)\n      return (\n        <div key={item.label}>\n          <div\n            className={clsx(\n              \"flex items-center gap-3 px-3 py-2.5 text-sm\",\n              hasActiveChild\n                ? \"text-blue-600 dark:text-blue-400 font-medium\"\n                : \"text-zinc-600 dark:text-zinc-400\"\n            )}\n          >\n            <span className=\"text-lg\">{item.icon}</span>\n            <span>{item.label}</span>\n          </div>\n          <div className=\"ml-4 mt-1 flex flex-col gap-1\">\n            {visibleChildren.map((child) => renderNavItem(child, true))}\n          </div>\n        </div>\n      );\n    }\n\n    return (\n      <Link\n        key={item.to}\n        to={item.to!}\n        onClick={onClose}\n        className={clsx(\n          \"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors\",\n          isChild && \"pl-4\",\n          isActive\n            ? \"bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 font-medium\"\n            : \"text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800\"\n        )}\n      >\n        <span className=\"text-lg\">{item.icon}</span>\n        <span>{item.label}</span>\n      </Link>\n    );\n  };\n\n  return (\n    <aside\n      className={clsx(\n        \"flex flex-col h-full bg-white dark:bg-zinc-900 border-r border-zinc-300 dark:border-zinc-700 transition-all duration-300\",\n        \"w-48\"\n      )}\n    >\n      {/* Mobile close button */}\n      <div className=\"md:hidden flex justify-end p-2 pt-[max(0.5rem,env(safe-area-inset-top))]\">\n        <button\n          onClick={onClose}\n          className=\"p-2 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors\"\n          aria-label=\"Close menu\"\n        >\n          <FaXmark className=\"text-lg\" />\n        </button>\n      </div>\n      <div className=\"flex-1 py-4 md:pt-4\">\n        <nav className=\"flex flex-col gap-1 px-2\">\n          {visibleItems.map((item) => renderNavItem(item))}\n        </nav>\n      </div>\n    </aside>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/Toast.tsx",
    "content": "import { useEffect } from \"react\";\nimport { FaCheck, FaXmark, FaCircleInfo, FaTriangleExclamation } from \"react-icons/fa6\";\nimport clsx from \"clsx\";\n\nexport type ToastType = \"success\" | \"error\" | \"info\" | \"warning\";\n\nexport interface ToastData {\n  id: string;\n  type: ToastType;\n  message: string;\n}\n\ninterface ToastProps {\n  toast: ToastData;\n  onDismiss: (id: string) => void;\n}\n\nexport function Toast({ toast, onDismiss }: ToastProps) {\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      onDismiss(toast.id);\n    }, 4000);\n    return () => clearTimeout(timer);\n  }, [toast.id, onDismiss]);\n\n  const icons = {\n    success: <FaCheck className=\"text-green-500 text-xl\" />,\n    error: <FaXmark className=\"text-red-500 text-xl\" />,\n    info: <FaCircleInfo className=\"text-blue-500 text-xl\" />,\n    warning: <FaTriangleExclamation className=\"text-amber-500 text-xl\" />,\n  };\n\n  return (\n    <div\n      className={clsx(\n        \"flex items-center gap-4 px-6 py-4 rounded-xl shadow-2xl\",\n        \"bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700\",\n        \"animate-in slide-in-from-top-2 fade-in duration-200\"\n      )}\n    >\n      {icons[toast.type]}\n      <span className=\"text-base font-medium text-zinc-700 dark:text-zinc-300\">\n        {toast.message}\n      </span>\n      <button\n        onClick={() => onDismiss(toast.id)}\n        className=\"ml-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200\"\n      >\n        <FaXmark className=\"text-base\" />\n      </button>\n    </div>\n  );\n}\n\ninterface ToastContainerProps {\n  toasts: ToastData[];\n  onDismiss: (id: string) => void;\n}\n\nexport function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {\n  if (toasts.length === 0) return null;\n\n  return (\n    <div className=\"fixed top-8 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2\">\n      {toasts.map((toast) => (\n        <Toast key={toast.id} toast={toast} onDismiss={onDismiss} />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/Torrent.tsx",
    "content": "import { useState } from \"react\";\nimport { Link } from \"@tanstack/react-router\";\nimport { useApp } from \"../context/AppContext\";\nimport { addTorrent } from \"../utils/apis\";\n\ninterface TorrentProps {\n  isConnected: boolean;\n  torrentEnabled: boolean;\n}\n\nexport function Torrent({ isConnected, torrentEnabled }: TorrentProps) {\n  const { t } = useApp();\n  const [magnetUrl, setMagnetUrl] = useState(\"\");\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [result, setResult] = useState<{\n    success: boolean;\n    message: string;\n    name?: string;\n  } | null>(null);\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!magnetUrl.trim() || isSubmitting) return;\n\n    setIsSubmitting(true);\n    setResult(null);\n\n    try {\n      const res = await addTorrent(magnetUrl.trim());\n      if (res.code === 200) {\n        setResult({\n          success: true,\n          message: res.data.duplicate\n            ? \"Torrent already exists\"\n            : t.torrent_success,\n          name: res.data.name,\n        });\n        setMagnetUrl(\"\");\n      } else {\n        setResult({\n          success: false,\n          message: res.message || \"Failed to add torrent\",\n        });\n      }\n    } catch {\n      setResult({\n        success: false,\n        message: \"Network error\",\n      });\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  if (!torrentEnabled) {\n    return (\n      <section className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg mb-6 overflow-hidden\">\n        <div className=\"flex justify-between items-center px-4 py-3\">\n          <h2 className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200\">\n            {t.torrent}\n          </h2>\n        </div>\n        <div className=\"p-4 border-t border-zinc-300 dark:border-zinc-700\">\n          <div className=\"text-sm text-zinc-500 dark:text-zinc-400 mb-3\">\n            {t.torrent_not_configured}\n          </div>\n          <Link\n            to=\"/config\"\n            className=\"inline-block px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 transition-colors\"\n          >\n            {t.settings}\n          </Link>\n        </div>\n      </section>\n    );\n  }\n\n  return (\n    <section className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg mb-6 overflow-hidden\">\n      <div className=\"flex justify-between items-center px-4 py-3\">\n        <h2 className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200\">\n          {t.torrent}\n        </h2>\n      </div>\n      <div className=\"p-4 border-t border-zinc-300 dark:border-zinc-700\">\n        <form className=\"flex gap-3 flex-wrap\" onSubmit={handleSubmit}>\n          <input\n            type=\"text\"\n            value={magnetUrl}\n            onChange={(e) => setMagnetUrl(e.target.value)}\n            placeholder={t.torrent_hint}\n            disabled={!isConnected || isSubmitting}\n            className=\"flex-1 min-w-50 px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-sm focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n          />\n          <button\n            type=\"submit\"\n            disabled={!isConnected || !magnetUrl.trim() || isSubmitting}\n            className=\"px-4 py-2 border-none rounded-md bg-blue-500 text-white text-sm font-medium cursor-pointer whitespace-nowrap hover:bg-blue-600 disabled:bg-zinc-300 dark:disabled:bg-zinc-700 disabled:cursor-not-allowed transition-colors\"\n          >\n            {isSubmitting ? t.torrent_submitting : t.torrent_submit}\n          </button>\n        </form>\n\n        {result && (\n          <div\n            className={`mt-3 px-3 py-2 rounded-md text-sm ${\n              result.success\n                ? \"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300\"\n                : \"bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300\"\n            }`}\n          >\n            <div>{result.message}</div>\n            {result.name && (\n              <div className=\"mt-1 text-xs opacity-75\">{result.name}</div>\n            )}\n          </div>\n        )}\n\n        <div className=\"mt-3 text-xs text-zinc-400 dark:text-zinc-600\">\n          Supports magnet links and .torrent URLs\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "ui/src/components/TorrentSettings.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useApp } from \"../context/AppContext\";\nimport {\n  fetchTorrentConfig,\n  saveTorrentConfig,\n  testTorrentConnection,\n  type TorrentConfig,\n} from \"../utils/apis\";\n\ninterface TorrentSettingsProps {\n  isConnected: boolean;\n}\n\nexport function TorrentSettings({ isConnected }: TorrentSettingsProps) {\n  const { t, refresh } = useApp();\n\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [testing, setTesting] = useState(false);\n  const [testResult, setTestResult] = useState<{\n    success: boolean;\n    message: string;\n  } | null>(null);\n\n  // Form state\n  const [enabled, setEnabled] = useState(false);\n  const [client, setClient] = useState(\"transmission\");\n  const [host, setHost] = useState(\"\");\n  const [username, setUsername] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const [useHttps, setUseHttps] = useState(false);\n  const [defaultSavePath, setDefaultSavePath] = useState(\"\");\n\n  // Load initial config\n  useEffect(() => {\n    const loadConfig = async () => {\n      try {\n        const res = await fetchTorrentConfig();\n        if (res.code === 200) {\n          setEnabled(res.data.enabled);\n          setClient(res.data.client || \"transmission\");\n          setHost(res.data.host || \"\");\n          setUsername(res.data.username || \"\");\n          setPassword(res.data.password || \"\");\n          setUseHttps(res.data.use_https || false);\n          setDefaultSavePath(res.data.default_save_path || \"\");\n        }\n      } catch {\n        // Ignore errors\n      } finally {\n        setLoading(false);\n      }\n    };\n    loadConfig();\n  }, []);\n\n  const handleSave = async () => {\n    setSaving(true);\n    setTestResult(null);\n    try {\n      const config: TorrentConfig = {\n        enabled,\n        client,\n        host,\n        username,\n        password,\n        use_https: useHttps,\n        default_save_path: defaultSavePath,\n      };\n      const res = await saveTorrentConfig(config);\n      if (res.code === 200) {\n        setTestResult({ success: true, message: \"Settings saved\" });\n        refresh();\n      } else {\n        setTestResult({ success: false, message: res.message });\n      }\n    } catch {\n      setTestResult({ success: false, message: \"Failed to save\" });\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleTest = async () => {\n    setTesting(true);\n    setTestResult(null);\n    try {\n      const res = await testTorrentConnection();\n      if (res.code === 200) {\n        setTestResult({\n          success: true,\n          message: `${t.torrent_test_success} (${res.data.client})`,\n        });\n      } else {\n        setTestResult({ success: false, message: res.message });\n      }\n    } catch {\n      setTestResult({ success: false, message: \"Connection failed\" });\n    } finally {\n      setTesting(false);\n    }\n  };\n\n  const inputBaseClass =\n    \"flex-1 px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm font-mono focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\";\n\n  if (loading) {\n    return (\n      <div className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg p-4\">\n        <div className=\"text-sm text-zinc-500\">Loading...</div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg p-4\">\n      <div className=\"flex justify-between items-center mb-4\">\n        <h2 className=\"text-sm font-semibold text-zinc-900 dark:text-white\">\n          {t.torrent_settings}\n        </h2>\n        <div className=\"flex gap-2\">\n          {enabled && (\n            <button\n              className=\"px-3 py-1.5 rounded text-xs cursor-pointer transition-colors bg-transparent border border-zinc-300 dark:border-zinc-700 text-zinc-500 hover:border-zinc-500 hover:text-zinc-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed\"\n              onClick={handleTest}\n              disabled={!isConnected || testing || !host}\n            >\n              {testing ? t.torrent_testing : t.torrent_test}\n            </button>\n          )}\n          <button\n            className=\"px-3 py-1.5 rounded text-xs cursor-pointer transition-colors bg-blue-500 border border-blue-500 text-white hover:bg-blue-600 hover:border-blue-600 disabled:opacity-50 disabled:cursor-not-allowed\"\n            onClick={handleSave}\n            disabled={!isConnected || saving}\n          >\n            {saving ? \"...\" : t.save}\n          </button>\n        </div>\n      </div>\n\n      {testResult && (\n        <div\n          className={`mb-4 px-3 py-2 rounded-md text-sm ${\n            testResult.success\n              ? \"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300\"\n              : \"bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300\"\n          }`}\n        >\n          {testResult.message}\n        </div>\n      )}\n\n      <div className=\"flex flex-col gap-3\">\n        {/* Enable Toggle */}\n        <div className=\"flex items-center gap-3\">\n          <span className=\"min-w-[120px] text-sm text-zinc-700 dark:text-zinc-200\">\n            {t.torrent_enabled}\n          </span>\n          <label className=\"relative inline-flex items-center cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={enabled}\n              onChange={(e) => setEnabled(e.target.checked)}\n              disabled={!isConnected || saving}\n              className=\"sr-only peer\"\n            />\n            <div className=\"w-9 h-5 bg-zinc-300 dark:bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-500\"></div>\n          </label>\n        </div>\n\n        {enabled && (\n          <>\n            {/* Client Type */}\n            <div className=\"flex items-center gap-3\">\n              <span className=\"min-w-[120px] text-sm text-zinc-700 dark:text-zinc-200\">\n                {t.torrent_client}\n              </span>\n              <select\n                value={client}\n                onChange={(e) => setClient(e.target.value)}\n                disabled={!isConnected || saving}\n                className=\"flex-1 px-2 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white text-sm focus:outline-none focus:border-blue-500 disabled:opacity-50 cursor-pointer\"\n              >\n                <option value=\"transmission\">Transmission</option>\n                <option value=\"qbittorrent\">qBittorrent</option>\n                <option value=\"synology\">Synology Download Station</option>\n              </select>\n            </div>\n\n            {/* Host */}\n            <div className=\"flex items-center gap-3\">\n              <span className=\"min-w-[120px] text-sm text-zinc-700 dark:text-zinc-200\">\n                {t.torrent_host}\n              </span>\n              <input\n                type=\"text\"\n                className={inputBaseClass}\n                placeholder=\"192.168.1.100:9091\"\n                value={host}\n                onChange={(e) => setHost(e.target.value)}\n                disabled={!isConnected || saving}\n              />\n            </div>\n\n            {/* Username */}\n            <div className=\"flex items-center gap-3\">\n              <span className=\"min-w-[120px] text-sm text-zinc-700 dark:text-zinc-200\">\n                {t.username}\n              </span>\n              <input\n                type=\"text\"\n                className={inputBaseClass}\n                placeholder=\"(optional)\"\n                value={username}\n                onChange={(e) => setUsername(e.target.value)}\n                disabled={!isConnected || saving}\n              />\n            </div>\n\n            {/* Password */}\n            <div className=\"flex items-center gap-3\">\n              <span className=\"min-w-[120px] text-sm text-zinc-700 dark:text-zinc-200\">\n                {t.password}\n              </span>\n              <input\n                type=\"password\"\n                className={inputBaseClass}\n                placeholder=\"(optional)\"\n                value={password}\n                onChange={(e) => setPassword(e.target.value)}\n                disabled={!isConnected || saving}\n              />\n            </div>\n\n            {/* HTTPS */}\n            <div className=\"flex items-center gap-3\">\n              <span className=\"min-w-[120px] text-sm text-zinc-700 dark:text-zinc-200\">\n                HTTPS\n              </span>\n              <label className=\"relative inline-flex items-center cursor-pointer\">\n                <input\n                  type=\"checkbox\"\n                  checked={useHttps}\n                  onChange={(e) => setUseHttps(e.target.checked)}\n                  disabled={!isConnected || saving}\n                  className=\"sr-only peer\"\n                />\n                <div className=\"w-9 h-5 bg-zinc-300 dark:bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-500\"></div>\n              </label>\n            </div>\n\n            {/* Default Save Path */}\n            <div className=\"flex items-center gap-3\">\n              <span className=\"min-w-[120px] text-sm text-zinc-700 dark:text-zinc-200\">\n                Save Path\n              </span>\n              <input\n                type=\"text\"\n                className={inputBaseClass}\n                placeholder=\"(use client default)\"\n                value={defaultSavePath}\n                onChange={(e) => setDefaultSavePath(e.target.value)}\n                disabled={!isConnected || saving}\n              />\n            </div>\n          </>\n        )}\n\n        <div className=\"text-xs text-zinc-400 dark:text-zinc-600 mt-2\">\n          {client === \"transmission\" && \"Default port: 9091\"}\n          {client === \"qbittorrent\" && \"Default port: 8080\"}\n          {client === \"synology\" && \"Default port: 5000 (HTTP) / 5001 (HTTPS)\"}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/context/AppContext.tsx",
    "content": "import {\n  createContext,\n  useContext,\n  useState,\n  useEffect,\n  useCallback,\n  type ReactNode,\n} from \"react\";\nimport {\n  ToastContainer,\n  type ToastData,\n  type ToastType,\n} from \"../components/Toast\";\nimport {\n  type UITranslations,\n  type ServerTranslations,\n  defaultTranslations,\n  defaultServerTranslations,\n} from \"../utils/translations\";\nimport {\n  type Job,\n  type HealthData,\n  type WebDAVServer,\n  fetchHealth,\n  fetchJobs,\n  fetchConfig,\n  fetchI18n,\n  updateConfig,\n  setConfigValue,\n  postDownload,\n  addWebDAVServer,\n  deleteWebDAVServer,\n  deleteJob,\n  clearHistory,\n} from \"../utils/apis\";\nimport { type ConfigValues } from \"../components/ConfigEditor\";\n\ninterface AppContextType {\n  // Connection state\n  health: HealthData | null;\n  isConnected: boolean;\n  loading: boolean;\n\n  // Jobs\n  jobs: Job[];\n\n  // Config\n  outputDir: string;\n  configLang: string;\n  configFormat: string;\n  configQuality: string;\n  serverPort: number;\n  maxConcurrent: number;\n  apiKey: string;\n  webdavServers: Record<string, WebDAVServer>;\n  kuaidi100Key: string;\n  kuaidi100Customer: string;\n  configExists: boolean;\n  torrentEnabled: boolean;\n  telegramTdataPath: string;\n\n  // Translations\n  t: UITranslations;\n  serverT: ServerTranslations;\n\n  // Theme\n  darkMode: boolean;\n  setDarkMode: (dark: boolean) => void;\n\n  // Actions\n  refresh: () => Promise<void>;\n  submitDownload: (url: string) => Promise<boolean>;\n  cancelDownload: (id: string) => Promise<void>;\n  removeJob: (id: string) => Promise<void>;\n  removeAllJobs: () => Promise<void>;\n  updateOutputDir: (dir: string) => Promise<boolean>;\n  saveConfig: (values: ConfigValues) => Promise<void>;\n  addWebDAV: (\n    name: string,\n    url: string,\n    username: string,\n    password: string\n  ) => Promise<void>;\n  deleteWebDAV: (name: string) => Promise<void>;\n  showToast: (type: ToastType, message: string) => void;\n}\n\nconst AppContext = createContext<AppContextType | null>(null);\n\nexport function AppProvider({ children }: { children: ReactNode }) {\n  const [health, setHealth] = useState<HealthData | null>(null);\n  const [jobs, setJobs] = useState<Job[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [outputDir, setOutputDir] = useState(\"\");\n  const [darkMode, setDarkModeState] = useState(() => {\n    const saved = localStorage.getItem(\"vget-theme\");\n    return saved ? saved === \"dark\" : true;\n  });\n  const [t, setT] = useState<UITranslations>(defaultTranslations);\n  const [serverT, setServerT] = useState<ServerTranslations>(\n    defaultServerTranslations\n  );\n  const [configExists, setConfigExists] = useState(true);\n  const [configLang, setConfigLang] = useState(\"\");\n  const [configFormat, setConfigFormat] = useState(\"\");\n  const [configQuality, setConfigQuality] = useState(\"\");\n  const [serverPort, setServerPort] = useState(8080);\n  const [maxConcurrent, setMaxConcurrent] = useState(10);\n  const [apiKey, setApiKey] = useState(\"\");\n  const [webdavServers, setWebdavServers] = useState<\n    Record<string, WebDAVServer>\n  >({});\n  const [kuaidi100Key, setKuaidi100Key] = useState(\"\");\n  const [kuaidi100Customer, setKuaidi100Customer] = useState(\"\");\n  const [torrentEnabled, setTorrentEnabled] = useState(false);\n  const [telegramTdataPath, setTelegramTdataPath] = useState(\"\");\n  const [toasts, setToasts] = useState<ToastData[]>([]);\n\n  const showToast = useCallback((type: ToastType, message: string) => {\n    const id = Math.random().toString(36).substring(2, 9);\n    setToasts((prev) => [...prev, { id, type, message }]);\n  }, []);\n\n  const dismissToast = useCallback((id: string) => {\n    setToasts((prev) => prev.filter((t) => t.id !== id));\n  }, []);\n\n  const setDarkMode = useCallback((dark: boolean) => {\n    setDarkModeState(dark);\n  }, []);\n\n  useEffect(() => {\n    if (darkMode) {\n      document.documentElement.classList.add(\"dark\");\n    } else {\n      document.documentElement.classList.remove(\"dark\");\n    }\n    localStorage.setItem(\"vget-theme\", darkMode ? \"dark\" : \"light\");\n  }, [darkMode]);\n\n  const refresh = useCallback(async () => {\n    try {\n      const [healthRes, jobsRes, configRes, i18nRes] = await Promise.all([\n        fetchHealth(),\n        fetchJobs(),\n        fetchConfig(),\n        fetchI18n(),\n      ]);\n      if (healthRes.code === 200) setHealth(healthRes.data);\n      if (jobsRes.code === 200) setJobs(jobsRes.data.jobs || []);\n      if (configRes.code === 200) {\n        setOutputDir(configRes.data.output_dir);\n        setConfigLang(configRes.data.language || \"\");\n        setConfigFormat(configRes.data.format || \"\");\n        setConfigQuality(configRes.data.quality || \"\");\n        setServerPort(configRes.data.server_port || 8080);\n        setMaxConcurrent(configRes.data.server_max_concurrent || 10);\n        setApiKey(configRes.data.server_api_key || \"\");\n        setWebdavServers(configRes.data.webdav_servers || {});\n        const kuaidi100Cfg = configRes.data.express?.kuaidi100;\n        setKuaidi100Key(kuaidi100Cfg?.key || \"\");\n        setKuaidi100Customer(kuaidi100Cfg?.customer || \"\");\n        setTorrentEnabled(configRes.data.torrent_enabled || false);\n        setTelegramTdataPath(configRes.data.telegram_tdata_path || \"\");\n      }\n      if (i18nRes.code === 200) {\n        // Merge with defaults to ensure new keys are available\n        setT({ ...defaultTranslations, ...i18nRes.data.ui });\n        setServerT({ ...defaultServerTranslations, ...i18nRes.data.server });\n        setConfigExists(i18nRes.data.config_exists);\n      }\n    } catch {\n      setHealth(null);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    refresh();\n    const interval = setInterval(refresh, 1000);\n    return () => clearInterval(interval);\n  }, [refresh]);\n\n  const submitDownload = useCallback(\n    async (url: string) => {\n      const res = await postDownload(url.trim());\n      if (res.code === 200) {\n        refresh();\n        return true;\n      }\n      return false;\n    },\n    [refresh]\n  );\n\n  // Cancel an active (queued/downloading) download\n  const cancelDownload = useCallback(\n    async (id: string) => {\n      await deleteJob(id);\n      refresh();\n    },\n    [refresh]\n  );\n\n  // Remove a finished (completed/failed/cancelled) job from the queue\n  const removeJob = useCallback(\n    async (id: string) => {\n      await deleteJob(id);\n      refresh();\n    },\n    [refresh]\n  );\n\n  // Remove all finished jobs from the queue\n  const removeAllJobs = useCallback(async () => {\n    await clearHistory();\n    refresh();\n  }, [refresh]);\n\n  const updateOutputDir = useCallback(async (dir: string) => {\n    const res = await updateConfig(dir.trim());\n    if (res.code === 200) {\n      setOutputDir(res.data.output_dir);\n      return true;\n    }\n    return false;\n  }, []);\n\n  const saveConfig = useCallback(\n    async (values: ConfigValues) => {\n      await setConfigValue(\"language\", values.language || \"en\");\n      await setConfigValue(\"format\", values.format || \"mp4\");\n      await setConfigValue(\"quality\", values.quality || \"best\");\n      await setConfigValue(\n        \"server_max_concurrent\",\n        values.maxConcurrent || \"10\"\n      );\n      await setConfigValue(\"server_api_key\", values.apiKey);\n      if (values.twitterAuth) {\n        await setConfigValue(\"twitter.auth_token\", values.twitterAuth);\n      }\n      if (values.kuaidi100Key) {\n        await setConfigValue(\"express.kuaidi100.key\", values.kuaidi100Key);\n      }\n      if (values.kuaidi100Customer) {\n        await setConfigValue(\n          \"express.kuaidi100.customer\",\n          values.kuaidi100Customer\n        );\n      }\n      if (values.telegramTdataPath) {\n        await setConfigValue(\"telegram.tdata_path\", values.telegramTdataPath);\n      }\n      refresh();\n    },\n    [refresh]\n  );\n\n  const addWebDAV = useCallback(\n    async (name: string, url: string, username: string, password: string) => {\n      const res = await addWebDAVServer(name, url, username, password);\n      if (res.code === 200) {\n        refresh();\n      }\n    },\n    [refresh]\n  );\n\n  const deleteWebDAV = useCallback(\n    async (name: string) => {\n      const res = await deleteWebDAVServer(name);\n      if (res.code === 200) {\n        refresh();\n      }\n    },\n    [refresh]\n  );\n\n  const isConnected = health?.status === \"ok\";\n\n  return (\n    <AppContext.Provider\n      value={{\n        health,\n        isConnected,\n        loading,\n        jobs,\n        outputDir,\n        configLang,\n        configFormat,\n        configQuality,\n        serverPort,\n        maxConcurrent,\n        apiKey,\n        webdavServers,\n        kuaidi100Key,\n        kuaidi100Customer,\n        configExists,\n        torrentEnabled,\n        telegramTdataPath,\n        t,\n        serverT,\n        darkMode,\n        setDarkMode,\n        refresh,\n        submitDownload,\n        cancelDownload,\n        removeJob,\n        removeAllJobs,\n        updateOutputDir,\n        saveConfig,\n        addWebDAV,\n        deleteWebDAV,\n        showToast,\n      }}\n    >\n      {children}\n      <ToastContainer toasts={toasts} onDismiss={dismissToast} />\n    </AppContext.Provider>\n  );\n}\n\n// eslint-disable-next-line react-refresh/only-export-components\nexport function useApp() {\n  const context = useContext(AppContext);\n  if (!context) {\n    throw new Error(\"useApp must be used within an AppProvider\");\n  }\n  return context;\n}\n"
  },
  {
    "path": "ui/src/index.css",
    "content": "@import \"tailwindcss\";\n\n/* Enable class-based dark mode for Tailwind v4 */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Indeterminate progress bar animation */\n@keyframes indeterminate {\n  0% {\n    background-position: 100% 0;\n  }\n  100% {\n    background-position: -100% 0;\n  }\n}\n\n.animate-indeterminate {\n  background: linear-gradient(\n    90deg,\n    #3b82f6 0%,\n    #3b82f6 40%,\n    rgba(59, 130, 246, 0.4) 50%,\n    #3b82f6 60%,\n    #3b82f6 100%\n  );\n  background-size: 200% 100%;\n  animation: indeterminate 1.5s ease-in-out infinite;\n}\n\nhtml {\n  background: #f5f5f5;\n}\n\nhtml.dark {\n  background: #0a0a0a;\n}\n\nbody {\n  font-family:\n    -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n  min-height: 100vh;\n  background: inherit;\n  color: #1a1a1a;\n}\n\nhtml.dark body {\n  color: #fff;\n}\n\nhtml,\nbody {\n  height: 100%;\n  width: 100%;\n  margin: 0;\n}\n\n#root {\n  height: 100%;\n  width: 100%;\n  display: flex;\n  justify-content: center;\n}\n\n"
  },
  {
    "path": "ui/src/main.tsx",
    "content": "import { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport \"./index.css\";\n\nimport { RouterProvider, createRouter } from \"@tanstack/react-router\";\n\n// Import the generated route tree\nimport { routeTree } from \"./routeTree.gen\";\n\n// Create a new router instance\nconst router = createRouter({ routeTree });\n\ndeclare module \"@tanstack/react-router\" {\n  interface Register {\n    router: typeof router;\n  }\n}\n\n// Render the app\nconst rootElement = document.getElementById(\"root\")!;\nif (!rootElement.innerHTML) {\n  const root = createRoot(rootElement);\n  root.render(\n    <StrictMode>\n      <RouterProvider router={router} />\n    </StrictMode>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/BilibiliPage.tsx",
    "content": "import { useState, useEffect, useRef, useCallback } from \"react\";\nimport { useApp } from \"../context/AppContext\";\nimport { setConfigValue } from \"../utils/apis\";\nimport { QRCodeSVG } from \"qrcode.react\";\n\ntype LoginMethod = \"qr\" | \"cookie\";\n\n// QR Status codes from Bilibili API\nconst QR_WAITING = 86101;\nconst QR_SCANNED = 86090;\nconst QR_EXPIRED = 86038;\nconst QR_CONFIRMED = 0;\n\ninterface CookieFields {\n  sessdata: string;\n  biliJct: string;\n  dedeUserId: string;\n}\n\ninterface QRSession {\n  url: string;\n  qrcode_key: string;\n}\n\ninterface BilibiliStatus {\n  logged_in: boolean;\n  username?: string;\n  error?: string;\n}\n\nfunction parseCookie(cookieStr: string): CookieFields {\n  const fields: CookieFields = { sessdata: \"\", biliJct: \"\", dedeUserId: \"\" };\n  if (!cookieStr) return fields;\n\n  const parts = cookieStr.split(\";\").map((p) => p.trim());\n  for (const part of parts) {\n    const [key, ...valueParts] = part.split(\"=\");\n    const value = valueParts.join(\"=\");\n    if (key === \"SESSDATA\") fields.sessdata = value;\n    else if (key === \"bili_jct\") fields.biliJct = value;\n    else if (key === \"DedeUserID\") fields.dedeUserId = value;\n  }\n  return fields;\n}\n\nfunction buildCookie(fields: CookieFields): string {\n  const parts: string[] = [];\n  if (fields.sessdata) parts.push(`SESSDATA=${fields.sessdata}`);\n  if (fields.biliJct) parts.push(`bili_jct=${fields.biliJct}`);\n  if (fields.dedeUserId) parts.push(`DedeUserID=${fields.dedeUserId}`);\n  return parts.join(\"; \");\n}\n\nexport function BilibiliPage() {\n  const { isConnected, showToast } = useApp();\n  const [loginMethod, setLoginMethod] = useState<LoginMethod>(\"qr\");\n  const [status, setStatus] = useState<BilibiliStatus | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  const fetchStatus = useCallback(async () => {\n    try {\n      const res = await fetch(\"/api/bilibili/status\");\n      const data = await res.json();\n      if (data.code === 200) {\n        setStatus(data.data);\n      }\n    } catch (error) {\n      console.error(\"Failed to fetch status:\", error);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  // Fetch login status on mount\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const handleLogout = async () => {\n    try {\n      await setConfigValue(\"bilibili.cookie\", \"\");\n      setStatus({ logged_in: false });\n      showToast(\"success\", \"已退出登录\");\n    } catch (error) {\n      console.error(\"Failed to logout:\", error);\n      showToast(\"error\", \"退出失败\");\n    }\n  };\n\n  if (loading) {\n    return (\n      <div className=\"max-w-3xl mx-auto p-6\">\n        <h1 className=\"text-2xl font-bold mb-6\">Bilibili</h1>\n        <div className=\"text-zinc-500\">Loading...</div>\n      </div>\n    );\n  }\n\n  // Already logged in view\n  if (status?.logged_in) {\n    return (\n      <div className=\"max-w-3xl mx-auto p-6\">\n        <h1 className=\"text-2xl font-bold mb-6\">Bilibili</h1>\n\n        <div className=\"bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-6\">\n          <div className=\"flex items-center gap-3 mb-4\">\n            <span className=\"inline-block w-3 h-3 rounded-full bg-green-500\" />\n            <span className=\"text-lg font-medium\">已登录</span>\n          </div>\n\n          <div className=\"mb-6\">\n            <span className=\"text-zinc-600 dark:text-zinc-400\">用户: </span>\n            <span className=\"font-medium\">{status.username || \"Unknown\"}</span>\n          </div>\n\n          <button\n            onClick={handleLogout}\n            disabled={!isConnected}\n            className=\"px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            退出登录\n          </button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"max-w-3xl mx-auto p-6\">\n      <h1 className=\"text-2xl font-bold mb-6\">Bilibili</h1>\n\n      {/* Status */}\n      <div className=\"mb-6 flex items-center gap-2\">\n        <span className=\"inline-block w-2 h-2 rounded-full bg-zinc-400\" />\n        <span className=\"text-sm text-zinc-600 dark:text-zinc-400\">未登录</span>\n      </div>\n\n      {/* Tab Buttons */}\n      <div className=\"flex gap-2 mb-6\">\n        <button\n          onClick={() => setLoginMethod(\"qr\")}\n          className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${\n            loginMethod === \"qr\"\n              ? \"bg-blue-600 text-white\"\n              : \"bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700\"\n          }`}\n        >\n          扫码登录\n        </button>\n        <button\n          onClick={() => setLoginMethod(\"cookie\")}\n          className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${\n            loginMethod === \"cookie\"\n              ? \"bg-blue-600 text-white\"\n              : \"bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700\"\n          }`}\n        >\n          Cookie 登录\n        </button>\n      </div>\n\n      {/* Content */}\n      {loginMethod === \"qr\" ? (\n        <QRLogin onSuccess={fetchStatus} />\n      ) : (\n        <CookieLogin onSuccess={fetchStatus} />\n      )}\n    </div>\n  );\n}\n\n// QR Code Login Component\nfunction QRLogin({ onSuccess }: { onSuccess: () => void }) {\n  const { isConnected, showToast } = useApp();\n  const [qrSession, setQrSession] = useState<QRSession | null>(null);\n  const [qrStatus, setQrStatus] = useState<number | null>(null);\n  const [generating, setGenerating] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const pollIntervalRef = useRef<number | null>(null);\n\n  const generateQR = useCallback(async () => {\n    console.log(\"[Bilibili] Generating QR code...\");\n    setGenerating(true);\n    setError(null);\n    setQrStatus(null);\n\n    try {\n      const res = await fetch(\"/api/bilibili/qr/generate\", { method: \"POST\" });\n      const data = await res.json();\n      console.log(\"[Bilibili] Generate response:\", data);\n\n      if (data.code === 200) {\n        console.log(\"[Bilibili] Setting qrSession and qrStatus to QR_WAITING:\", QR_WAITING);\n        setQrSession(data.data);\n        setQrStatus(QR_WAITING);\n      } else {\n        setError(data.message || \"生成二维码失败\");\n      }\n    } catch (err) {\n      console.error(\"[Bilibili] Generate error:\", err);\n      setError(\"网络错误，请重试\");\n    } finally {\n      setGenerating(false);\n    }\n  }, []);\n\n  const pollStatus = useCallback(async () => {\n    if (!qrSession) return;\n\n    try {\n      const res = await fetch(\n        `/api/bilibili/qr/poll?qrcode_key=${encodeURIComponent(qrSession.qrcode_key)}`\n      );\n      const data = await res.json();\n      console.log(\"Poll response:\", data);\n\n      if (data.code === 200) {\n        const status = data.data.status;\n        console.log(\"QR status:\", status, \"status_text:\", data.data.status_text);\n        setQrStatus(status);\n\n        if (status === QR_CONFIRMED) {\n          // Login successful\n          console.log(\"Login confirmed! Username:\", data.data.username);\n          if (pollIntervalRef.current) {\n            clearInterval(pollIntervalRef.current);\n            pollIntervalRef.current = null;\n          }\n          showToast(\"success\", `登录成功！欢迎，${data.data.username || \"用户\"}`);\n          onSuccess();\n        } else if (status === QR_EXPIRED) {\n          // QR expired\n          if (pollIntervalRef.current) {\n            clearInterval(pollIntervalRef.current);\n            pollIntervalRef.current = null;\n          }\n        }\n      } else {\n        console.error(\"Poll error response:\", data);\n      }\n    } catch (err) {\n      console.error(\"Poll error:\", err);\n    }\n  }, [qrSession, showToast, onSuccess]);\n\n  // Generate QR on mount\n  useEffect(() => {\n    if (isConnected) {\n      generateQR();\n    }\n  }, [isConnected, generateQR]);\n\n  // Poll status while waiting or scanned\n  useEffect(() => {\n    // Only poll when we have a session and status is waiting or scanned\n    const shouldPoll =\n      qrSession &&\n      (qrStatus === QR_WAITING || qrStatus === QR_SCANNED);\n\n    console.log(\"[Bilibili] Polling useEffect:\", {\n      hasSession: !!qrSession,\n      qrStatus,\n      shouldPoll,\n      QR_WAITING,\n      QR_SCANNED\n    });\n\n    if (shouldPoll) {\n      // Clear any existing interval first\n      if (pollIntervalRef.current) {\n        clearInterval(pollIntervalRef.current);\n      }\n      console.log(\"[Bilibili] Starting poll interval\");\n      pollIntervalRef.current = window.setInterval(pollStatus, 1500);\n    }\n\n    return () => {\n      if (pollIntervalRef.current) {\n        console.log(\"[Bilibili] Clearing poll interval\");\n        clearInterval(pollIntervalRef.current);\n        pollIntervalRef.current = null;\n      }\n    };\n  }, [qrSession, qrStatus, pollStatus]);\n\n  const getStatusText = () => {\n    switch (qrStatus) {\n      case QR_WAITING:\n        return \"请使用 Bilibili 客户端扫描二维码\";\n      case QR_SCANNED:\n        return \"扫码成功！请在手机上确认登录\";\n      case QR_EXPIRED:\n        return \"二维码已过期\";\n      case QR_CONFIRMED:\n        return \"登录成功！\";\n      default:\n        return \"\";\n    }\n  };\n\n  return (\n    <div className=\"bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-6\">\n      <h3 className=\"text-lg font-medium mb-4\">扫码登录</h3>\n\n      <div className=\"flex flex-col items-center\">\n        {/* QR Code Display */}\n        <div className=\"mb-4 p-4 bg-white rounded-lg\">\n          {generating ? (\n            <div className=\"w-48 h-48 flex items-center justify-center text-zinc-500\">\n              生成中...\n            </div>\n          ) : error ? (\n            <div className=\"w-48 h-48 flex items-center justify-center text-red-500 text-center text-sm\">\n              {error}\n            </div>\n          ) : qrSession ? (\n            <QRCodeSVG\n              value={qrSession.url}\n              size={192}\n              level=\"L\"\n              className={qrStatus === QR_EXPIRED ? \"opacity-30\" : \"\"}\n            />\n          ) : (\n            <div className=\"w-48 h-48 flex items-center justify-center text-zinc-500\">\n              等待生成...\n            </div>\n          )}\n        </div>\n\n        {/* Status Text */}\n        <div className=\"mb-4 text-center\">\n          {qrStatus === QR_SCANNED ? (\n            <span className=\"text-green-600 dark:text-green-400 font-medium\">\n              {getStatusText()}\n            </span>\n          ) : qrStatus === QR_EXPIRED ? (\n            <span className=\"text-red-600 dark:text-red-400\">\n              {getStatusText()}\n            </span>\n          ) : (\n            <span className=\"text-zinc-600 dark:text-zinc-400\">\n              {getStatusText()}\n            </span>\n          )}\n        </div>\n\n        {/* Refresh Button */}\n        {(qrStatus === QR_EXPIRED || error) && (\n          <button\n            onClick={generateQR}\n            disabled={!isConnected || generating}\n            className=\"px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            {generating ? \"...\" : \"重新生成\"}\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n\n// Cookie Login Component\nfunction CookieLogin({ onSuccess }: { onSuccess: () => void }) {\n  const { isConnected, showToast } = useApp();\n  const [fields, setFields] = useState<CookieFields>({\n    sessdata: \"\",\n    biliJct: \"\",\n    dedeUserId: \"\",\n  });\n  const [saving, setSaving] = useState(false);\n\n  // Load existing cookie on mount\n  useEffect(() => {\n    fetch(\"/api/config\")\n      .then((res) => res.json())\n      .then((data) => {\n        if (data.data?.bilibili_cookie) {\n          setFields(parseCookie(data.data.bilibili_cookie));\n        }\n      })\n      .catch(() => {});\n  }, []);\n\n  const handleSave = async () => {\n    const cookie = buildCookie(fields);\n    if (!cookie) return;\n\n    setSaving(true);\n    try {\n      await setConfigValue(\"bilibili.cookie\", cookie);\n      showToast(\"success\", \"登录成功！前往首页开始下载 Bilibili 视频\");\n      onSuccess();\n    } catch (error) {\n      console.error(\"Failed to save cookie:\", error);\n      showToast(\"error\", \"保存失败，请重试\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const hasAnyInput = fields.sessdata || fields.biliJct || fields.dedeUserId;\n\n  return (\n    <div className=\"bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-6\">\n      {/* Instructions */}\n      <div className=\"mb-6 p-4 bg-zinc-50 dark:bg-zinc-900 rounded-lg\">\n        <h3 className=\"text-sm font-medium mb-3\">获取 Cookie 的方法</h3>\n        <ol className=\"space-y-2 text-sm text-zinc-600 dark:text-zinc-400\">\n          <li>1. 在浏览器中打开 bilibili.com 并登录</li>\n          <li>2. 按 F12 打开开发者工具</li>\n          <li>3. 切换到「应用」(Application) 标签</li>\n          <li>4. 在左侧找到 Cookies → bilibili.com</li>\n          <li>5. 复制以下字段的值，粘贴到下方输入框</li>\n        </ol>\n      </div>\n\n      {/* Cookie Input */}\n      <div className=\"space-y-4\">\n        {/* SESSDATA */}\n        <div>\n          <label className=\"block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1\">\n            SESSDATA\n          </label>\n          <input\n            type=\"text\"\n            value={fields.sessdata}\n            onChange={(e) =>\n              setFields((f) => ({ ...f, sessdata: e.target.value }))\n            }\n            placeholder=\"粘贴 SESSDATA 值\"\n            className=\"w-full px-3 py-2 text-sm bg-zinc-50 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono\"\n            disabled={!isConnected || saving}\n          />\n        </div>\n\n        {/* bili_jct */}\n        <div>\n          <label className=\"block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1\">\n            bili_jct\n          </label>\n          <input\n            type=\"text\"\n            value={fields.biliJct}\n            onChange={(e) =>\n              setFields((f) => ({ ...f, biliJct: e.target.value }))\n            }\n            placeholder=\"粘贴 bili_jct 值\"\n            className=\"w-full px-3 py-2 text-sm bg-zinc-50 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono\"\n            disabled={!isConnected || saving}\n          />\n        </div>\n\n        {/* DedeUserID */}\n        <div>\n          <label className=\"block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1\">\n            DedeUserID\n          </label>\n          <input\n            type=\"text\"\n            value={fields.dedeUserId}\n            onChange={(e) =>\n              setFields((f) => ({ ...f, dedeUserId: e.target.value }))\n            }\n            placeholder=\"粘贴 DedeUserID 值\"\n            className=\"w-full px-3 py-2 text-sm bg-zinc-50 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono\"\n            disabled={!isConnected || saving}\n          />\n        </div>\n\n        {/* Save Button */}\n        <button\n          onClick={handleSave}\n          disabled={!isConnected || saving || !hasAnyInput}\n          className=\"w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\"\n        >\n          {saving ? \"保存中...\" : \"保存\"}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/BulkDownloadPage.tsx",
    "content": "import { useState, useRef, useCallback } from \"react\";\nimport clsx from \"clsx\";\nimport { useApp } from \"../context/AppContext\";\nimport { FaUpload, FaFileAlt } from \"react-icons/fa\";\nimport { postBulkDownload } from \"../utils/apis\";\n\nexport function BulkDownloadPage() {\n  const { t, isConnected, showToast, refresh } = useApp();\n  const [urlText, setUrlText] = useState(\"\");\n  const [submitting, setSubmitting] = useState(false);\n  const [dragOver, setDragOver] = useState(false);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  // Parse URLs from text, filtering empty lines and comments\n  const parseUrls = useCallback((text: string): string[] => {\n    return text\n      .split(\"\\n\")\n      .map((line) => line.trim())\n      .filter((line) => line && !line.startsWith(\"#\"));\n  }, []);\n\n  const urls = parseUrls(urlText);\n\n  // Handle file selection\n  const handleFileSelect = useCallback(\n    async (file: File) => {\n      if (!file.name.endsWith(\".txt\")) {\n        showToast(\"error\", \"Please select a .txt file\");\n        return;\n      }\n\n      try {\n        const text = await file.text();\n        setUrlText(text);\n      } catch {\n        showToast(\"error\", \"Failed to read file\");\n      }\n    },\n    [showToast]\n  );\n\n  // Handle file input change\n  const handleFileInputChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const file = e.target.files?.[0];\n      if (file) {\n        handleFileSelect(file);\n      }\n      // Reset input so the same file can be selected again\n      e.target.value = \"\";\n    },\n    [handleFileSelect]\n  );\n\n  // Handle drag events\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setDragOver(true);\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setDragOver(false);\n  }, []);\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setDragOver(false);\n\n      const file = e.dataTransfer.files?.[0];\n      if (file) {\n        handleFileSelect(file);\n      }\n    },\n    [handleFileSelect]\n  );\n\n  // Handle submit all URLs\n  const handleSubmitAll = useCallback(async () => {\n    if (urls.length === 0 || submitting) return;\n\n    setSubmitting(true);\n\n    try {\n      const res = await postBulkDownload(urls);\n      if (res.code === 200) {\n        const { queued, failed } = res.data;\n        setUrlText(\"\");\n        refresh();\n        if (queued > 0 && failed === 0) {\n          showToast(\"success\", `${queued} ${t.downloads_queued}`);\n        } else if (queued > 0 && failed > 0) {\n          showToast(\"warning\", `${queued} queued, ${failed} invalid`);\n        } else if (failed > 0) {\n          showToast(\"error\", `${failed} invalid URL(s)`);\n        }\n      } else {\n        showToast(\"error\", res.message || \"Failed to queue downloads\");\n      }\n    } catch {\n      showToast(\"error\", \"Failed to submit downloads\");\n    } finally {\n      setSubmitting(false);\n    }\n  }, [urls, submitting, showToast, t, refresh]);\n\n  // Handle clear\n  const handleClear = useCallback(() => {\n    setUrlText(\"\");\n  }, []);\n\n  return (\n    <div className=\"max-w-3xl mx-auto flex flex-col gap-4\">\n      <h1 className=\"text-lg sm:text-xl font-semibold text-zinc-800 dark:text-zinc-100\">\n        {t.bulk_download}\n      </h1>\n\n      {/* File drop zone / select */}\n      <div\n        className={clsx(\n          \"border-2 border-dashed rounded-lg p-4 sm:p-6 transition-colors\",\n          \"flex flex-col items-center gap-3\",\n          dragOver\n            ? \"border-blue-500 bg-blue-50 dark:bg-blue-950/30\"\n            : \"border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600\"\n        )}\n        onDragOver={handleDragOver}\n        onDragLeave={handleDragLeave}\n        onDrop={handleDrop}\n      >\n        <FaUpload\n          className={clsx(\n            \"text-2xl sm:text-3xl\",\n            dragOver\n              ? \"text-blue-500\"\n              : \"text-zinc-400 dark:text-zinc-600\"\n          )}\n        />\n        <div className=\"flex flex-col sm:flex-row items-center gap-2 sm:gap-3\">\n          <button\n            type=\"button\"\n            onClick={() => fileInputRef.current?.click()}\n            className=\"px-4 py-2 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n            disabled={!isConnected}\n          >\n            <FaFileAlt />\n            {t.bulk_select_file}\n          </button>\n          <span className=\"text-zinc-500 dark:text-zinc-400 text-xs sm:text-sm text-center\">\n            {t.bulk_drag_drop}\n          </span>\n        </div>\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\".txt\"\n          onChange={handleFileInputChange}\n          className=\"hidden\"\n        />\n      </div>\n\n      {/* URL textarea */}\n      <div className=\"flex flex-col gap-2\">\n        <textarea\n          className={clsx(\n            \"w-full h-48 sm:h-64 px-3 sm:px-4 py-3 border rounded-lg font-mono text-xs sm:text-sm resize-y\",\n            \"bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white\",\n            \"border-zinc-300 dark:border-zinc-700\",\n            \"focus:outline-none focus:border-blue-500\",\n            \"placeholder:text-zinc-400 dark:placeholder:text-zinc-600\",\n            \"disabled:opacity-50\"\n          )}\n          value={urlText}\n          onChange={(e) => setUrlText(e.target.value)}\n          placeholder={t.bulk_paste_urls}\n          disabled={!isConnected || submitting}\n        />\n        <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">\n          {t.bulk_invalid_hint}\n        </p>\n      </div>\n\n      {/* Actions */}\n      <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\">\n        <div className=\"text-sm text-zinc-600 dark:text-zinc-400\">\n          {urls.length > 0 && (\n            <span>\n              {urls.length} {t.bulk_url_count}\n            </span>\n          )}\n        </div>\n        <div className=\"flex gap-3\">\n          <button\n            type=\"button\"\n            onClick={handleClear}\n            className=\"flex-1 sm:flex-none px-4 py-2 border border-zinc-300 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-lg text-sm hover:border-zinc-400 dark:hover:border-zinc-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n            disabled={!urlText || submitting}\n          >\n            {t.bulk_clear}\n          </button>\n          <button\n            type=\"button\"\n            onClick={handleSubmitAll}\n            className=\"flex-1 sm:flex-none px-6 py-2 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n            disabled={!isConnected || urls.length === 0 || submitting}\n          >\n            {submitting ? t.bulk_submitting : t.bulk_submit_all}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/ConfigPage.tsx",
    "content": "import { useApp } from \"../context/AppContext\";\nimport { ConfigEditor, type ConfigValues } from \"../components/ConfigEditor\";\nimport { TorrentSettings } from \"../components/TorrentSettings\";\n\nexport function ConfigPage() {\n  const {\n    isConnected,\n    t,\n    serverT,\n    configExists,\n    configLang,\n    configFormat,\n    configQuality,\n    serverPort,\n    maxConcurrent,\n    apiKey,\n    kuaidi100Key,\n    kuaidi100Customer,\n    telegramTdataPath,\n    webdavServers,\n    saveConfig,\n    addWebDAV,\n    deleteWebDAV,\n  } = useApp();\n\n  const handleSaveConfig = async (values: ConfigValues) => {\n    await saveConfig(values);\n  };\n\n  return (\n    <div className=\"max-w-3xl mx-auto flex flex-col gap-4\">\n      {!configExists && (\n        <div className=\"flex items-start gap-3 p-3 bg-amber-100 dark:bg-amber-900 border border-amber-500 rounded-lg\">\n          <span className=\"text-xl leading-none\">⚠️</span>\n          <div className=\"flex-1\">\n            <p className=\"text-amber-800 dark:text-amber-100 text-sm\">\n              {serverT.no_config_warning}\n            </p>\n            <p className=\"text-amber-700 dark:text-amber-200 text-xs mt-1 opacity-80\">\n              {serverT.run_init_hint}\n            </p>\n          </div>\n        </div>\n      )}\n\n      <ConfigEditor\n        isConnected={isConnected}\n        t={t}\n        initialLang={configLang}\n        initialFormat={configFormat}\n        initialQuality={configQuality}\n        initialMaxConcurrent={maxConcurrent}\n        initialApiKey={apiKey}\n        initialKuaidi100Key={kuaidi100Key}\n        initialKuaidi100Customer={kuaidi100Customer}\n        initialTelegramTdataPath={telegramTdataPath}\n        serverPort={serverPort}\n        webdavServers={webdavServers}\n        onSave={handleSaveConfig}\n        onCancel={() => {}}\n        onAddWebDAV={addWebDAV}\n        onDeleteWebDAV={deleteWebDAV}\n      />\n\n      <TorrentSettings isConnected={isConnected} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/DownloadPage.tsx",
    "content": "import { useState } from \"react\";\nimport clsx from \"clsx\";\nimport { useApp } from \"../context/AppContext\";\nimport { DownloadJobCard } from \"../components/DownloadJobCard\";\n\nexport function DownloadPage() {\n  const {\n    isConnected,\n    loading,\n    jobs,\n    outputDir,\n    t,\n    submitDownload,\n    cancelDownload,\n    removeJob,\n    removeAllJobs,\n    updateOutputDir,\n  } = useApp();\n\n  const [url, setUrl] = useState(\"\");\n  const [submitting, setSubmitting] = useState(false);\n  const [editingDir, setEditingDir] = useState(false);\n  const [newOutputDir, setNewOutputDir] = useState(\"\");\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!url.trim() || submitting) return;\n\n    setSubmitting(true);\n    try {\n      const success = await submitDownload(url.trim());\n      if (success) {\n        setUrl(\"\");\n      }\n    } finally {\n      setSubmitting(false);\n    }\n  };\n\n  const handleEditDir = () => {\n    setNewOutputDir(outputDir);\n    setEditingDir(true);\n  };\n\n  const handleSaveDir = async () => {\n    if (!newOutputDir.trim()) return;\n    const success = await updateOutputDir(newOutputDir.trim());\n    if (success) {\n      setEditingDir(false);\n    }\n  };\n\n  const handleCancelEdit = () => {\n    setEditingDir(false);\n    setNewOutputDir(\"\");\n  };\n\n  // Sort by title (filename or URL) for stable ordering\n  const sortedJobs = [...jobs].sort((a, b) => {\n    const titleA = a.filename || a.url;\n    const titleB = b.filename || b.url;\n    return titleA.localeCompare(titleB);\n  });\n\n  return (\n    <div className=\"max-w-3xl mx-auto flex flex-col gap-4\">\n      <div className=\"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3\">\n        <span className=\"text-zinc-700 dark:text-zinc-200 text-sm whitespace-nowrap\">\n          {t.download_to}\n        </span>\n        <input\n          type=\"text\"\n          className={clsx(\n            \"flex-1 px-3 py-2 border rounded font-mono text-xs sm:text-sm transition-colors focus:outline-none placeholder:text-zinc-400 dark:placeholder:text-zinc-600\",\n            editingDir\n              ? \"border-blue-500 bg-zinc-100 dark:bg-zinc-950 text-zinc-900 dark:text-white\"\n              : \"border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-200 cursor-default\"\n          )}\n          value={editingDir ? newOutputDir : outputDir}\n          onChange={(e) => setNewOutputDir(e.target.value)}\n          onKeyDown={(e) => {\n            if (editingDir && e.key === \"Enter\") handleSaveDir();\n            if (editingDir && e.key === \"Escape\") handleCancelEdit();\n          }}\n          readOnly={!editingDir}\n          placeholder=\"...\"\n        />\n        {editingDir ? (\n          <div className=\"flex gap-2 self-end sm:self-auto\">\n            <button\n              onClick={handleSaveDir}\n              className=\"px-3 py-1.5 border border-green-500 text-green-500 rounded text-xs cursor-pointer whitespace-nowrap hover:bg-green-500 hover:text-white transition-colors\"\n            >\n              {t.save}\n            </button>\n            <button\n              onClick={handleCancelEdit}\n              className=\"px-3 py-1.5 border border-zinc-300 dark:border-zinc-700 text-zinc-500 rounded text-xs cursor-pointer whitespace-nowrap hover:border-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors\"\n            >\n              {t.cancel}\n            </button>\n          </div>\n        ) : (\n          <button\n            onClick={handleEditDir}\n            className=\"px-3 py-1.5 border border-zinc-300 dark:border-zinc-700 text-zinc-500 rounded text-xs cursor-pointer whitespace-nowrap hover:border-blue-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors self-end sm:self-auto\"\n            disabled={!isConnected}\n          >\n            {t.edit}\n          </button>\n        )}\n      </div>\n\n      <form className=\"flex flex-col sm:flex-row gap-3\" onSubmit={handleSubmit}>\n        <input\n          type=\"text\"\n          className=\"flex-1 px-4 py-3 border border-zinc-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-base focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n          value={url}\n          onChange={(e) => setUrl(e.target.value)}\n          placeholder={t.paste_url}\n          disabled={!isConnected || submitting}\n        />\n        <button\n          type=\"submit\"\n          className=\"px-6 py-3 border-none rounded-lg bg-blue-500 text-white text-base font-medium cursor-pointer hover:bg-blue-600 disabled:bg-zinc-300 dark:disabled:bg-zinc-700 disabled:cursor-not-allowed transition-colors\"\n          disabled={!isConnected || !url.trim() || submitting}\n        >\n          {submitting ? t.adding : t.download}\n        </button>\n      </form>\n\n      <section className=\"mt-4\">\n        <div className=\"flex items-center gap-3 mb-4\">\n          <h2 className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200\">\n            {t.jobs}\n          </h2>\n          <span className=\"text-zinc-700 dark:text-zinc-200 text-sm\">\n            {jobs.length} {t.total}\n          </span>\n          <div className=\"flex gap-2 ml-auto\">\n            <button\n              className=\"px-2 py-1 border border-zinc-300 dark:border-zinc-700 rounded bg-transparent text-zinc-500 text-[0.7rem] cursor-pointer transition-colors hover:border-red-500 hover:text-red-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n              onClick={removeAllJobs}\n              disabled={\n                !isConnected ||\n                !jobs.some(\n                  (j) =>\n                    j.status === \"completed\" ||\n                    j.status === \"failed\" ||\n                    j.status === \"cancelled\"\n                )\n              }\n              title={t.clear_all}\n            >\n              {t.clear_all}\n            </button>\n          </div>\n        </div>\n\n        {loading ? (\n          <div className=\"text-center py-12 text-zinc-400 dark:text-zinc-600\">\n            Loading...\n          </div>\n        ) : sortedJobs.length === 0 ? (\n          <div className=\"text-center py-12 text-zinc-400 dark:text-zinc-600\">\n            <p>{t.no_downloads}</p>\n            <p className=\"text-sm mt-2\">{t.paste_hint}</p>\n          </div>\n        ) : (\n          <div className=\"flex flex-col gap-3\">\n            {sortedJobs.map((job) => (\n              <DownloadJobCard\n                key={job.id}\n                job={job}\n                onCancel={() => cancelDownload(job.id)}\n                onClear={() => removeJob(job.id)}\n                t={t}\n              />\n            ))}\n          </div>\n        )}\n      </section>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/HistoryPage.tsx",
    "content": "import { useState, useEffect, useCallback } from \"react\";\nimport clsx from \"clsx\";\nimport prettyBytes from \"pretty-bytes\";\nimport { useApp } from \"../context/AppContext\";\nimport {\n  fetchHistory,\n  deleteHistoryRecord,\n  clearAllHistory,\n  type HistoryRecord,\n  type HistoryStats,\n} from \"../utils/apis\";\nimport {\n  FaCheck,\n  FaXmark,\n  FaTrash,\n  FaChevronLeft,\n  FaChevronRight,\n} from \"react-icons/fa6\";\n\nfunction formatDuration(seconds: number): string {\n  if (seconds < 60) return `${seconds}s`;\n  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;\n  const hours = Math.floor(seconds / 3600);\n  const mins = Math.floor((seconds % 3600) / 60);\n  return `${hours}h ${mins}m`;\n}\n\nfunction formatDate(unixTimestamp: number): string {\n  const date = new Date(unixTimestamp * 1000);\n  return date.toLocaleString();\n}\n\nfunction extractFilename(record: HistoryRecord): string {\n  if (record.filename) {\n    // Get just the filename from path\n    const parts = record.filename.split(\"/\");\n    return parts[parts.length - 1];\n  }\n  // Fallback to URL\n  try {\n    const url = new URL(record.url);\n    return url.pathname.split(\"/\").pop() || record.url;\n  } catch {\n    return record.url;\n  }\n}\n\nexport function HistoryPage() {\n  const { isConnected, t } = useApp();\n\n  const [records, setRecords] = useState<HistoryRecord[]>([]);\n  const [stats, setStats] = useState<HistoryStats | null>(null);\n  const [total, setTotal] = useState(0);\n  const [loading, setLoading] = useState(true);\n  const [page, setPage] = useState(0);\n  const [deleting, setDeleting] = useState<string | null>(null);\n  const [clearing, setClearing] = useState(false);\n\n  const limit = 20;\n  const totalPages = Math.ceil(total / limit);\n\n  const loadHistory = useCallback(async () => {\n    if (!isConnected) return;\n    setLoading(true);\n    try {\n      const res = await fetchHistory(limit, page * limit);\n      if (res.code === 200) {\n        setRecords(res.data.records || []);\n        setTotal(res.data.total);\n        setStats(res.data.stats);\n      }\n    } catch (err) {\n      console.error(\"Failed to load history:\", err);\n    } finally {\n      setLoading(false);\n    }\n  }, [isConnected, page]);\n\n  useEffect(() => {\n    loadHistory();\n  }, [loadHistory]);\n\n  const handleDelete = async (id: string) => {\n    setDeleting(id);\n    try {\n      const res = await deleteHistoryRecord(id);\n      if (res.code === 200) {\n        loadHistory();\n      }\n    } catch (err) {\n      console.error(\"Failed to delete record:\", err);\n    } finally {\n      setDeleting(null);\n    }\n  };\n\n  const handleClearAll = async () => {\n    if (!confirm(\"Clear all download history?\")) return;\n    setClearing(true);\n    try {\n      const res = await clearAllHistory();\n      if (res.code === 200) {\n        setPage(0);\n        loadHistory();\n      }\n    } catch (err) {\n      console.error(\"Failed to clear history:\", err);\n    } finally {\n      setClearing(false);\n    }\n  };\n\n  return (\n    <div className=\"max-w-4xl mx-auto flex flex-col gap-4\">\n      <div className=\"flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4\">\n        <h1 className=\"text-lg font-semibold text-zinc-900 dark:text-white\">\n          {t.history_title}\n        </h1>\n\n        {stats && (\n          <div className=\"flex gap-4 text-sm text-zinc-500 dark:text-zinc-400\">\n            <span className=\"flex items-center gap-1\">\n              <FaCheck className=\"text-green-500\" />\n              {stats.completed}\n            </span>\n            <span className=\"flex items-center gap-1\">\n              <FaXmark className=\"text-red-500\" />\n              {stats.failed}\n            </span>\n            <span>{prettyBytes(stats.total_bytes)}</span>\n          </div>\n        )}\n\n        <div className=\"ml-auto\">\n          <button\n            onClick={handleClearAll}\n            disabled={!isConnected || total === 0 || clearing}\n            className=\"px-3 py-1.5 border border-zinc-300 dark:border-zinc-700 rounded text-xs text-zinc-500 hover:border-red-500 hover:text-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n          >\n            {clearing ? \"...\" : t.history_clear_all}\n          </button>\n        </div>\n      </div>\n\n      {loading ? (\n        <div className=\"text-center py-12 text-zinc-400 dark:text-zinc-600\">\n          {t.loading}\n        </div>\n      ) : records.length === 0 ? (\n        <div className=\"text-center py-12 text-zinc-400 dark:text-zinc-600\">\n          <p>{t.history_empty}</p>\n          <p className=\"text-sm mt-2\">{t.history_empty_hint}</p>\n        </div>\n      ) : (\n        <>\n          <div className=\"flex flex-col gap-2\">\n            {records.map((record) => (\n              <div\n                key={record.id}\n                className={clsx(\n                  \"flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors\",\n                  record.status === \"completed\"\n                    ? \"border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900\"\n                    : \"border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30\",\n                )}\n              >\n                <div className=\"shrink-0\">\n                  {record.status === \"completed\" ? (\n                    <FaCheck className=\"text-green-500\" />\n                  ) : (\n                    <FaXmark className=\"text-red-500\" />\n                  )}\n                </div>\n\n                <div className=\"flex-1 min-w-0\">\n                  <div className=\"truncate text-sm font-medium text-zinc-900 dark:text-white\">\n                    {extractFilename(record)}\n                  </div>\n                  <div className=\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500 dark:text-zinc-400 mt-1\">\n                    <span>{formatDate(record.completed_at)}</span>\n                    {record.size_bytes > 0 && (\n                      <span>{prettyBytes(record.size_bytes)}</span>\n                    )}\n                    <span>{formatDuration(record.duration_seconds)}</span>\n                  </div>\n                  {record.error && (\n                    <div className=\"text-xs text-red-500 mt-1 truncate\">\n                      {record.error}\n                    </div>\n                  )}\n                </div>\n\n                <button\n                  onClick={() => handleDelete(record.id)}\n                  disabled={deleting === record.id}\n                  className=\"shrink-0 p-2 text-zinc-400 hover:text-red-500 disabled:opacity-50 transition-colors\"\n                  title={t.delete}\n                >\n                  <FaTrash className=\"text-sm cursor-pointer\" />\n                </button>\n              </div>\n            ))}\n          </div>\n\n          {totalPages > 1 && (\n            <div className=\"flex items-center justify-center gap-4 mt-4\">\n              <button\n                onClick={() => setPage((p) => Math.max(0, p - 1))}\n                disabled={page === 0}\n                className=\"p-2 text-zinc-500 hover:text-zinc-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n              >\n                <FaChevronLeft />\n              </button>\n              <span className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                {page + 1} / {totalPages}\n              </span>\n              <button\n                onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}\n                disabled={page >= totalPages - 1}\n                className=\"p-2 text-zinc-500 hover:text-zinc-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n              >\n                <FaChevronRight />\n              </button>\n            </div>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/Kuaidi100Page.tsx",
    "content": "import { useApp } from \"../context/AppContext\";\nimport { Kuaidi100 } from \"../components/Kuaidi100\";\n\nexport function Kuaidi100Page() {\n  const { isConnected } = useApp();\n\n  return (\n    <div className=\"max-w-3xl mx-auto\">\n      <Kuaidi100 isConnected={isConnected} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/PodcastPage.tsx",
    "content": "import { useState } from \"react\";\nimport { useApp } from \"../context/AppContext\";\nimport {\n  searchPodcasts,\n  fetchPodcastEpisodes,\n  postDownload,\n  type PodcastChannel,\n  type PodcastEpisode,\n  type PodcastSearchResult,\n} from \"../utils/apis\";\nimport { FaArrowLeft, FaPlay, FaPodcast } from \"react-icons/fa6\";\n\ntype ViewState =\n  | { type: \"search\" }\n  | { type: \"results\"; results: PodcastSearchResult[] }\n  | { type: \"channel\"; channel: PodcastChannel; episodes: PodcastEpisode[] };\n\nexport function PodcastPage() {\n  const { isConnected, t, configLang, showToast } = useApp();\n\n  const [query, setQuery] = useState(\"\");\n  const [searching, setSearching] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [viewState, setViewState] = useState<ViewState>({ type: \"search\" });\n\n  const handleSearch = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!query.trim() || searching) return;\n\n    setSearching(true);\n    try {\n      const res = await searchPodcasts(query.trim(), configLang);\n      if (res.code === 200) {\n        setViewState({ type: \"results\", results: res.data.results });\n      }\n    } finally {\n      setSearching(false);\n    }\n  };\n\n  const handleChannelClick = async (channel: PodcastChannel) => {\n    setLoading(true);\n    try {\n      const res = await fetchPodcastEpisodes(channel.id, channel.source);\n      if (res.code === 200) {\n        setViewState({\n          type: \"channel\",\n          channel,\n          episodes: res.data.episodes,\n        });\n      }\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleEpisodeClick = async (episode: PodcastEpisode) => {\n    // Download the episode by submitting its URL\n    if (episode.download_url) {\n      // Use podcast name + episode title as filename\n      const filename = `${episode.podcast_name} - ${episode.title}`;\n      const res = await postDownload(episode.download_url, filename);\n      if (res.code === 200) {\n        showToast(\"success\", t.podcast_download_started);\n      }\n    }\n  };\n\n  const handleBack = () => {\n    if (viewState.type === \"channel\") {\n      // Go back to results if we have them\n      setViewState({ type: \"search\" });\n      // Re-trigger search with existing query\n      if (query.trim()) {\n        handleSearchWithQuery(query.trim());\n      }\n    }\n  };\n\n  const handleSearchWithQuery = async (q: string) => {\n    setSearching(true);\n    try {\n      const res = await searchPodcasts(q, configLang);\n      if (res.code === 200) {\n        setViewState({ type: \"results\", results: res.data.results });\n      }\n    } finally {\n      setSearching(false);\n    }\n  };\n\n  const formatDuration = (seconds: number): string => {\n    if (seconds <= 0) return \"?\";\n    const h = Math.floor(seconds / 3600);\n    const m = Math.floor((seconds % 3600) / 60);\n    const s = seconds % 60;\n    if (h > 0) {\n      return `${h}:${m.toString().padStart(2, \"0\")}:${s.toString().padStart(2, \"0\")}`;\n    }\n    return `${m}:${s.toString().padStart(2, \"0\")}`;\n  };\n\n  const formatDate = (dateStr?: string): string => {\n    if (!dateStr) return \"\";\n    try {\n      const date = new Date(dateStr);\n      return date.toLocaleDateString();\n    } catch {\n      return \"\";\n    }\n  };\n\n  // Get all channels and episodes from results\n  const getAllChannels = (): PodcastChannel[] => {\n    if (viewState.type !== \"results\") return [];\n    return viewState.results.flatMap((r) => r.podcasts);\n  };\n\n  const getAllEpisodes = (): PodcastEpisode[] => {\n    if (viewState.type !== \"results\") return [];\n    return viewState.results.flatMap((r) => r.episodes);\n  };\n\n  return (\n    <div className=\"max-w-3xl mx-auto flex flex-col gap-4\">\n      {/* Search Bar */}\n      <form className=\"flex flex-col sm:flex-row gap-3\" onSubmit={handleSearch}>\n        <div className=\"flex gap-3 flex-1\">\n          {viewState.type === \"channel\" && (\n            <button\n              type=\"button\"\n              onClick={handleBack}\n              className=\"px-4 py-3 border border-zinc-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors shrink-0\"\n            >\n              <FaArrowLeft />\n            </button>\n          )}\n          <input\n            type=\"text\"\n            className=\"flex-1 min-w-0 px-4 py-3 border border-zinc-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white text-base focus:outline-none focus:border-blue-500 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 disabled:opacity-50\"\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder={t.podcast_search_hint}\n            disabled={!isConnected || searching}\n          />\n        </div>\n        <button\n          type=\"submit\"\n          className=\"px-6 py-3 border-none rounded-lg bg-blue-500 text-white text-base font-medium cursor-pointer hover:bg-blue-600 disabled:bg-zinc-300 dark:disabled:bg-zinc-700 disabled:cursor-not-allowed transition-colors\"\n          disabled={!isConnected || !query.trim() || searching}\n        >\n          {searching ? t.podcast_searching : t.podcast_search}\n        </button>\n      </form>\n\n      {/* Loading State */}\n      {(searching || loading) && (\n        <div className=\"text-center py-12 text-zinc-400 dark:text-zinc-600\">\n          {t.loading}\n        </div>\n      )}\n\n      {/* Search Results View */}\n      {!searching && !loading && viewState.type === \"results\" && (\n        <div className=\"flex flex-col gap-6\">\n          {getAllChannels().length === 0 && getAllEpisodes().length === 0 ? (\n            <div className=\"text-center py-12 text-zinc-400 dark:text-zinc-600\">\n              <p>{t.podcast_no_results}</p>\n            </div>\n          ) : (\n            <>\n              {/* Channels Section */}\n              {getAllChannels().length > 0 && (\n                <section>\n                  <h2 className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200 mb-3\">\n                    {t.podcast_channels} ({getAllChannels().length})\n                  </h2>\n                  <div className=\"flex flex-col gap-2\">\n                    {getAllChannels().map((channel) => (\n                      <div\n                        key={`${channel.source}-${channel.id}`}\n                        onClick={() => handleChannelClick(channel)}\n                        className=\"flex items-center gap-3 p-3 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 cursor-pointer transition-colors\"\n                      >\n                        <div className=\"w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-500\">\n                          <FaPodcast />\n                        </div>\n                        <div className=\"flex-1 min-w-0\">\n                          <div className=\"font-medium text-zinc-900 dark:text-white truncate\">\n                            {channel.title}\n                          </div>\n                          <div className=\"text-sm text-zinc-500 dark:text-zinc-400 truncate\">\n                            {channel.author} | {channel.episode_count}{\" \"}\n                            {t.podcast_episodes_count}\n                          </div>\n                        </div>\n                        <div className=\"text-xs text-zinc-400 dark:text-zinc-500 uppercase\">\n                          {channel.source}\n                        </div>\n                      </div>\n                    ))}\n                  </div>\n                </section>\n              )}\n\n              {/* Episodes Section */}\n              {getAllEpisodes().length > 0 && (\n                <section>\n                  <h2 className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200 mb-3\">\n                    {t.podcast_episodes} ({getAllEpisodes().length})\n                  </h2>\n                  <div className=\"flex flex-col gap-2\">\n                    {getAllEpisodes().map((episode) => (\n                      <div\n                        key={`${episode.source}-${episode.id}`}\n                        onClick={() => handleEpisodeClick(episode)}\n                        className=\"flex items-center gap-3 p-3 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 cursor-pointer transition-colors\"\n                      >\n                        <div className=\"w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center text-green-500\">\n                          <FaPlay className=\"text-sm\" />\n                        </div>\n                        <div className=\"flex-1 min-w-0\">\n                          <div className=\"font-medium text-zinc-900 dark:text-white truncate\">\n                            {episode.title}\n                          </div>\n                          <div className=\"text-sm text-zinc-500 dark:text-zinc-400 truncate\">\n                            {episode.podcast_name} | {formatDuration(episode.duration)}\n                            {episode.pub_date && ` | ${formatDate(episode.pub_date)}`}\n                          </div>\n                        </div>\n                        <div className=\"text-xs text-zinc-400 dark:text-zinc-500 uppercase\">\n                          {episode.source}\n                        </div>\n                      </div>\n                    ))}\n                  </div>\n                </section>\n              )}\n            </>\n          )}\n        </div>\n      )}\n\n      {/* Channel Episodes View */}\n      {!loading && viewState.type === \"channel\" && (\n        <div className=\"flex flex-col gap-4\">\n          {/* Channel Header */}\n          <div className=\"flex items-center gap-3 p-4 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-zinc-50 dark:bg-zinc-800\">\n            <div className=\"w-12 h-12 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-500 text-xl\">\n              <FaPodcast />\n            </div>\n            <div className=\"flex-1 min-w-0\">\n              <div className=\"font-medium text-zinc-900 dark:text-white text-lg\">\n                {viewState.channel.title}\n              </div>\n              <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                {viewState.channel.author}\n              </div>\n            </div>\n          </div>\n\n          {/* Episodes List */}\n          <section>\n            <h2 className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200 mb-3\">\n              {t.podcast_episodes} ({viewState.episodes.length})\n            </h2>\n            {viewState.episodes.length === 0 ? (\n              <div className=\"text-center py-8 text-zinc-400 dark:text-zinc-600\">\n                {t.podcast_no_results}\n              </div>\n            ) : (\n              <div className=\"flex flex-col gap-2\">\n                {viewState.episodes.map((episode) => (\n                  <div\n                    key={`${episode.source}-${episode.id}`}\n                    onClick={() => handleEpisodeClick(episode)}\n                    className=\"flex items-center gap-3 p-3 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 cursor-pointer transition-colors\"\n                  >\n                    <div className=\"w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center text-green-500\">\n                      <FaPlay className=\"text-sm\" />\n                    </div>\n                    <div className=\"flex-1 min-w-0\">\n                      <div className=\"font-medium text-zinc-900 dark:text-white truncate\">\n                        {episode.title}\n                      </div>\n                      <div className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n                        {formatDuration(episode.duration)}\n                        {episode.pub_date && ` | ${formatDate(episode.pub_date)}`}\n                      </div>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            )}\n          </section>\n        </div>\n      )}\n\n      {/* Initial State */}\n      {!searching && !loading && viewState.type === \"search\" && (\n        <div className=\"text-center py-12 text-zinc-400 dark:text-zinc-600\">\n          <p>{t.podcast_search_hint}</p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/TokenPage.tsx",
    "content": "import { useState } from \"react\";\nimport { useApp } from \"../context/AppContext\";\n\ninterface GenerateTokenResponse {\n  code: number;\n  data: { jwt: string } | null;\n  message: string;\n}\n\nexport function TokenPage() {\n  const { isConnected, t } = useApp();\n  const [payload, setPayload] = useState(\"{}\");\n  const [payloadError, setPayloadError] = useState<string | null>(null);\n  const [generatedToken, setGeneratedToken] = useState<string | null>(null);\n  const [generating, setGenerating] = useState(false);\n  const [copied, setCopied] = useState(false);\n  const [errorMessage, setErrorMessage] = useState<string | null>(null);\n\n  const validatePayload = (value: string): boolean => {\n    try {\n      JSON.parse(value);\n      setPayloadError(null);\n      return true;\n    } catch {\n      setPayloadError(t.token_invalid_json);\n      return false;\n    }\n  };\n\n  const handlePayloadChange = (value: string) => {\n    setPayload(value);\n    if (value.trim()) {\n      validatePayload(value);\n    } else {\n      setPayloadError(null);\n    }\n  };\n\n  const handleGenerate = async () => {\n    setGenerating(true);\n    setErrorMessage(null);\n    setGeneratedToken(null);\n\n    try {\n      let parsedPayload = {};\n      if (payload.trim()) {\n        if (!validatePayload(payload)) {\n          setGenerating(false);\n          return;\n        }\n        parsedPayload = JSON.parse(payload);\n      }\n\n      const res = await fetch(\"/api/auth/token\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ payload: parsedPayload }),\n      });\n\n      const data: GenerateTokenResponse = await res.json();\n\n      if (data.code === 201 && data.data?.jwt) {\n        setGeneratedToken(data.data.jwt);\n      } else {\n        setErrorMessage(data.message);\n      }\n    } catch (err) {\n      setErrorMessage(\"Failed to generate token\");\n    } finally {\n      setGenerating(false);\n    }\n  };\n\n  const handleCopy = async () => {\n    if (generatedToken) {\n      await navigator.clipboard.writeText(generatedToken);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    }\n  };\n\n  return (\n    <div className=\"max-w-2xl mx-auto\">\n      <div className=\"bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-lg p-4 sm:p-6\">\n        <h1 className=\"text-base sm:text-lg font-semibold text-zinc-900 dark:text-white mb-4\">\n          {t.token_title}\n        </h1>\n\n        <p className=\"text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 mb-6\">\n          {t.token_description}\n        </p>\n\n        {/* Custom Payload */}\n        <div className=\"mb-4\">\n          <label className=\"block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2\">\n            {t.token_custom_payload}\n          </label>\n          <textarea\n            className={`w-full h-24 sm:h-32 px-3 py-2 font-mono text-xs sm:text-sm border rounded-lg bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${\n              payloadError\n                ? \"border-red-500\"\n                : \"border-zinc-300 dark:border-zinc-700\"\n            }`}\n            placeholder='{\"user\": \"john\", \"scope\": \"read\"}'\n            value={payload}\n            onChange={(e) => handlePayloadChange(e.target.value)}\n            disabled={!isConnected || generating}\n          />\n          {payloadError && (\n            <p className=\"mt-1 text-xs text-red-500\">{payloadError}</p>\n          )}\n          <p className=\"mt-1 text-xs text-zinc-500\">\n            {t.token_custom_payload_hint}\n          </p>\n        </div>\n\n        {/* Generate Button */}\n        <button\n          className=\"w-full px-4 py-2.5 rounded-lg text-sm font-medium cursor-pointer transition-colors bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed\"\n          onClick={handleGenerate}\n          disabled={!isConnected || generating || !!payloadError}\n        >\n          {generating ? t.token_generating : t.token_generate}\n        </button>\n\n        {/* Error Message */}\n        {errorMessage && (\n          <div className=\"mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg\">\n            <p className=\"text-sm text-red-600 dark:text-red-400\">\n              {errorMessage}\n            </p>\n          </div>\n        )}\n\n        {/* Generated Token */}\n        {generatedToken && (\n          <div className=\"mt-6 p-4 bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-700 rounded-lg\">\n            <div className=\"flex items-center justify-between mb-2\">\n              <span className=\"text-sm font-medium text-zinc-700 dark:text-zinc-300\">\n                {t.token_generated}\n              </span>\n              <button\n                className=\"px-3 py-1 rounded text-xs font-medium cursor-pointer transition-colors bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300\"\n                onClick={handleCopy}\n              >\n                {copied ? t.token_copied : t.token_copy}\n              </button>\n            </div>\n            <code className=\"block w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded text-xs font-mono text-zinc-800 dark:text-zinc-200 break-all select-all overflow-x-auto\">\n              {generatedToken}\n            </code>\n            <div className=\"mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded\">\n              <p className=\"text-xs text-blue-700 dark:text-blue-300 font-medium mb-1\">\n                {t.token_usage}:\n              </p>\n              <code className=\"text-xs text-blue-600 dark:text-blue-400 font-mono\">\n                Authorization: Bearer {generatedToken.slice(0, 20)}...\n              </code>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/pages/TorrentPage.tsx",
    "content": "import { useApp } from \"../context/AppContext\";\n// import { Torrent } from \"../components/Torrent\";\n\nexport function TorrentPage() {\n  // const { isConnected, torrentEnabled } = useApp();\n  const { t } = useApp();\n\n  // Coming Soon - uncomment below to enable torrent feature\n  return (\n    <div className=\"max-w-3xl mx-auto p-0\">\n      <h1 className=\"text-xl sm:text-2xl font-bold mb-4 sm:mb-6\">{t.torrent}</h1>\n      <div className=\"bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 sm:p-8 text-center\">\n        <p className=\"text-zinc-500 dark:text-zinc-400\">{t.coming_soon}</p>\n      </div>\n    </div>\n  );\n\n  // return (\n  //   <div className=\"max-w-3xl mx-auto\">\n  //     <Torrent isConnected={isConnected} torrentEnabled={torrentEnabled} />\n  //   </div>\n  // );\n}\n"
  },
  {
    "path": "ui/src/pages/WebDAVPage.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useApp } from \"../context/AppContext\";\nimport { Link } from \"@tanstack/react-router\";\nimport clsx from \"clsx\";\nimport {\n  fetchWebDAVRemotes,\n  fetchWebDAVList,\n  submitWebDAVDownload,\n  type WebDAVRemote,\n  type WebDAVFile,\n} from \"../utils/apis\";\nimport {\n  FaFolder,\n  FaFile,\n  FaChevronRight,\n  FaDownload,\n  FaArrowUp,\n} from \"react-icons/fa6\";\n\nfunction formatSize(bytes: number): string {\n  if (bytes === 0) return \"-\";\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(1024));\n  return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + \" \" + units[i];\n}\n\nexport function WebDAVPage() {\n  const { t, isConnected, refresh, showToast } = useApp();\n\n  // State\n  const [remotes, setRemotes] = useState<WebDAVRemote[]>([]);\n  const [remotesLoaded, setRemotesLoaded] = useState(false);\n  const [selectedRemote, setSelectedRemote] = useState<string>(\"\");\n  const [currentPath, setCurrentPath] = useState<string>(\"/\");\n  const [files, setFiles] = useState<WebDAVFile[]>([]);\n  const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [submitting, setSubmitting] = useState(false);\n\n  // Load remotes on mount\n  useEffect(() => {\n    if (!isConnected) return;\n\n    fetchWebDAVRemotes().then((res) => {\n      if (res.code === 200 && res.data.remotes) {\n        setRemotes(res.data.remotes);\n        // Auto-select first remote if available\n        if (res.data.remotes.length > 0) {\n          setSelectedRemote(res.data.remotes[0].name);\n        }\n      }\n      setRemotesLoaded(true);\n    });\n  }, [isConnected]);\n\n  // Load directory contents when remote or path changes\n  useEffect(() => {\n    if (!selectedRemote) return;\n\n    const loadDirectory = async () => {\n      setLoading(true);\n      setError(null);\n      setSelectedFiles(new Set());\n\n      try {\n        const res = await fetchWebDAVList(selectedRemote, currentPath);\n        if (res.code === 200) {\n          setFiles(res.data.files || []);\n        } else {\n          setError(res.message);\n          setFiles([]);\n        }\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : \"Failed to load directory\"\n        );\n        setFiles([]);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    loadDirectory();\n  }, [selectedRemote, currentPath]);\n\n  // Navigate to a path\n  const navigateTo = (path: string) => {\n    setCurrentPath(path);\n  };\n\n  // Navigate up one level\n  const navigateUp = () => {\n    if (currentPath === \"/\") return;\n    const parts = currentPath.split(\"/\").filter(Boolean);\n    parts.pop();\n    setCurrentPath(\"/\" + parts.join(\"/\"));\n  };\n\n  // Toggle file selection\n  const toggleSelect = (path: string) => {\n    const newSelected = new Set(selectedFiles);\n    if (newSelected.has(path)) {\n      newSelected.delete(path);\n    } else {\n      newSelected.add(path);\n    }\n    setSelectedFiles(newSelected);\n  };\n\n  // Select/deselect all files\n  const toggleSelectAll = () => {\n    const selectableFiles = files.filter((f) => !f.isDir);\n    if (selectedFiles.size === selectableFiles.length) {\n      setSelectedFiles(new Set());\n    } else {\n      setSelectedFiles(new Set(selectableFiles.map((f) => f.path)));\n    }\n  };\n\n  // Download selected files\n  const handleDownload = async () => {\n    if (selectedFiles.size === 0) return;\n\n    setSubmitting(true);\n    try {\n      const res = await submitWebDAVDownload(\n        selectedRemote,\n        Array.from(selectedFiles)\n      );\n      if (res.code === 200) {\n        const count = selectedFiles.size;\n        setSelectedFiles(new Set());\n        refresh(); // Refresh jobs list\n        showToast(\n          \"success\",\n          count === 1\n            ? t.download_queued ||\n                \"Download started. Check progress on Download page.\"\n            : `${count} ${t.downloads_queued || \"downloads started. Check progress on Download page.\"}`\n        );\n      } else {\n        setError(res.message);\n        showToast(\"error\", res.message);\n      }\n    } catch (err) {\n      const msg =\n        err instanceof Error ? err.message : \"Failed to start download\";\n      setError(msg);\n      showToast(\"error\", msg);\n    } finally {\n      setSubmitting(false);\n    }\n  };\n\n  // Build breadcrumb parts\n  const pathParts = currentPath.split(\"/\").filter(Boolean);\n\n  // Calculate selected size\n  const selectedSize = files\n    .filter((f) => selectedFiles.has(f.path))\n    .reduce((sum, f) => sum + f.size, 0);\n\n  const selectableFiles = files.filter((f) => !f.isDir);\n  const allSelected =\n    selectableFiles.length > 0 && selectedFiles.size === selectableFiles.length;\n\n  // Show loading while fetching remotes\n  if (!remotesLoaded) {\n    return (\n      <div className=\"p-0\">\n        <h1 className=\"text-xl sm:text-2xl font-bold mb-4 sm:mb-6\">{t.webdav_browser}</h1>\n        <div className=\"bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 sm:p-8 text-center\">\n          <p className=\"text-zinc-500 dark:text-zinc-400\">{t.loading}</p>\n        </div>\n      </div>\n    );\n  }\n\n  // No remotes configured\n  if (remotes.length === 0) {\n    return (\n      <div className=\"p-0\">\n        <h1 className=\"text-xl sm:text-2xl font-bold mb-4 sm:mb-6\">{t.webdav_browser}</h1>\n        <div className=\"bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6 sm:p-8 text-center\">\n          <p className=\"text-zinc-500 dark:text-zinc-400 mb-4\">\n            {t.no_webdav_servers}\n          </p>\n          <Link\n            to=\"/config\"\n            className=\"inline-block px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\"\n          >\n            {t.go_to_settings}\n          </Link>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"p-0\">\n      <h1 className=\"text-xl sm:text-2xl font-bold mb-4 sm:mb-6\">{t.webdav_browser}</h1>\n\n      {/* Remote Selector */}\n      <div className=\"mb-4\">\n        <label className=\"block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2\">\n          {t.select_remote}\n        </label>\n        <select\n          value={selectedRemote}\n          onChange={(e) => {\n            setSelectedRemote(e.target.value);\n            setCurrentPath(\"/\");\n          }}\n          className=\"w-full sm:max-w-xs px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white\"\n        >\n          {remotes.map((remote) => (\n            <option key={remote.name} value={remote.name}>\n              {remote.name}\n            </option>\n          ))}\n        </select>\n      </div>\n\n      {/* File Browser */}\n      <div className=\"bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 overflow-hidden\">\n        {/* Breadcrumb */}\n        <div className=\"px-4 py-3 bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-700 flex items-center gap-1 text-sm overflow-x-auto\">\n          <button\n            onClick={() => navigateTo(\"/\")}\n            className=\"text-blue-600 dark:text-blue-400 hover:underline font-medium\"\n          >\n            {selectedRemote}:\n          </button>\n          <FaChevronRight className=\"text-zinc-400 text-xs shrink-0\" />\n          {pathParts.map((part, i) => (\n            <span key={i} className=\"flex items-center gap-1\">\n              <button\n                onClick={() =>\n                  navigateTo(\"/\" + pathParts.slice(0, i + 1).join(\"/\"))\n                }\n                className=\"text-blue-600 dark:text-blue-400 hover:underline\"\n              >\n                {part}\n              </button>\n              {i < pathParts.length - 1 && (\n                <FaChevronRight className=\"text-zinc-400 text-xs shrink-0\" />\n              )}\n            </span>\n          ))}\n        </div>\n\n        {/* Error */}\n        {error && (\n          <div className=\"px-4 py-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-b border-red-200 dark:border-red-800\">\n            {error}\n          </div>\n        )}\n\n        {/* File List */}\n        <div className=\"divide-y divide-zinc-200 dark:divide-zinc-700\">\n          {loading ? (\n            <div className=\"px-4 py-8 text-center text-zinc-500 dark:text-zinc-400\">\n              {t.loading}\n            </div>\n          ) : files.length === 0 ? (\n            <div className=\"px-4 py-8 text-center text-zinc-500 dark:text-zinc-400\">\n              {t.empty_directory}\n            </div>\n          ) : (\n            <>\n              {/* Header */}\n              <div className=\"px-3 sm:px-4 py-2 bg-zinc-50 dark:bg-zinc-900 flex items-center gap-2 sm:gap-4 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase\">\n                <div className=\"w-5 sm:w-6 shrink-0\">\n                  {selectableFiles.length > 0 && (\n                    <input\n                      type=\"checkbox\"\n                      checked={allSelected}\n                      onChange={toggleSelectAll}\n                      className=\"rounded\"\n                    />\n                  )}\n                </div>\n                <div className=\"flex-1 min-w-0\">{t.name}</div>\n                <div className=\"w-16 sm:w-24 text-right shrink-0\">Size</div>\n              </div>\n\n              {/* Parent directory */}\n              {currentPath !== \"/\" && (\n                <button\n                  onClick={navigateUp}\n                  className=\"w-full px-3 sm:px-4 py-3 flex items-center gap-2 sm:gap-4 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 text-left\"\n                >\n                  <div className=\"w-5 sm:w-6 shrink-0\"></div>\n                  <div className=\"flex items-center gap-2 flex-1 text-zinc-600 dark:text-zinc-400 min-w-0\">\n                    <FaArrowUp className=\"text-zinc-400 shrink-0\" />\n                    <span>..</span>\n                  </div>\n                  <div className=\"w-16 sm:w-24 text-right text-zinc-400 shrink-0\">-</div>\n                </button>\n              )}\n\n              {/* Files */}\n              {files.map((file) => (\n                <div\n                  key={file.path}\n                  className={clsx(\n                    \"px-3 sm:px-4 py-3 flex items-center gap-2 sm:gap-4\",\n                    file.isDir\n                      ? \"cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-700/50\"\n                      : selectedFiles.has(file.path)\n                        ? \"bg-blue-50 dark:bg-blue-900/20\"\n                        : \"hover:bg-zinc-50 dark:hover:bg-zinc-700/50\"\n                  )}\n                  onClick={() => file.isDir && navigateTo(file.path)}\n                >\n                  <div className=\"w-5 sm:w-6 shrink-0\">\n                    {!file.isDir && (\n                      <input\n                        type=\"checkbox\"\n                        checked={selectedFiles.has(file.path)}\n                        onChange={(e) => {\n                          e.stopPropagation();\n                          toggleSelect(file.path);\n                        }}\n                        onClick={(e) => e.stopPropagation()}\n                        className=\"rounded\"\n                      />\n                    )}\n                  </div>\n                  <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                    {file.isDir ? (\n                      <FaFolder className=\"text-amber-500 shrink-0\" />\n                    ) : (\n                      <FaFile className=\"text-zinc-400 shrink-0\" />\n                    )}\n                    <span className=\"truncate text-sm sm:text-base\">{file.name}</span>\n                  </div>\n                  <div className=\"w-16 sm:w-24 text-right text-zinc-500 dark:text-zinc-400 text-xs sm:text-sm shrink-0\">\n                    {formatSize(file.size)}\n                  </div>\n                </div>\n              ))}\n            </>\n          )}\n        </div>\n\n        {/* Actions */}\n        {selectedFiles.size > 0 && (\n          <div className=\"px-3 sm:px-4 py-3 bg-zinc-50 dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-700 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\">\n            <span className=\"text-xs sm:text-sm text-zinc-600 dark:text-zinc-400\">\n              {selectedFiles.size} {t.selected_files} (\n              {formatSize(selectedSize)})\n            </span>\n            <button\n              onClick={handleDownload}\n              disabled={submitting}\n              className={clsx(\n                \"px-4 py-2 rounded-lg flex items-center justify-center gap-2 text-white w-full sm:w-auto\",\n                submitting\n                  ? \"bg-zinc-400 cursor-not-allowed\"\n                  : \"bg-blue-600 hover:bg-blue-700\"\n              )}\n            >\n              <FaDownload />\n              {submitting ? t.adding : t.download_selected}\n            </button>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ui/src/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\nimport { Route as rootRouteImport } from './routes/__root'\nimport { Route as WebdavRouteImport } from './routes/webdav'\nimport { Route as TorrentRouteImport } from './routes/torrent'\nimport { Route as TokenRouteImport } from './routes/token'\nimport { Route as PodcastRouteImport } from './routes/podcast'\nimport { Route as Kuaidi100RouteImport } from './routes/kuaidi100'\nimport { Route as HistoryRouteImport } from './routes/history'\nimport { Route as ConfigRouteImport } from './routes/config'\nimport { Route as BulkRouteImport } from './routes/bulk'\nimport { Route as BilibiliRouteImport } from './routes/bilibili'\nimport { Route as IndexRouteImport } from './routes/index'\n\nconst WebdavRoute = WebdavRouteImport.update({\n  id: '/webdav',\n  path: '/webdav',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst TorrentRoute = TorrentRouteImport.update({\n  id: '/torrent',\n  path: '/torrent',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst TokenRoute = TokenRouteImport.update({\n  id: '/token',\n  path: '/token',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst PodcastRoute = PodcastRouteImport.update({\n  id: '/podcast',\n  path: '/podcast',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst Kuaidi100Route = Kuaidi100RouteImport.update({\n  id: '/kuaidi100',\n  path: '/kuaidi100',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst HistoryRoute = HistoryRouteImport.update({\n  id: '/history',\n  path: '/history',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst ConfigRoute = ConfigRouteImport.update({\n  id: '/config',\n  path: '/config',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst BulkRoute = BulkRouteImport.update({\n  id: '/bulk',\n  path: '/bulk',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst BilibiliRoute = BilibiliRouteImport.update({\n  id: '/bilibili',\n  path: '/bilibili',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst IndexRoute = IndexRouteImport.update({\n  id: '/',\n  path: '/',\n  getParentRoute: () => rootRouteImport,\n} as any)\n\nexport interface FileRoutesByFullPath {\n  '/': typeof IndexRoute\n  '/bilibili': typeof BilibiliRoute\n  '/bulk': typeof BulkRoute\n  '/config': typeof ConfigRoute\n  '/history': typeof HistoryRoute\n  '/kuaidi100': typeof Kuaidi100Route\n  '/podcast': typeof PodcastRoute\n  '/token': typeof TokenRoute\n  '/torrent': typeof TorrentRoute\n  '/webdav': typeof WebdavRoute\n}\nexport interface FileRoutesByTo {\n  '/': typeof IndexRoute\n  '/bilibili': typeof BilibiliRoute\n  '/bulk': typeof BulkRoute\n  '/config': typeof ConfigRoute\n  '/history': typeof HistoryRoute\n  '/kuaidi100': typeof Kuaidi100Route\n  '/podcast': typeof PodcastRoute\n  '/token': typeof TokenRoute\n  '/torrent': typeof TorrentRoute\n  '/webdav': typeof WebdavRoute\n}\nexport interface FileRoutesById {\n  __root__: typeof rootRouteImport\n  '/': typeof IndexRoute\n  '/bilibili': typeof BilibiliRoute\n  '/bulk': typeof BulkRoute\n  '/config': typeof ConfigRoute\n  '/history': typeof HistoryRoute\n  '/kuaidi100': typeof Kuaidi100Route\n  '/podcast': typeof PodcastRoute\n  '/token': typeof TokenRoute\n  '/torrent': typeof TorrentRoute\n  '/webdav': typeof WebdavRoute\n}\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath\n  fullPaths:\n    | '/'\n    | '/bilibili'\n    | '/bulk'\n    | '/config'\n    | '/history'\n    | '/kuaidi100'\n    | '/podcast'\n    | '/token'\n    | '/torrent'\n    | '/webdav'\n  fileRoutesByTo: FileRoutesByTo\n  to:\n    | '/'\n    | '/bilibili'\n    | '/bulk'\n    | '/config'\n    | '/history'\n    | '/kuaidi100'\n    | '/podcast'\n    | '/token'\n    | '/torrent'\n    | '/webdav'\n  id:\n    | '__root__'\n    | '/'\n    | '/bilibili'\n    | '/bulk'\n    | '/config'\n    | '/history'\n    | '/kuaidi100'\n    | '/podcast'\n    | '/token'\n    | '/torrent'\n    | '/webdav'\n  fileRoutesById: FileRoutesById\n}\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute\n  BilibiliRoute: typeof BilibiliRoute\n  BulkRoute: typeof BulkRoute\n  ConfigRoute: typeof ConfigRoute\n  HistoryRoute: typeof HistoryRoute\n  Kuaidi100Route: typeof Kuaidi100Route\n  PodcastRoute: typeof PodcastRoute\n  TokenRoute: typeof TokenRoute\n  TorrentRoute: typeof TorrentRoute\n  WebdavRoute: typeof WebdavRoute\n}\n\ndeclare module '@tanstack/react-router' {\n  interface FileRoutesByPath {\n    '/webdav': {\n      id: '/webdav'\n      path: '/webdav'\n      fullPath: '/webdav'\n      preLoaderRoute: typeof WebdavRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/torrent': {\n      id: '/torrent'\n      path: '/torrent'\n      fullPath: '/torrent'\n      preLoaderRoute: typeof TorrentRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/token': {\n      id: '/token'\n      path: '/token'\n      fullPath: '/token'\n      preLoaderRoute: typeof TokenRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/podcast': {\n      id: '/podcast'\n      path: '/podcast'\n      fullPath: '/podcast'\n      preLoaderRoute: typeof PodcastRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/kuaidi100': {\n      id: '/kuaidi100'\n      path: '/kuaidi100'\n      fullPath: '/kuaidi100'\n      preLoaderRoute: typeof Kuaidi100RouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/history': {\n      id: '/history'\n      path: '/history'\n      fullPath: '/history'\n      preLoaderRoute: typeof HistoryRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/config': {\n      id: '/config'\n      path: '/config'\n      fullPath: '/config'\n      preLoaderRoute: typeof ConfigRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/bulk': {\n      id: '/bulk'\n      path: '/bulk'\n      fullPath: '/bulk'\n      preLoaderRoute: typeof BulkRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/bilibili': {\n      id: '/bilibili'\n      path: '/bilibili'\n      fullPath: '/bilibili'\n      preLoaderRoute: typeof BilibiliRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/': {\n      id: '/'\n      path: '/'\n      fullPath: '/'\n      preLoaderRoute: typeof IndexRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n  }\n}\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  BilibiliRoute: BilibiliRoute,\n  BulkRoute: BulkRoute,\n  ConfigRoute: ConfigRoute,\n  HistoryRoute: HistoryRoute,\n  Kuaidi100Route: Kuaidi100Route,\n  PodcastRoute: PodcastRoute,\n  TokenRoute: TokenRoute,\n  TorrentRoute: TorrentRoute,\n  WebdavRoute: WebdavRoute,\n}\nexport const routeTree = rootRouteImport\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>()\n"
  },
  {
    "path": "ui/src/routes/__root.tsx",
    "content": "import { createRootRoute } from \"@tanstack/react-router\";\nimport { TanStackRouterDevtools } from \"@tanstack/react-router-devtools\";\nimport { AppProvider } from \"../context/AppContext\";\nimport { Layout } from \"../components/Layout\";\n\nconst RootLayout = () => (\n  <AppProvider>\n    <Layout />\n    <TanStackRouterDevtools />\n  </AppProvider>\n);\n\nexport const Route = createRootRoute({ component: RootLayout });\n"
  },
  {
    "path": "ui/src/routes/bilibili.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { BilibiliPage } from \"../pages/BilibiliPage\";\n\nexport const Route = createFileRoute(\"/bilibili\")({\n  component: BilibiliPage,\n});\n"
  },
  {
    "path": "ui/src/routes/bulk.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { BulkDownloadPage } from \"../pages/BulkDownloadPage\";\n\nexport const Route = createFileRoute(\"/bulk\")({\n  component: BulkDownloadPage,\n});\n"
  },
  {
    "path": "ui/src/routes/config.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { ConfigPage } from \"../pages/ConfigPage\";\n\nexport const Route = createFileRoute(\"/config\")({\n  component: ConfigPage,\n});\n"
  },
  {
    "path": "ui/src/routes/history.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { HistoryPage } from \"../pages/HistoryPage\";\n\nexport const Route = createFileRoute(\"/history\")({\n  component: HistoryPage,\n});\n"
  },
  {
    "path": "ui/src/routes/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { DownloadPage } from \"../pages/DownloadPage\";\n\nexport const Route = createFileRoute(\"/\")({\n  component: DownloadPage,\n});\n"
  },
  {
    "path": "ui/src/routes/kuaidi100.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { Kuaidi100Page } from \"../pages/Kuaidi100Page\";\n\nexport const Route = createFileRoute(\"/kuaidi100\")({\n  component: Kuaidi100Page,\n});\n"
  },
  {
    "path": "ui/src/routes/podcast.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { PodcastPage } from \"../pages/PodcastPage\";\n\nexport const Route = createFileRoute(\"/podcast\")({\n  component: PodcastPage,\n});\n"
  },
  {
    "path": "ui/src/routes/token.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { TokenPage } from \"../pages/TokenPage\";\n\nexport const Route = createFileRoute(\"/token\")({\n  component: TokenPage,\n});\n"
  },
  {
    "path": "ui/src/routes/torrent.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { TorrentPage } from \"../pages/TorrentPage\";\n\nexport const Route = createFileRoute(\"/torrent\")({\n  component: TorrentPage,\n});\n"
  },
  {
    "path": "ui/src/routes/webdav.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { WebDAVPage } from \"../pages/WebDAVPage\";\n\nexport const Route = createFileRoute(\"/webdav\")({\n  component: WebDAVPage,\n});\n"
  },
  {
    "path": "ui/src/utils/apis.ts",
    "content": "import type { UITranslations, ServerTranslations } from \"./translations\";\n\nexport type JobStatus =\n  | \"queued\"\n  | \"downloading\"\n  | \"completed\"\n  | \"failed\"\n  | \"cancelled\";\n\nexport interface Job {\n  id: string;\n  url: string;\n  status: JobStatus;\n  progress: number;\n  downloaded: number;\n  total: number;\n  filename?: string;\n  error?: string;\n}\n\nexport interface ApiResponse<T> {\n  code: number;\n  data: T;\n  message: string;\n}\n\nexport interface HealthData {\n  status: string;\n  version: string;\n}\n\nexport interface WebDAVServer {\n  url: string;\n  username: string;\n  password: string;\n}\n\nexport interface ConfigData {\n  output_dir: string;\n  language: string;\n  format: string;\n  quality: string;\n  twitter_auth_token: string;\n  server_port: number;\n  server_max_concurrent: number;\n  server_api_key: string;\n  webdav_servers: Record<string, WebDAVServer>;\n  express?: Record<string, Record<string, string>>;\n  torrent_enabled?: boolean;\n  bilibili_cookie?: string;\n  telegram_tdata_path?: string;\n}\n\nexport interface TorrentConfig {\n  enabled: boolean;\n  client: string;\n  host: string;\n  username: string;\n  password: string;\n  use_https: boolean;\n  default_save_path: string;\n}\n\nexport interface TorrentAddResult {\n  id: string;\n  hash: string;\n  name: string;\n  duplicate: boolean;\n}\n\nexport interface JobsData {\n  jobs: Job[];\n}\n\nexport interface I18nData {\n  language: string;\n  ui: UITranslations;\n  server: ServerTranslations;\n  config_exists: boolean;\n}\n\nexport async function fetchHealth(): Promise<ApiResponse<HealthData>> {\n  const res = await fetch(\"/api/health\");\n  return res.json();\n}\n\n// Auth APIs\n\nexport interface AuthStatusData {\n  api_key_configured: boolean;\n}\n\nexport interface GenerateTokenData {\n  jwt: string;\n}\n\nexport async function fetchAuthStatus(): Promise<ApiResponse<AuthStatusData>> {\n  const res = await fetch(\"/api/auth/status\");\n  return res.json();\n}\n\nexport async function generateApiToken(): Promise<ApiResponse<GenerateTokenData>> {\n  const res = await fetch(\"/api/auth/token\", { method: \"POST\" });\n  return res.json();\n}\n\nexport async function fetchJobs(): Promise<ApiResponse<JobsData>> {\n  const res = await fetch(\"/api/jobs\");\n  return res.json();\n}\n\nexport async function fetchConfig(): Promise<ApiResponse<ConfigData>> {\n  const res = await fetch(\"/api/config\");\n  return res.json();\n}\n\nexport async function fetchI18n(): Promise<ApiResponse<I18nData>> {\n  const res = await fetch(\"/api/i18n\");\n  return res.json();\n}\n\nexport async function updateConfig(\n  outputDir: string\n): Promise<ApiResponse<ConfigData>> {\n  const res = await fetch(\"/api/config\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ output_dir: outputDir }),\n  });\n  return res.json();\n}\n\nexport async function setConfigValue(\n  key: string,\n  value: string\n): Promise<ApiResponse<{ key: string; value: string }>> {\n  const res = await fetch(\"/api/config\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ key, value }),\n  });\n  return res.json();\n}\n\nexport async function postDownload(\n  url: string,\n  filename?: string\n): Promise<ApiResponse<{ id: string; status: string }>> {\n  const res = await fetch(\"/api/download\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ url, filename }),\n  });\n  return res.json();\n}\n\nexport interface BulkDownloadJob {\n  id: string;\n  url: string;\n  status: string;\n  error?: string;\n}\n\nexport interface BulkDownloadResult {\n  jobs: BulkDownloadJob[];\n  queued: number;\n  failed: number;\n}\n\nexport async function postBulkDownload(\n  urls: string[]\n): Promise<ApiResponse<BulkDownloadResult>> {\n  const res = await fetch(\"/api/bulk-download\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ urls }),\n  });\n  return res.json();\n}\n\nexport async function addWebDAVServer(\n  name: string,\n  url: string,\n  username: string,\n  password: string\n): Promise<ApiResponse<{ name: string }>> {\n  const res = await fetch(\"/api/config/webdav\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ name, url, username, password }),\n  });\n  return res.json();\n}\n\nexport async function deleteWebDAVServer(\n  name: string\n): Promise<ApiResponse<{ name: string }>> {\n  const res = await fetch(`/api/config/webdav/${encodeURIComponent(name)}`, {\n    method: \"DELETE\",\n  });\n  return res.json();\n}\n\nexport async function deleteJob(\n  id: string\n): Promise<ApiResponse<{ id: string }>> {\n  const res = await fetch(`/api/jobs/${id}`, { method: \"DELETE\" });\n  return res.json();\n}\n\nexport async function clearHistory(): Promise<\n  ApiResponse<{ cleared: number }>\n> {\n  const res = await fetch(\"/api/jobs\", { method: \"DELETE\" });\n  return res.json();\n}\n\n// Torrent APIs\n\nexport async function fetchTorrentConfig(): Promise<\n  ApiResponse<TorrentConfig>\n> {\n  const res = await fetch(\"/api/config/torrent\");\n  return res.json();\n}\n\nexport async function saveTorrentConfig(\n  config: TorrentConfig\n): Promise<ApiResponse<{ enabled: boolean }>> {\n  const res = await fetch(\"/api/config/torrent\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(config),\n  });\n  return res.json();\n}\n\nexport async function testTorrentConnection(): Promise<\n  ApiResponse<{ client: string }>\n> {\n  const res = await fetch(\"/api/config/torrent/test\", {\n    method: \"POST\",\n  });\n  return res.json();\n}\n\nexport async function addTorrent(\n  url: string,\n  savePath?: string\n): Promise<ApiResponse<TorrentAddResult>> {\n  const res = await fetch(\"/api/torrent\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ url, save_path: savePath }),\n  });\n  return res.json();\n}\n\n// WebDAV Browsing APIs\n\nexport interface WebDAVRemote {\n  name: string;\n  url: string;\n  hasAuth: boolean;\n}\n\nexport interface WebDAVFile {\n  name: string;\n  path: string;\n  size: number;\n  isDir: boolean;\n}\n\nexport interface WebDAVListData {\n  remote: string;\n  path: string;\n  files: WebDAVFile[];\n}\n\nexport async function fetchWebDAVRemotes(): Promise<\n  ApiResponse<{ remotes: WebDAVRemote[] }>\n> {\n  const res = await fetch(\"/api/webdav/remotes\");\n  return res.json();\n}\n\nexport async function fetchWebDAVList(\n  remote: string,\n  path: string\n): Promise<ApiResponse<WebDAVListData>> {\n  const params = new URLSearchParams({ remote, path });\n  const res = await fetch(`/api/webdav/list?${params}`);\n  return res.json();\n}\n\nexport async function submitWebDAVDownload(\n  remote: string,\n  files: string[]\n): Promise<ApiResponse<{ jobIds: string[]; count: number }>> {\n  const res = await fetch(\"/api/webdav/download\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ remote, files }),\n  });\n  return res.json();\n}\n\n// Podcast APIs\n\nexport interface PodcastChannel {\n  id: string;\n  title: string;\n  author: string;\n  description: string;\n  episode_count: number;\n  feed_url?: string;\n  source: \"xiaoyuzhou\" | \"itunes\";\n}\n\nexport interface PodcastEpisode {\n  id: string;\n  title: string;\n  podcast_name: string;\n  duration: number;\n  pub_date?: string;\n  download_url: string;\n  source: \"xiaoyuzhou\" | \"itunes\";\n}\n\nexport interface PodcastSearchResult {\n  source: \"xiaoyuzhou\" | \"itunes\";\n  podcasts: PodcastChannel[];\n  episodes: PodcastEpisode[];\n}\n\nexport async function searchPodcasts(\n  query: string,\n  lang?: string\n): Promise<ApiResponse<{ results: PodcastSearchResult[] }>> {\n  const res = await fetch(\"/api/podcast/search\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ query, lang }),\n  });\n  return res.json();\n}\n\nexport async function fetchPodcastEpisodes(\n  podcastId: string,\n  source: \"xiaoyuzhou\" | \"itunes\"\n): Promise<ApiResponse<{ podcast_title: string; episodes: PodcastEpisode[] }>> {\n  const res = await fetch(\"/api/podcast/episodes\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ podcast_id: podcastId, source }),\n  });\n  return res.json();\n}\n\n// History APIs\n\nexport interface HistoryRecord {\n  id: string;\n  url: string;\n  filename: string;\n  status: \"completed\" | \"failed\";\n  size_bytes: number;\n  started_at: number;   // Unix timestamp\n  completed_at: number; // Unix timestamp\n  duration_seconds: number;\n  error?: string;\n}\n\nexport interface HistoryStats {\n  completed: number;\n  failed: number;\n  total_bytes: number;\n}\n\nexport interface HistoryData {\n  records: HistoryRecord[];\n  total: number;\n  limit: number;\n  offset: number;\n  stats: HistoryStats;\n}\n\nexport async function fetchHistory(\n  limit = 50,\n  offset = 0\n): Promise<ApiResponse<HistoryData>> {\n  const params = new URLSearchParams({\n    limit: limit.toString(),\n    offset: offset.toString(),\n  });\n  const res = await fetch(`/api/history?${params}`);\n  return res.json();\n}\n\nexport async function deleteHistoryRecord(\n  id: string\n): Promise<ApiResponse<{ id: string }>> {\n  const res = await fetch(`/api/history/${id}`, { method: \"DELETE\" });\n  return res.json();\n}\n\nexport async function clearAllHistory(): Promise<\n  ApiResponse<{ cleared: number }>\n> {\n  const res = await fetch(\"/api/history\", { method: \"DELETE\" });\n  return res.json();\n}\n"
  },
  {
    "path": "ui/src/utils/translations.ts",
    "content": "export interface UITranslations {\n  download_to: string;\n  edit: string;\n  save: string;\n  cancel: string;\n  paste_url: string;\n  download: string;\n  bulk_download: string;\n  coming_soon: string;\n  bulk_paste_urls: string;\n  bulk_select_file: string;\n  bulk_drag_drop: string;\n  bulk_url_count: string;\n  bulk_submit_all: string;\n  bulk_submitting: string;\n  bulk_clear: string;\n  bulk_invalid_hint: string;\n  adding: string;\n  jobs: string;\n  total: string;\n  no_downloads: string;\n  paste_hint: string;\n  queued: string;\n  downloading: string;\n  completed: string;\n  failed: string;\n  cancelled: string;\n  settings: string;\n  language: string;\n  format: string;\n  quality: string;\n  twitter_auth: string;\n  server_port: string;\n  max_concurrent: string;\n  api_key: string;\n  webdav_servers: string;\n  add: string;\n  delete: string;\n  name: string;\n  url: string;\n  username: string;\n  password: string;\n  no_webdav_servers: string;\n  clear_history: string;\n  clear_all: string;\n  // WebDAV\n  webdav_browser: string;\n  select_remote: string;\n  empty_directory: string;\n  download_selected: string;\n  selected_files: string;\n  loading: string;\n  go_to_settings: string;\n  // Torrent\n  torrent: string;\n  torrent_hint: string;\n  torrent_submit: string;\n  torrent_submitting: string;\n  torrent_success: string;\n  torrent_not_configured: string;\n  torrent_settings: string;\n  torrent_client: string;\n  torrent_host: string;\n  torrent_test: string;\n  torrent_testing: string;\n  torrent_test_success: string;\n  torrent_enabled: string;\n  // Toast\n  download_queued: string;\n  downloads_queued: string;\n  // Podcast\n  podcast: string;\n  podcast_search: string;\n  podcast_search_hint: string;\n  podcast_searching: string;\n  podcast_channels: string;\n  podcast_episodes: string;\n  podcast_no_results: string;\n  podcast_episodes_count: string;\n  podcast_back: string;\n  podcast_download_started: string;\n  // API Token\n  token_title: string;\n  token_description: string;\n  token_custom_payload: string;\n  token_custom_payload_hint: string;\n  token_generate: string;\n  token_generating: string;\n  token_generated: string;\n  token_copy: string;\n  token_copied: string;\n  token_usage: string;\n  token_invalid_json: string;\n  // History\n  history: string;\n  history_title: string;\n  history_empty: string;\n  history_empty_hint: string;\n  history_clear_all: string;\n  history_stats: string;\n  history_total_downloaded: string;\n}\n\nexport interface ServerTranslations {\n  no_config_warning: string;\n  run_init_hint: string;\n}\n\nexport const defaultTranslations: UITranslations = {\n  download_to: \"Download to:\",\n  edit: \"Edit\",\n  save: \"Save\",\n  cancel: \"Cancel\",\n  paste_url: \"Paste URL to download...\",\n  download: \"Download\",\n  bulk_download: \"Bulk Download\",\n  coming_soon: \"Coming Soon\",\n  bulk_paste_urls: \"Paste URLs here (one per line)...\",\n  bulk_select_file: \"Select File\",\n  bulk_drag_drop: \"or drag and drop a .txt file here\",\n  bulk_url_count: \"URLs\",\n  bulk_submit_all: \"Download All\",\n  bulk_submitting: \"Submitting...\",\n  bulk_clear: \"Clear\",\n  bulk_invalid_hint: \"Empty lines and lines starting with # are ignored\",\n  adding: \"Adding...\",\n  jobs: \"Jobs\",\n  total: \"total\",\n  no_downloads: \"No downloads yet\",\n  paste_hint: \"Paste a URL above to get started\",\n  queued: \"queued\",\n  downloading: \"downloading\",\n  completed: \"completed\",\n  failed: \"failed\",\n  cancelled: \"cancelled\",\n  settings: \"Settings\",\n  language: \"Language\",\n  format: \"Format\",\n  quality: \"Quality\",\n  twitter_auth: \"Twitter Auth\",\n  server_port: \"Server Port\",\n  max_concurrent: \"Max Concurrent\",\n  api_key: \"API Key\",\n  webdav_servers: \"WebDAV Servers\",\n  add: \"Add\",\n  delete: \"Delete\",\n  name: \"Name\",\n  url: \"URL\",\n  username: \"Username\",\n  password: \"Password\",\n  no_webdav_servers: \"No WebDAV servers configured\",\n  clear_history: \"Clear\",\n  clear_all: \"Clear All\",\n  // WebDAV\n  webdav_browser: \"WebDAV\",\n  select_remote: \"Select Remote\",\n  empty_directory: \"Empty directory\",\n  download_selected: \"Download Selected\",\n  selected_files: \"selected\",\n  loading: \"Loading...\",\n  go_to_settings: \"Go to Settings\",\n  // Torrent\n  torrent: \"BT/Magnet\",\n  torrent_hint: \"Paste magnet link or torrent URL...\",\n  torrent_submit: \"Send\",\n  torrent_submitting: \"Sending...\",\n  torrent_success: \"Torrent added successfully\",\n  torrent_not_configured: \"Torrent client not configured. Go to Settings to set up.\",\n  torrent_settings: \"Torrent Client\",\n  torrent_client: \"Client Type\",\n  torrent_host: \"Host\",\n  torrent_test: \"Test Connection\",\n  torrent_testing: \"Testing...\",\n  torrent_test_success: \"Connection successful\",\n  torrent_enabled: \"Enable Torrent\",\n  // Toast\n  download_queued: \"Download started. Check progress on Download page.\",\n  downloads_queued: \"downloads started. Check progress on Download page.\",\n  // Podcast\n  podcast: \"Podcast\",\n  podcast_search: \"Search\",\n  podcast_search_hint: \"Search podcasts or episodes...\",\n  podcast_searching: \"Searching...\",\n  podcast_channels: \"Podcasts\",\n  podcast_episodes: \"Episodes\",\n  podcast_no_results: \"No results found\",\n  podcast_episodes_count: \"episodes\",\n  podcast_back: \"Back\",\n  podcast_download_started: \"Download started\",\n  // API Token\n  token_title: \"API Token Generator\",\n  token_description: \"Generate JWT tokens for external API access (Chrome extension, scripts, etc.). Tokens are signed with your configured api_key.\",\n  token_custom_payload: \"Custom Payload (optional)\",\n  token_custom_payload_hint: \"Add custom claims to include in the JWT token. Must be valid JSON.\",\n  token_generate: \"Generate Token\",\n  token_generating: \"Generating...\",\n  token_generated: \"Generated Token\",\n  token_copy: \"Copy\",\n  token_copied: \"Copied!\",\n  token_usage: \"Usage\",\n  token_invalid_json: \"Invalid JSON\",\n  // History\n  history: \"History\",\n  history_title: \"Download History\",\n  history_empty: \"No download history\",\n  history_empty_hint: \"Completed downloads will appear here\",\n  history_clear_all: \"Clear All History\",\n  history_stats: \"Statistics\",\n  history_total_downloaded: \"Total Downloaded\",\n};\n\nexport const defaultServerTranslations: ServerTranslations = {\n  no_config_warning: \"No config file found. Using default settings.\",\n  run_init_hint: \"Run 'vget init' to configure vget interactively.\",\n};\n"
  },
  {
    "path": "ui/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"vite/client\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "ui/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "ui/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "ui/vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { tanstackRouter } from \"@tanstack/router-plugin/vite\";\nimport tailwindcss from \"@tailwindcss/vite\";\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [\n    tailwindcss(),\n    tanstackRouter({\n      target: \"react\",\n      autoCodeSplitting: true,\n    }),\n    react(),\n  ],\n  server: {\n    proxy: {\n      \"/api\": \"http://localhost:8080\",\n    },\n  },\n});\n"
  }
]