[
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Release\non:\n  push:\n    tags:\n      - 'v*'\n  workflow_dispatch:\n\njobs:\n  release:\n    strategy:\n      fail-fast: false\n      matrix:\n        platform: [macos-latest, windows-latest]\n    runs-on: ${{ matrix.platform }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v2\n\n      - name: Node.js setup\n        uses: actions/setup-node@v1\n        with:\n          node-version: 16\n\n      - name: Rust setup\n        uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n\n      - name: Install dependencies (ubuntu only)\n        if: matrix.platform == 'ubuntu-latest'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf\n      - name: Install app dependencies and build web\n        run: npm i && npm run tauri build\n\n      - name: Build the app\n        uses: tauri-apps/tauri-action@v0\n\n        env:\n          GITHUB_TOKEN: ${{ secrets.TOKEN }}\n        with:\n          tagName: v__VERSION__ # tauri-action replaces \\_\\_VERSION\\_\\_ with the app version\n          releaseName: 'v__VERSION__'\n          releaseBody: 'See the assets to download this version and install.'\n          releaseDraft: true\n          prerelease: false\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n.idea/\npackage-lock.json"
  },
  {
    "path": "README.md",
    "content": "## 抖音下载器\n\n⚠ 接口挂了，暂时没时间更新软件了。着急下载的话，可以直接在 电脑 浏览器查看抖音，进入 Devtools，执行以下代码进行下载：\n\n```js\nopen(\n  document.querySelectorAll(\"video\")[\n    document.querySelectorAll(\"video\").length == 1 ? 0 : 1\n  ].children[0].src\n);\n```\n---\n\n> 在线解析版本：[https://apis.leping.fun/dy/](https://apis.leping.fun/dy/)，代码在 [php_ver.php](./php_ver.php) 中。\n\n![image](https://user-images.githubusercontent.com/11046969/182412269-8ac2dee8-fb30-40b1-b4b3-190c99496759.png)\n\n- 支持下载无水印视频\n- 支持下载某个账号号的所有视频\n\n## 下载软件\n\n软件采用 Rust + Tauri 编写，安装包非常小，只有 5MB 左右。\n\n- Windows 下载地址：[douyin-downloader_0.1.0_x64_en-US.msi](https://github.com/lecepin/douyin-downloader/releases/download/v0.1.0/douyin-downloader_0.1.0_x64_en-US.msi)\n- Mac 下载地址：[douyin-downloader_0.1.0_x64.dmg](https://github.com/lecepin/douyin-downloader/releases/download/v0.1.0/douyin-downloader_0.1.0_x64.dmg)\n\n> 国内访问速度慢，可以使用以下加速地址：\n> - Windows 下载地址：[douyin-downloader_0.1.0_x64_en-US.msi](https://github.91chi.fun/https://github.com//lecepin/douyin-downloader/releases/download/v0.1.0/douyin-downloader_0.1.0_x64_en-US.msi)\n> - Mac 下载地址：[douyin-downloader_0.1.0_x64.dmg](https://github.91chi.fun/https://github.com//lecepin/douyin-downloader/releases/download/v0.1.0/douyin-downloader_0.1.0_x64.dmg)\n\n\n## 使用\n\n如下方式使用。\n\n### 下载单个视频\n\n![image](https://user-images.githubusercontent.com/11046969/182413296-1a97050c-f7fd-4912-bf09-e064d67c888f.png)\n\n手机端、网页端都可，点击分享按钮，把口令复制到本软件中，进行解析即可。\n\n口令类似 `1.20 fBt:/ 拿好纸巾（有双倍福利呦） # 美女合集 # 气质美女 # 变装 @抖音小助手 https://v.douyin.com/23FsM5g/ 复制此链接，打开Dou音搜索，直接观看视频！`\n\n![image](https://user-images.githubusercontent.com/11046969/182413713-7d540831-44cc-42ef-99d9-a30c54300da1.png)\n\n### 下载某个账号号的所有视频\n\n网页版，进入个人页，网址类似 `https://www.douyin.com/user/MS4wLjABAAAAWiOs23d6NtmiUg98zONd6wQhmPsy1WLwZn0jEaCbDL8`：\n\n![image](https://user-images.githubusercontent.com/11046969/182414514-e2e15549-ec85-4dad-b821-3382b16f4abd.png)\n\n复制网址，粘贴到 “用户所有视频” 类型下，解析即可：\n\n![image](https://user-images.githubusercontent.com/11046969/182414926-10d4526d-fff3-495a-8b9b-e2949e3018e4.png)\n\n点击 “全部下载” 按钮，就可以进行全部下载了：\n\n![image](https://user-images.githubusercontent.com/11046969/182415286-851f802d-305b-4684-b6a2-c10976c1338d.png)\n\n\n一键下载完成：\n\n![image](https://user-images.githubusercontent.com/11046969/182416193-f009597e-9ee4-4c41-aca4-eecbfeafe76d.png)\n\n"
  },
  {
    "path": "config-overrides.js",
    "content": "const { override, addLessLoader, adjustStyleLoaders } = require('customize-cra');\n\nmodule.exports = override(\n  addLessLoader({ lessOptions: { javascriptEnabled: true } }),\n  adjustStyleLoaders(({ use: [, , postcss] }) => {\n    postcss.options = { postcssOptions: postcss.options };\n  }),\n  function (config) { return config; },\n);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"douyin-downloader\",\n  \"version\": \"0.1.1\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@ant-design/icons\": \"^4.7.0\",\n    \"@tauri-apps/api\": \"^1.0.2\",\n    \"antd\": \"^4.21.7\",\n    \"lodash\": \"^4.17.21\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\"\n  },\n  \"scripts\": {\n    \"start\": \"cross-env BROWSER=none react-app-rewired start\",\n    \"build\": \"react-app-rewired build\",\n    \"tauri\": \"tauri\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@tauri-apps/cli\": \"^1.0.5\",\n    \"cross-env\": \"^7.0.3\",\n    \"customize-cra\": \"^1.0.0\",\n    \"less\": \"^4.1.2\",\n    \"less-loader\": \"^11.0.0\",\n    \"react-app-rewired\": \"^2.2.1\",\n    \"react-scripts\": \"5.0.1\"\n  }\n}\n"
  },
  {
    "path": "php_ver.php",
    "content": "<?php\nerror_reporting(0);\n\n$real_url = $g_url = $_GET['url'];\npreg_match('/https:\\/\\/v.douyin.com\\/[^\\s ]*/', $real_url, $match);\n\nif ($match[0]) {\n    file_get_contents($match[0]);\n    preg_match('/https:[^\\s]*/', $http_response_header[6], $match);\n    $real_url = $match[0];\n}\n\npreg_match('/https:\\/\\/www.(douyin.com|iesdouyin.com\\/share)\\/video\\/([^?&=\\s\\/]+)/', $real_url, $match);\n$real_id = $match[2];\n\nif ($real_id) {\n    $video_info = json_decode(file_get_contents('https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=' . $real_id));\n    $v_url = str_replace('playwm', 'play', $video_info->item_list[0]->video->play_addr->url_list[0]);\n    $v_title = $video_info->item_list[0]->desc;\n    $v_ratio = $video_info->item_list[0]->video->ratio;\n    $v_cover = $video_info->item_list[0]->video->cover->url_list[0];\n}\n?>\n\n<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Document</title>\n</head>\n\n<body>\n    <h2>抖音视频下载</h2>\n    <div>\n        <form>\n            <input name=\"url\" autocomplete=\"off\" placeholder=\"请输入抖音分享的地址\" style=\"width: 300px;\" />\n            <button>解析</button>\n        </form>\n\n        <?php echo $g_url ? '解析地址：' . $g_url : ''; ?>\n\n        <?php if (!$v_url && $g_url) {\n            echo '<br /> 解析失败，请重！';\n        } ?>\n\n        <?php if ($v_url) {\n            echo '<hr />';\n            echo $v_title;\n            echo '<br />' . $v_ratio;\n            echo '<br /> 下载地址：<a target=\"_blank\" href=\"' . $v_url . '\">' . $v_url . '</a>';\n            echo '<br /><img src=\"' . $v_cover . '\" />';\n        } ?>\n        <a href=\"https://github.com/lecepin/douyin-downloader\" class=\"github-corner\" aria-label=\"View source on GitHub\"><svg width=\"80\" height=\"80\" viewBox=\"0 0 250 250\" style=\"fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;\" aria-hidden=\"true\">\n                <path d=\"M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z\"></path>\n                <path d=\"M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2\" fill=\"currentColor\" style=\"transform-origin: 130px 106px;\" class=\"octo-arm\"></path>\n                <path d=\"M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z\" fill=\"currentColor\" class=\"octo-body\"></path>\n            </svg></a>\n        <style>\n            .github-corner:hover .octo-arm {\n                animation: octocat-wave 560ms ease-in-out\n            }\n\n            @keyframes octocat-wave {\n\n                0%,\n                100% {\n                    transform: rotate(0)\n                }\n\n                20%,\n                60% {\n                    transform: rotate(-25deg)\n                }\n\n                40%,\n                80% {\n                    transform: rotate(10deg)\n                }\n            }\n\n            @media (max-width:500px) {\n                .github-corner:hover .octo-arm {\n                    animation: none\n                }\n\n                .github-corner .octo-arm {\n                    animation: octocat-wave 560ms ease-in-out\n                }\n            }\n        </style>\n    </div>\n\n</body>\n\n</html>\n"
  },
  {
    "path": "public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"%PUBLIC_URL%/favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <meta name=\"theme-color\" content=\"#000000\" />\n    <meta\n      name=\"description\"\n      content=\"Web site created using create-react-app\"\n    />\n    <link rel=\"apple-touch-icon\" href=\"%PUBLIC_URL%/logo192.png\" />\n    <!--\n      manifest.json provides metadata used when your web app is installed on a\n      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/\n    -->\n    <link rel=\"manifest\" href=\"%PUBLIC_URL%/manifest.json\" />\n    <!--\n      Notice the use of %PUBLIC_URL% in the tags above.\n      It will be replaced with the URL of the `public` folder during the build.\n      Only files inside the `public` folder can be referenced from the HTML.\n\n      Unlike \"/favicon.ico\" or \"favicon.ico\", \"%PUBLIC_URL%/favicon.ico\" will\n      work correctly both with client-side routing and a non-root public URL.\n      Learn how to configure a non-root public URL by running `npm run build`.\n    -->\n    <title>React App</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n    <script>\n      document.addEventListener('contextmenu', event => event.preventDefault());\n    </script>\n    <!--\n      This HTML file is a template.\n      If you open it directly in the browser, you will see an empty page.\n\n      You can add webfonts, meta tags, or analytics to this file.\n      The build step will place the bundled scripts into the <body> tag.\n\n      To begin the development, run `npm start` or `yarn start`.\n      To create a production bundle, use `npm run build` or `yarn build`.\n    -->\n  </body>\n</html>\n"
  },
  {
    "path": "public/manifest.json",
    "content": "{\n  \"short_name\": \"React App\",\n  \"name\": \"Create React App Sample\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n      \"sizes\": \"64x64 32x32 24x24 16x16\",\n      \"type\": \"image/x-icon\"\n    },\n    {\n      \"src\": \"logo192.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\"\n    },\n    {\n      \"src\": \"logo512.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\"\n    }\n  ],\n  \"start_url\": \".\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#000000\",\n  \"background_color\": \"#ffffff\"\n}\n"
  },
  {
    "path": "public/robots.txt",
    "content": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "src/App.js",
    "content": "import { Button, Input, Space, Select, Popover, Table, message, BackTop } from \"antd\";\nimport { useState } from \"react\";\nimport { invoke } from \"@tauri-apps/api/tauri\";\nimport { open } from \"@tauri-apps/api/dialog\";\nimport { open as openFile } from \"@tauri-apps/api/shell\";\nimport { QuestionCircleOutlined, PlaySquareOutlined, GithubFilled, EyeOutlined, DownloadOutlined, CloudDownloadOutlined } from \"@ant-design/icons\";\nimport imgLogo from \"./logo.png\";\nimport \"./App.less\";\n\nexport default function App() {\n  const [parseType, setParseType] = useState(\"video\");\n  const [url, setUrl] = useState(\"\");\n  const [videoInfo, setVideoInfo] = useState([]);\n  const [isParseLoading, setIsParseLoading] = useState(false);\n  const [status, setStatus] = useState({});\n  const [allDownloading, setAllDownloading] = useState(false);\n\n  return (\n    <div className=\"App\">\n      <Space className=\"App-topbar\">\n        <span>类型</span>\n        <Popover\n          placement=\"bottomLeft\"\n          content={\n            <div style={{ maxWidth: 520, wordBreak: \"break-all\" }}>\n              <p>单个视频：如 “9.94 Eho:/ 我把事情拖到最后一分钟做不是因为我懒而是那个时候我更老了 做事情也更成熟了# 叮叮当当舞 # 杰星编舞 https://v.douyin.com/2vLYnCp/ 复制此链接，打开Dou音搜索，直接观看视频！”</p>\n              <hr />\n              <p>用户所有视频：如“https://www.douyin.com/user/MS4wLjABAAAABsXrboCFzZqd2HrqUMBCUmMWRHDqjMdrW0WndNDaFAbO924AWWF7fk8YJUdZYmjk”</p>\n            </div>\n          }\n          trigger=\"hover\"\n        >\n          <QuestionCircleOutlined />\n        </Popover>\n        <Select\n          value={parseType}\n          disabled={false}\n          onChange={(value) => setParseType(value)}\n        >\n          <Select.Option key=\"video\">单 个 视 频</Select.Option>\n          <Select.Option key=\"userVideo\">用户所有视频</Select.Option>\n        </Select>\n        <Input\n          placeholder={ parseType === \"video\" ? \"请填入分享的视频链接\" : \"请填入用户的页面网址\" }\n          disabled={false}\n          value={url}\n          onChange={({ target }) => { setUrl(target.value); }}\n        ></Input>\n        <Button\n          type=\"primary\"\n          loading={isParseLoading}\n          onClick={async () => {\n            setIsParseLoading(true);\n\n            try {\n              if (parseType === \"video\") {\n                const id = await invoke(\"get_url_id\", { addr: url });\n                const info = await invoke(\"get_video_info_by_id\", { id });\n\n                setVideoInfo([info]);\n              } else {\n                const { video_count, uid } = await invoke(\"get_user_info_by_url\", { addr: url, });\n                const info = await invoke(\"get_list_by_user_id\", { uid, count: video_count, maxCursor: 0 });\n\n                setVideoInfo(info);\n              }\n            } catch (error) {\n              message.error(error);\n            }\n\n            setIsParseLoading(false);\n          }}\n        >\n          解析{parseType === \"video\" ? \"单个视频\" : \"所有视频\"}\n        </Button>\n        <Button\n          icon={<GithubFilled />}\n          onClick={() => open_url(\"https://github.com/lecepin/douyin-downloader\") }\n        >\n          <b> Star</b>\n        </Button>\n      </Space>\n\n      {videoInfo?.length > 0 ? (\n        <>\n          <div>\n            <Button\n              loading={allDownloading}\n              icon={<CloudDownloadOutlined />}\n              type=\"primary\"\n              ghost\n              onClick={async () => {\n                const dir = await open({ directory: true });\n\n                if (!dir) {\n                  return;\n                }\n\n                setAllDownloading(true);\n\n                for (let _index = 0; _index < videoInfo.length; _index++) {\n                  const { id, title, url } = videoInfo[_index];\n                  const fileName = `${title}${Date.now()}.mp4`;\n\n                  try {\n                    setStatus((status) => ({\n                      ...status,\n                      [id]: {\n                        status: \"downloading\",\n                      },\n                    }));\n\n                    const filePath = await invoke(\"download_video\", {\n                      url,\n                      writePath: dir,\n                      fileName,\n                      id: id,\n                    });\n\n                    setStatus((status) => ({\n                      ...status,\n                      [id]: {\n                        status: \"done\",\n                        filePath,\n                      },\n                    }));\n                  } catch (error) {\n                    message.error(error);\n                    setStatus({\n                      ...status,\n                      [id]: null,\n                    });\n                  }\n                }\n\n                setAllDownloading(false);\n              }}\n            >\n              全部下载\n            </Button>\n          </div>\n          <Table\n            sticky\n            rowKey=\"id\"\n            dataSource={videoInfo}\n            columns={[\n              {\n                title: \"序号\",\n                dataIndex: \"index\",\n                key: \"index\",\n                ellipsis: true,\n                width: 80,\n                render: (_a, _b, index) => index + 1,\n              },\n              {\n                title: \"封面\",\n                dataIndex: \"cover\",\n                key: \"cover\",\n                render: (value) =>\n                  value ? (\n                    <img\n                      style={{ maxHeight: 100, maxWidth: 100 }}\n                      src={value}\n                    />\n                  ) : null,\n                width: 100,\n              },\n              {\n                title: \"标题\",\n                dataIndex: \"title\",\n                key: \"title\",\n                ellipsis: true,\n              },\n              {\n                title: \"分辨率\",\n                dataIndex: \"ratio\",\n                key: \"ratio\",\n                width: 100,\n                ellipsis: true,\n              },\n              {\n                title: \"操作\",\n                dataIndex: \"action\",\n                key: \"action\",\n                width: \"180px\",\n                render: (_, { url, title, id }) => (\n                  <div>\n                    {status[id]?.status == \"done\" ? (\n                      <Button\n                        icon={<EyeOutlined />}\n                        type=\"primary\"\n                        onClick={() => {\n                          status[id].filePath &&\n                            openFile(status[id].filePath).catch(() => {});\n                        }}\n                        size=\"small\"\n                        ghost\n                      >\n                        查看\n                      </Button>\n                    ) : (\n                      <Button\n                        icon={<DownloadOutlined />}\n                        loading={\n                          status[id]?.status == \"downloading\" || allDownloading\n                        }\n                        type=\"primary\"\n                        size=\"small\"\n                        onClick={async () => {\n                          const fileName = `${title}${Date.now()}.mp4`;\n                          const dir = await open({ directory: true });\n\n                          if (!dir) {\n                            return;\n                          }\n\n                          try {\n                            setStatus({\n                              ...status,\n                              [id]: {\n                                status: \"downloading\",\n                              },\n                            });\n\n                            const filePath = await invoke(\"download_video\", {\n                              url,\n                              writePath: dir,\n                              fileName,\n                              id: id,\n                            });\n\n                            setStatus({\n                              ...status,\n                              [id]: {\n                                status: \"done\",\n                                filePath,\n                              },\n                            });\n                          } catch (error) {\n                            message.error(error);\n                            setStatus({\n                              ...status,\n                              [id]: null,\n                            });\n                          }\n                        }}\n                      >\n                        下载\n                      </Button>\n                    )}\n                    &nbsp; &nbsp;\n                    <Button\n                      icon={<PlaySquareOutlined />}\n                      onClick={() => open_url(url)}\n                      size=\"small\"\n                    >\n                      预览\n                    </Button>\n                  </div>\n                ),\n              },\n            ]}\n            pagination={false}\n          ></Table>\n        </>\n      ) : (\n        <div className=\"App-logo\">\n          <img src={imgLogo} />\n          <Button\n            icon={<GithubFilled />}\n            size=\"large\"\n            onClick={() =>\n              open_url(\"https://github.com/lecepin/douyin-downloader\")\n            }\n          >\n            <b> Star</b>\n          </Button>\n        </div>\n      )}\n      <BackTop style={{ left: 50 }} />\n    </div>\n  );\n}\n\nfunction open_url(url) {\n  const el = document.createElement(\"a\");\n  el.style.display = \"none\";\n  el.setAttribute(\"target\", \"_blank\");\n  el.href = url;\n  document.body.appendChild(el);\n  el.click();\n  document.body.removeChild(el);\n}\n"
  },
  {
    "path": "src/App.less",
    "content": ".App {\n  padding: 20px;\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n\n  &-topbar {\n    width: 100%;\n    padding-bottom: 20px;\n\n    & > .ant-space-item {\n      &:nth-last-child(3) {\n        flex-grow: 1;\n      }\n    }\n  }\n\n  &-logo {\n    flex-grow: 1;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    flex-direction: column;\n\n    & > img {\n      margin-bottom: 10px;\n    }\n  }\n}\n"
  },
  {
    "path": "src/index.js",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport \"antd/dist/antd.min.css\";\nimport App from \"./App\";\n\nReactDOM.createRoot(document.getElementById(\"root\")).render(<App />);\n"
  },
  {
    "path": "src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n"
  },
  {
    "path": "src-tauri/Cargo.toml",
    "content": "[package]\nname = \"app\"\nversion = \"0.1.0\"\ndescription = \"A Tauri App\"\nauthors = [\"lecepin\"]\nlicense = \"\"\nrepository = \"\"\ndefault-run = \"app\"\nedition = \"2021\"\nrust-version = \"1.57\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[build-dependencies]\ntauri-build = { version = \"1.0.4\", features = [] }\n\n[dependencies]\nserde_json = \"1.0\"\nserde = { version = \"1.0\", features = [\"derive\"] }\ntauri = { version = \"1.0.5\", features = [\"api-all\"] }\nreqwest = { version = \"0.11.11\", features = [\"stream\"] }\nfutures-util = \"0.3.21\"\nasync-recursion = \"1.0.0\"\n\n[features]\n# by default Tauri runs in production mode\n# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL\ndefault = [\"custom-protocol\"]\n# this feature is used used for production builds where `devPath` points to the filesystem\n# DO NOT remove this\ncustom-protocol = [\"tauri/custom-protocol\"]\n"
  },
  {
    "path": "src-tauri/build.rs",
    "content": "fn main() {\n  tauri_build::build()\n}\n"
  },
  {
    "path": "src-tauri/src/command.rs",
    "content": "use async_recursion::async_recursion;\nuse futures_util::StreamExt;\nuse std::fs::File;\nuse std::io::Write;\nuse std::path::Path;\nuse tauri::regex::Regex;\n\n#[derive(serde::Serialize)]\npub struct VideoInfo {\n    title: String,\n    ratio: String,\n    cover: String,\n    url: String,\n    id: String,\n}\n\n#[derive(serde::Serialize)]\npub struct UserInfo {\n    nick_name: String,\n    video_count: u64,\n    avatar: String,\n    uid: String,\n}\n\n#[derive(Clone, serde::Serialize)]\npub struct DownloadProgress {\n    current: u64,\n    total: u64,\n    id: String,\n}\n\n// 取各种 url 的 id\n#[tauri::command]\npub async fn get_url_id(addr: String) -> Result<String, String> {\n    let mut _addr = addr;\n    let mut result = \"\".to_string();\n    let reg_get_share_url = Regex::new(r#\"https://v.douyin.com/[^\\s ]*\"#).unwrap();\n    let reg_get_id = Regex::new(r#\"https://www.douyin.com/video/([^?&=\\s]+)\"#).unwrap();\n\n    match reg_get_share_url.captures(&_addr) {\n        Some(cap) => {\n            let url = cap.get(0).map_or(\"\", |value| value.as_str());\n\n            if url.len() > 0 {\n                _addr = reqwest::get(url)\n                    .await\n                    .map_err(|_| \"网络错误\")?\n                    .url()\n                    .as_str()\n                    .to_string();\n            }\n        }\n        _ => (),\n    }\n\n    if let Some(cap) = reg_get_id.captures(&_addr) {\n        result = cap\n            .get(1)\n            .map_or(\"\".to_string(), |value| value.as_str().to_string());\n    }\n\n    if result.len() > 0 {\n        return Ok(result);\n    }\n\n    Err(\"解析失败\".into())\n}\n\n// 取视频信息\n#[tauri::command]\npub async fn get_video_info_by_id(id: &str) -> Result<VideoInfo, String> {\n    let res_text = reqwest::get(\n        \"https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=\".to_string() + id,\n    )\n    .await\n    .map_err(|_| \"网络错误\")?\n    .text()\n    .await\n    .map_err(|_| \"网络错误\")?;\n    let raw_info = serde_json::from_str::<serde_json::Value>(&res_text).map_err(|_| \"解析错误\")?;\n    let url = raw_info[\"item_list\"][0][\"video\"][\"play_addr\"][\"url_list\"][0]\n        .as_str()\n        .unwrap_or(\"\")\n        .replace(\"playwm\", \"play\");\n\n    if url.len() == 0 {\n        return Err(\"此视频地址无效\".into());\n    }\n\n    Ok(VideoInfo {\n        title: raw_info[\"item_list\"][0][\"desc\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_string(),\n        ratio: raw_info[\"item_list\"][0][\"video\"][\"ratio\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_string(),\n        cover: raw_info[\"item_list\"][0][\"video\"][\"cover\"][\"url_list\"][0]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_string(),\n        id: raw_info[\"aweme_id\"].as_str().unwrap_or(\"\").to_string(),\n        url,\n    })\n}\n\n// 取完整视频信息\n#[tauri::command]\npub async fn get_video_full_info_by_id(id: &str) -> Result<serde_json::Value, String> {\n    let res_text = reqwest::get(\n        \"https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=\".to_string() + id,\n    )\n    .await\n    .map_err(|_| \"网络错误\")?\n    .text()\n    .await\n    .map_err(|_| \"网络错误\")?;\n\n    Ok(serde_json::from_str::<serde_json::Value>(&res_text).map_err(|_| \"解析错误\")?)\n}\n\n// 视频下载\n#[tauri::command]\npub async fn download_video(\n    url: &str,\n    write_path: &str,\n    file_name: &str,\n    id: &str,\n    window: tauri::Window,\n) -> Result<String, String> {\n    let file_path = Path::new(write_path).join(file_name.replace(\n        |item: char| ['\\\\', '/', ':', '?', '*', '\"', '<', '>', '|'].contains(&item),\n        \"_\",\n    ));\n    let res = reqwest::Client::new()\n        .get(url)\n        .header(\"user-agent\",\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36\")\n        .send()\n        .await\n        .map_err(|_| \"网络错误\")?;\n    let res_len = res.content_length().unwrap_or(0);\n\n    if res_len == 0 {\n        return Err(\"视频长度为 0\".into());\n    }\n\n    let mut downloaded_len = 0_u64;\n    let mut stream = res.bytes_stream();\n    let mut file = File::create(&file_path).map_err(|_| \"文件创建失败\")?;\n\n    while let Some(chunk) = stream.next().await {\n        let chunk = chunk.map_err(|_| \"网络错误\")?;\n\n        file.write_all(&chunk).map_err(|_| \"文件写入失败\")?;\n        downloaded_len += chunk.len() as u64;\n\n        window\n            .emit(\n                \"e_download_progress\",\n                DownloadProgress {\n                    current: downloaded_len,\n                    total: res_len,\n                    id: id.into(),\n                },\n            )\n            .unwrap();\n    }\n\n    Ok(file_path.to_str().unwrap().into())\n}\n\n// 取用户信息\n#[tauri::command]\npub async fn get_user_info_by_url(addr: &str) -> Result<UserInfo, String> {\n    let reg_get_user_id = Regex::new(r#\"https://www.douyin.com/user/([\\w-]+)\"#).unwrap();\n    let uid = reg_get_user_id\n        .captures(addr)\n        .map_or(Err(\"地址错误\"), |cap| {\n            Ok(cap.get(1).map_or(\"\", |value| value.as_str()))\n        })?;\n    let res_text =\n        reqwest::get(\"https://www.iesdouyin.com/web/api/v2/user/info/?sec_uid=\".to_string() + uid)\n            .await\n            .map_err(|_| \"网络错误\")?\n            .text()\n            .await\n            .map_err(|_| \"网络错误\")?;\n    let raw_info = serde_json::from_str::<serde_json::Value>(&res_text).map_err(|_| \"解析错误\")?;\n    let video_count = raw_info[\"user_info\"][\"aweme_count\"]\n        .as_u64()\n        .unwrap_or(0_u64);\n\n    if video_count == 0 {\n        return Err(\"用户视频数为 0\".into());\n    }\n\n    Ok(UserInfo {\n        nick_name: raw_info[\"user_info\"][\"nickname\"]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_string(),\n        video_count,\n        avatar: raw_info[\"user_info\"][\"avatar_larger\"][\"url_list\"][0]\n            .as_str()\n            .unwrap_or(\"\")\n            .to_string(),\n        uid: uid.into(),\n    })\n}\n\n// 取完整用户信息\n#[tauri::command]\npub async fn get_user_full_info_by_url(addr: &str) -> Result<serde_json::Value, String> {\n    let reg_get_user_id = Regex::new(r#\"https://www.douyin.com/user/(\\w+)\"#).unwrap();\n    let uid = reg_get_user_id\n        .captures(addr)\n        .map_or(Err(\"地址错误\"), |cap| {\n            Ok(cap.get(1).map_or(\"\", |value| value.as_str()))\n        })?;\n    let res_text =\n        reqwest::get(\"https://www.iesdouyin.com/web/api/v2/user/info/?sec_uid=\".to_string() + uid)\n            .await\n            .map_err(|_| \"网络错误\")?\n            .text()\n            .await\n            .map_err(|_| \"网络错误\")?;\n\n    Ok(serde_json::from_str::<serde_json::Value>(&res_text).map_err(|_| \"解析错误\")?)\n}\n\n// 取用户下的所有个人视频\n#[tauri::command]\n#[async_recursion]\npub async fn get_list_by_user_id(\n    uid: &str,\n    count: u64,\n    max_cursor: u64,\n) -> Result<Vec<VideoInfo>, String> {\n    let mut res: Vec<VideoInfo> = vec![];\n    let res_text =\n        reqwest::get(format!(\"https://www.iesdouyin.com/web/api/v2/aweme/post/?sec_uid={uid}&count={count}&max_cursor={max_cursor}\"))\n            .await\n            .map_err(|_| \"网络错误\")?\n            .text()\n            .await\n            .map_err(|_| \"网络错误\")?;\n    let raw_info = serde_json::from_str::<serde_json::Value>(&res_text).map_err(|_| \"解析错误\")?;\n    let has_more = raw_info[\"has_more\"].as_bool().unwrap_or(false);\n    let max_cursor = raw_info[\"max_cursor\"].as_u64().unwrap_or(0_u64);\n    let video_list = match raw_info[\"aweme_list\"].is_array() {\n        true => raw_info[\"aweme_list\"].as_array().unwrap(),\n        _ => {\n            return Err(\"用户视频数为 0\".into());\n        }\n    };\n\n    res.append(\n        video_list\n            .iter()\n            .map(|item| VideoInfo {\n                title: item[\"desc\"].as_str().unwrap_or(\"\").to_string(),\n                ratio: item[\"video\"][\"ratio\"].as_str().unwrap_or(\"\").to_string(),\n                cover: item[\"video\"][\"cover\"][\"url_list\"][0]\n                    .as_str()\n                    .unwrap_or(\"\")\n                    .to_string(),\n                url: item[\"video\"][\"play_addr\"][\"url_list\"][0]\n                    .as_str()\n                    .unwrap_or(\"\")\n                    .replace(\"playwm\", \"play\"),\n                id: item[\"aweme_id\"].as_str().unwrap_or(\"\").to_string(),\n            })\n            .collect::<Vec<VideoInfo>>()\n            .as_mut(),\n    );\n\n    if !has_more {\n        return Ok(res);\n    }\n\n    res.append(get_list_by_user_id(uid, count, max_cursor).await?.as_mut());\n\n    Ok(res)\n}\n\n// 取用户下的所有点赞视频\n#[allow(dead_code)]\npub fn get_list_like_by_user_id() {}\n\n// 取用户下的所有收藏视频\n#[allow(dead_code)]\npub fn get_list_favorite_by_user_id() {}\n\n// 取 #tag 下的所有视频\n#[allow(dead_code)]\npub fn get_list_by_hash_tag() {}\n"
  },
  {
    "path": "src-tauri/src/main.rs",
    "content": "#![cfg_attr(\n    all(not(debug_assertions), target_os = \"windows\"),\n    windows_subsystem = \"windows\"\n)]\n\nuse tauri::{AboutMetadata, Menu, MenuItem, Submenu};\nmod command;\n\nfn main() {\n    let mut menu = Menu::new();\n\n    #[cfg(target_os = \"macos\")]\n    {\n        menu = menu.add_submenu(Submenu::new(\n            \"抖音视频下载\",\n            Menu::new()\n                .add_native_item(MenuItem::About(\"\".into(), AboutMetadata::default()))\n                .add_native_item(MenuItem::SelectAll)\n                .add_native_item(MenuItem::Quit),\n        ));\n    }\n\n    menu = menu.add_submenu(Submenu::new(\n        \"文件\",\n        Menu::new()\n            .add_native_item(MenuItem::CloseWindow)\n            .add_native_item(MenuItem::Undo)\n            .add_native_item(MenuItem::Redo)\n            .add_native_item(MenuItem::Cut)\n            .add_native_item(MenuItem::Copy)\n            .add_native_item(MenuItem::Paste),\n    ));\n\n    tauri::Builder::default()\n        .invoke_handler(tauri::generate_handler![\n            command::get_url_id,\n            command::get_video_info_by_id,\n            command::get_video_full_info_by_id,\n            command::download_video,\n            command::get_user_info_by_url,\n            command::get_user_full_info_by_url,\n            command::get_list_by_user_id,\n        ])\n        .menu(menu)\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n"
  },
  {
    "path": "src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"../node_modules/@tauri-apps/cli/schema.json\",\n  \"build\": {\n    \"beforeBuildCommand\": \"npm run build\",\n    \"beforeDevCommand\": \"npm run start\",\n    \"devPath\": \"http://localhost:3000\",\n    \"distDir\": \"../build\",\n    \"withGlobalTauri\": true\n  },\n  \"package\": {\n    \"productName\": \"douyin-downloader\",\n    \"version\": \"0.1.0\"\n  },\n  \"tauri\": {\n    \"allowlist\": {\n      \"all\": true\n    },\n    \"bundle\": {\n      \"active\": true,\n      \"category\": \"DeveloperTool\",\n      \"copyright\": \"lecepin\",\n      \"deb\": {\n        \"depends\": []\n      },\n      \"externalBin\": [],\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      \"identifier\": \"com.lecepin.douyindownloader\",\n      \"longDescription\": \"\",\n      \"macOS\": {\n        \"entitlements\": null,\n        \"exceptionDomain\": \"\",\n        \"frameworks\": [],\n        \"providerShortName\": null,\n        \"signingIdentity\": null\n      },\n      \"resources\": [],\n      \"shortDescription\": \"\",\n      \"targets\": \"all\",\n      \"windows\": {\n        \"certificateThumbprint\": null,\n        \"digestAlgorithm\": \"sha256\",\n        \"timestampUrl\": \"\"\n      }\n    },\n    \"security\": {\n      \"csp\": null\n    },\n    \"updater\": {\n      \"active\": false\n    },\n    \"windows\": [\n      {\n        \"fullscreen\": false,\n        \"height\": 600,\n        \"resizable\": true,\n        \"title\": \"抖音视频下载\",\n        \"width\": 800\n      }\n    ]\n  }\n}\n"
  }
]