[
  {
    "path": ".dockerignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Node.js dependencies\n/node_modules\n/jspm_packages\n\n# TypeScript v1 declaration files\ntypings\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variable files\n.env\n.env.test\n\n# local env files\n.env*.local\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n\n# Vuepress build output\n.vuepress/dist\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# Temporary folders\ntmp\ntemp\n\n# IDE and editor directories\n.idea\n.vscode\n*.swp\n*.swo\n*~\n.history\n\n# OS generated files\n.DS_Store\nThumbs.db\n\n# secret key\n*.key\n*.key.pub\n"
  },
  {
    "path": ".eslintignore",
    "content": ".now/*\n*.css\n.changeset\ndist\nesm/*\npublic/*\ntests/*\nscripts/*\n*.config.js\n.DS_Store\nnode_modules\ncoverage\n.next\nbuild\n!.commitlintrc.cjs\n!.lintstagedrc.cjs\n!jest.config.js\n!plopfile.js\n!react-shim.js\n!tsup.config.ts"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/eslintrc.json\",\n  \"env\": {\n    \"browser\": false,\n    \"es2021\": true,\n    \"node\": true\n  },\n  \"extends\": [\n    \"plugin:react/recommended\",\n    \"plugin:prettier/recommended\",\n    \"plugin:react-hooks/recommended\",\n    \"plugin:jsx-a11y/recommended\"\n  ],\n  \"plugins\": [\"react\", \"unused-imports\", \"import\", \"@typescript-eslint\", \"jsx-a11y\", \"prettier\"],\n  \"parser\": \"@typescript-eslint/parser\",\n  \"parserOptions\": {\n    \"ecmaFeatures\": {\n      \"jsx\": true\n    },\n    \"ecmaVersion\": 12,\n    \"sourceType\": \"module\"\n  },\n  \"settings\": {\n    \"react\": {\n      \"version\": \"detect\"\n    }\n  },\n  \"rules\": {\n    \"no-console\": \"warn\",\n    \"react/prop-types\": \"off\",\n    \"react/jsx-uses-react\": \"off\",\n    \"react/react-in-jsx-scope\": \"off\",\n    \"react-hooks/exhaustive-deps\": \"off\",\n    \"jsx-a11y/click-events-have-key-events\": \"warn\",\n    \"jsx-a11y/interactive-supports-focus\": \"warn\",\n    \"prettier/prettier\": \"warn\",\n    \"no-unused-vars\": \"off\",\n    \"unused-imports/no-unused-vars\": \"off\",\n    \"unused-imports/no-unused-imports\": \"warn\",\n    \"@typescript-eslint/no-unused-vars\": [\n      \"warn\",\n      {\n        \"args\": \"after-used\",\n        \"ignoreRestSiblings\": false,\n        \"argsIgnorePattern\": \"^_.*?$\"\n      }\n    ],\n    \"import/order\": [\n      \"warn\",\n      {\n        \"groups\": [\n          \"type\",\n          \"builtin\",\n          \"object\",\n          \"external\",\n          \"internal\",\n          \"parent\",\n          \"sibling\",\n          \"index\"\n        ],\n        \"pathGroups\": [\n          {\n            \"pattern\": \"~/**\",\n            \"group\": \"external\",\n            \"position\": \"after\"\n          }\n        ],\n        \"newlines-between\": \"always\"\n      }\n    ],\n    \"react/self-closing-comp\": \"warn\",\n    \"react/jsx-sort-props\": [\n      \"warn\",\n      {\n        \"callbacksLast\": true,\n        \"shorthandFirst\": true,\n        \"noSortAlphabetically\": false,\n        \"reservedFirst\": true\n      }\n    ],\n    \"padding-line-between-statements\": [\n      \"warn\",\n      {\"blankLine\": \"always\", \"prev\": \"*\", \"next\": \"return\"},\n      {\"blankLine\": \"always\", \"prev\": [\"const\", \"let\", \"var\"], \"next\": \"*\"},\n      {\n        \"blankLine\": \"any\",\n        \"prev\": [\"const\", \"let\", \"var\"],\n        \"next\": [\"const\", \"let\", \"var\"]\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: Publish Docker image\n\non:\n  workflow_dispatch:\n  release:\n    types: [published]\n\njobs:\n  push_to_registry:\n    name: Push Docker image to Docker Hub\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Check out the repo\n        uses: actions/checkout@v3\n      -\n        name: Log in to Docker Hub\n        uses: docker/login-action@v2\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - \n        name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: journey0ad/bitmagnet-next-web\n          tags: |\n            type=raw,value=latest,enable={{is_default_branch}}\n            type=ref,event=branch\n            type=ref,event=tag\n\n      - \n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n\n      - \n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v2\n\n      - \n        name: Build and push Docker image\n        uses: docker/build-push-action@v4\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n            \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# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n.history"
  },
  {
    "path": ".npmrc",
    "content": "package-lock=false\n"
  },
  {
    "path": ".prettierrc.js",
    "content": "module.exports = {\n  printWidth: 80,\n  tabWidth: 2,\n  useTabs: false,\n  semi: true,\n  singleQuote: false,\n  trailingComma: 'all',\n  bracketSpacing: true,\n  arrowParens: 'always',\n  endOfLine: 'auto',\n};\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"typescript.tsdk\": \"node_modules/typescript/lib\"\n}"
  },
  {
    "path": "Dockerfile",
    "content": "# Use node:20-alpine as the base image\nFROM node:20-alpine AS base\n\n# Set the working directory\nWORKDIR /app\n\n# Install dependencies\nFROM base AS deps\nCOPY package.json ./\nRUN npm install\n\n# Build the application\nFROM base AS builder\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY . .\nRUN npm run build\n\n# Prepare the runner stage\nFROM node:20-alpine AS runner\n\n# Set the working directory\nWORKDIR /app\n\n# Copy the necessary files from the builder stage\nCOPY --from=builder /app/package.json ./package.json\nCOPY --from=builder /app/public ./public\nCOPY --from=builder /app/.next/standalone ./\nCOPY --from=builder /app/.next/static ./.next/static\nCOPY --from=builder /app/.next/server ./.next/server\n\n# Expose the application port\nEXPOSE 3000\n\n# Set environment variables\nENV HOSTNAME=:: PORT=3000\n\n# Start the application\nCMD [\"node\", \"server.js\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 journey-ad\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<img src=\".readme/Logo.svg\" width=\"100\" height=\"100\" alt=\"Bitmagnet-Next-Web\" />\n\n<h1>Bitmagnet-Next-Web</h1>\n\nEnglish / [中文文档](./README_zh-CN.md)\n\nA more modern magnet search website program, developed using [Next.js 14](https://nextjs.org/docs/getting-started) + [NextUI v2](https://nextui.org/), with the backend powered by [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet).\n\n![Index](.readme/en_Index.jpg)\n![Search](.readme/en_Search.jpg)\n\n</div>\n\n## Deployment Instructions\n\n### Container Deployment\n\nThe most convenient way to deploy is using Docker Compose. Refer to the [docker-compose.yml](./docker-compose.yml)\n\n### Running with docker run\n\nIf not using Docker Compose, you can run each container separately using the following commands:\n\n1. Run the PostgreSQL container:\n\n```bash\ndocker run -d \\\n  --name bitmagnet-postgres \\\n  -p 5432:5432 \\\n  -e POSTGRES_PASSWORD=postgres \\\n  -e POSTGRES_DB=bitmagnet \\\n  -e PGUSER=postgres \\\n  -v ./data/postgres:/var/lib/postgresql/data \\\n  --shm-size=1g \\\n  postgres:16-alpine\n```\n\n2. Run the Bitmagnet container:\n\n```bash\ndocker run -d \\\n  --name bitmagnet \\\n  --link bitmagnet-postgres:postgres \\\n  -p 3333:3333 \\\n  -p 3334:3334/tcp \\\n  -p 3334:3334/udp \\\n  -e POSTGRES_HOST=postgres \\\n  -e POSTGRES_PASSWORD=postgres \\\n  ghcr.io/bitmagnet-io/bitmagnet:latest \\\n  worker run --keys=http_server --keys=queue_server --keys=dht_crawler\n```\n\n3. Run the Bitmagnet-Next-Web container:\n\n```bash\ndocker run -d \\\n  --name bitmagnet-next-web \\\n  --link bitmagnet-postgres:postgres \\\n  -p 3000:3000 \\\n  -e POSTGRES_DB_URL=postgres://postgres:postgres@postgres:5432/bitmagnet \\\n  journey0ad/bitmagnet-next-web:latest\n```\n\n### Full-Text Search Optimization\n\nThe search capability relies on the `torrents.name` and `torrent_files.path` columns. The original Bitmagnet does not index these columns, so it's recommended to create indexes to improve query efficiency:\n\n```sql\ncreate extension pg_trgm; -- Enable pg_trgm extension\n\n-- Create indexes on `torrents.name` and `torrent_files.path`\nCREATE INDEX idx_torrents_name_1 ON torrents USING gin (name gin_trgm_ops);\nCREATE INDEX idx_torrent_files_path_1 ON torrent_files USING gin (path gin_trgm_ops);\n```\n\n## Development Guide\n\nBefore starting development, create a `.env.local` file in the project root directory and fill in the environment variables:\n\n```bash\n# .env.local\nPOSTGRES_DB_URL=postgres://postgres:postgres@localhost:5432/bitmagnet\n```\n\nIt's recommended to use `pnpm` as the package manager.\n\n### Install Dependencies\n\n```bash\npnpm install\n```\n\n### Run Development Environment\n\n```bash\npnpm run dev\n```\n\n### Build & Deploy\n\n```bash\npnpm run build\npnpm run serve\n```\n\n## Credits\n\n- [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet)\n- [Next.js](https://nextjs.org/)\n- [NextUI](https://nextui.org/)\n- [Tailwind CSS](https://tailwindcss.com/)\n- [Fluent Emoji](https://github.com/microsoft/fluentui-emoji)\n\n## License\n\nLicensed under the [MIT license](./LICENSE).\n"
  },
  {
    "path": "README_zh-CN.md",
    "content": "<div align=\"center\">\n<img src=\".readme/Logo.svg\" width=\"100\" height=\"100\" alt=\"Bitmagnet-Next-Web\" />\n\n<h1>Bitmagnet-Next-Web</h1>\n\n[English](./README.md) / 中文文档\n\n更现代的磁力搜索网站程序，使用 [Next.js 14](https://nextjs.org/docs/getting-started) + [NextUI v2](https://nextui.org/) 开发，后端使用 [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet)\n\n![Index](.readme/zh_Index.jpg)\n![Search](.readme/zh_Search.jpg)\n\n</div>\n\n## 部署说明\n\n### 容器部署\n\n最方便的部署方式是用 Docker Compose，参考 [docker-compose.yml](./docker-compose.yml) 配置\n\n#### 使用 docker run 运行\n\n如果不使用 Docker Compose，可以使用以下命令分别运行各个容器：\n\n1. 运行 PostgreSQL 容器：\n\n```bash\ndocker run -d \\\n  --name bitmagnet-postgres \\\n  -p 5432:5432 \\\n  -e POSTGRES_PASSWORD=postgres \\\n  -e POSTGRES_DB=bitmagnet \\\n  -e PGUSER=postgres \\\n  -v ./data/postgres:/var/lib/postgresql/data \\\n  --shm-size=1g \\\n  postgres:16-alpine\n```\n\n2. 运行 Bitmagnet 容器：\n\n```bash\ndocker run -d \\\n  --name bitmagnet \\\n  --link bitmagnet-postgres:postgres \\\n  -p 3333:3333 \\\n  -p 3334:3334/tcp \\\n  -p 3334:3334/udp \\\n  -e POSTGRES_HOST=postgres \\\n  -e POSTGRES_PASSWORD=postgres \\\n  ghcr.io/bitmagnet-io/bitmagnet:latest \\\n  worker run --keys=http_server --keys=queue_server --keys=dht_crawler\n```\n\n3. 运行 Bitmagnet-Next-Web 容器：\n\n```bash\ndocker run -d \\\n  --name bitmagnet-next-web \\\n  --link bitmagnet-postgres:postgres \\\n  -p 3000:3000 \\\n  -e POSTGRES_DB_URL=postgres://postgres:postgres@postgres:5432/bitmagnet \\\n  journey0ad/bitmagnet-next-web:latest\n```\n\n### 全文搜索优化\n\n搜索能力依赖 `torrents.name` 和 `torrent_files.path` 两列数据，原版 Bitmagnet 未对此建立索引，建议先建立索引提升查询效率：\n\n```sql\ncreate extension pg_trgm; -- 启用 pg_trgm 扩展\n\n-- 对 `torrents.name` 和 `torrent_files.path` 建立索引\nCREATE INDEX idx_torrents_name_1 ON torrents USING gin (name gin_trgm_ops);\nCREATE INDEX idx_torrent_files_path_1 ON torrent_files USING gin (path gin_trgm_ops);\n```\n\n## 开发指引\n\n开发之前，需要先在项目根目录创建一个 `.env.local` 文件，并填写环境变量：\n\n```bash\n# .env.local\nPOSTGRES_DB_URL=postgres://postgres:postgres@localhost:5432/bitmagnet\n```\n\n推荐使用 `pnpm` 作为包管理器\n\n### 安装依赖\n\n```bash\npnpm install\n```\n\n### 开发环境运行\n\n```bash\npnpm run dev\n```\n\n### 打包 & 部署\n\n```bash\npnpm run build\npnpm run serve\n```\n\n## Credits\n\n- [Bitmagnet](https://github.com/bitmagnet-io/bitmagnet)\n- [Next.js](https://nextjs.org/)\n- [NextUI](https://nextui.org/)\n- [Tailwind CSS](https://tailwindcss.com/)\n- [Fluent Emoji](https://github.com/microsoft/fluentui-emoji)\n\n## License\n\nLicensed under the [MIT license](./LICENSE).\n\n## 免责声明\n\n- 本程序为免费开源项目，旨在方便对 Bitmagnet 程序的索引数据进行检索和重新排版，以及学习 Next.js 开发，本程序不涉及采集、存储和下载功能；\n- 本程序仅用于学习和研究，不得用于商业用途，使用时请遵守相关法律法规，不得侵犯任何第三方的知识产权；\n- 本程序不提供任何支持或保证，由使用者自身滥用本程序导致的一切后果均由使用者自行承担。使用者对本程序的使用即表示接受并同意本声明。\n"
  },
  {
    "path": "app/api/detail/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { gql } from \"@apollo/client\";\n\nimport client from \"@/lib/apolloClient\";\n\n// Define the GraphQL query to fetch torrent details by hash\nconst query = gql`\n  query TorrentByHash($hash: String!) {\n    torrentByHash(hash: $hash) {\n      hash\n      name\n      size\n      magnet_uri\n      single_file\n      files_count\n      files {\n        index\n        path\n        extension\n        size\n      }\n      created_at\n      updated_at\n    }\n  }\n`;\n\n// Function to handle GET requests\nconst handler = async (request: Request) => {\n  const { searchParams } = new URL(request.url);\n  const hash = searchParams.get(\"hash\");\n\n  // Return a 400 response if the hash parameter is missing\n  if (!hash) {\n    return NextResponse.json(\n      {\n        message: \"`hash` is required\",\n        status: 400,\n      },\n      {\n        status: 400,\n      },\n    );\n  }\n\n  try {\n    // Execute the GraphQL query with the provided hash variable\n    const { data } = await client.query({\n      query,\n      variables: { hash },\n    });\n\n    // Return a 200 response with the query data\n    return NextResponse.json(\n      {\n        data: data.torrentByHash,\n        message: \"success\",\n        status: 200,\n      },\n      {\n        status: 200,\n        headers: {\n          \"Content-Type\": \"application/json; charset=utf-8\",\n        },\n      },\n    );\n  } catch (error: any) {\n    console.error(error);\n\n    // Return a 500 response if there's an error during the query execution\n    return NextResponse.json(\n      {\n        message: error?.message || \"Internal Server Error\",\n        status: 500,\n      },\n      {\n        status: 500,\n      },\n    );\n  }\n};\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "app/api/graphql/moke.ts",
    "content": "import mokeData from \"@/moke\";\n\nexport function search() {\n  return mokeData.search;\n}\n\nexport function torrentByHash() {\n  return mokeData.detail;\n}\n\nexport function statsInfo() {\n  return mokeData.stats;\n}\n"
  },
  {
    "path": "app/api/graphql/route.ts",
    "content": "import { ApolloServer } from \"@apollo/server\";\nimport { startServerAndCreateNextHandler } from \"@as-integrations/next/dist\";\nimport { gql } from \"graphql-tag\";\nimport { NextRequest } from \"next/server\";\n\n// import { search, torrentByHash, statsInfo } from \"./service\";\n\nconst isDemoMode = process.env.DEMO_MODE === \"true\";\nconst { search, torrentByHash, statsInfo } = isDemoMode\n  ? require(\"./moke\")\n  : require(\"./service\");\n\nif (isDemoMode) {\n  console.log(\"[Bitmagnet-Next-Web] This website is running in demo mode.\");\n}\n\n// Define GraphQL Schema\nconst typeDefs = gql`\n  type TorrentFile {\n    index: Int\n    path: String\n    extension: String\n    size: String\n  }\n\n  type Torrent {\n    hash: String!\n    name: String!\n    size: String!\n    magnet_uri: String!\n    single_file: Boolean!\n    files_count: Int!\n    files: [TorrentFile!]!\n    created_at: Int!\n    updated_at: Int!\n  }\n\n  input SearchQueryInput {\n    keyword: String!\n    offset: Int!\n    limit: Int!\n    sortType: String\n    filterTime: String\n    filterSize: String\n    withTotalCount: Boolean\n  }\n\n  type SearchResult {\n    keywords: [String!]!\n    torrents: [Torrent!]!\n    total_count: Int!\n    has_more: Boolean!\n  }\n\n  type statsInfoResult {\n    size: String!\n    total_count: Int!\n    updated_at: Int!\n    latest_torrent_hash: String\n    latest_torrent: Torrent\n  }\n\n  type Query {\n    search(queryInput: SearchQueryInput!): SearchResult!\n    torrentByHash(hash: String!): Torrent\n    statsInfo: statsInfoResult\n  }\n`;\n\n// Create Apollo Server instance\nconst server = new ApolloServer({\n  typeDefs,\n  resolvers: {\n    Query: {\n      search,\n      torrentByHash,\n      statsInfo,\n    },\n  },\n});\n\n// req has the type NextRequest\nconst handler = startServerAndCreateNextHandler<NextRequest>(server, {\n  context: async (req) => ({ req }),\n});\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "app/api/graphql/service.ts",
    "content": "import { query } from \"@/lib/pgdb\";\nimport { jiebaCut } from \"@/lib/jieba\";\nimport { SEARCH_KEYWORD_SPLIT_REGEX } from \"@/config/constant\";\n\ntype Torrent = {\n  info_hash: Buffer; // The hash info of the torrent\n  name: string; // The name of the torrent\n  size: string; // The size of the torrent\n  files_count: number; // The count of files in the torrent\n  files: TorrentFile[]; // The list of files in the torrent\n  created_at: number; // The timestamp when the torrent was created\n  updated_at: number; // The timestamp when the torrent was last updated\n};\n\ntype TorrentFile = {\n  index: number; // The index of the file in the torrent\n  path: string; // The path of the file in the torrent\n  size: string; // The size of the file in the torrent\n  extension: string; // The extension of the file\n};\n\nconst REGEX_PADDING_FILE = /^(_____padding_file_|\\.pad\\/\\d+&)/; // Regular expression to identify padding files\n\nexport function formatTorrent(row: Torrent) {\n  const hash = row.info_hash.toString(\"hex\"); // Convert info_hash from Buffer to hex string\n\n  const generateSingleFiles = (row: Torrent) => {\n    return [\n      {\n        index: 0,\n        path: row.name,\n        size: row.size,\n        extension: row.name.split(\".\").pop() || \"\",\n      },\n    ];\n  };\n\n  return {\n    hash: hash,\n    name: row.name,\n    size: row.size,\n    magnet_uri: `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(row.name)}&xl=${row.size}`, // Create magnet URI\n    single_file: row.files_count <= 1,\n    files_count: row.files_count || 1,\n    files: (row.files_count > 0 ? row.files : generateSingleFiles(row))\n      .map((file) => ({\n        index: file.index,\n        path: file.path,\n        size: file.size,\n        extension: file.extension,\n      }))\n      .sort((a, b) => {\n        // Sorting priority: padding_file lowest -> extension empty next -> ascending index\n        const aPadding = REGEX_PADDING_FILE.test(a.path) ? 1 : 0;\n        const bPadding = REGEX_PADDING_FILE.test(b.path) ? 1 : 0;\n\n        if (aPadding !== bPadding) {\n          return aPadding - bPadding; // padding_file has the lowest priority\n        }\n\n        const aNoExtension = !a.extension ? 1 : 0;\n        const bNoExtension = !b.extension ? 1 : 0;\n\n        if (aNoExtension !== bNoExtension) {\n          return aNoExtension - bNoExtension; // Files with no extension have lower priority\n        }\n\n        return a.index - b.index; // Within the same priority, sort by index in ascending order\n      }),\n    created_at: Math.floor(row.created_at / 1000), // Convert timestamps to seconds\n    updated_at: Math.floor(row.updated_at / 1000), // Convert timestamps to seconds\n  };\n}\n\n// Utility functions for query building\nconst buildOrderBy = (sortType: keyof typeof orderByMap) => {\n  const orderByMap = {\n    size: \"torrents.size DESC\",\n    count: \"COALESCE(torrents.files_count, 0) DESC\",\n    date: \"torrents.created_at ASC\",\n  };\n\n  return orderByMap[sortType] || \"torrents.created_at DESC\";\n};\n\nconst buildTimeFilter = (filterTime: keyof typeof timeFilterMap) => {\n  const timeFilterMap = {\n    \"gt-1day\": \"AND torrents.created_at > now() - interval '1 day'\",\n    \"gt-7day\": \"AND torrents.created_at > now() - interval '1 week'\",\n    \"gt-31day\": \"AND torrents.created_at > now() - interval '1 month'\",\n    \"gt-365day\": \"AND torrents.created_at > now() - interval '1 year'\",\n  };\n\n  return timeFilterMap[filterTime] || \"\";\n};\n\nconst buildSizeFilter = (filterSize: keyof typeof sizeFilterMap) => {\n  const sizeFilterMap = {\n    lt100mb: \"AND torrents.size < 100 * 1024 * 1024::bigint\",\n    \"gt100mb-lt500mb\":\n      \"AND torrents.size BETWEEN 100 * 1024 * 1024::bigint AND 500 * 1024 * 1024::bigint\",\n    \"gt500mb-lt1gb\":\n      \"AND torrents.size BETWEEN 500 * 1024 * 1024::bigint AND 1024 * 1024 * 1024::bigint\",\n    \"gt1gb-lt5gb\":\n      \"AND torrents.size BETWEEN 1 * 1024 * 1024 * 1024::bigint AND 5 * 1024 * 1024 * 1024::bigint\",\n    gt5gb: \"AND torrents.size > 5 * 1024 * 1024 * 1024::bigint\",\n  };\n\n  return sizeFilterMap[filterSize] || \"\";\n};\n\nconst QUOTED_KEYWORD_REGEX = /\"([^\"]+)\"/g;\nconst extractKeywords = (\n  keyword: string,\n): { keyword: string; required: boolean }[] => {\n  let keywords = [];\n  let match;\n\n  // Extract exact keywords using quotation marks\n  while ((match = QUOTED_KEYWORD_REGEX.exec(keyword)) !== null) {\n    keywords.push({ keyword: match[1], required: true });\n  }\n\n  const remainingKeywords = keyword.replace(QUOTED_KEYWORD_REGEX, \"\");\n\n  // Extract remaining keywords using regex tokenizer\n  keywords.push(\n    ...remainingKeywords\n      .trim()\n      .split(SEARCH_KEYWORD_SPLIT_REGEX)\n      .map((k) => ({ keyword: k, required: false })),\n  );\n\n  // Use jieba to words segment if input is a full sentence\n  if (keywords.length === 1 && keyword.length >= 4) {\n    keywords.push(...jiebaCut(keyword));\n  }\n\n  // Remove duplicates and filter out keywords shorter than 2 characters to avoid slow SQL queries\n  keywords = Array.from(\n    new Map(keywords.map((k) => [k.keyword, k])).values(),\n  ).filter(({ keyword }) => keyword.trim().length >= 2);\n\n  // Ensure at least 1/3 keyword is required when there is no required keyword\n  if (keywords.length && !keywords.some(({ required }) => required)) {\n    [...keywords]\n      .sort((a, b) => b.keyword.length - a.keyword.length)\n      .slice(0, Math.ceil(keywords.length / 3))\n      .forEach((k) => (k.required = true));\n  }\n\n  const fullKeyword = keyword.replace(/\"/g, \"\");\n\n  // Ensure full keyword is the first item\n  if (!keywords.some((k) => k.keyword === fullKeyword)) {\n    keywords.unshift({ keyword: fullKeyword, required: false });\n  }\n\n  return keywords;\n};\n\nexport async function search(_: any, { queryInput }: any) {\n  try {\n    console.info(\"-\".repeat(50));\n    console.info(\"search params\", queryInput);\n\n    // trim keyword\n    queryInput.keyword = queryInput.keyword.trim();\n\n    const no_result = {\n      keywords: [queryInput.keyword],\n      torrents: [],\n      total_count: 0,\n      has_more: false,\n    };\n\n    // Return an empty result if no keywords are provided\n    if (queryInput.keyword.length < 2) {\n      return no_result;\n    }\n\n    const REGEX_HASH = /^[a-f0-9]{40}$/;\n\n    if (REGEX_HASH.test(queryInput.keyword)) {\n      const torrent = await torrentByHash(_, { hash: queryInput.keyword });\n\n      if (torrent) {\n        return {\n          keywords: [queryInput.keyword],\n          torrents: [torrent],\n          total_count: 1,\n          has_more: false,\n        };\n      }\n\n      return no_result;\n    }\n\n    // Build SQL conditions and parameters\n    const orderBy = buildOrderBy(queryInput.sortType);\n    const timeFilter = buildTimeFilter(queryInput.filterTime);\n    const sizeFilter = buildSizeFilter(queryInput.filterSize);\n\n    const keywords = extractKeywords(queryInput.keyword);\n\n    // Construct the keyword filter condition\n    const requiredKeywords: string[] = [];\n    const optionalKeywords: string[] = [];\n\n    keywords.forEach(({ required }, i) => {\n      const condition = `torrents.name ILIKE $${i + 1}`;\n\n      if (required) {\n        requiredKeywords.push(condition);\n      } else {\n        optionalKeywords.push(condition);\n      }\n    });\n\n    const fullConditions = [...requiredKeywords];\n\n    if (optionalKeywords.length > 0) {\n      optionalKeywords.push(\"TRUE\");\n      fullConditions.push(`(${optionalKeywords.join(\" OR \")})`);\n    }\n\n    const keywordFilter = fullConditions.join(\" AND \");\n\n    const keywordsParams = keywords.map(({ keyword }) => `%${keyword}%`);\n    const keywordsPlain = keywords.map(({ keyword }) => keyword);\n\n    // SQL query to fetch filtered torrent data and files information\n    const sql = `\n-- 先查到符合过滤条件的数据\nWITH filtered AS (\n  SELECT \n    torrents.info_hash,    -- 种子哈希\n    torrents.name,         -- 种子名称\n    torrents.size,         -- 种子大小\n    torrents.created_at,   -- 创建时间戳\n    torrents.updated_at,   -- 更新时间戳\n    torrents.files_count   -- 种子文件数\n  FROM \n    torrents\n  WHERE \n    (${keywordFilter})   -- 关键词过滤条件\n    ${timeFilter}   -- 时间范围过滤条件\n    ${sizeFilter}   -- 大小范围过滤条件\n  ${orderBy ? `ORDER BY ${orderBy}` : \"\"} -- 排序方式\n  LIMIT $${keywords.length + 1}    -- 返回数量\n  OFFSET $${keywords.length + 2}   -- 分页偏移\n)\n-- 从过滤后的数据中查询文件信息\nSELECT \n  filtered.info_hash,    -- 种子哈希\n  filtered.name,         -- 种子名称\n  filtered.size,         -- 种子大小\n  filtered.created_at,   -- 创建时间戳\n  filtered.updated_at,   -- 更新时间戳\n  filtered.files_count,  -- 种子文件数\n  -- 检查 files_count, 是否有文件数量\n  CASE\n    WHEN filtered.files_count IS NOT NULL THEN (\n      -- 如果有数量, 根据 info_hash 查询文件信息到 'files' 列, 聚合成JSON\n      SELECT json_agg(json_build_object(\n        'index', torrent_files.index,         -- 文件在种子中的索引\n        'path', torrent_files.path,           -- 文件在种子中的路径\n        'size', torrent_files.size,           -- 文件大小\n        'extension', torrent_files.extension  -- 文件扩展名\n      ))\n      FROM torrent_files\n      WHERE torrent_files.info_hash = filtered.info_hash   -- 根据 info_hash 匹配文件\n    )\n    ELSE NULL   -- 如果 files_count 为空, 则设置为NULL\n  END AS files  -- 结果别名设为 'files'\nFROM \n  filtered;   -- 从过滤后的数据中查询\n`;\n\n    const params = [...keywordsParams, queryInput.limit, queryInput.offset];\n\n    console.debug(\"SQL:\", sql, params);\n    console.debug(\n      \"keywords:\",\n      keywords.map((item, i) => ({ _: `$${i + 1}`, ...item })),\n    );\n\n    const queryArr = [query(sql, params)];\n\n    // SQL query to get the total count if requested\n    if (queryInput.withTotalCount) {\n      const countSql = `\nSELECT COUNT(*) AS total\nFROM (\n  SELECT 1\n  FROM torrents\n  WHERE\n    (${keywordFilter})\n    ${timeFilter}\n    ${sizeFilter}\n) AS limited_total;\n        `;\n      const countParams = [...keywordsParams];\n\n      queryArr.push(query(countSql, countParams));\n    } else {\n      queryArr.push(Promise.resolve({ rows: [{ total: 0 }] }) as any);\n    }\n\n    // Execute queries and process results\n    const [{ rows: torrentsResp }, { rows: countResp }] =\n      await Promise.all(queryArr);\n\n    const torrents = torrentsResp.map(formatTorrent);\n    const total_count = countResp[0].total;\n\n    const has_more =\n      queryInput.withTotalCount &&\n      queryInput.offset + queryInput.limit < total_count;\n\n    return { keywords: keywordsPlain, torrents, total_count, has_more };\n  } catch (error) {\n    console.error(\"Error in search resolver:\", error);\n    throw new Error(\"Failed to execute search query\");\n  }\n}\n\nexport async function torrentByHash(_: any, { hash }: { hash: string }) {\n  try {\n    // SQL query to fetch torrent data and files information by hash\n    const sql = `\nSELECT\n  t.info_hash,\n  t.name,\n  t.size,\n  t.created_at,\n  t.updated_at,\n  t.files_count,\n  json_agg(json_build_object(\n    'index', f.index,\n    'path', f.path,\n    'size', f.size,\n    'extension', f.extension\n  )) AS files\nFROM torrents t\nLEFT JOIN torrent_files f ON t.info_hash = f.info_hash\nWHERE t.info_hash = decode($1, 'hex')\nGROUP BY t.info_hash, t.name, t.size, t.created_at, t.updated_at, t.files_count;\n    `;\n\n    const params = [hash];\n\n    const { rows } = await query(sql, params);\n    const torrent = rows[0];\n\n    if (!torrent) {\n      return null;\n    }\n\n    return formatTorrent(torrent);\n  } catch (error) {\n    console.error(\"Error in torrentByHash resolver:\", error);\n    throw new Error(\"Failed to fetch torrent by hash\");\n  }\n}\n\nexport async function statsInfo() {\n  try {\n    const sql = `\nWITH db_size AS (\n  SELECT pg_database_size('bitmagnet') AS size\n),\ntorrent_count AS (\n  SELECT COUNT(*) AS total_count FROM torrents\n),\nlatest_torrent AS (\n  SELECT *\n    FROM torrents\n    ORDER BY created_at DESC\n    LIMIT 1\n)\nSELECT \n  db_size.size,\n  latest_torrent.created_at as updated_at,\n  torrent_count.total_count,\n  encode(latest_torrent.info_hash, 'hex') AS latest_torrent_hash,\n  json_build_object(\n    'hash', encode(latest_torrent.info_hash, 'hex'),\n    'name', latest_torrent.name,\n    'size', latest_torrent.size,\n    'created_at', latest_torrent.created_at,\n    'updated_at', latest_torrent.updated_at\n  ) AS latest_torrent\nFROM \n  db_size,\n  torrent_count,\n  latest_torrent;\n    `;\n\n    const { rows } = await query(sql, []);\n    const data = rows[0];\n\n    if (!data) {\n      return null;\n    }\n\n    return {\n      ...data,\n      updated_at: Math.floor(new Date(data.updated_at).getTime() / 1000),\n      latest_torrent: {\n        ...data.latest_torrent,\n        created_at: Math.floor(\n          new Date(data.latest_torrent.created_at).getTime() / 1000,\n        ),\n        updated_at: Math.floor(\n          new Date(data.latest_torrent.updated_at).getTime() / 1000,\n        ),\n      },\n    };\n  } catch (error) {\n    console.error(\"Error in statsInfo resolver:\", error);\n    throw new Error(\"Failed to fetch torrents count\");\n  }\n}\n"
  },
  {
    "path": "app/api/preview/[hash64]/[id]/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { fail, getPreviewInfo } from \"../../service\";\n\n// Function to handle GET requests\nconst handler = async (\n  request: Request,\n  { params }: { params: { hash64: string; id: number } },\n) => {\n  try {\n    const linkInfo = await getPreviewInfo(params.hash64);\n\n    const screenshots = linkInfo.screenshots?.map((item) => item.screenshot);\n\n    const imageUrl = screenshots?.[params.id];\n\n    if (!imageUrl) {\n      return fail(\"Image not found\", 404);\n    }\n\n    // Fetch the image from the URL and return it\n    const response = await fetch(imageUrl, {\n      headers: {\n        Referer: request.url,\n      },\n    });\n    const buffer = await response.arrayBuffer();\n\n    console.log(\"====================================\");\n    console.log(\"imageUrl\", imageUrl);\n    console.log(\"buffer\", buffer);\n    console.log(\"====================================\");\n\n    // Return a 200 response with the image data\n    return new Response(buffer, {\n      headers: {\n        \"Content-Type\": response.headers.get(\"content-type\") || \"image/jpeg\",\n      },\n    });\n  } catch (error: any) {\n    console.error(error);\n\n    // Return a 500 response if there's an error during the query execution\n    return NextResponse.json(\n      {\n        message: error?.message || \"Internal Server Error\",\n        status: 500,\n      },\n      {\n        status: 500,\n      },\n    );\n  }\n};\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "app/api/preview/[hash64]/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { base64ToHex, getLinkInfoFromWhatsLink } from \"@/utils\";\nimport { getPreviewInfo, success, fail } from \"../service\";\n\nconst invalid = (message: string) => {\n  return NextResponse.json(\n    {\n      message,\n      status: 400,\n    },\n    {\n      status: 400,\n    },\n  );\n};\n\n// Function to handle GET requests\nconst handler = async (\n  request: Request,\n  { params }: { params: { hash64: string } },\n) => {\n  try {\n    const linkInfo = await getPreviewInfo(params.hash64);\n\n    console.log(linkInfo);\n\n    const data = {\n      name: linkInfo.name,\n      size: linkInfo.size,\n      screenshots: linkInfo.screenshots?.map(\n        (_item, index) => `${request.url}/${index}`,\n      ),\n    };\n\n    return success(data);\n  } catch (error: any) {\n    console.error(error);\n\n    return fail(error.message || \"Internal Server Error\");\n  }\n};\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "app/api/preview/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nconst handler = async () => {\n  return NextResponse.json(\n    {\n      message: \"Invalid request\",\n      status: 400,\n    },\n    {\n      status: 400,\n    },\n  );\n};\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "app/api/preview/service.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { base64ToHex, getLinkInfoFromWhatsLink } from '@/utils';\n\nexport const fail = (message: string, status: number = 500) => {\n  return NextResponse.json(\n    {\n      message,\n      status,\n    },\n    {\n      status,\n      headers: {\n        \"Content-Type\": \"application/json; charset=utf-8\",\n      },\n    },\n  );\n};\n\nexport const success = (data: any) => {\n  return NextResponse.json(\n    {\n      data,\n      message: \"success\",\n      status: 200,\n    },\n    {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json; charset=utf-8\",\n      },\n    },\n  );\n};\n\nexport async function getPreviewInfo(hash64: string) {\n  const hash = base64ToHex(hash64);\n\n  if (!hash || hash.length !== 40) {\n    console.error(\"Invalid hash\", hash);\n\n    throw new Error(\"Invalid hash\");\n  }\n\n  const magnet_uri = `magnet:?xt=urn:btih:${hash}`;\n\n  const linkInfo = await getLinkInfoFromWhatsLink(magnet_uri);\n\n  if (!linkInfo || linkInfo.error) {\n    console.error(\"Invalid link\", linkInfo);\n\n    throw new Error(\"Invalid link\");\n  }\n\n  return linkInfo;\n}\n"
  },
  {
    "path": "app/api/search/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { gql } from \"@apollo/client\";\nimport { z } from \"zod\";\n\nimport client from \"@/lib/apolloClient\";\nimport {\n  SEARCH_PARAMS,\n  SEARCH_KEYWORD_LENGTH_MIN,\n  SEARCH_KEYWORD_LENGTH_MAX,\n  SEARCH_PAGE_SIZE,\n  DEFAULT_SORT_TYPE,\n  DEFAULT_FILTER_TIME,\n  DEFAULT_FILTER_SIZE,\n} from \"@/config/constant\";\n\n// GraphQL query to search for torrents\nconst SEARCH = gql`\n  query Search($queryInput: SearchQueryInput!) {\n    search(queryInput: $queryInput) {\n      keywords\n      torrents {\n        hash\n        name\n        size\n        magnet_uri\n        single_file\n        files_count\n        files {\n          index\n          path\n          extension\n          size\n        }\n        created_at\n        updated_at\n      }\n      total_count\n      has_more\n    }\n  }\n`;\n\n// Define the schema for the request parameters using Zod\nconst schema = z.object({\n  keyword: z\n    .string()\n    .min(SEARCH_KEYWORD_LENGTH_MIN)\n    .max(SEARCH_KEYWORD_LENGTH_MAX),\n  offset: z.coerce.number().min(0).default(0),\n  limit: z.coerce\n    .number()\n    .min(1)\n    .max(SEARCH_PAGE_SIZE)\n    .default(SEARCH_PAGE_SIZE),\n  sortType: z.enum(SEARCH_PARAMS.sortType).default(DEFAULT_SORT_TYPE),\n  filterTime: z.enum(SEARCH_PARAMS.filterTime).default(DEFAULT_FILTER_TIME),\n  filterSize: z.enum(SEARCH_PARAMS.filterSize).default(DEFAULT_FILTER_SIZE),\n  withTotalCount: z\n    .enum([\"0\", \"1\"])\n    .transform((value) => value === \"1\")\n    .default(\"1\"),\n});\n\nconst handler = async (request: Request) => {\n  // Extract search parameters from the request URL\n  const { searchParams } = new URL(request.url);\n  const params = Object.fromEntries(searchParams.entries());\n\n  let safeParams;\n\n  // Validate and parse the parameters using Zod schema\n  try {\n    safeParams = schema.parse(params);\n  } catch (error: any) {\n    console.error(error);\n\n    const { path, message } = error.errors[0] || {};\n    const errMessage = path ? `${path[0]}: ${message}` : message;\n\n    return NextResponse.json(\n      {\n        data: null,\n        message: errMessage || \"Invalid request\",\n        status: 400,\n      },\n      {\n        status: 400,\n      },\n    );\n  }\n\n  // Perform the search query using Apollo Client\n  try {\n    const { data } = await client.query({\n      query: SEARCH,\n      variables: {\n        queryInput: safeParams,\n      },\n      fetchPolicy: \"no-cache\",\n    });\n\n    return NextResponse.json(\n      {\n        data: data.search,\n        message: \"success\",\n        status: 200,\n      },\n      {\n        status: 200,\n        headers: {\n          \"Content-Type\": \"application/json; charset=utf-8\",\n        },\n      },\n    );\n  } catch (error: any) {\n    console.error(error);\n\n    return NextResponse.json(\n      {\n        data: null,\n        message: error?.message || \"Internal Server Error\",\n        status: 500,\n      },\n      {\n        status: 500,\n      },\n    );\n  }\n};\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "app/api/stats/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { gql } from \"@apollo/client\";\n\nimport client from \"@/lib/apolloClient\";\n\n// Define the GraphQL query to fetch torrent details by hash\nconst query = gql`\n  query StatsInfo {\n    statsInfo {\n      size\n      total_count\n      updated_at\n      latest_torrent_hash\n      latest_torrent {\n        hash\n        name\n        size\n        created_at\n        updated_at\n      }\n    }\n  }\n`;\n\n// Function to handle GET requests\nconst handler = async () => {\n  try {\n    // Execute the GraphQL query with the provided hash variable\n    const { data } = await client.query({ query, fetchPolicy: \"no-cache\" });\n\n    await new Promise((resolve) => setTimeout(resolve, 5000));\n\n    // Return a 200 response with the query data\n    return NextResponse.json(\n      {\n        data: data.statsInfo,\n        message: \"success\",\n        status: 200,\n      },\n      {\n        status: 200,\n        headers: {\n          \"Content-Type\": \"application/json; charset=utf-8\",\n        },\n      },\n    );\n  } catch (error: any) {\n    console.error(error);\n\n    // Return a 500 response if there's an error during the query execution\n    return NextResponse.json(\n      {\n        message: error?.message || \"Internal Server Error\",\n        status: 500,\n      },\n      {\n        status: 500,\n      },\n    );\n  }\n};\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "app/detail/[hash64]/layout.tsx",
    "content": "import { Link } from \"@nextui-org/react\";\n\nimport { FloatTool } from \"@/components/FloatTool\";\nimport { SearchInput } from \"@/components/SearchInput\";\nimport { MagnetIcon } from \"@/components/icons\";\nimport { siteConfig } from \"@/config/site\";\n\nexport default function DetailLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <section className=\"flex flex-col justify-center gap-4 px-3 py-3 md:py-8\">\n      <div className=\"flex items-center mb-4\">\n        <Link\n          className=\"mb-[-2px] mr-2 md:mr-4 leading-none text-[50px] md:text-[60px]\"\n          href=\"/\"\n          title={siteConfig.name}\n        >\n          <MagnetIcon />\n        </Link>\n        <SearchInput />\n      </div>\n      {children}\n      <FloatTool />\n    </section>\n  );\n}\n"
  },
  {
    "path": "app/detail/[hash64]/page.tsx",
    "content": "import { Metadata } from \"next\";\nimport { notFound } from \"next/navigation\";\n\nimport { base64ToHex, getLinkInfoFromWhatsLink } from \"@/utils\";\nimport apiFetch from \"@/utils/api\";\nimport { DetailContent } from \"@/components/DetailContent\";\n\n// Function to fetch torrent data based on the hash\nasync function fetchData(hash64: string) {\n  const hash = base64ToHex(hash64); // Convert base64 hash to hex\n\n  if (!hash || hash.length !== 40) {\n    console.error(\"Invalid hash\", hash);\n    notFound();\n  }\n\n  const data = await apiFetch(`/api/detail?hash=${hash}`, {\n    next: { revalidate: 60 * 60 * 24 * 7 }, // cache for 7 days\n  });\n\n  return data;\n}\n\n// Function to generate metadata for the page\nexport async function generateMetadata({\n  params: { hash64 },\n}: {\n  params: { hash64: string };\n}): Promise<Metadata> {\n  const { data } = await fetchData(hash64);\n\n  return {\n    title: data.name,\n  };\n}\n\n// Component to render the detail page\nexport default async function Detail({\n  params: { hash64 },\n}: {\n  params: { hash64: string };\n}) {\n  const { data } = await fetchData(hash64);\n\n  const linkInfo = getLinkInfoFromWhatsLink(data.magnet_uri);\n\n  return (\n    <>\n      <DetailContent data={data} linkInfo={linkInfo} />\n    </>\n  );\n}\n"
  },
  {
    "path": "app/detail/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\nexport default function DetailPage() {\n  redirect(\"/\");\n}\n"
  },
  {
    "path": "app/error.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nexport default function Error({\n  error,\n  reset: _reset,\n}: {\n  error: Error & { digest?: string };\n  reset: () => void;\n}) {\n  useEffect(() => {\n    // Log the error to an error reporting service\n    /* eslint-disable no-console */\n    console.error(\"error\", error.message, error.digest);\n  }, [error]);\n\n  const t = useTranslations(\"ERROR_MESSAGE\");\n\n  return (\n    <div className=\"flex flex-col justify-center mx-auto w-4/5 pb-[10vh] max-w-lg h-full\">\n      <h1 className=\"mb-4 text-[30px] md:text-[50px] leading-tight font-medium text-gray-700\">\n        {t(\"INTERNAL_SERVER_ERROR\")}\n      </h1>\n\n      <div className=\"flex flex-col gap-y-1 w-full mb-6 break-words leading-tight text-sm md:text-lg text-gray-600\">\n        <p className=\"mb-2\">\n          <span className=\"font-medium\">{t(\"Message\")}</span>: {error.message}\n        </p>\n        <p>\n          <span className=\"font-medium\">{t(\"Digest\")}</span>: {error.digest}\n        </p>\n      </div>\n\n      <a\n        className=\"px-4 py-2 mr-auto font-medium text-white bg-indigo-500 rounded-md hover:bg-indigo-600 transition-all duration-200 ease-in-out\"\n        href=\"/\"\n      >\n        {t(\"GoHome\")}\n      </a>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/layout.tsx",
    "content": "import \"@/styles/globals.css\";\nimport { Metadata, Viewport } from \"next\";\nimport { NextIntlClientProvider } from \"next-intl\";\nimport { getLocale, getMessages } from \"next-intl/server\";\nimport clsx from \"clsx\";\n\nimport { Providers } from \"./providers\";\n\nimport { siteConfig } from \"@/config/site\";\nimport { fontSans, fontNoto, fontMono } from \"@/config/fonts\";\nimport { DemoMode } from \"@/components/DemoMode\";\nimport { BgEffect } from \"@/components/BgEffect\";\n\nexport const metadata: Metadata = {\n  title: {\n    default: siteConfig.name,\n    template: `%s - ${siteConfig.name}`,\n  },\n  description: siteConfig.description,\n  icons: {\n    icon: \"/favicon.ico\",\n  },\n};\n\nexport const viewport: Viewport = {\n  themeColor: [\n    { media: \"(prefers-color-scheme: light)\", color: \"white\" },\n    { media: \"(prefers-color-scheme: dark)\", color: \"black\" },\n  ],\n  width: \"device-width\",\n  height: \"device-height\",\n  initialScale: 1,\n  maximumScale: 1,\n  userScalable: false,\n  viewportFit: \"cover\",\n};\n\nexport default async function RootLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const locale = await getLocale();\n  const messages = await getMessages();\n\n  return (\n    <html suppressHydrationWarning lang={locale}>\n      <head />\n      <body\n        className={clsx(\n          \"h-full bg-background font-sans antialiased\",\n          fontSans.variable,\n          fontMono.variable,\n          locale.startsWith(\"zh\") ? fontNoto.className : \"\",\n        )}\n      >\n        <NextIntlClientProvider messages={messages}>\n          <Providers\n            themeProps={{\n              attribute: \"class\",\n              defaultTheme: \"system\",\n              enableSystem: true,\n            }}\n          >\n            <div className=\"relative flex flex-col h-full\">\n              <DemoMode />\n              <BgEffect />\n              <main className=\"container w-full md:w-4/5 mx-auto max-w-6xl flex-grow z-10\">\n                {children}\n              </main>\n            </div>\n          </Providers>\n        </NextIntlClientProvider>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "app/not-found.tsx",
    "content": "import { getTranslations } from \"next-intl/server\";\n\nexport default async function NotFound() {\n  const t = await getTranslations(\"ERROR_MESSAGE\");\n\n  return (\n    <div className=\"flex flex-col justify-center mx-auto w-4/5 pb-[10vh] max-w-lg h-full\">\n      <h1 className=\"mb-4 text-[60px] md:text-[100px] leading-tight font-medium text-gray-700\">\n        404\n      </h1>\n\n      <div className=\"flex flex-col gap-y-1 w-full mb-6 break-words text-sm md:text-lg text-gray-600\">\n        {t(\"NOT_FOUND\")}\n      </div>\n\n      <a\n        className=\"px-4 py-2 mr-auto font-medium text-white bg-indigo-500 rounded-md hover:bg-indigo-600 transition-all duration-200 ease-in-out\"\n        href=\"/\"\n      >\n        {t(\"GoHome\")}\n      </a>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/page.tsx",
    "content": "import { HomeLogo } from \"@/components/HomeLogo\";\nimport { SearchInput } from \"@/components/SearchInput\";\nimport { ToggleTheme, SwitchLanguage } from \"@/components/FloatTool\";\nimport { Stats } from \"@/components/Stats\";\n\nexport default function Home() {\n  return (\n    <section className=\"flex flex-col items-center justify-center gap-4 w-4/5 md:w-3/5 h-full mx-auto pb-24 md:pb-20\">\n      <HomeLogo />\n      <SearchInput />\n      <div className=\"fixed bottom-4 right-4 invisible md:visible\">\n        <Stats />\n      </div>\n      <div className=\"fixed top-4 right-4 flex gap-1\">\n        <SwitchLanguage noBg />\n        <ToggleTheme noBg />\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "app/providers.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { NextUIProvider } from \"@nextui-org/system\";\nimport { useRouter } from \"next/navigation\";\nimport { ThemeProvider as NextThemesProvider } from \"next-themes\";\nimport { ThemeProviderProps } from \"next-themes/dist/types\";\nimport { ApolloProvider } from \"@apollo/client\";\n\nimport apolloClient from \"@/lib/apolloClient\";\n\nexport interface ProvidersProps {\n  children: React.ReactNode;\n  themeProps?: ThemeProviderProps;\n}\n\nexport function Providers({ children, themeProps }: ProvidersProps) {\n  const router = useRouter();\n\n  return (\n    <ApolloProvider client={apolloClient}>\n      <NextUIProvider className=\"h-full\" navigate={router.push}>\n        <NextThemesProvider {...themeProps}>{children}</NextThemesProvider>\n      </NextUIProvider>\n    </ApolloProvider>\n  );\n}\n"
  },
  {
    "path": "app/search/layout.tsx",
    "content": "import { FloatTool } from \"@/components/FloatTool\";\n\nexport default function SearchLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <>\n      <section className=\"flex flex-col justify-center gap-4 px-3 py-3 pb-6 md:py-8\">\n        {children}\n      </section>\n      <FloatTool />\n    </>\n  );\n}\n"
  },
  {
    "path": "app/search/page.tsx",
    "content": "import { Metadata } from \"next\";\nimport { getTranslations } from \"next-intl/server\";\nimport { Link } from \"@nextui-org/react\";\n\nimport { SearchInput } from \"@/components/SearchInput\";\nimport SearchResultsList from \"@/components/SearchResultsList\";\nimport apiFetch from \"@/utils/api\";\nimport { MagnetIcon } from \"@/components/icons\";\nimport { siteConfig } from \"@/config/site\";\nimport {\n  DEFAULT_SORT_TYPE,\n  SEARCH_PAGE_SIZE,\n  DEFAULT_FILTER_TIME,\n  DEFAULT_FILTER_SIZE,\n  SEARCH_PAGE_MAX,\n} from \"@/config/constant\";\n\ntype SearchParams = {\n  keyword: string;\n  p?: number;\n  ps?: number;\n  sortType?: string;\n  filterTime?: string;\n  filterSize?: string;\n};\n\ntype SearchRequestType = {\n  keyword: string;\n  limit?: number;\n  offset?: number;\n  sortType?: string;\n  filterTime?: string;\n  filterSize?: string;\n};\n\nlet cachedSearchOption: SearchParams | null = null;\nlet totalCount = 0;\n\n// Fetch data from the API based on search parameters\nasync function fetchData({\n  keyword,\n  limit = SEARCH_PAGE_SIZE,\n  offset = 0,\n  sortType,\n  filterTime,\n  filterSize,\n}: SearchRequestType): Promise<any> {\n  const params = new URLSearchParams({\n    keyword,\n    limit: String(limit),\n    offset: String(offset),\n  });\n\n  if (sortType) params.set(\"sortType\", sortType);\n  if (filterTime) params.set(\"filterTime\", filterTime);\n  if (filterSize) params.set(\"filterSize\", filterSize);\n\n  // Check if it is a new search\n  const isNewSearch =\n    !cachedSearchOption ||\n    keyword !== cachedSearchOption.keyword ||\n    filterTime !== cachedSearchOption.filterTime ||\n    filterSize !== cachedSearchOption.filterSize;\n\n  if (isNewSearch) {\n    cachedSearchOption = null; // Reset cachedSearchOption for new search\n  } else {\n    params.set(\"withTotalCount\", \"0\");\n  }\n\n  try {\n    const resp = await apiFetch(`/api/search?${params.toString()}`, {\n      next: { revalidate: 60 * 60 * 6 }, // cache for 6 hours\n    });\n\n    if (isNewSearch) {\n      totalCount = resp.data.total_count;\n    }\n    cachedSearchOption = {\n      keyword,\n      sortType,\n      filterTime,\n      filterSize,\n      p: cachedSearchOption?.p,\n    };\n\n    return resp;\n  } catch (error: any) {\n    console.error(error);\n\n    throw error;\n  }\n}\n\n// Generate metadata for the search page\nexport async function generateMetadata({\n  searchParams: { keyword },\n}: {\n  searchParams: { keyword: string };\n}): Promise<Metadata> {\n  const t = await getTranslations();\n\n  return {\n    title: t(\"Metadata.search.title\", { keyword }),\n  };\n}\n\n// Get search options from the search parameters\nfunction getSearchOption(searchParams: SearchParams) {\n  const isNewSearch =\n    !cachedSearchOption || searchParams.keyword !== cachedSearchOption.keyword;\n\n  return {\n    keyword: searchParams.keyword,\n    p: Math.min(isNewSearch ? 1 : searchParams.p || 1, SEARCH_PAGE_MAX),\n    ps: searchParams.ps || SEARCH_PAGE_SIZE,\n    sortType: searchParams.sortType || DEFAULT_SORT_TYPE,\n    filterTime: searchParams.filterTime || DEFAULT_FILTER_TIME,\n    filterSize: searchParams.filterSize || DEFAULT_FILTER_SIZE,\n  };\n}\n\n// Component to render the search page\nexport default async function SearchPage({\n  searchParams,\n}: {\n  searchParams: SearchParams;\n}) {\n  const searchOption = getSearchOption(searchParams);\n\n  const start_time = Date.now();\n  const { data } = await fetchData({\n    keyword: searchOption.keyword,\n    limit: searchOption.ps, // Number of items per page\n    offset: (searchOption.p - 1) * searchOption.ps, // Offset calculated based on the page number\n    sortType: searchOption.sortType,\n    filterTime: searchOption.filterTime,\n    filterSize: searchOption.filterSize,\n  });\n  const cost_time = Date.now() - start_time;\n\n  return (\n    <div className=\"w-full md:max-w-3xl lg:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl\">\n      <div className=\"flex items-center mb-7\">\n        <Link\n          className=\"mb-[-2px] mr-2 md:mr-4 leading-none text-[50px] md:text-[60px]\"\n          href=\"/\"\n          title={siteConfig.name}\n        >\n          <MagnetIcon />\n        </Link>\n        <SearchInput defaultValue={searchOption.keyword} />\n      </div>\n      <SearchResultsList\n        cost_time={cost_time}\n        keywords={data.keywords}\n        resultList={data.torrents}\n        searchOption={searchOption}\n        total_count={totalCount}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/BgEffect.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState, useMemo } from \"react\";\nimport Particles, { initParticlesEngine } from \"@tsparticles/react\";\nimport { loadSlim } from \"@tsparticles/slim\";\nimport { useIsSSR } from \"@react-aria/ssr\";\nimport { useTheme } from \"next-themes\";\n\nimport { UI_BACKGROUND_ANIMATION } from \"@/config/constant\";\n\nexport const BgEffect = () => {\n  const [init, setInit] = useState(false);\n  const [colorScheme, setColorScheme] = useState<\"light\" | \"dark\">(\"light\");\n\n  const { theme } = useTheme();\n  const isSSR = useIsSSR();\n\n  const colorPreset = useMemo(\n    () => ({\n      light: [\"#f1f1f1\", \"#d1d9e1\"],\n      dark: [\"#1d1d1d\", \"#0d1521\"],\n    }),\n    [],\n  );\n\n  useEffect(() => {\n    if (theme === \"system\") {\n      const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n      if (!mediaQuery) return;\n\n      mediaQuery.addEventListener(\"change\", (event) => {\n        setColorScheme(event.matches ? \"dark\" : \"light\");\n      });\n\n      setColorScheme(mediaQuery.matches ? \"dark\" : \"light\");\n    } else {\n      setColorScheme(theme === \"dark\" ? \"dark\" : \"light\");\n    }\n  }, [theme]);\n\n  useEffect(() => {\n    if (!UI_BACKGROUND_ANIMATION) return;\n\n    initParticlesEngine(async (engine) => {\n      await loadSlim(engine);\n    }).then(() => {\n      setInit(true);\n    });\n\n    return () => setInit(false);\n  }, []);\n\n  const particlesOptions = useMemo(\n    () =>\n      ({\n        background: {\n          image:\n            colorScheme === \"light\"\n              ? `linear-gradient(145deg, ${colorPreset.light[0]}, ${colorPreset.light[1]})`\n              : `linear-gradient(145deg, ${colorPreset.dark[0]}, ${colorPreset.dark[1]})`,\n        },\n        fpsLimit: 120,\n        interactivity: {\n          events: {\n            onHover: {\n              enable: true,\n              mode: \"grab\",\n            },\n          },\n          modes: {\n            push: {\n              quantity: 4,\n            },\n            repulse: {\n              distance: 200,\n              duration: 0.4,\n            },\n          },\n        },\n        particles: {\n          color: {\n            value: colorScheme === \"light\" ? \"#c1c7d1\" : \"#3b4250\",\n          },\n          links: {\n            value: colorScheme === \"light\" ? \"#c1c7d1\" : \"#3b4250\",\n            distance: 150,\n            enable: true,\n            opacity: colorScheme === \"light\" ? 0.8 : 0.1,\n            width: 1,\n          },\n          move: {\n            direction: \"none\",\n            enable: true,\n            outModes: {\n              default: \"bounce\",\n            },\n            random: false,\n            speed: 1,\n            straight: false,\n          },\n          number: {\n            density: {\n              enable: true,\n            },\n            value: 80,\n          },\n          opacity: {\n            value: 0.8,\n          },\n          shape: {\n            type: \"circle\",\n          },\n          size: {\n            value: { min: 1, max: 5 },\n          },\n        },\n        detectRetina: true,\n      }) as any,\n    [colorScheme, colorPreset],\n  );\n\n  if (isSSR) return null;\n\n  if (!UI_BACKGROUND_ANIMATION) {\n    const linearGradient = colorPreset[colorScheme];\n\n    return (\n      <div\n        className=\"fixed top-0 left-0 w-full h-full bg-gradient-to-br z-0\"\n        style={\n          {\n            \"--tw-gradient-stops\": `${linearGradient[0]}, ${linearGradient[1]}`,\n          } as React.CSSProperties\n        }\n      />\n    );\n  }\n\n  if (!init) return null;\n\n  return (\n    <Particles\n      options={particlesOptions}\n      particlesLoaded={async (_container) => {\n        // console.log(container);\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "components/DemoMode.tsx",
    "content": "import { getTranslations } from \"next-intl/server\";\n\nconst isDemoMode = process.env.DEMO_MODE === \"true\";\n\nexport const DemoMode = async () => {\n  if (!isDemoMode) {\n    return null;\n  }\n\n  const t = await getTranslations();\n\n  return (\n    <div className=\"fixed top-0 left-0 z-[10001]\">\n      <a\n        className=\"bg-default-100 rounded-sm px-2 py-1 text-sm text-red-500 hover:underline\"\n        href=\"https://github.com/journey-ad/Bitmagnet-Next-Web\"\n        rel=\"noreferrer\"\n        target=\"_blank\"\n      >\n        {t(\"COMMON.DEMO_MODE_TIPS\")}\n      </a>\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/DetailContent.tsx",
    "content": "\"use client\";\n\nimport {\n  Card,\n  CardHeader,\n  CardBody,\n  CardFooter,\n  Divider,\n  Link,\n  Button,\n  Image,\n  Modal,\n  ModalContent,\n  ModalBody,\n  useDisclosure,\n} from \"@nextui-org/react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState, Suspense } from \"react\";\n\nimport { TorrentItemProps } from \"@/types\";\nimport {\n  formatByteSize,\n  formatDate,\n  GetLinkInfoFromWhatsLinkResponse,\n  setClipboard,\n  Toast,\n} from \"@/utils\";\nimport useBreakpoint from \"@/hooks/useBreakpoints\";\nimport FileList from \"@/components/FileList\";\nimport { CopyIcon } from \"@/components/icons\";\nimport EmblaCarousel from \"@/components//EmblaCarousel\";\nimport { useHydration } from \"@/hooks/useHydration\";\n\nconst Preview = ({\n  linkInfo,\n}: {\n  linkInfo: Promise<GetLinkInfoFromWhatsLinkResponse>;\n}) => {\n  const t = useTranslations();\n\n  const { isOpen, onOpen, onOpenChange } = useDisclosure();\n  const [curIdx, setCurIdx] = useState(0);\n  const [linkData, setLinkData] = useState<GetLinkInfoFromWhatsLinkResponse>();\n\n  useEffect(() => {\n    linkInfo.then((data) => {\n      // console.log(\"linkData\", data);\n      setLinkData(data);\n    });\n  }, [linkInfo]);\n\n  if (!linkData || !linkData.screenshots) return null;\n\n  const screenshots = linkData.screenshots ?? [];\n\n  return (\n    <>\n      <Card className=\"bg-opacity-80\">\n        <CardHeader className=\"flex py-2 bg-gray-100 dark:bg-slate-800\">\n          {t(\"Detail.preview\")}\n        </CardHeader>\n        <Divider className=\"bg-gray-200 dark:bg-slate-700\" />\n        <CardBody>\n          <div className=\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-3\">\n            {screenshots.map((item, index) => (\n              <Image\n                key={item.screenshot}\n                isZoomed\n                className=\"min-h-20 max-h-48 sm:min-h-28 cursor-pointer\"\n                classNames={{\n                  wrapper: \"w-full !max-w-full\",\n                  img: \"w-full hover:scale-105 dark:brightness-50\",\n                }}\n                radius=\"sm\"\n                src={item.screenshot}\n                onClick={() => {\n                  setCurIdx(index);\n                  onOpen();\n                }}\n              />\n            ))}\n          </div>\n          <Modal\n            className=\"px-4 py-4 rounded-md !m-0\"\n            classNames={{\n              body: \"p-0\",\n              wrapper: \"items-center justify-center\",\n              closeButton:\n                \"z-20 top-[10px] right-[10px] bg-default-500 text-[24px] text-white bg-opacity-40 hover:bg-default-600 dark:hover:text-default-200\",\n            }}\n            isOpen={isOpen}\n            size=\"2xl\"\n            onOpenChange={onOpenChange}\n          >\n            <ModalContent className=\"w-auto\">\n              {() => (\n                <ModalBody>\n                  <EmblaCarousel\n                    images={screenshots.map((item) => item.screenshot)}\n                    options={{\n                      startIndex: curIdx,\n                      duration: 22,\n                      loop: true,\n                    }}\n                  />\n                </ModalBody>\n              )}\n            </ModalContent>\n          </Modal>\n        </CardBody>\n      </Card>\n    </>\n  );\n};\n\nexport const DetailContent = ({\n  data,\n  linkInfo,\n}: {\n  data: TorrentItemProps;\n  linkInfo: Promise<GetLinkInfoFromWhatsLinkResponse>;\n}) => {\n  const t = useTranslations();\n  const { isXs } = useBreakpoint();\n\n  const hydrated = useHydration();\n\n  return (\n    <>\n      {/* Torrent name */}\n      <h1 className=\"text-xl md:text-2xl break-all\">{data.name}</h1>\n\n      {/* Magnet link and file list */}\n      <div className=\"grid grid-cols-1 gap-5\">\n        {/* Torrent details card */}\n        <Card className=\"bg-opacity-80\">\n          <CardHeader className=\"flex py-2 bg-gray-100 dark:bg-slate-800\">\n            {t(\"Detail.details\")}\n          </CardHeader>\n          <Divider className=\"bg-gray-200 dark:bg-slate-700\" />\n          <CardBody>\n            <div className=\"flex flex-col gap-y-[2px] break-all text-xs md:text-sm text-gray-600 dark:text-slate-400\">\n              <span>\n                {t(\"Search.file_size\")}\n                {formatByteSize(data.size)}\n              </span>\n              <span>\n                {t(\"Search.file_count\")}\n                {data.files.length}\n              </span>\n              <Suspense key={hydrated ? \"load\" : \"loading\"}>\n                <span>\n                  {t(\"Search.created_at\")}\n                  {formatDate(\n                    data.created_at,\n                    t(\"COMMON.DATE_FORMAT\"),\n                    !hydrated,\n                  )}\n                </span>\n              </Suspense>\n              <span>\n                {t(\"Search.hash\")}\n                <span className=\"border rounded-sm px-1 font-mono bg-gray-100 dark:bg-inherit dark:border-slate-800\">\n                  {data.hash}\n                </span>\n              </span>\n            </div>\n          </CardBody>\n        </Card>\n\n        {/* Magnet link card */}\n        <Card className=\"bg-opacity-80\">\n          <CardHeader className=\"flex py-2 bg-gray-100 dark:bg-slate-800\">\n            {t(\"Detail.magnet\")}\n          </CardHeader>\n          <Divider className=\"bg-gray-200 dark:bg-slate-700\" />\n          <CardBody>\n            <div className=\"flex mb-1 break-all\">\n              <span className=\"mr-1 pointer-events-none select-none dark:brightness-90\">\n                🧲\n              </span>\n              <Link className=\"text-sm\" href={data.magnet_uri}>\n                {`magnet:?xt=urn:btih:${data.hash}`}\n              </Link>\n            </div>\n            <div className=\"mt-1\">\n              <Button\n                className=\"bg-opacity-80\"\n                color=\"primary\"\n                radius=\"sm\"\n                size={isXs ? \"sm\" : \"md\"}\n                startContent={<CopyIcon />}\n                variant=\"flat\"\n                onClick={() => {\n                  setClipboard(data.magnet_uri);\n                  Toast.success(t(\"Toast.copy_success\"));\n                }}\n              >\n                {t(\"Detail.copy\")}\n              </Button>\n            </div>\n          </CardBody>\n        </Card>\n\n        {/* Magnet content preview */}\n        <Preview linkInfo={linkInfo} />\n\n        {/* File list card */}\n        <Card className=\"bg-opacity-80\">\n          <CardHeader className=\"flex py-2 bg-gray-100 dark:bg-slate-800\">\n            {t(\"Detail.file_list\")}\n          </CardHeader>\n          <Divider className=\"bg-gray-200 dark:bg-slate-700\" />\n          <CardBody className=\"md:px-4\">\n            <FileList torrent={data as TorrentItemProps} />\n          </CardBody>\n          <Divider className=\"bg-gray-200 dark:bg-slate-700\" />\n          <CardFooter className=\"bg-gray-100 dark:bg-slate-800 p-2 px-3\">\n            <div className=\"flex flex-col mr-auto gap-x-2 text-xs text-gray-500 md:flex-row md:mr-0 md:ml-2 md:text-sm\">\n              <span>\n                {t(\"Search.file_size\")}\n                {formatByteSize(data.size)}\n              </span>\n              <span>\n                {t(\"Search.file_count\")}\n                {data.files.length}\n              </span>\n              <Suspense key={hydrated ? \"load\" : \"loading\"}>\n                <span>\n                  {t(\"Search.created_at\")}\n                  {formatDate(\n                    data.created_at,\n                    t(\"COMMON.DATE_FORMAT\"),\n                    !hydrated,\n                  )}\n                </span>\n              </Suspense>\n            </div>\n          </CardFooter>\n        </Card>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "components/EmblaCarousel.css",
    "content": ".embla {\n  max-width: 48rem;\n  margin: auto;\n  --slide-height: 19rem;\n  --slide-spacing: 1rem;\n  --slide-size: 100%;\n\n  --detail-medium-contrast: hsl(var(--nextui-default-200));\n  --detail-high-contrast: hsl(var(--nextui-default-300));\n  --text-body: hsl(var(--nextui-default-600));\n}\n.embla__viewport {\n  overflow: hidden;\n}\n.embla__container {\n  backface-visibility: hidden;\n  display: flex;\n  touch-action: pan-y pinch-zoom;\n  margin-left: calc(var(--slide-spacing) * -1);\n}\n.embla__slide {\n  flex: 0 0 var(--slide-size);\n  min-width: 0;\n  max-height: 80vh;\n  padding-left: var(--slide-spacing);\n}\n.embla__slide__img {\n  width: var(--slide-size);\n  height: var(--slide-size);\n  object-fit: contain;\n  cursor: grab;\n}\n.embla__slide__img:active {\n  cursor: grabbing;\n}\n.embla__controls {\n  display: grid;\n  grid-template-columns: auto 1fr;\n  justify-content: space-between;\n  gap: 1.2rem;\n  margin-top: 1rem;\n}\n.embla__buttons {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 0.4rem;\n  align-items: center;\n}\n.embla__button {\n  -webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);\n  -webkit-appearance: none;\n  appearance: none;\n  background-color: transparent;\n  touch-action: manipulation;\n  display: inline-flex;\n  text-decoration: none;\n  cursor: pointer;\n  border: 0;\n  padding: 0;\n  margin: 0;\n  box-shadow: inset 0 0 0 0.2rem var(--detail-medium-contrast);\n  z-index: 1;\n  border-radius: 50%;\n  color: var(--text-body);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n.embla__button:disabled {\n  color: var(--detail-high-contrast);\n}\n.embla__button__svg {\n  width: 35%;\n  height: 35%;\n}\n.embla__dots {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: flex-end;\n  align-items: center;\n  margin-right: calc((1.4rem - 0.8rem) / 2 * -1);\n}\n.embla__dot {\n  -webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);\n  -webkit-appearance: none;\n  appearance: none;\n  background-color: transparent;\n  touch-action: manipulation;\n  display: inline-flex;\n  text-decoration: none;\n  cursor: pointer;\n  border: 0;\n  padding: 0;\n  margin: 0;\n  width: 1.4rem;\n  height: 1.4rem;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n}\n.embla__dot:after {\n  box-shadow: inset 0 0 0 0.1rem var(--detail-medium-contrast);\n  width: 0.8rem;\n  height: 0.8rem;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  content: '';\n}\n.embla__dot--selected:after {\n  box-shadow: inset 0 0 0 0.1rem var(--text-body);\n}\n"
  },
  {
    "path": "components/EmblaCarousel.tsx",
    "content": "import React, {\n  ComponentPropsWithRef,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\nimport { EmblaOptionsType, EmblaCarouselType } from \"embla-carousel\";\nimport useEmblaCarousel from \"embla-carousel-react\";\nimport \"./EmblaCarousel.css\";\n\ntype UsePrevNextButtonsType = {\n  prevBtnDisabled: boolean;\n  nextBtnDisabled: boolean;\n  onPrevButtonClick: () => void;\n  onNextButtonClick: () => void;\n};\n\nconst usePrevNextButtons = (\n  emblaApi: EmblaCarouselType | undefined,\n): UsePrevNextButtonsType => {\n  const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);\n  const [nextBtnDisabled, setNextBtnDisabled] = useState(true);\n\n  const onPrevButtonClick = useCallback(() => {\n    if (!emblaApi) return;\n    emblaApi.scrollPrev();\n  }, [emblaApi]);\n\n  const onNextButtonClick = useCallback(() => {\n    if (!emblaApi) return;\n    emblaApi.scrollNext();\n  }, [emblaApi]);\n\n  const onSelect = useCallback((emblaApi: EmblaCarouselType) => {\n    setPrevBtnDisabled(!emblaApi.canScrollPrev());\n    setNextBtnDisabled(!emblaApi.canScrollNext());\n  }, []);\n\n  useEffect(() => {\n    if (!emblaApi) return;\n\n    onSelect(emblaApi);\n    emblaApi.on(\"reInit\", onSelect).on(\"select\", onSelect);\n  }, [emblaApi, onSelect]);\n\n  return {\n    prevBtnDisabled,\n    nextBtnDisabled,\n    onPrevButtonClick,\n    onNextButtonClick,\n  };\n};\n\ntype ButtonPropType = ComponentPropsWithRef<\"button\">;\n\nconst PrevButton: React.FC<ButtonPropType> = (props) => {\n  const { children, ...restProps } = props;\n\n  return (\n    <button\n      className=\"embla__button embla__button--prev w-10 h-10\"\n      type=\"button\"\n      {...restProps}\n    >\n      <svg className=\"embla__button__svg\" viewBox=\"0 0 532 532\">\n        <path\n          d=\"M355.66 11.354c13.793-13.805 36.208-13.805 50.001 0 13.785 13.804 13.785 36.238 0 50.034L201.22 266l204.442 204.61c13.785 13.805 13.785 36.239 0 50.044-13.793 13.796-36.208 13.796-50.002 0a5994246.277 5994246.277 0 0 0-229.332-229.454 35.065 35.065 0 0 1-10.326-25.126c0-9.2 3.393-18.26 10.326-25.2C172.192 194.973 332.731 34.31 355.66 11.354Z\"\n          fill=\"currentColor\"\n        />\n      </svg>\n      {children}\n    </button>\n  );\n};\n\nconst NextButton: React.FC<ButtonPropType> = (props) => {\n  const { children, ...restProps } = props;\n\n  return (\n    <button\n      className=\"embla__button embla__button--next w-10 h-10\"\n      type=\"button\"\n      {...restProps}\n    >\n      <svg className=\"embla__button__svg\" viewBox=\"0 0 532 532\">\n        <path\n          d=\"M176.34 520.646c-13.793 13.805-36.208 13.805-50.001 0-13.785-13.804-13.785-36.238 0-50.034L330.78 266 126.34 61.391c-13.785-13.805-13.785-36.239 0-50.044 13.793-13.796 36.208-13.796 50.002 0 22.928 22.947 206.395 206.507 229.332 229.454a35.065 35.065 0 0 1 10.326 25.126c0 9.2-3.393 18.26-10.326 25.2-45.865 45.901-206.404 206.564-229.332 229.52Z\"\n          fill=\"currentColor\"\n        />\n      </svg>\n      {children}\n    </button>\n  );\n};\n\ntype UseDotButtonType = {\n  selectedIndex: number;\n  scrollSnaps: number[];\n  onDotButtonClick: (index: number) => void;\n};\n\nexport const useDotButton = (\n  emblaApi: EmblaCarouselType | undefined,\n): UseDotButtonType => {\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);\n\n  const onDotButtonClick = useCallback(\n    (index: number) => {\n      if (!emblaApi) return;\n      emblaApi.scrollTo(index);\n    },\n    [emblaApi],\n  );\n\n  const onInit = useCallback((emblaApi: EmblaCarouselType) => {\n    setScrollSnaps(emblaApi.scrollSnapList());\n  }, []);\n\n  const onSelect = useCallback((emblaApi: EmblaCarouselType) => {\n    setSelectedIndex(emblaApi.selectedScrollSnap());\n  }, []);\n\n  useEffect(() => {\n    if (!emblaApi) return;\n\n    onInit(emblaApi);\n    onSelect(emblaApi);\n    emblaApi.on(\"reInit\", onInit).on(\"reInit\", onSelect).on(\"select\", onSelect);\n  }, [emblaApi, onInit, onSelect]);\n\n  return {\n    selectedIndex,\n    scrollSnaps,\n    onDotButtonClick,\n  };\n};\n\nexport const DotButton: React.FC<ButtonPropType> = (props) => {\n  const { children, ...restProps } = props;\n\n  return (\n    <button type=\"button\" {...restProps}>\n      {children}\n    </button>\n  );\n};\n\ntype PropType = {\n  images: string[] | { image: string; caption: string }[];\n  options?: EmblaOptionsType;\n};\n\nconst EmblaCarousel: React.FC<PropType> = (props) => {\n  const { images, options } = props;\n\n  const slides: { image: string; caption: string }[] = images.map((image) => {\n    if (typeof image === \"string\") {\n      return { image, caption: \"\" };\n    } else {\n      return image;\n    }\n  });\n\n  const [emblaRef, emblaApi] = useEmblaCarousel(options);\n\n  const { selectedIndex, scrollSnaps, onDotButtonClick } =\n    useDotButton(emblaApi);\n\n  const {\n    prevBtnDisabled,\n    nextBtnDisabled,\n    onPrevButtonClick,\n    onNextButtonClick,\n  } = usePrevNextButtons(emblaApi);\n\n  return (\n    <section className=\"embla\">\n      <div ref={emblaRef} className=\"embla__viewport\">\n        <div className=\"embla__container\">\n          {slides.map(({ image, caption }, index) => (\n            <div key={index} className=\"embla__slide\">\n              <img alt={caption} className=\"embla__slide__img\" src={image} />\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"embla__controls\">\n        <div className=\"embla__buttons\">\n          <PrevButton disabled={prevBtnDisabled} onClick={onPrevButtonClick} />\n          <NextButton disabled={nextBtnDisabled} onClick={onNextButtonClick} />\n        </div>\n\n        <div className=\"embla__dots\">\n          {scrollSnaps.map((_, index) => (\n            <DotButton\n              key={index}\n              className={\"embla__dot\".concat(\n                index === selectedIndex ? \" embla__dot--selected\" : \"\",\n              )}\n              onClick={() => onDotButtonClick(index)}\n            />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default EmblaCarousel;\n"
  },
  {
    "path": "components/FileList.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { Link, Chip } from \"@nextui-org/react\";\nimport { useTranslations } from \"next-intl\";\nimport clsx from \"clsx\";\n\nimport { TorrentItemProps } from \"@/types\";\nimport {\n  hexToBase64,\n  formatByteSize,\n  getSizeColor,\n  parseHighlight,\n} from \"@/utils\";\nimport FileTypeIcon from \"@/components/FileTypeIcon\";\n\ntype FileItem = TorrentItemProps[\"files\"][0] & {\n  index: number | string;\n  path: string;\n  extension?: string;\n  size?: number | string;\n  type: \"file\";\n  name: string;\n};\n\ntype Directory = {\n  index: string;\n  type: \"folder\";\n  name: string;\n  path: string;\n  children: (Directory | FileItem)[];\n};\n\n/**\n * Constructs a file tree from a flat list of file items.\n *\n * @param {FileItem[]} data - The flat list of file items.\n * @param {number} [maxDepth=3] - The maximum depth of the tree.\n * @returns {Directory[]} The constructed file tree.\n */\nfunction fileTree(data: FileItem[], maxDepth: number = 3): Directory[] {\n  const root: Directory = {\n    index: \"root\",\n    type: \"folder\",\n    name: \"\",\n    path: \"\",\n    children: [],\n  };\n\n  for (const file of data) {\n    const parts = file.path.split(\"/\");\n    const rootName = parts[0];\n\n    if (parts.length === 1) {\n      // This is a root-level file\n      file.type = \"file\";\n      file.name = rootName;\n      root.children.push(file);\n      continue;\n    }\n\n    let currentLevel = root;\n\n    for (let i = 0; i < parts.length; i++) {\n      const part = parts[i];\n\n      if (i === parts.length - 1) {\n        // It's the last part, so it's a file\n        file.type = \"file\";\n        file.name = part;\n        currentLevel.children.push(file);\n      } else if (i === maxDepth) {\n        // If max depth is reached, treat remaining parts as part of the file name\n        const remainingPath = parts.slice(i).join(\"/\");\n        const sub = {\n          ...file,\n          path: remainingPath,\n          name: remainingPath,\n          type: \"file\",\n        };\n\n        currentLevel.children.push(sub as FileItem);\n        break;\n      } else {\n        let nextLevel = currentLevel.children.find(\n          (child): child is Directory => {\n            return child.type === \"folder\" && child.name === part;\n          },\n        );\n\n        if (!nextLevel) {\n          nextLevel = {\n            index: \"_\" + part,\n            type: \"folder\",\n            name: part,\n            path: part, // Path is now just the part name\n            children: [],\n          };\n          currentLevel.children.push(nextLevel);\n        }\n\n        currentLevel = nextLevel;\n      }\n    }\n  }\n\n  return root.children as Directory[];\n}\n\n/**\n * Renders a file or directory item.\n *\n * @param {Object} props - The component props.\n * @param {FileItem | Directory} props.file - The file or directory item.\n * @param {string} [props.highlight] - The text to highlight.\n * @returns {JSX.Element} The rendered file item.\n */\nfunction FileItem({\n  file,\n  highlight,\n}: {\n  file: FileItem | Directory;\n  highlight?: string | string[];\n}) {\n  return (\n    <li\n      key={file.index}\n      className=\"flex flex-col justify-center mb-1\"\n      // data-extension={file.type === \"file\" ? file.extension : null}\n      // data-index={file.index}\n      // data-name={file.name}\n      // data-path={file.path}\n      // data-size={file.type === \"file\" ? file.size : null}\n      // data-type={file.type}\n    >\n      <div className=\"file-item flex items-center text-xs md:text-sm md:leading-[1rem]\">\n        <FileTypeIcon\n          className=\"dark:brightness-90\"\n          extension={file.type === \"folder\" ? \"folder\" : file.extension}\n        />\n        <span\n          dangerouslySetInnerHTML={{\n            __html: highlight\n              ? parseHighlight(file.name, highlight)\n              : file.name,\n          }}\n          className={clsx(\n            \"min-w-0 break-all min-h-5\",\n            file.type === \"folder\" && \"text-default-500\",\n          )}\n          title={file.path}\n        />\n        {file.type === \"file\" && file.size && (\n          <Chip\n            className={clsx(\n              \"h-[18px] mx-1 mt-[-1px] mb-auto px-[2px] text-[10px] font-bold dark:invert dark:brightness-105\",\n              getSizeColor(file.size),\n            )}\n            size=\"sm\"\n          >\n            {formatByteSize(file.size)}\n          </Chip>\n        )}\n      </div>\n      {file.type === \"folder\" && (\n        <ul className=\"sub-list pl-6 pt-1\">\n          {file.children.map((child) => (\n            <FileItem key={child.index} file={child} highlight={highlight} />\n          ))}\n        </ul>\n      )}\n    </li>\n  );\n}\n\n/**\n * Renders a file list for a torrent.\n *\n * @param {Object} props - The component props.\n * @param {TorrentItemProps} props.torrent - The torrent data.\n * @param {string} [props.highlight] - The text to highlight.\n * @param {number} [props.max=-1] - The maximum number of files to show.\n * @returns {JSX.Element} The rendered file list.\n */\nexport default function FileList({\n  torrent,\n  highlight,\n  max = -1,\n}: {\n  torrent: TorrentItemProps;\n  highlight?: string | string[];\n  max?: number;\n}) {\n  const t = useTranslations();\n  const list = max > 0 ? torrent.files.slice(0, max) : torrent.files;\n\n  const tree = fileTree(list as FileItem[], 3);\n\n  return (\n    <ul>\n      {tree.map((file) => (\n        <FileItem key={file.index} file={file} highlight={highlight} />\n      ))}\n      {max > 0 && torrent.files.length > max && (\n        <Link\n          isExternal\n          className=\"text-sm italic text-gray-500\"\n          href={`/detail/${hexToBase64(torrent.hash)}`}\n        >\n          {t(\"Search.more_files\", {\n            count: torrent.files.length - max,\n          })}\n        </Link>\n      )}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "components/FileTypeIcon.tsx",
    "content": "\"use client\";\n\nconst extensionMap = {\n  folder: \"folder\",\n  audio: \"mp3,wav,ogg,m4a,flac,wma,aac,mid,midi,cue\",\n  image: \"jpg,jpeg,png,gif,bmp,svg,webp,tiff,ico,heic,raw,psd,ai\",\n  video: \"mp4,mkv,webm,avi,mov,flv,wmv,mpeg,mpg,3gp,m4v,rm,rmvb,ts,m2ts,pmp\",\n  book: \"pdf,epub,fb2,mobi,azw,azw3,cbr,cbz,chm\",\n  web: \"torrent,html,htm,php,url,asp,aspx,jsp\",\n  archive: \"zip,rar,7z,gz,bz2,tar,xpi,rpm,cab,lzh,dmg,z,lz,xz,tgz,tbz2\",\n  disk: \"iso,img,vmdk,vdi\",\n  executable: \"exe,msi,apk,xpi,deb,bat,sh,bin,dll,so,cmd,com,run,vbs,app\",\n  subtitle: \"srt,sub,ssa,ass,vtt,rt,rtx,smi\",\n};\n\nconst extensionArr = Object.fromEntries(\n  Object.entries(extensionMap).map(([key, value]) => [key, value.split(\",\")]),\n);\n\nconst getFileType = (extension?: string) => {\n  if (!extension) return \"file\";\n\n  extension = String(extension).toLowerCase();\n\n  for (const [type, extensions] of Object.entries(extensionArr)) {\n    if (extensions.includes(extension)) {\n      return type;\n    }\n  }\n\n  return \"file\"; // Default type for unknown file extensions\n};\n\nexport default function FileTypeIcon({\n  extension,\n  className,\n}: {\n  extension?: string;\n  className?: string;\n}) {\n  const type = getFileType(extension);\n\n  const defaultClassName = \"file-type-icon\";\n\n  if (!className) className = defaultClassName;\n  else className = `${defaultClassName} ${className}`;\n\n  return <span className={className} data-icon={type} />;\n}\n"
  },
  {
    "path": "components/FloatTool.tsx",
    "content": "/* eslint-disable jsx-a11y/click-events-have-key-events */\n/* eslint-disable jsx-a11y/no-static-element-interactions */\n\"use client\";\n\nimport { useTheme } from \"next-themes\";\nimport { useEffect, useState } from \"react\";\nimport {\n  Dropdown,\n  DropdownItem,\n  DropdownMenu,\n  DropdownTrigger,\n} from \"@nextui-org/react\";\nimport clsx from \"clsx\";\n\nimport { $env, Cookie } from \"@/utils\";\nimport {\n  SunFilledIcon,\n  MoonFilledIcon,\n  LangFilledIcon,\n} from \"@/components/icons\";\nimport { locales, defaultLocale } from \"@/i18n/config\";\n\nfunction handleBackTop() {\n  window.scrollTo({\n    top: 0,\n    behavior: \"smooth\",\n  });\n}\n\nconst BackTop = () => {\n  const [showBackTop, setShowBackTop] = useState(false);\n\n  useEffect(() => {\n    const handleScroll = () => {\n      if (window.scrollY > 800) {\n        setShowBackTop(true);\n      } else {\n        setShowBackTop(false);\n      }\n    };\n\n    handleScroll();\n    window.addEventListener(\"scroll\", handleScroll);\n\n    return () => {\n      window.removeEventListener(\"scroll\", handleScroll);\n    };\n  }, []);\n\n  return (\n    <div\n      className={clsx(\n        \"back-top flex justify-center items-center w-[50px] h-[50px] rounded-medium cursor-pointer transition-all text-stone-600 bg-gray-100 dark:bg-slate-800 hover:bg-gray-50 dark:hover:bg-slate-700\",\n        showBackTop\n          ? \"visible opacity-100\"\n          : \"invisible opacity-0 pointer-events-none\",\n      )}\n      onClick={() => handleBackTop()}\n    >\n      <svg\n        className=\"feather feather-arrow-up\"\n        fill=\"none\"\n        height=\"26\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"2\"\n        viewBox=\"0 0 24 24\"\n        width=\"26\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <line x1=\"12\" x2=\"12\" y1=\"19\" y2=\"5\" />\n        <polyline points=\"5 12 12 5 19 12\" />\n      </svg>\n    </div>\n  );\n};\n\nexport const ToggleTheme = ({ noBg = false }: { noBg?: boolean }) => {\n  const { theme, setTheme } = useTheme();\n  const [colorScheme, setColorScheme] = useState<\"light\" | \"dark\">(\"light\");\n\n  useEffect(() => {\n    if (theme === \"system\") {\n      const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n      if (!mediaQuery) return;\n\n      mediaQuery.addEventListener(\"change\", (event) => {\n        setColorScheme(event.matches ? \"dark\" : \"light\");\n      });\n\n      setColorScheme(mediaQuery.matches ? \"dark\" : \"light\");\n    } else {\n      setColorScheme(theme === \"dark\" ? \"dark\" : \"light\");\n    }\n  }, [theme]);\n\n  return (\n    <div\n      className={clsx(\n        \"flex justify-center items-center rounded-medium cursor-pointer transition-all text-stone-600\",\n        !noBg\n          ? \"w-[50px] h-[50px] bg-gray-100 dark:bg-slate-800 hover:bg-gray-50 dark:hover:bg-slate-700\"\n          : \"w-[32px] h-[32px]\",\n      )}\n      onClick={() => setTheme(colorScheme === \"dark\" ? \"light\" : \"dark\")}\n    >\n      {colorScheme === \"dark\" ? <SunFilledIcon /> : <MoonFilledIcon />}\n    </div>\n  );\n};\n\nexport const SwitchLanguage = ({ noBg = false }: { noBg?: boolean }) => {\n  const cookieLocale =\n    typeof window !== \"undefined\" ? Cookie.get(\"NEXT_LOCALE\") : null;\n  const browserLocale =\n    typeof window !== \"undefined\" ? navigator.language : null;\n\n  const locale = cookieLocale || browserLocale || defaultLocale;\n  const [lang, setlang] = useState(new Set([locale]));\n\n  const handleChangeLocale = (key: Set<string>) => {\n    setlang(key);\n    Cookie.set(\"NEXT_LOCALE\", Array.from(key)[0], {\n      path: \"/\",\n      expires: 365,\n    });\n\n    if (typeof window !== \"undefined\") {\n      window.location.reload();\n    }\n  };\n\n  return (\n    <>\n      <Dropdown className=\"min-w-0 bg-opacity-80\">\n        <DropdownTrigger>\n          <div\n            className={clsx(\n              \"flex justify-center items-center rounded-medium cursor-pointer transition-all text-stone-600\",\n              !noBg\n                ? \"w-[50px] h-[50px] bg-gray-100 dark:bg-slate-800 hover:bg-gray-50 dark:hover:bg-slate-700\"\n                : \"w-[32px] h-[32px]\",\n            )}\n          >\n            <LangFilledIcon />\n          </div>\n        </DropdownTrigger>\n        <DropdownMenu\n          disallowEmptySelection\n          selectedKeys={lang}\n          selectionMode=\"single\"\n          variant=\"flat\"\n          onSelectionChange={handleChangeLocale as any}\n        >\n          {Object.entries(locales).map(([key, value]) => (\n            <DropdownItem key={key} value={key}>\n              <small>{key}</small> {value}\n            </DropdownItem>\n          ))}\n        </DropdownMenu>\n      </Dropdown>\n    </>\n  );\n};\n\nexport const FloatTool = () => {\n  const [enabled, setEnabled] = useState(false);\n\n  useEffect(() => {\n    if ($env.isMobile) {\n      setEnabled(false);\n\n      return;\n    }\n\n    setEnabled(true);\n  }, []);\n\n  if (!enabled) return null;\n\n  return (\n    <div className=\"fixed right-6 bottom-10 flex flex-col gap-y-2 items-center z-20\">\n      {/* <ToggleTheme /> */}\n      <BackTop />\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/HomeLogo.tsx",
    "content": "\"use client\";\n\nimport clsx from \"clsx\";\nimport { useState } from \"react\";\n\nimport { MagnetIcon } from \"@/components/icons\";\nimport { siteConfig } from \"@/config/site\";\nimport { $env } from \"@/utils\";\n\nexport const HomeLogo = () => {\n  const [isAnimating, setIsAnimating] = useState(false);\n\n  const doClickAnimation = () => {\n    if (!$env.isMobile) {\n      return;\n    }\n\n    if (isAnimating) {\n      return;\n    }\n\n    setIsAnimating(true);\n\n    setTimeout(() => {\n      setIsAnimating(false);\n    }, 400);\n  };\n\n  return (\n    <h1\n      className=\"logo\"\n      title={siteConfig.name}\n      onPointerDown={() => doClickAnimation()}\n    >\n      <MagnetIcon\n        className={clsx(\n          \"w-[140px] h-[140px] transition-all duration-400 hover:scale-105\",\n          isAnimating && \"animate-pop\",\n        )}\n      />\n    </h1>\n  );\n};\n"
  },
  {
    "path": "components/SearchInput.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\n\"use client\";\n\nimport { Input, Button, Spinner } from \"@nextui-org/react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport clsx from \"clsx\";\n\nimport { SearchIcon } from \"@/components/icons\";\nimport { $env } from \"@/utils\";\n\nexport const SearchInput = ({\n  defaultValue = \"\",\n  isReplace = false,\n}: {\n  defaultValue?: string;\n  isReplace?: boolean;\n}) => {\n  const [keyword, setKeyword] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n  const [active, setActive] = useState(false);\n  const [errMessage, setErrMessage] = useState(\"\");\n  const router = useRouter();\n  const searchParams = useSearchParams();\n\n  useEffect(() => {\n    // Reset loading state when search parameters change\n    setLoading(false);\n  }, [searchParams]);\n\n  useEffect(() => {\n    // Set default value for keyword when provided\n    if (defaultValue) {\n      setKeyword(defaultValue);\n    }\n  }, [defaultValue]);\n\n  function handleSearch() {\n    // Trim the keyword and set it to state\n    setKeyword(keyword.trim());\n\n    // If keyword is empty, do nothing\n    if (!keyword) {\n      return;\n    }\n\n    // If search params equals current search params, do nothing\n    if (searchParams.get(\"keyword\") === keyword && !searchParams.get(\"p\")) {\n      return;\n    }\n\n    if (keyword.length < 2) {\n      // If keyword length is less than 2, display warning toast\n      // Toast.warn(t(\"Toast.keyword_too_short\"));\n      setErrMessage(t(\"Toast.keyword_too_short\"));\n\n      return;\n    }\n\n    if (keyword.length > 100) {\n      // limit keyword length to 100 characters\n      setKeyword(keyword.slice(0, 100));\n    }\n\n    const params = new URLSearchParams(); // Create URLSearchParams object\n\n    params.set(\"keyword\", keyword.trim()); // Set keyword in URLSearchParams\n\n    const url = `/search?${params.toString()}`; // Construct URL with search keyword\n\n    setLoading(true); // Set loading state to true\n    if (isReplace) {\n      router.replace(url);\n    } else {\n      router.push(url);\n    }\n  }\n\n  function handleKeyup(e: any) {\n    // Handle Enter key press for triggering search\n    if (e.key === \"Enter\" || e.keyCode === 13) {\n      // If on desktop, trigger search\n      if (!$env.isMobile) {\n        handleSearch();\n      }\n\n      // Blur input, on mobile that will trigger search\n      e.target.blur();\n    }\n  }\n\n  function handleBlur() {\n    if ($env.isMobile) {\n      // If on mobile, trigger search\n      handleSearch();\n    }\n\n    setActive(false);\n  }\n\n  function handleFocus() {\n    setErrMessage(\"\");\n    setActive(true);\n  }\n\n  const t = useTranslations(); // Translation function\n\n  return (\n    <Input\n      aria-label=\"Search\"\n      classNames={{\n        inputWrapper: \"h-12 px-4 bg-default-100\",\n        input: \"text-base\",\n        helperWrapper: \"absolute bottom-[-25px]\",\n      }}\n      defaultValue={defaultValue}\n      endContent={\n        <>\n          <span\n            className={clsx(\n              \"p-2 -m-2 z-10 invisible absolute right-[60px] appearance-none select-none opacity-0 hover:!opacity-60 cursor-pointer active:!opacity-40 rounded-full outline-none text-large transition-opacity motion-reduce:transition-none\",\n              { \"!visible opacity-40\": active && !!keyword }, // Show clear button if keyword is not empty\n            )}\n            onPointerDown={() => setKeyword(\"\")}\n          >\n            <svg\n              aria-hidden=\"true\"\n              focusable=\"false\"\n              height=\"1em\"\n              role=\"presentation\"\n              viewBox=\"0 0 24 24\"\n              width=\"1em\"\n            >\n              <path\n                d=\"M12 2a10 10 0 1010 10A10.016 10.016 0 0012 2zm3.36 12.3a.754.754 0 010 1.06.748.748 0 01-1.06 0l-2.3-2.3-2.3 2.3a.748.748 0 01-1.06 0 .754.754 0 010-1.06l2.3-2.3-2.3-2.3A.75.75 0 019.7 8.64l2.3 2.3 2.3-2.3a.75.75 0 011.06 1.06l-2.3 2.3z\"\n                fill=\"currentColor\"\n              />\n            </svg>\n          </span>\n          <Button\n            isIconOnly\n            className={clsx(\n              \"border-none active:bg-default\",\n              { \"cursor-progress\": loading }, // Change cursor to progress when loading\n            )}\n            variant=\"ghost\"\n            onClick={handleSearch}\n          >\n            {loading ? ( // Show spinner if loading, else show search icon\n              <Spinner size=\"sm\" />\n            ) : (\n              <SearchIcon className=\"text-xl text-default-400 pointer-events-none flex-shrink-0\" />\n            )}\n          </Button>\n        </>\n      }\n      errorMessage={errMessage}\n      isInvalid={!!errMessage}\n      labelPlacement=\"outside\"\n      placeholder={t(\"Search.placeholder\")}\n      value={keyword}\n      onBlur={handleBlur}\n      onFocus={handleFocus}\n      onKeyUp={handleKeyup}\n      onValueChange={setKeyword}\n    />\n  );\n};\n"
  },
  {
    "path": "components/SearchResultsItem.tsx",
    "content": "\"use client\";\n\nimport { Suspense } from \"react\";\nimport {\n  Card,\n  CardHeader,\n  CardBody,\n  CardFooter,\n  Divider,\n  Link,\n} from \"@nextui-org/react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { TorrentItemProps } from \"@/types\";\nimport {\n  $env,\n  hexToBase64,\n  formatByteSize,\n  formatDate,\n  parseHighlight,\n  setClipboard,\n  Toast,\n} from \"@/utils\";\nimport FileList from \"@/components/FileList\";\nimport { SEARCH_DISPLAY_FILES_MAX } from \"@/config/constant\";\nimport { useHydration } from \"@/hooks/useHydration\";\n\nexport default function SearchResultsItem({\n  item,\n  keywords,\n}: {\n  item: TorrentItemProps;\n  keywords: string | string[];\n}) {\n  const data = {\n    ...item,\n    name: item.name,\n    url: `/detail/${hexToBase64(item.hash)}`,\n    files: item.files,\n  };\n\n  const t = useTranslations();\n\n  const hydrated = useHydration();\n\n  return (\n    <Card className=\"w-full bg-opacity-80 dark:brightness-95\">\n      <CardHeader className=\"flex gap-3 bg-gray-100 dark:bg-slate-800\">\n        <div className=\"flex flex-col break-all\">\n          <Link isExternal href={data.url} title={data.name}>\n            <h2\n              dangerouslySetInnerHTML={{\n                __html: parseHighlight(data.name, keywords),\n              }}\n              className=\"text-md leading-normal\"\n            />\n          </Link>\n        </div>\n      </CardHeader>\n      <Divider className=\"bg-gray-200 dark:bg-slate-700\" />\n      <CardBody className=\"px-4\">\n        <FileList\n          highlight={keywords}\n          max={SEARCH_DISPLAY_FILES_MAX}\n          torrent={data as TorrentItemProps}\n        />\n      </CardBody>\n      <Divider className=\"bg-gray-200 dark:bg-slate-700\" />\n      <CardFooter className=\"bg-gray-100 dark:bg-slate-800 flex-row-reverse p-[10px] px-3 md:flex-row md:p-3\">\n        <Link\n          className=\"mt-auto text-sm\"\n          href={data.magnet_uri}\n          onClick={(e) => {\n            if ($env.isMobile) {\n              e.preventDefault();\n              setClipboard(data.magnet_uri);\n              Toast.success(t(\"Toast.copy_success\"));\n            }\n          }}\n        >\n          <span className=\" mr-1 pointer-events-none select-none dark:brightness-90\">\n            🧲\n          </span>\n          {t(\"Search.magnet\")}\n        </Link>\n        <div className=\"flex flex-col mr-auto gap-x-2 text-xs text-gray-500 md:flex-row md:mr-0 md:ml-2 md:text-sm\">\n          <span>\n            {t(\"Search.file_size\")}\n            {formatByteSize(data.size)}\n          </span>\n          <span>\n            {t(\"Search.file_count\")}\n            {data.files.length}\n          </span>\n          <Suspense key={hydrated ? \"load\" : \"loading\"}>\n            <span>\n              {t(\"Search.created_at\")}\n              {formatDate(data.created_at, t(\"COMMON.DATE_FORMAT\"), !hydrated)}\n            </span>\n          </Suspense>\n        </div>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/SearchResultsList.tsx",
    "content": "\"use client\";\nimport { useRouter } from \"next/navigation\";\nimport { Pagination, Select, SelectItem } from \"@nextui-org/react\";\nimport { useTranslations } from \"next-intl\";\nimport { useIsSSR } from \"@react-aria/ssr\";\n\nimport SearchResultsItem from \"./SearchResultsItem\";\n\nimport { SearchResultsListProps } from \"@/types\";\nimport { $env } from \"@/utils\";\nimport { SEARCH_PARAMS, SEARCH_PAGE_MAX } from \"@/config/constant\";\n\nexport default function SearchResultsList({\n  resultList,\n  keywords,\n  cost_time = 0,\n  total_count = 0,\n  searchOption,\n}: {\n  resultList: SearchResultsListProps[\"torrents\"];\n  keywords: string[];\n  cost_time: number;\n  total_count: number;\n  searchOption: {\n    keyword: string;\n    p: number;\n    ps: number;\n    sortType: string;\n    filterTime: string;\n    filterSize: string;\n  };\n}) {\n  const router = useRouter();\n  const isSSR = useIsSSR();\n  const t = useTranslations();\n\n  const handleFilterChange = (type: string, value: string) => {\n    const updatedSearchOption = {\n      ...searchOption,\n      [type]: value,\n    };\n\n    handlePageChange(1, updatedSearchOption);\n  };\n\n  const handlePageChange = (\n    page: number,\n    newSearchOption: typeof searchOption,\n  ) => {\n    const params = new URLSearchParams();\n\n    params.set(\"keyword\", newSearchOption.keyword);\n    params.set(\"p\", String(page));\n    params.set(\"ps\", String(newSearchOption.ps));\n\n    if (newSearchOption.sortType) {\n      params.set(\"sortType\", newSearchOption.sortType);\n    }\n\n    if (newSearchOption.filterTime) {\n      params.set(\"filterTime\", newSearchOption.filterTime);\n    }\n\n    if (newSearchOption.filterSize) {\n      params.set(\"filterSize\", newSearchOption.filterSize);\n    }\n\n    const url = `/search?${params.toString()}`;\n\n    router.push(url);\n  };\n\n  const pagiConf = {\n    page: searchOption.p,\n    total: Math.min(Math.ceil(total_count / searchOption.ps), SEARCH_PAGE_MAX),\n    siblinds: $env.isMobile ? 1 : 3,\n  };\n\n  return (\n    <>\n      <div className=\"flex gap-2 my-4\">\n        {Object.entries(SEARCH_PARAMS).map(([key, value]) => (\n          <Select\n            key={key}\n            className=\"w-full\"\n            classNames={{\n              label: \"text-xs md:text-sm\",\n              trigger: \"h-10 min-h-10 md:h-12 md:min-h-12\",\n              value: \"text-xs md:text-sm\",\n            }}\n            defaultSelectedKeys={[\n              searchOption[key as keyof typeof searchOption],\n            ]}\n            label={t(`Search.filterLabel.${key}`)}\n            popoverProps={{\n              className: \"w-full flex justify-center\",\n              classNames: {\n                content: \"bg-opacity-70 backdrop-blur-sm min-w-fit px-1\",\n              },\n            }}\n            selectedKeys={[searchOption[key as keyof typeof searchOption]]}\n            size=\"sm\"\n            onChange={(e) => handleFilterChange(key, e.target.value)}\n          >\n            {value.map((item) => (\n              <SelectItem\n                key={item}\n                className=\"w-full !bg-opacity-60\"\n                classNames={{\n                  title: \"text-xs md:text-sm\",\n                }}\n              >\n                {t(`Search.${key}.${item}`)}\n              </SelectItem>\n            ))}\n          </Select>\n        ))}\n      </div>\n\n      <div className=\"text-sm text-gray-500 mb-4\">\n        {t(\"Search.results_found\", { count: total_count })}\n\n        {cost_time > 0 && (\n          <span className=\"ml-1 text-xs\">\n            {t(\"Search.cost_time\", { cost_time: cost_time })}\n          </span>\n        )}\n      </div>\n\n      {resultList.map((item) => (\n        <div key={item.hash} className=\"mb-6\">\n          <SearchResultsItem item={item} keywords={keywords} />\n        </div>\n      ))}\n\n      {!isSSR && pagiConf.total > 1 && (\n        <Pagination\n          key={`pagi_${Object.values(searchOption).join(\"_\")}`}\n          className=\"flex justify-center\"\n          classNames={{\n            wrapper: \"gap-x-2\",\n          }}\n          initialPage={pagiConf.page}\n          page={pagiConf.page}\n          showControls={$env.isDesktop}\n          siblings={pagiConf.siblinds}\n          size={$env.isMobile ? \"lg\" : \"md\"}\n          total={pagiConf.total}\n          onChange={(page) => handlePageChange(page, searchOption)}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/Stats.tsx",
    "content": "import { Suspense } from \"react\";\nimport { getTranslations } from \"next-intl/server\";\nimport { Tooltip, Spinner } from \"@nextui-org/react\";\n\nimport apiFetch from \"@/utils/api\";\nimport { InfoFilledIcon } from \"@/components/icons\";\nimport { formatByteSize, formatDate } from \"@/utils\";\n\nasync function StatsCard() {\n  const t = await getTranslations();\n\n  const { data } = await apiFetch(\"/api/stats\", {\n    next: { revalidate: 60 },\n  });\n\n  return (\n    <div className=\"text-xs text-foreground-600\">\n      <h4 className=\"font-bold\">{t(\"Stats.title\")}</h4>\n      <ul>\n        <li>{t(\"Stats.size\", { size: formatByteSize(data.size) })}</li>\n        <li>\n          {t(\"Stats.total_count\", {\n            total_count: data.total_count.toLocaleString(),\n          })}\n        </li>\n        <li>\n          {t(\"Stats.updated_at\", {\n            updated_at: formatDate(\n              data.updated_at,\n              t(\"COMMON.DATE_FORMAT_SHORT\"),\n            ),\n          })}\n        </li>\n      </ul>\n    </div>\n  );\n}\n\nexport function Stats() {\n  return (\n    <Tooltip\n      classNames={{\n        content: \"bg-opacity-60\",\n      }}\n      closeDelay={0}\n      content={\n        <Suspense fallback={<Spinner size=\"sm\" />}>\n          <StatsCard />\n        </Suspense>\n      }\n      delay={0}\n      radius=\"sm\"\n    >\n      <InfoFilledIcon className=\"cursor-pointer text-gray-500\" size={15} />\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "components/icons.tsx",
    "content": "import * as React from \"react\";\n\nimport { IconSvgProps } from \"@/types\";\n\nexport const Logo: React.FC<IconSvgProps> = ({\n  size = 36,\n  width,\n  height,\n  ...props\n}) => (\n  <svg\n    fill=\"none\"\n    height={size || height}\n    viewBox=\"0 0 32 32\"\n    width={size || width}\n    {...props}\n  >\n    <path\n      clipRule=\"evenodd\"\n      d=\"M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n\nexport const DiscordIcon: React.FC<IconSvgProps> = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}) => {\n  return (\n    <svg\n      height={size || height}\n      viewBox=\"0 0 24 24\"\n      width={size || width}\n      {...props}\n    >\n      <path\n        d=\"M14.82 4.26a10.14 10.14 0 0 0-.53 1.1 14.66 14.66 0 0 0-4.58 0 10.14 10.14 0 0 0-.53-1.1 16 16 0 0 0-4.13 1.3 17.33 17.33 0 0 0-3 11.59 16.6 16.6 0 0 0 5.07 2.59A12.89 12.89 0 0 0 8.23 18a9.65 9.65 0 0 1-1.71-.83 3.39 3.39 0 0 0 .42-.33 11.66 11.66 0 0 0 10.12 0q.21.18.42.33a10.84 10.84 0 0 1-1.71.84 12.41 12.41 0 0 0 1.08 1.78 16.44 16.44 0 0 0 5.06-2.59 17.22 17.22 0 0 0-3-11.59 16.09 16.09 0 0 0-4.09-1.35zM8.68 14.81a1.94 1.94 0 0 1-1.8-2 1.93 1.93 0 0 1 1.8-2 1.93 1.93 0 0 1 1.8 2 1.93 1.93 0 0 1-1.8 2zm6.64 0a1.94 1.94 0 0 1-1.8-2 1.93 1.93 0 0 1 1.8-2 1.92 1.92 0 0 1 1.8 2 1.92 1.92 0 0 1-1.8 2z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n};\n\nexport const TwitterIcon: React.FC<IconSvgProps> = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}) => {\n  return (\n    <svg\n      height={size || height}\n      viewBox=\"0 0 24 24\"\n      width={size || width}\n      {...props}\n    >\n      <path\n        d=\"M19.633 7.997c.013.175.013.349.013.523 0 5.325-4.053 11.461-11.46 11.461-2.282 0-4.402-.661-6.186-1.809.324.037.636.05.973.05a8.07 8.07 0 0 0 5.001-1.721 4.036 4.036 0 0 1-3.767-2.793c.249.037.499.062.761.062.361 0 .724-.05 1.061-.137a4.027 4.027 0 0 1-3.23-3.953v-.05c.537.299 1.16.486 1.82.511a4.022 4.022 0 0 1-1.796-3.354c0-.748.199-1.434.548-2.032a11.457 11.457 0 0 0 8.306 4.215c-.062-.3-.1-.611-.1-.923a4.026 4.026 0 0 1 4.028-4.028c1.16 0 2.207.486 2.943 1.272a7.957 7.957 0 0 0 2.556-.973 4.02 4.02 0 0 1-1.771 2.22 8.073 8.073 0 0 0 2.319-.624 8.645 8.645 0 0 1-2.019 2.083z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n};\n\nexport const GithubIcon: React.FC<IconSvgProps> = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}) => {\n  return (\n    <svg\n      height={size || height}\n      viewBox=\"0 0 24 24\"\n      width={size || width}\n      {...props}\n    >\n      <path\n        clipRule=\"evenodd\"\n        d=\"M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z\"\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n      />\n    </svg>\n  );\n};\n\nexport const MoonFilledIcon = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    focusable=\"false\"\n    height={size || height}\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width={size || width}\n    {...props}\n  >\n    <path\n      d=\"M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const SunFilledIcon = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    focusable=\"false\"\n    height={size || height}\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width={size || width}\n    {...props}\n  >\n    <g fill=\"currentColor\">\n      <path d=\"M19 12a7 7 0 11-7-7 7 7 0 017 7z\" />\n      <path d=\"M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z\" />\n    </g>\n  </svg>\n);\n\nexport const HeartFilledIcon = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    focusable=\"false\"\n    height={size || height}\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width={size || width}\n    {...props}\n  >\n    <path\n      d=\"M12.62 20.81c-.34.12-.9.12-1.24 0C8.48 19.82 2 15.69 2 8.69 2 5.6 4.49 3.1 7.56 3.1c1.82 0 3.43.88 4.44 2.24a5.53 5.53 0 0 1 4.44-2.24C19.51 3.1 22 5.6 22 8.69c0 7-6.48 11.13-9.38 12.12Z\"\n      fill=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={1.5}\n    />\n  </svg>\n);\n\nexport const SearchIcon = (props: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    fill=\"none\"\n    focusable=\"false\"\n    height=\"1em\"\n    role=\"presentation\"\n    viewBox=\"0 0 24 24\"\n    width=\"1em\"\n    {...props}\n  >\n    <path\n      d=\"M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n    />\n    <path\n      d=\"M22 22L20 20\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n    />\n  </svg>\n);\n\nexport const NextUILogo: React.FC<IconSvgProps> = (props) => {\n  const { width, height = 40 } = props;\n\n  return (\n    <svg\n      fill=\"none\"\n      height={height}\n      viewBox=\"0 0 161 32\"\n      width={width}\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        className=\"fill-black dark:fill-white\"\n        d=\"M55.6827 5V26.6275H53.7794L41.1235 8.51665H40.9563V26.6275H39V5H40.89L53.5911 23.1323H53.7555V5H55.6827ZM67.4831 26.9663C66.1109 27.0019 64.7581 26.6329 63.5903 25.9044C62.4852 25.185 61.6054 24.1633 61.0537 22.9582C60.4354 21.5961 60.1298 20.1106 60.1598 18.6126C60.132 17.1113 60.4375 15.6228 61.0537 14.2563C61.5954 13.0511 62.4525 12.0179 63.5326 11.268C64.6166 10.5379 65.8958 10.16 67.1986 10.1852C68.0611 10.1837 68.9162 10.3468 69.7187 10.666C70.5398 10.9946 71.2829 11.4948 71.8992 12.1337C72.5764 12.8435 73.0985 13.6889 73.4318 14.6152C73.8311 15.7483 74.0226 16.9455 73.9968 18.1479V19.0773H63.2262V17.4194H72.0935C72.1083 16.4456 71.8952 15.4821 71.4714 14.6072C71.083 13.803 70.4874 13.1191 69.7472 12.6272C68.9887 12.1348 68.1022 11.8812 67.2006 11.8987C66.2411 11.8807 65.3005 12.1689 64.5128 12.7223C63.7332 13.2783 63.1083 14.0275 62.6984 14.8978C62.2582 15.8199 62.0314 16.831 62.0352 17.8546V18.8476C62.009 20.0078 62.2354 21.1595 62.6984 22.2217C63.1005 23.1349 63.7564 23.9108 64.5864 24.4554C65.4554 24.9973 66.4621 25.2717 67.4831 25.2448C68.1676 25.2588 68.848 25.1368 69.4859 24.8859C70.0301 24.6666 70.5242 24.3376 70.9382 23.919C71.3183 23.5345 71.6217 23.0799 71.8322 22.5799L73.5995 23.1604C73.3388 23.8697 72.9304 24.5143 72.4019 25.0506C71.8132 25.6529 71.1086 26.1269 70.3314 26.4434C69.4258 26.8068 68.4575 26.9846 67.4831 26.9663V26.9663ZM78.8233 10.4075L82.9655 17.325L87.1076 10.4075H89.2683L84.1008 18.5175L89.2683 26.6275H87.103L82.9608 19.9317L78.8193 26.6275H76.6647L81.7711 18.5169L76.6647 10.4062L78.8233 10.4075ZM99.5142 10.4075V12.0447H91.8413V10.4075H99.5142ZM94.2427 6.52397H96.1148V22.3931C96.086 22.9446 96.2051 23.4938 96.4597 23.9827C96.6652 24.344 96.9805 24.629 97.3589 24.7955C97.7328 24.9548 98.1349 25.0357 98.5407 25.0332C98.7508 25.0359 98.9607 25.02 99.168 24.9857C99.3422 24.954 99.4956 24.9205 99.6283 24.8853L100.026 26.5853C99.8062 26.6672 99.5805 26.7327 99.3511 26.7815C99.0274 26.847 98.6977 26.8771 98.3676 26.8712C97.6854 26.871 97.0119 26.7156 96.3973 26.4166C95.7683 26.1156 95.2317 25.6485 94.8442 25.0647C94.4214 24.4018 94.2097 23.6242 94.2374 22.8363L94.2427 6.52397ZM118.398 5H120.354V19.3204C120.376 20.7052 120.022 22.0697 119.328 23.2649C118.644 24.4235 117.658 25.3698 116.477 26.0001C115.168 26.6879 113.708 27.0311 112.232 26.9978C110.759 27.029 109.302 26.6835 107.996 25.9934C106.815 25.362 105.827 24.4161 105.141 23.2582C104.447 22.0651 104.092 20.7022 104.115 19.319V5H106.08V19.1831C106.061 20.2559 106.324 21.3147 106.843 22.2511C107.349 23.1459 108.094 23.8795 108.992 24.3683C109.993 24.9011 111.111 25.1664 112.242 25.139C113.373 25.1656 114.493 24.9003 115.495 24.3683C116.395 23.8815 117.14 23.1475 117.644 22.2511C118.16 21.3136 118.421 20.2553 118.402 19.1831L118.398 5ZM128 5V26.6275H126.041V5H128Z\"\n      />\n      <path\n        className=\"fill-black dark:fill-white\"\n        d=\"M23.5294 0H8.47059C3.79241 0 0 3.79241 0 8.47059V23.5294C0 28.2076 3.79241 32 8.47059 32H23.5294C28.2076 32 32 28.2076 32 23.5294V8.47059C32 3.79241 28.2076 0 23.5294 0Z\"\n      />\n      <path\n        className=\"fill-white dark:fill-black\"\n        d=\"M17.5667 9.21729H18.8111V18.2403C18.8255 19.1128 18.6 19.9726 18.159 20.7256C17.7241 21.4555 17.0968 22.0518 16.3458 22.4491C15.5717 22.8683 14.6722 23.0779 13.6473 23.0779C12.627 23.0779 11.7286 22.8672 10.9521 22.4457C10.2007 22.0478 9.5727 21.4518 9.13602 20.7223C8.6948 19.9705 8.4692 19.1118 8.48396 18.2403V9.21729H9.72854V18.1538C9.71656 18.8298 9.88417 19.4968 10.2143 20.0868C10.5362 20.6506 11.0099 21.1129 11.5814 21.421C12.1689 21.7448 12.8576 21.9067 13.6475 21.9067C14.4374 21.9067 15.1272 21.7448 15.7169 21.421C16.2895 21.1142 16.7635 20.6516 17.0844 20.0868C17.4124 19.4961 17.5788 18.8293 17.5667 18.1538V9.21729ZM23.6753 9.21729V22.845H22.4309V9.21729H23.6753Z\"\n      />\n    </svg>\n  );\n};\n\nexport const LangFilledIcon: React.FC<IconSvgProps> = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    focusable=\"false\"\n    height={size || height}\n    role=\"presentation\"\n    viewBox=\"0 0 640 512\"\n    width={size || width}\n    {...props}\n  >\n    <path\n      d=\"M0 128C0 92.7 28.7 64 64 64H256h48 16H576c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H320 304 256 64c-35.3 0-64-28.7-64-64V128zm320 0V384H576V128H320zM178.3 175.9c-3.2-7.2-10.4-11.9-18.3-11.9s-15.1 4.7-18.3 11.9l-64 144c-4.5 10.1 .1 21.9 10.2 26.4s21.9-.1 26.4-10.2l8.9-20.1h73.6l8.9 20.1c4.5 10.1 16.3 14.6 26.4 10.2s14.6-16.3 10.2-26.4l-64-144zM160 233.2L179 276H141l19-42.8zM448 164c11 0 20 9 20 20v4h44 16c11 0 20 9 20 20s-9 20-20 20h-2l-1.6 4.5c-8.9 24.4-22.4 46.6-39.6 65.4c.9 .6 1.8 1.1 2.7 1.6l18.9 11.3c9.5 5.7 12.5 18 6.9 27.4s-18 12.5-27.4 6.9l-18.9-11.3c-4.5-2.7-8.8-5.5-13.1-8.5c-10.6 7.5-21.9 14-34 19.4l-3.6 1.6c-10.1 4.5-21.9-.1-26.4-10.2s.1-21.9 10.2-26.4l3.6-1.6c6.4-2.9 12.6-6.1 18.5-9.8l-12.2-12.2c-7.8-7.8-7.8-20.5 0-28.3s20.5-7.8 28.3 0l14.6 14.6 .5 .5c12.4-13.1 22.5-28.3 29.8-45H448 376c-11 0-20-9-20-20s9-20 20-20h52v-4c0-11 9-20 20-20z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const InfoFilledIcon: React.FC<IconSvgProps> = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    aria-hidden=\"true\"\n    focusable=\"false\"\n    height={size || height}\n    role=\"presentation\"\n    viewBox=\"0 0 512 512\"\n    width={size || width}\n    {...props}\n  >\n    <path\n      d=\"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const MagnetIcon: React.FC<IconSvgProps> = (props: IconSvgProps) => (\n  <svg\n    height=\"1em\"\n    viewBox=\"0 0 1024 1024\"\n    width=\"1em\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      d=\"M85.333 330.667v138.666C85.333 704.981 276.352 896 512 896c235.648 0 426.667-191.019 426.667-426.667V330.667H725.333v138.666c0 164.224-177.777 266.864-320 184.752-66.005-38.108-106.666-108.535-106.666-184.752V330.667H85.333zm213.334-64V192c0-35.346-28.654-64-64-64h-85.334c-35.346 0-64 28.654-64 64v74.667h213.334zm426.666 0h213.334V192c0-35.346-28.654-64-64-64h-85.334c-35.346 0-64 28.654-64 64v74.667z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const CopyIcon: React.FC<IconSvgProps> = (props: IconSvgProps) => (\n  <svg\n    height=\"1em\"\n    viewBox=\"0 0 448 512\"\n    width=\"1em\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      d=\"M384 336H192c-8.8 0-16-7.2-16-16V64c0-8.8 7.2-16 16-16l140.1 0L400 115.9V320c0 8.8-7.2 16-16 16zM192 384H384c35.3 0 64-28.7 64-64V115.9c0-12.7-5.1-24.9-14.1-33.9L366.1 14.1c-9-9-21.2-14.1-33.9-14.1H192c-35.3 0-64 28.7-64 64V320c0 35.3 28.7 64 64 64zM64 128c-35.3 0-64 28.7-64 64V448c0 35.3 28.7 64 64 64H256c35.3 0 64-28.7 64-64V416H272v32c0 8.8-7.2 16-16 16H64c-8.8 0-16-7.2-16-16V192c0-8.8 7.2-16 16-16H96V128H64z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const PrevIcon: React.FC<IconSvgProps> = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    height={size || height}\n    viewBox=\"0 0 320 512\"\n    width={size || width}\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      d=\"M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const NextIcon: React.FC<IconSvgProps> = ({\n  size = 24,\n  width,\n  height,\n  ...props\n}: IconSvgProps) => (\n  <svg\n    height={size || height}\n    viewBox=\"0 0 320 512\"\n    width={size || width}\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      d=\"M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n"
  },
  {
    "path": "config/constant.ts",
    "content": "// Define search parameters\nexport const SEARCH_PARAMS = {\n  sortType: [\"default\", \"size\", \"count\", \"date\"],\n  filterSize: [\n    \"all\",\n    \"lt100mb\",\n    \"gt100mb-lt500mb\",\n    \"gt500mb-lt1gb\",\n    \"gt1gb-lt5gb\",\n    \"gt5gb\",\n  ],\n  filterTime: [\"all\", \"gt-1day\", \"gt-7day\", \"gt-31day\", \"gt-365day\"],\n} as const;\n\n// Tokenizer for search keywords\nexport const SEARCH_KEYWORD_SPLIT_REGEX =\n  /[.,!?;—()\\[\\]{}<>@#%^&*~`\"'|\\-，。！？；“”‘’“”「」『』《》、【】……（）·　\\s]/g;\n\n// Using for Search page\nexport const SEARCH_DISPLAY_FILES_MAX = 10;\nexport const SEARCH_KEYWORD_LENGTH_MIN = 2;\nexport const SEARCH_KEYWORD_LENGTH_MAX = 100;\nexport const SEARCH_PAGE_SIZE = 10;\nexport const SEARCH_PAGE_MAX = 100;\n\nexport const DEFAULT_SORT_TYPE = \"default\";\nexport const DEFAULT_FILTER_TIME = \"all\";\nexport const DEFAULT_FILTER_SIZE = \"all\";\n\n// TODO: Support UI_HIDE_PADDING_FILE\nexport const UI_HIDE_PADDING_FILE = true; // https://www.bittorrent.org/beps/bep_0047.html\n\nexport const UI_BACKGROUND_ANIMATION = true;\n\nexport const UI_BREAKPOINTS = {\n  xs: \"(max-width: 649px)\",\n  sm: \"(min-width: 650px)\",\n  md: \"(min-width: 960px)\",\n  lg: \"(min-width: 1280px)\",\n  xl: \"(min-width: 1400px)\",\n};\n"
  },
  {
    "path": "config/fonts.ts",
    "content": "import {\n  Fira_Code as FontMono,\n  Inter as FontSans,\n  Noto_Sans_SC,\n} from \"next/font/google\";\n\nexport const fontSans = FontSans({\n  subsets: [\"latin\"],\n  variable: \"--font-sans\",\n});\n\nexport const fontMono = FontMono({\n  subsets: [\"latin\"],\n  variable: \"--font-mono\",\n});\n\nexport const fontNoto = Noto_Sans_SC({\n  weight: [\"300\", \"400\", \"500\", \"700\"],\n  preload: false,\n});\n"
  },
  {
    "path": "config/site.ts",
    "content": "export type SiteConfig = typeof siteConfig;\n\nexport const siteConfig = {\n  name: \"Bitmagnet Next Web\",\n  description: \"🧲 A modern BitTorrent indexer, powered by Bitmagnet.\",\n};\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3\"\n\nservices:\n  bitmagnet-next-web:\n    image: journey0ad/bitmagnet-next-web:latest\n    container_name: bitmagnet-next-web\n    ports:\n      - \"3000:3000\"\n    restart: unless-stopped\n    environment:\n      - POSTGRES_DB_URL=postgres://postgres:postgres@postgres:5432/bitmagnet\n      # - POSTGRES_HOST=postgres\n      # - POSTGRES_PASSWORD=postgres\n    depends_on:\n      postgres:\n        condition: service_healthy\n\n  bitmagnet:\n    image: ghcr.io/bitmagnet-io/bitmagnet:latest\n    container_name: bitmagnet\n    ports:\n      # API and WebUI port:\n      - \"3333:3333\"\n      # BitTorrent ports:\n      - \"3334:3334/tcp\"\n      - \"3334:3334/udp\"\n    restart: unless-stopped\n    environment:\n      - POSTGRES_HOST=postgres\n      - POSTGRES_PASSWORD=postgres\n      # - TMDB_API_KEY=your_api_key\n    command:\n      - worker\n      - run\n      - --keys=http_server\n      - --keys=queue_server\n      # disable the next line to run without DHT crawler\n      - --keys=dht_crawler\n    depends_on:\n      postgres:\n        condition: service_healthy\n\n  postgres:\n    image: postgres:16-alpine\n    container_name: bitmagnet-postgres\n    volumes:\n      - ./data/postgres:/var/lib/postgresql/data\n    ports:\n      - \"5432:5432\"\n    restart: unless-stopped\n    environment:\n      - POSTGRES_PASSWORD=postgres\n      - POSTGRES_DB=bitmagnet\n      - PGUSER=postgres\n    shm_size: 1g\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - pg_isready\n      start_period: 20s\n      interval: 10s\n"
  },
  {
    "path": "hooks/useBreakpoints.ts",
    "content": "import { useIsSSR } from \"@react-aria/ssr\";\nimport { useMediaQuery } from \"react-responsive\";\n\nconst useBreakpoints = () => {\n  const isSSR = useIsSSR();\n\n  // detect screen size\n  const isXs = useMediaQuery({ query: \"(max-width: 649px)\" });\n  const isSmUp = useMediaQuery({ query: \"(min-width: 650px)\" });\n  const isMdUp = useMediaQuery({ query: \"(min-width: 960px)\" });\n  const isLgUp = useMediaQuery({ query: \"(min-width: 1280px)\" });\n  const isXlUp = useMediaQuery({ query: \"(min-width: 1400px)\" });\n\n  // client-side rendering return the actual media query result\n  // server-side rendering return the default value\n  return {\n    isXs: !isSSR && isXs,\n    isSmUp: !isSSR && isSmUp,\n    isMdUp: !isSSR && isMdUp,\n    isLgUp: !isSSR && isLgUp,\n    isXlUp: !isSSR && isXlUp,\n  };\n};\n\nexport default useBreakpoints;\n"
  },
  {
    "path": "hooks/useHydration.ts",
    "content": "import { useState, useEffect } from \"react\";\n\nexport function useHydration() {\n  const [hydrated, setHydrated] = useState(false);\n\n  useEffect(() => {\n    setHydrated(true);\n  }, []);\n\n  return hydrated;\n}\n"
  },
  {
    "path": "i18n/config.ts",
    "content": "export const defaultLocale = \"en\" as const;\nexport const locales = {\n  en: \"English\",\n  \"zh-CN\": \"简体中文\",\n  \"zh-TW\": \"繁體中文\",\n} as const;\n"
  },
  {
    "path": "i18n/index.ts",
    "content": "import { getRequestConfig } from \"next-intl/server\";\nimport { headers, cookies } from \"next/headers\";\nimport { mergeDeep } from \"@apollo/client/utilities\";\n\nimport { defaultLocale } from \"./config\";\n\nexport default getRequestConfig(async () => {\n  // Provide a static locale, fetch a user setting,\n  // read from `cookies()`, `headers()`, etc.\n\n  const browserLocale = (() => {\n    let locale = headers().get(\"accept-language\") ?? \"\";\n\n    locale = locale?.split(\",\")[0];\n\n    if (!locale.startsWith(\"zh\")) {\n      locale = locale.split(\"-\")[0];\n    }\n\n    return locale;\n  })();\n\n  const cookieLocale = (() => {\n    const locale = cookies().get(\"NEXT_LOCALE\")?.value;\n\n    return locale;\n  })();\n\n  const locale = cookieLocale || browserLocale || defaultLocale;\n\n  const defaultLocaleFile = (await import(`./locales/${defaultLocale}.json`))\n    .default;\n\n  if (!defaultLocaleFile) {\n    throw new Error(\"Default locale file not found\");\n  }\n\n  try {\n    const localeFile = (await import(`./locales/${locale}.json`)).default;\n\n    const localeMessages = mergeDeep(defaultLocaleFile, localeFile);\n\n    return {\n      locale,\n      messages: localeMessages,\n    };\n  } catch (error) {\n    return {\n      locale: defaultLocale,\n      messages: defaultLocaleFile,\n    };\n  }\n});\n"
  },
  {
    "path": "i18n/locales/en.json",
    "content": "{\n  \"COMMON\": {\n    \"DATE_FORMAT\": \"MM/DD/YYYY HH:mm:ss\",\n    \"DATE_FORMAT_SHORT\": \"MM/DD/YYYY HH:mm\",\n    \"DATE_FORMAT_DATE\": \"MM/DD/YYYY\",\n    \"DATE_FORMAT_TIME\": \"HH:mm:ss\",\n    \"DEMO_MODE_TIPS\": \"This website is running in demo mode\"\n  },\n  \"Stats\": {\n    \"title\": \"Statistics\",\n    \"size\": \"Database size: {size}\",\n    \"total_count\": \"Total torrents: {total_count}\",\n    \"updated_at\": \"Last updated: {updated_at}\"\n  },\n  \"ERROR_MESSAGE\": { \n    \"INTERNAL_SERVER_ERROR\": \"Something went wrong!\",\n    \"NOT_FOUND\": \"Resource not found\",\n    \"Digest\": \"Digest\",\n    \"Message\": \"Message\",\n    \"GoHome\": \"Go Home\"\n  },\n  \"Metadata\": {\n    \"search\": {\n      \"title\": \"{keyword} search results\"\n    }\n  },\n  \"Search\": {\n    \"placeholder\": \"Search what you want...\",\n    \"filterLabel\": {\n      \"sortType\": \"Sort by\",\n      \"filterTime\": \"Filter by time\",\n      \"filterSize\": \"Filter by size\"\n    },\n    \"sortType\": {\n      \"default\": \"Default sorting\",\n      \"size\": \"File size\",\n      \"count\": \"File count\",\n      \"date\": \"Date added\"\n    },\n    \"filterTime\": {\n      \"all\": \"All time\",\n      \"gt-1day\": \"Past day\",\n      \"gt-7day\": \"Past week\",\n      \"gt-31day\": \"Past month\",\n      \"gt-365day\": \"Past year\"\n    },\n    \"filterSize\": {\n      \"all\": \"All sizes\",\n      \"lt100mb\": \"Less than 100MB\",\n      \"gt100mb-lt500mb\": \"100MB-500MB\",\n      \"gt500mb-lt1gb\": \"500MB-1GB\",\n      \"gt1gb-lt5gb\": \"1GB-5GB\",\n      \"gt5gb\": \"More than 5GB\"\n    },\n    \"results_found\": \"About {count} results found\",\n    \"cost_time\": \"(Processed in {cost_time}ms)\",\n    \"more_files\": \"…and {count} more files\",\n    \"magnet\": \"Magnet link\",\n    \"file_size\": \"File size: \",\n    \"file_count\": \"File count: \",\n    \"created_at\": \"Date added: \",\n    \"hash\": \"Hash: \"\n  },\n  \"Detail\": {\n    \"details\": \"Torrent details\",\n    \"preview\": \"Preview\",\n    \"magnet\": \"Magnet link\",\n    \"file_list\": \"File list\",\n    \"copy\": \"Copy magnet link\"\n  },\n  \"Toast\": {\n    \"keyword_too_short\": \"Keyword needs to be at least 2 characters\",\n    \"copy_success\": \"Magnet link copied to clipboard\"\n  }\n}\n"
  },
  {
    "path": "i18n/locales/zh-CN.json",
    "content": "{\n  \"COMMON\": {\n    \"DATE_FORMAT\": \"YYYY-MM-DD HH:mm:ss\",\n    \"DATE_FORMAT_SHORT\": \"YYYY-MM-DD HH:mm\",\n    \"DATE_FORMAT_DATE\": \"YYYY-MM-DD\",\n    \"DATE_FORMAT_TIME\": \"HH:mm:ss\",\n    \"DEMO_MODE_TIPS\": \"这个网站正在演示模式下运行\"\n  },\n  \"Stats\": {\n    \"title\": \"统计信息\",\n    \"size\": \"数据库大小: {size}\",\n    \"total_count\": \"收录数量: {total_count}\",\n    \"updated_at\": \"最后更新: {updated_at}\"\n  },\n  \"ERROR_MESSAGE\": {\n    \"INTERNAL_SERVER_ERROR\": \"发生意外错误\",\n    \"NOT_FOUND\": \"资源不存在\",\n    \"GoHome\": \"返回首页\"\n  },\n  \"Metadata\": {\n    \"search\": {\n      \"title\": \"{keyword} 的搜索结果\"\n    }\n  },\n  \"Search\": {\n    \"placeholder\": \"搜你想搜...\",\n    \"filterLabel\": {\n      \"sortType\": \"排序方式\",\n      \"filterTime\": \"按时间筛选\",\n      \"filterSize\": \"按大小筛选\"\n    },\n    \"sortType\": {\n      \"default\": \"默认排序\",\n      \"size\": \"文件大小\",\n      \"count\": \"文件数量\",\n      \"date\": \"收录时间\"\n    },\n    \"filterTime\": {\n      \"all\": \"不限时间\",\n      \"gt-1day\": \"最近一天内\",\n      \"gt-7day\": \"最近一周内\",\n      \"gt-31day\": \"最近一月内\",\n      \"gt-365day\": \"最近一年内\"\n    },\n    \"filterSize\": {\n      \"all\": \"不限大小\",\n      \"lt100mb\": \"小于100MB\",\n      \"gt100mb-lt500mb\": \"100MB-500MB\",\n      \"gt500mb-lt1gb\": \"500MB-1GB\",\n      \"gt1gb-lt5gb\": \"1GB-5GB\",\n      \"gt5gb\": \"大于5GB\"\n    },\n    \"results_found\": \"找到约 {count} 条结果\",\n    \"cost_time\": \"(用时 {cost_time}ms)\",\n    \"more_files\": \"…还有 {count} 个文件\",\n    \"magnet\": \"磁力链接\",\n    \"file_size\": \"文件大小: \",\n    \"file_count\": \"文件数量: \",\n    \"created_at\": \"收录时间: \",\n    \"hash\": \"哈希值: \"\n  },\n  \"Detail\": {\n    \"details\": \"资源详情\",\n    \"preview\": \"内容预览\",\n    \"magnet\": \"磁链地址\",\n    \"file_list\": \"文件列表\",\n    \"copy\": \"复制磁链\"\n  },\n  \"Toast\": {\n    \"keyword_too_short\": \"关键词需要大于两个字符\",\n    \"copy_success\": \"磁力链接已复制到剪贴板\"\n  }\n}\n"
  },
  {
    "path": "i18n/locales/zh-TW.json",
    "content": "{\n  \"COMMON\": {\n    \"DATE_FORMAT\": \"YYYY-MM-DD HH:mm:ss\",\n    \"DATE_FORMAT_SHORT\": \"YYYY-MM-DD HH:mm\",\n    \"DATE_FORMAT_DATE\": \"YYYY-MM-DD\",\n    \"DATE_FORMAT_TIME\": \"HH:mm:ss\",\n    \"DEMO_MODE_TIPS\": \"這個網站正在演示模式下運行\"\n  },\n  \"Stats\": {\n    \"title\": \"統計資訊\",\n    \"size\": \"資料庫大小: {size}\",\n    \"total_count\": \"收錄數量: {total_count}\",\n    \"updated_at\": \"最後更新: {updated_at}\"\n  },\n  \"ERROR_MESSAGE\": {\n    \"INTERNAL_SERVER_ERROR\": \"發生意外錯誤\",\n    \"NOT_FOUND\": \"資源不存在\",\n    \"GoHome\": \"返回首頁\"\n  },\n  \"Metadata\": {\n    \"search\": {\n      \"title\": \"{keyword} 的搜尋結果\"\n    }\n  },\n  \"Search\": {\n    \"placeholder\": \"搜尋你想要的...\",\n    \"filterLabel\": {\n      \"sortType\": \"排序方式\",\n      \"filterTime\": \"按時間篩選\",\n      \"filterSize\": \"按大小篩選\"\n    },\n    \"sortType\": {\n      \"default\": \"默認排序\",\n      \"size\": \"文件大小\",\n      \"count\": \"文件數量\",\n      \"date\": \"收錄時間\"\n    },\n    \"filterTime\": {\n      \"all\": \"不限時間\",\n      \"gt-1day\": \"最近一天內\",\n      \"gt-7day\": \"最近一週內\",\n      \"gt-31day\": \"最近一月內\",\n      \"gt-365day\": \"最近一年內\"\n    },\n    \"filterSize\": {\n      \"all\": \"不限大小\",\n      \"lt100mb\": \"小於100MB\",\n      \"gt100mb-lt500mb\": \"100MB-500MB\",\n      \"gt500mb-lt1gb\": \"500MB-1GB\",\n      \"gt1gb-lt5gb\": \"1GB-5GB\",\n      \"gt5gb\": \"大於5GB\"\n    },\n    \"results_found\": \"找到約 {count} 條結果\",\n    \"cost_time\": \"(耗時 {cost_time}ms)\",\n    \"more_files\": \"…還有 {count} 個文件\",\n    \"magnet\": \"磁力連結\",\n    \"file_size\": \"文件大小: \",\n    \"file_count\": \"文件數量: \",\n    \"created_at\": \"收錄時間: \",\n    \"hash\": \"Hash: \"\n  },\n  \"Detail\": {\n    \"details\": \"詳細資訊\",\n    \"preview\": \"內容預覽\",\n    \"magnet\": \"磁力連結\",\n    \"file_list\": \"檔案清單\",\n    \"copy\": \"複製磁力連結\"\n  },\n  \"Toast\": {\n    \"keyword_too_short\": \"關鍵字需大於兩個字元\",\n    \"copy_success\": \"磁力連結已複製到剪貼簿\"\n  }\n}\n"
  },
  {
    "path": "lib/apolloClient.ts",
    "content": "import { ApolloClient, InMemoryCache, from, HttpLink } from \"@apollo/client\";\nimport { removeTypenameFromVariables } from \"@apollo/client/link/remove-typename\";\n\nimport { getBaseUrl } from \"@/utils/api\";\n\nconst httpLink = new HttpLink({\n  uri: `${getBaseUrl()}/api/graphql`, // 从环境变量中获取 URI\n});\n\nconst removeTypename = removeTypenameFromVariables();\n\nconst client = new ApolloClient({\n  cache: new InMemoryCache({\n    addTypename: false,\n  }),\n  link: from([removeTypename, httpLink]),\n});\n\nexport default client;\n"
  },
  {
    "path": "lib/jieba.ts",
    "content": "import { tag } from \"@node-rs/jieba\";\n\nconst requiredTags = [\n  [\"n\", \"nr\", \"ns\", \"nt\", \"nz\"], // noun\n  \"vn\", // gerund\n  \"x\", // other\n].flat();\n\nexport function jiebaCut(text: string) {\n  // return cut(text, true);\n  return tag(text, true).map((_) => ({\n    keyword: _.word,\n    required: requiredTags.includes(_.tag),\n  }));\n}\n"
  },
  {
    "path": "lib/pgdb.ts",
    "content": "import { Pool } from \"pg\";\n\n// Load connection string from environment\nlet connectionString = process.env.POSTGRES_DB_URL;\n\nif (!connectionString) {\n  const host = process.env.POSTGRES_HOST;\n  const password = process.env.POSTGRES_PASSWORD;\n  const user = process.env.POSTGRES_USER || \"postgres\"; // optional, defaults to 'postgres'\n  const db = process.env.POSTGRES_DB || \"bitmagnet\"; // optional, defaults to 'bitmagnet'\n  const port = process.env.POSTGRES_PORT || \"5432\"; // optional, defaults to 5432\n\n  if (!host || !password) {\n    // eslint-disable-next-line no-console\n    console.warn(\n      \"Missing environment variables `POSTGRES_DB_URL` or `POSTGRES_HOST` and `POSTGRES_PASSWORD`\",\n    );\n  }\n\n  // Build connection string\n  connectionString = `postgres://${user}:${password}@${host}:${port}/${db}`;\n}\n\nconst pool = new Pool({\n  connectionString,\n  ssl: false,\n});\n\nexport const query = (text: string, params: any) => pool.query(text, params);\n"
  },
  {
    "path": "moke/detail.json",
    "content": "{\n  \"data\": {\n      \"hash\": \"3e14aa4ca819da595e311629a42e952804179b1e\",\n      \"name\": \"[异域-11番小队][攻壳机动队_GHOST IN THE SHELL][TV+MOV+OVA][BDRIP][X264-10bit_AAC][720P]\",\n      \"size\": \"13725714362\",\n      \"magnet_uri\": \"magnet:?xt=urn:btih:3e14aa4ca819da595e311629a42e952804179b1e&dn=%5B%E5%BC%82%E5%9F%9F-11%E7%95%AA%E5%B0%8F%E9%98%9F%5D%5B%E6%94%BB%E5%A3%B3%E6%9C%BA%E5%8A%A8%E9%98%9F_GHOST%20IN%20THE%20SHELL%5D%5BTV%2BMOV%2BOVA%5D%5BBDRIP%5D%5BX264-10bit_AAC%5D%5B720P%5D&xl=13725714362\",\n      \"single_file\": false,\n      \"files_count\": 55,\n      \"files\": [\n          {\n              \"index\": 0,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell- 1995-2.0][OVA1][BDRIP][X264-10bit_AAC][720P][B2CA6F21].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"820940608\"\n          },\n          {\n              \"index\": 1,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell- Innocence][OVA2][BDRIP][X264-10bit_AAC][720P][6D31B2CC].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"1477291205\"\n          },\n          {\n              \"index\": 2,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell- Solid_State_Society][MOV][BDRIP][X264-10bit_AAC][720P][7D82BAB9].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"1211589555\"\n          },\n          {\n              \"index\": 3,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][01][BDRIP][X264-10bit_AAC][720P][245D25CF].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"179138179\"\n          },\n          {\n              \"index\": 4,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][02][BDRIP][X264-10bit_AAC][720P][2C45FC38].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"192401042\"\n          },\n          {\n              \"index\": 5,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][03][BDRIP][X264-10bit_AAC][720P][F0F773B3].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"158353368\"\n          },\n          {\n              \"index\": 6,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][04][BDRIP][X264-10bit_AAC][720P][2817DC37].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"233529190\"\n          },\n          {\n              \"index\": 7,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][05][BDRIP][X264-10bit_AAC][720P][3FF5288A].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"180157857\"\n          },\n          {\n              \"index\": 8,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][06][BDRIP][X264-10bit_AAC][720P][1C651B8C].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"169356140\"\n          },\n          {\n              \"index\": 9,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][07][BDRIP][X264-10bit_AAC][720P][A678C81E].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"162118487\"\n          },\n          {\n              \"index\": 10,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][08][BDRIP][X264-10bit_AAC][720P][F6BCD6D9].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"178676935\"\n          },\n          {\n              \"index\": 11,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][09][BDRIP][X264-10bit_AAC][720P][974EB705].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"218396271\"\n          },\n          {\n              \"index\": 12,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][10][BDRIP][X264-10bit_AAC][720P][BF25A962].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"129101309\"\n          },\n          {\n              \"index\": 13,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][11][BDRIP][X264-10bit_AAC][720P][86EC6E14].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"167561897\"\n          },\n          {\n              \"index\": 14,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][12][BDRIP][X264-10bit_AAC][720P][0DE533B5].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"226534058\"\n          },\n          {\n              \"index\": 15,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][13][BDRIP][X264-10bit_AAC][720P][0E1FD3BA].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"170549748\"\n          },\n          {\n              \"index\": 16,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][14][BDRIP][X264-10bit_AAC][720P][F0EBAD7B].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"193001803\"\n          },\n          {\n              \"index\": 17,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][15][BDRIP][X264-10bit_AAC][720P][F9FFB81F].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"244055506\"\n          },\n          {\n              \"index\": 18,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][16][BDRIP][X264-10bit_AAC][720P][9965B364].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"394481670\"\n          },\n          {\n              \"index\": 19,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][17][BDRIP][X264-10bit_AAC][720P][C5A2E8BD].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"153461978\"\n          },\n          {\n              \"index\": 20,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][18][BDRIP][X264-10bit_AAC][720P][564D5DF6].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"196135187\"\n          },\n          {\n              \"index\": 21,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][19][BDRIP][X264-10bit_AAC][720P][5F0E7F39].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"276436131\"\n          },\n          {\n              \"index\": 22,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][20][BDRIP][X264-10bit_AAC][720P][3855C712].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"159015017\"\n          },\n          {\n              \"index\": 23,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][21][BDRIP][X264-10bit_AAC][720P][342A6443].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"209327874\"\n          },\n          {\n              \"index\": 24,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][22][BDRIP][X264-10bit_AAC][720P][13E82C1F].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"163124932\"\n          },\n          {\n              \"index\": 25,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][23][BDRIP][X264-10bit_AAC][720P][CB606A0C].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"191399204\"\n          },\n          {\n              \"index\": 26,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][24][BDRIP][X264-10bit_AAC][720P][CEBA6E78].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"207995098\"\n          },\n          {\n              \"index\": 27,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][25][BDRIP][X264-10bit_AAC][720P][AE3BCF93].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"195694248\"\n          },\n          {\n              \"index\": 28,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-S.A.C. 2nd GIG][26][BDRIP][X264-10bit_AAC][720P][E1A9C9E3].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"234359649\"\n          },\n          {\n              \"index\": 29,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][01][BDRIP][X264-10bit_AAC][720P][71E585A9].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"211892101\"\n          },\n          {\n              \"index\": 30,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][02][BDRIP][X264-10bit_AAC][720P][8F3A41C0].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"234208152\"\n          },\n          {\n              \"index\": 31,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][03][BDRIP][X264-10bit_AAC][720P][AA23400C].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"183357713\"\n          },\n          {\n              \"index\": 32,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][04][BDRIP][X264-10bit_AAC][720P][43FAD622].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"200668950\"\n          },\n          {\n              \"index\": 33,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][05][BDRIP][X264-10bit_AAC][720P][CE4C5012].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"203272712\"\n          },\n          {\n              \"index\": 34,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][06][BDRIP][X264-10bit_AAC][720P][7BE1C43B].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"197970638\"\n          },\n          {\n              \"index\": 35,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][07][BDRIP][X264-10bit_AAC][720P][B0295CB4].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"187205757\"\n          },\n          {\n              \"index\": 36,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][08][BDRIP][X264-10bit_AAC][720P][E8186BE1].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"184845049\"\n          },\n          {\n              \"index\": 37,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][09][BDRIP][X264-10bit_AAC][720P][581B1DF2].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"233519030\"\n          },\n          {\n              \"index\": 38,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][10][BDRIP][X264-10bit_AAC][720P][6E0BC9E8].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"181936037\"\n          },\n          {\n              \"index\": 39,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][11][BDRIP][X264-10bit_AAC][720P][A8C2BCEA].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"176088934\"\n          },\n          {\n              \"index\": 40,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][12][BDRIP][X264-10bit_AAC][720P][64107CBF].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"212766756\"\n          },\n          {\n              \"index\": 41,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][13][BDRIP][X264-10bit_AAC][720P][0D41BFC6].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"195490816\"\n          },\n          {\n              \"index\": 42,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][14][BDRIP][X264-10bit_AAC][720P][BCD3CF8F].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"195657219\"\n          },\n          {\n              \"index\": 43,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][15][BDRIP][X264-10bit_AAC][720P][C6185A93].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"210634833\"\n          },\n          {\n              \"index\": 44,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][16][BDRIP][X264-10bit_AAC][720P][6CB1728E].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"185283575\"\n          },\n          {\n              \"index\": 45,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][17][BDRIP][X264-10bit_AAC][720P][BAC8DAD5].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"146701611\"\n          },\n          {\n              \"index\": 46,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][18][BDRIP][X264-10bit_AAC][720P][B18FDFE8].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"153565409\"\n          },\n          {\n              \"index\": 47,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][19][BDRIP][X264-10bit_AAC][720P][33014230].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"168688704\"\n          },\n          {\n              \"index\": 48,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][20][BDRIP][X264-10bit_AAC][720P][B30ECD8E].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"184604559\"\n          },\n          {\n              \"index\": 49,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][21][BDRIP][X264-10bit_AAC][720P][93392110].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"200411359\"\n          },\n          {\n              \"index\": 50,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][22][BDRIP][X264-10bit_AAC][720P][FE607B50].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"175165358\"\n          },\n          {\n              \"index\": 51,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][23][BDRIP][X264-10bit_AAC][720P][6E436042].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"178309602\"\n          },\n          {\n              \"index\": 52,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][24][BDRIP][X264-10bit_AAC][720P][45164B6F].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"178795852\"\n          },\n          {\n              \"index\": 53,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][25][BDRIP][X264-10bit_AAC][720P][0DCFFC26].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"252105105\"\n          },\n          {\n              \"index\": 54,\n              \"path\": \"[YYDM-11FANS][Ghost_In_The_Shell-Stand_Alone_Complex][26][BDRIP][X264-10bit_AAC][720P][C2FD9A64].mp4\",\n              \"extension\": \"mp4\",\n              \"size\": \"198384385\"\n          }\n      ],\n      \"created_at\": 1719147067,\n      \"updated_at\": 1719147067\n  },\n  \"message\": \"success\",\n  \"status\": 200\n}"
  },
  {
    "path": "moke/index.ts",
    "content": "import search from \"./search.json\";\nimport detail from \"./detail.json\";\nimport stats from \"./stats.json\";\n\nexport default {\n  search: search.data,\n  detail: detail.data,\n  stats: stats.data,\n};\n"
  },
  {
    "path": "moke/search.json",
    "content": "{\n  \"data\": {\n      \"keywords\": [\n          \"哆啦a梦\"\n      ],\n      \"torrents\": [\n          {\n              \"hash\": \"181bf8eced82d3d215187f920d0ea4a511bfd17c\",\n              \"name\": \"[搬运整理]哆啦A梦好看的剧集\",\n              \"size\": \"55356425642\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:181bf8eced82d3d215187f920d0ea4a511bfd17c&dn=%5B%E6%90%AC%E8%BF%90%E6%95%B4%E7%90%86%5D%E5%93%86%E5%95%A6A%E6%A2%A6%E5%A5%BD%E7%9C%8B%E7%9A%84%E5%89%A7%E9%9B%86&xl=55356425642\",\n              \"single_file\": false,\n              \"files_count\": 363,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了！！&唤醒记忆！那天的感动[480P][MP4]/5CA2E549434ADF8B14C2BEF81AE9BC3B2941431C.torrent\",\n                      \"extension\": \"torrent\",\n                      \"size\": \"39673\"\n                  },\n                  {\n                      \"index\": 2,\n                      \"path\": \"001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了！！&唤醒记忆！那天的感动[480P][MP4]/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了！！&唤醒记忆！那天的感动[480P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"243497353\"\n                  },\n                  {\n                      \"index\": 4,\n                      \"path\": \"001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了！！&唤醒记忆！那天的感动[480P][MP4]/关于字幕组招募通知.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"13824\"\n                  },\n                  {\n                      \"index\": 6,\n                      \"path\": \"001/[银光字幕组][哆啦A梦新番Doraemon][001][GB][2005.04.15]在书房里钓鱼&时光机不见了！！&唤醒记忆！那天的感动[480P][MP4]/关于银光动漫招聘工作成员.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"17920\"\n                  },\n                  {\n                      \"index\": 8,\n                      \"path\": \"002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞，乱糟糟 & 大雄的新娘[480P][MP4].mp4/F893E84493B1C62F23013604F9B5F2EF86B7C181.torrent\",\n                      \"extension\": \"torrent\",\n                      \"size\": \"21058\"\n                  },\n                  {\n                      \"index\": 10,\n                      \"path\": \"002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞，乱糟糟 & 大雄的新娘[480P][MP4].mp4/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞，乱糟糟 & 大雄的新娘[480P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"124983831\"\n                  },\n                  {\n                      \"index\": 12,\n                      \"path\": \"002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞，乱糟糟 & 大雄的新娘[480P][MP4].mp4/关于字幕组招募通知.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"13824\"\n                  },\n                  {\n                      \"index\": 14,\n                      \"path\": \"002/[银光字幕组][哆啦A梦新番Doraemon][002][GB][2005.04.22]慢吞吞，乱糟糟 & 大雄的新娘[480P][MP4].mp4/关于银光动漫招聘工作成员.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"17920\"\n                  },\n                  {\n                      \"index\": 16,\n                      \"path\": \"003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/B5AA4660B6DE824290C6B0F4BBAE617B406EF0AE.torrent\",\n                      \"extension\": \"torrent\",\n                      \"size\": \"24739\"\n                  },\n                  {\n                      \"index\": 18,\n                      \"path\": \"003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"145345377\"\n                  },\n                  {\n                      \"index\": 20,\n                      \"path\": \"003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/关于字幕组招募通知.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"13824\"\n                  },\n                  {\n                      \"index\": 22,\n                      \"path\": \"003/[银光字幕组][哆啦A梦新番Doraemon][003][GB][2005.04.29]独裁者按钮[720P][MP4]/关于银光动漫招聘工作成员.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"17920\"\n                  },\n                  {\n                      \"index\": 24,\n                      \"path\": \"007/[银光字幕组][哆啦A梦新番Doraemon][007][GB][2005.05.27]大雄的地底国[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"208938030\"\n                  },\n                  {\n                      \"index\": 26,\n                      \"path\": \"009/[银光字幕组][哆啦A梦新番Doraemon][009][GB][2005.06.10]到处是哆啦A梦&心情香水[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"195064761\"\n                  },\n                  {\n                      \"index\": 28,\n                      \"path\": \"013/[银光字幕组][哆啦A梦新番Doraemon][013][GB][2005.07.08]变身饼干&再见了！静香[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"192928020\"\n                  },\n                  {\n                      \"index\": 30,\n                      \"path\": \"032/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2005.12.31][032][GB]除夕特别篇[HDRip][X264-AAC][720P][MP4].MP4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"657966509\"\n                  },\n                  {\n                      \"index\": 32,\n                      \"path\": \"035/[银光字幕组][哆啦A梦新番Doraemon][035][GB][2006.01.27]榻榻米水田&夸张外套[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"190951847\"\n                  },\n                  {\n                      \"index\": 34,\n                      \"path\": \"041/[银光字幕组][哆啦A梦新番Doraemon][041][GB][2006.03.10]生存下来的人是谁！？&无人岛的大怪物[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"357760351\"\n                  },\n                  {\n                      \"index\": 36,\n                      \"path\": \"044/[银光字幕组][哆啦A梦新番Doraemon][044][GB][2006.04.21]从未来之国千里迢迢而来[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"183420521\"\n                  },\n                  {\n                      \"index\": 38,\n                      \"path\": \"045/[银光字幕组][哆啦A梦新番Doraemon][045][GB][2006.04.28]梦想的小镇 大雄乐园&大雄的25年后[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"218778228\"\n                  },\n                  {\n                      \"index\": 40,\n                      \"path\": \"047/[银光字幕组][哆啦A梦新番Doraemon][2006.05.12][047]即使在胃之中、水之中&复活吧！佩罗[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"184085686\"\n                  },\n                  {\n                      \"index\": 42,\n                      \"path\": \"054/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2006.06.30][054][GB]吃糖果做歌星&怀念奶奶&令人感动的麦克风[HDRip][X264-AAC][720P][MP4].MP4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"323174538\"\n                  },\n                  {\n                      \"index\": 44,\n                      \"path\": \"062/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2006.09.01][062][GB]哆啦A梦生日特别篇之大雄 再见了！哆啦A梦 回未来了...[HDRip][X264-AAC][720P][MP4].MP4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"288228916\"\n                  },\n                  {\n                      \"index\": 46,\n                      \"path\": \"067/[银光字幕组][哆啦A梦新番Doraemon][2006.10.27][067]变身树叶&对神仙机器人伸出爱的援手[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"218046461\"\n                  },\n                  {\n                      \"index\": 48,\n                      \"path\": \"072/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2006.12.01][072][GB]切浦岛糖果&哆啦A梦和哆啦美酱[HDRip][X264-AAC][720P][MP4].MP4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"232498857\"\n                  },\n                  {\n                      \"index\": 50,\n                      \"path\": \"075/[YGSUB][2007.01.12][075][HDTV][X264.MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"177596651\"\n                  },\n                  {\n                      \"index\": 52,\n                      \"path\": \"076/[银光字幕组][哆啦A梦新番Doraemon][2007.01.19][076]漂亮的小咪& 把静香夺回来！（后篇）[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"151202437\"\n                  },\n                  {\n                      \"index\": 54,\n                      \"path\": \"080/[银光字幕组][哆啦A梦新番Doraemon][2007.02.16][080]地底之国的探险（上集）[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"158217771\"\n                  },\n                  {\n                      \"index\": 56,\n                      \"path\": \"081/[银光字幕组][哆啦A梦新番Doraemon][2007.02.23][081]地底之国的探险（下集）[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"186907099\"\n                  },\n                  {\n                      \"index\": 58,\n                      \"path\": \"084/[银光字幕组][哆啦A梦新番Doraemon][2007.03.16][084]大雄的黑洞&不可能制作动画[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"156549644\"\n                  },\n                  {\n                      \"index\": 60,\n                      \"path\": \"086/[银光字幕组][哆啦A梦新番Doraemon][2007.04.29][086]穿着红色鞋子的女孩&空地上的大白鲨[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"181393575\"\n                  },\n                  {\n                      \"index\": 62,\n                      \"path\": \"094/[银光字幕组][哆啦A梦新番Doraemon][2007.06.29][094]海贼大决战南海的爱情罗曼史[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"423946689\"\n                  },\n                  {\n                      \"index\": 64,\n                      \"path\": \"102/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2007.09.07][102][GB]哆啦A梦生日特别篇之哆啦A梦的重生之日[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"364222712\"\n                  },\n                  {\n                      \"index\": 66,\n                      \"path\": \"109/[银光字幕组][哆啦A梦新番Doraemon][2007.12.07][109]保护好王子！传说中的哆啦美三剑士[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"354175558\"\n                  },\n                  {\n                      \"index\": 68,\n                      \"path\": \"120/[银光字幕组][哆啦A梦新番Doraemon][2008.03.14][120]森林活起来了[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"177836666\"\n                  },\n                  {\n                      \"index\": 70,\n                      \"path\": \"123/[银光字幕组][哆啦A梦新番Doraemon][123][GB][2008.04.25]我出生的那一天[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"126308164\"\n                  },\n                  {\n                      \"index\": 72,\n                      \"path\": \"124/[银光字幕组][哆啦A梦新番Doraemon][124][GB][2008.05.02]试着说再见[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"133803565\"\n                  },\n                  {\n                      \"index\": 74,\n                      \"path\": \"127/[银光字幕组][哆啦A梦新番Doraemon][127][GB][2008.05.23]戏剧性瓦斯&大雄真了不起！再来一次[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"133455382\"\n                  },\n                  {\n                      \"index\": 76,\n                      \"path\": \"128/[银光字幕组][哆啦A梦新番Doraemon][128][GB][2008.06.06]送给静香的礼物是大雄[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"155692995\"\n                  },\n                  {\n                      \"index\": 78,\n                      \"path\": \"132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/AEF3FEF54C489C6FDA79DD33111055E968782DBF.torrent\",\n                      \"extension\": \"torrent\",\n                      \"size\": \"27243\"\n                  },\n                  {\n                      \"index\": 80,\n                      \"path\": \"132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"322440312\"\n                  },\n                  {\n                      \"index\": 82,\n                      \"path\": \"132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/关于字幕组招募通知.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"13824\"\n                  },\n                  {\n                      \"index\": 84,\n                      \"path\": \"132/[银光字幕组][哆啦A梦新番Doraemon][132][GB][2008.06.27]1小时特别篇-沉睡的海之王国[720P][MP4]/关于银光动漫招聘工作成员.doc\",\n                      \"extension\": \"doc\",\n                      \"size\": \"17920\"\n                  },\n                  {\n                      \"index\": 86,\n                      \"path\": \"133/[银光字幕组][哆啦A梦新番Doraemon][133][GB][2008.07.11]在七夕的宇宙战争[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"198786331\"\n                  },\n                  {\n                      \"index\": 88,\n                      \"path\": \"134/[银光字幕组][哆啦A梦新番Doraemon][134][GB][2008.07.18]比我更差的家伙来了&保镖是背后灵[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"168116259\"\n                  },\n                  {\n                      \"index\": 90,\n                      \"path\": \"136/[银光字幕组][哆啦A梦新番Doraemon][136][GB][2008.08.01]鬼魂出现了[720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"117184890\"\n                  },\n                  {\n                      \"index\": 92,\n                      \"path\": \"141/[银光字幕组YGSUB][哆啦A梦新番Doraemon][2008.09.05][141][GB]哆啦A梦生日特别篇之哆啦A梦的青之泪[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"313876425\"\n                  },\n                  {\n                      \"index\": 94,\n                      \"path\": \"142/[Doraemon][142][2008.09.12][HDTVrip][129.3字幕组][720P][AVC_AAC][GB][冒险茶&舌相神卜！][重播版](DA7FEFB3).mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"297519066\"\n                  },\n                  {\n                      \"index\": 96,\n                      \"path\": \"157/[Doraemon][157][2009.01.23][HDTVRip][sy2006]一半的一半的又一半&那一天.那一刻.那个不倒翁.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"145482921\"\n                  },\n                  {\n                      \"index\": 98,\n                      \"path\": \"162/[银光字幕组][哆啦A梦新番Doraemon][2009.03.06][162]银河铁道之夜[HDRip][X264-AAC][720P][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"221172956\"\n                  },\n                  {\n                      \"index\": 1,\n                      \"path\": \"_____padding_file_0_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8348935\"\n                  },\n                  {\n                      \"index\": 3,\n                      \"path\": \"_____padding_file_1_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8160887\"\n                  },\n                  {\n                      \"index\": 5,\n                      \"path\": \"_____padding_file_2_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8374784\"\n                  },\n                  {\n                      \"index\": 7,\n                      \"path\": \"_____padding_file_3_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8370688\"\n                  },\n                  {\n                      \"index\": 9,\n                      \"path\": \"_____padding_file_4_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8367550\"\n                  },\n                  {\n                      \"index\": 11,\n                      \"path\": \"_____padding_file_5_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"845289\"\n                  },\n                  {\n                      \"index\": 13,\n                      \"path\": \"_____padding_file_6_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8374784\"\n                  },\n                  {\n                      \"index\": 15,\n                      \"path\": \"_____padding_file_7_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8370688\"\n                  },\n                  {\n                      \"index\": 17,\n                      \"path\": \"_____padding_file_8_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8363869\"\n                  },\n                  {\n                      \"index\": 19,\n                      \"path\": \"_____padding_file_9_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"5649567\"\n                  },\n                  {\n                      \"index\": 21,\n                      \"path\": \"_____padding_file_10_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8374784\"\n                  },\n                  {\n                      \"index\": 23,\n                      \"path\": \"_____padding_file_11_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8370688\"\n                  },\n                  {\n                      \"index\": 25,\n                      \"path\": \"_____padding_file_12_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"777170\"\n                  },\n                  {\n                      \"index\": 27,\n                      \"path\": \"_____padding_file_13_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"6261831\"\n                  },\n                  {\n                      \"index\": 29,\n                      \"path\": \"_____padding_file_14_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"9964\"\n                  },\n                  {\n                      \"index\": 31,\n                      \"path\": \"_____padding_file_15_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"4733523\"\n                  },\n                  {\n                      \"index\": 33,\n                      \"path\": \"_____padding_file_16_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"1986137\"\n                  },\n                  {\n                      \"index\": 35,\n                      \"path\": \"_____padding_file_17_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"2949793\"\n                  },\n                  {\n                      \"index\": 37,\n                      \"path\": \"_____padding_file_18_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"1128855\"\n                  },\n                  {\n                      \"index\": 39,\n                      \"path\": \"_____padding_file_19_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"7714188\"\n                  },\n                  {\n                      \"index\": 41,\n                      \"path\": \"_____padding_file_20_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"463690\"\n                  },\n                  {\n                      \"index\": 43,\n                      \"path\": \"_____padding_file_21_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"3981174\"\n                  },\n                  {\n                      \"index\": 45,\n                      \"path\": \"_____padding_file_22_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"5372364\"\n                  },\n                  {\n                      \"index\": 47,\n                      \"path\": \"_____padding_file_23_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"57347\"\n                  },\n                  {\n                      \"index\": 49,\n                      \"path\": \"_____padding_file_24_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"2382167\"\n                  },\n                  {\n                      \"index\": 51,\n                      \"path\": \"_____padding_file_25_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"6952725\"\n                  },\n                  {\n                      \"index\": 53,\n                      \"path\": \"_____padding_file_26_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8181115\"\n                  },\n                  {\n                      \"index\": 55,\n                      \"path\": \"_____padding_file_27_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"1165781\"\n                  },\n                  {\n                      \"index\": 57,\n                      \"path\": \"_____padding_file_28_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"6030885\"\n                  },\n                  {\n                      \"index\": 59,\n                      \"path\": \"_____padding_file_29_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"2833908\"\n                  },\n                  {\n                      \"index\": 61,\n                      \"path\": \"_____padding_file_30_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"3155801\"\n                  },\n                  {\n                      \"index\": 63,\n                      \"path\": \"_____padding_file_31_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"3872319\"\n                  },\n                  {\n                      \"index\": 65,\n                      \"path\": \"_____padding_file_32_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"4876040\"\n                  },\n                  {\n                      \"index\": 67,\n                      \"path\": \"_____padding_file_33_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"6534586\"\n                  },\n                  {\n                      \"index\": 69,\n                      \"path\": \"_____padding_file_34_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"6712710\"\n                  },\n                  {\n                      \"index\": 71,\n                      \"path\": \"_____padding_file_35_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"7909564\"\n                  },\n                  {\n                      \"index\": 73,\n                      \"path\": \"_____padding_file_36_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"414163\"\n                  },\n                  {\n                      \"index\": 75,\n                      \"path\": \"_____padding_file_37_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"762346\"\n                  },\n                  {\n                      \"index\": 77,\n                      \"path\": \"_____padding_file_38_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"3690557\"\n                  },\n                  {\n                      \"index\": 79,\n                      \"path\": \"_____padding_file_39_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8361365\"\n                  },\n                  {\n                      \"index\": 81,\n                      \"path\": \"_____padding_file_40_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"4715400\"\n                  },\n                  {\n                      \"index\": 83,\n                      \"path\": \"_____padding_file_41_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8374784\"\n                  },\n                  {\n                      \"index\": 85,\n                      \"path\": \"_____padding_file_42_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8370688\"\n                  },\n                  {\n                      \"index\": 87,\n                      \"path\": \"_____padding_file_43_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"2540261\"\n                  },\n                  {\n                      \"index\": 89,\n                      \"path\": \"_____padding_file_44_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"8044509\"\n                  },\n                  {\n                      \"index\": 91,\n                      \"path\": \"_____padding_file_45_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"255622\"\n                  },\n                  {\n                      \"index\": 93,\n                      \"path\": \"_____padding_file_46_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"4890679\"\n                  },\n                  {\n                      \"index\": 95,\n                      \"path\": \"_____padding_file_47_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"4470822\"\n                  },\n                  {\n                      \"index\": 97,\n                      \"path\": \"_____padding_file_48_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"5512023\"\n                  },\n                  {\n                      \"index\": 99,\n                      \"path\": \"_____padding_file_49_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"5319460\"\n                  }\n              ],\n              \"created_at\": 1719328705,\n              \"updated_at\": 1719328705\n          },\n          {\n              \"hash\": \"b3858d7ba81101fbed15c5ff2086976b848bf0f4\",\n              \"name\": \"[哆啦A梦新番][Doraemon][2013]318-347\",\n              \"size\": \"9413092144\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:b3858d7ba81101fbed15c5ff2086976b848bf0f4&dn=%5B%E5%93%86%E5%95%A6A%E6%A2%A6%E6%96%B0%E7%95%AA%5D%5BDoraemon%5D%5B2013%5D318-347&xl=9413092144\",\n              \"single_file\": false,\n              \"files_count\": 33,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"DodSpeed@tvboxnow.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"60\"\n                  },\n                  {\n                      \"index\": 1,\n                      \"path\": \"[哆啦A梦新番][Doraemon][2013]318-347.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"4184\"\n                  },\n                  {\n                      \"index\": 2,\n                      \"path\": \"[天空字幕组][[哆啦A梦新番][Doraemon].jpg\",\n                      \"extension\": \"jpg\",\n                      \"size\": \"152328\"\n                  },\n                  {\n                      \"index\": 3,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][318][2013.01.11][720P][GB]人体储蓄罐&诞生！名侦探大雄.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307517228\"\n                  },\n                  {\n                      \"index\": 4,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][319][2013.01.18][720P][GB]我想吃螃蟹！&跨时代购物.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307507593\"\n                  },\n                  {\n                      \"index\": 5,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][320][2013.01.25][720P][GB]超级惠方卷&雪好热.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307558859\"\n                  },\n                  {\n                      \"index\": 6,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][321][2013.02.01][720P][GB]小夫一见钟情了&静香的宇宙露天浴池.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307515277\"\n                  },\n                  {\n                      \"index\": 7,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][322][2013.02.15][720P][GB]怪盗大雄参上！&用小鸟帽飞到天空.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307577531\"\n                  },\n                  {\n                      \"index\": 8,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][323][2013.02.22][720P][GB]爸爸妈妈家庭大战&绝对神准？手相组合.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307528720\"\n                  },\n                  {\n                      \"index\": 9,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][324][2013.03.01][720P][GB]最强！跌倒专家Z.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307538634\"\n                  },\n                  {\n                      \"index\": 10,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][326][2013.04.12][720P][GB]爆发胡椒&一定要去赏花.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"304726661\"\n                  },\n                  {\n                      \"index\": 11,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][327][2013.04.26][720P][GB]节约攒钱去夏威夷旅行&静香的羽衣.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307828211\"\n                  },\n                  {\n                      \"index\": 12,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][328][2013.05.03][720P][GB]在天国睡午觉&独裁者按钮.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307056316\"\n                  },\n                  {\n                      \"index\": 13,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][329][2013.05.10][720P][GB]速成妈妈&被盯上的胖虎.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307133480\"\n                  },\n                  {\n                      \"index\": 14,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][330][2013.05.17][720P][GB]逃出！恐怖的骨川家豪宅&洞悉头盔.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307104598\"\n                  },\n                  {\n                      \"index\": 15,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][331][2013.05.24][720P][GB]房屋竹蜻蜓&静香最糟糕的生日.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307137523\"\n                  },\n                  {\n                      \"index\": 16,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][332][2013.05.31][720P][GB]近朱者棒&胖虎是熊猫.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307119346\"\n                  },\n                  {\n                      \"index\": 17,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][333][2013.06.07][720P][GB]送礼包巾&最强！黑带大雄.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307080489\"\n                  },\n                  {\n                      \"index\": 18,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][334][2013.06.14][720P][GB]胖虎的告别演唱会&梦境导演椅.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307053950\"\n                  },\n                  {\n                      \"index\": 19,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][335][2013.06.21][720P][GB]颠倒世界镜&大雄的秘密隧道.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307081243\"\n                  },\n                  {\n                      \"index\": 20,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][336][2013.07.05][720P][GB]用搬家地图来搬家&用镜子做广告.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307130249\"\n                  },\n                  {\n                      \"index\": 21,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][337][2013.07.12][720P][GB]万能冰棍&决心混凝土.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307041957\"\n                  },\n                  {\n                      \"index\": 22,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][338][2013.07.26][720P][GB]夏季大冒险1小时特别篇.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"543134387\"\n                  },\n                  {\n                      \"index\": 23,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][339][2013.08.09][720P][GB]深海骑行&大雄的整人摄像机.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307440793\"\n                  },\n                  {\n                      \"index\": 24,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][340][2013.08.16][720P][GB]种烟花吧！&分身槌.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307535174\"\n                  },\n                  {\n                      \"index\": 25,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][341][2013.08.23][720P][GB]和妖怪们共度的暑假&凝固灯.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"307507234\"\n                  },\n                  {\n                      \"index\": 26,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][342][2013.08.30][720P][GB]大雄的夏日祭典大作战&在撒哈拉沙漠无法学习.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"306373991\"\n                  },\n                  {\n                      \"index\": 27,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][343][2013.09.06][720P][GB]大雄能量的使用方法&用鼻子气球飞到天空.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"315509263\"\n                  },\n                  {\n                      \"index\": 28,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][344][2013.09.13][720P][GB]1小时生日特别篇_深夜的巨大哆啦狸猫.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"545206665\"\n                  },\n                  {\n                      \"index\": 29,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][345][2013.10.18][720P]人体火车头&在山水盆景里采松茸.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"315713375\"\n                  },\n                  {\n                      \"index\": 30,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][346][2013.10.25][720P]万圣节是什么节日？&品尝汤匙.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"312584893\"\n                  },\n                  {\n                      \"index\": 31,\n                      \"path\": \"[天空字幕组][哆啦A梦新番][347][2013.11.01][720P]用魔术手为所欲为&戈耳工之首.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"315690000\"\n                  },\n                  {\n                      \"index\": 32,\n                      \"path\": \"公仔箱論壇 - Powered by Discuz!.url\",\n                      \"extension\": \"url\",\n                      \"size\": \"1932\"\n                  }\n              ],\n              \"created_at\": 1719239454,\n              \"updated_at\": 1719239454\n          },\n          {\n              \"hash\": \"88f82254253637f8e6f4e4ca38f495e157c18e13\",\n              \"name\": \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[765][2023.07.01][AVC][1080P][GB_JP][MP4].mp4\",\n              \"size\": \"571197146\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:88f82254253637f8e6f4e4ca38f495e157c18e13&dn=%5B%E6%A2%A6%E8%93%9D%E5%AD%97%E5%B9%95%E7%BB%84%5DNew%20Doraemon%20%E5%93%86%E5%95%A6A%E6%A2%A6%E6%96%B0%E7%95%AA%5B765%5D%5B2023.07.01%5D%5BAVC%5D%5B1080P%5D%5BGB_JP%5D%5BMP4%5D.mp4&xl=571197146\",\n              \"single_file\": true,\n              \"files_count\": 1,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[765][2023.07.01][AVC][1080P][GB_JP][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"571197146\"\n                  }\n              ],\n              \"created_at\": 1719229193,\n              \"updated_at\": 1719229193\n          },\n          {\n              \"hash\": \"6f5131c7936d09f7dec03be071f634daa3d140fb\",\n              \"name\": \"[电影天堂www.dytt89.com]哆啦A梦：大雄的新魔界大冒险之7个魔法师-2007_BD日粤国三语中字.mp4\",\n              \"size\": \"1754739449\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:6f5131c7936d09f7dec03be071f634daa3d140fb&dn=%5B%E7%94%B5%E5%BD%B1%E5%A4%A9%E5%A0%82www.dytt89.com%5D%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E7%9A%84%E6%96%B0%E9%AD%94%E7%95%8C%E5%A4%A7%E5%86%92%E9%99%A9%E4%B9%8B7%E4%B8%AA%E9%AD%94%E6%B3%95%E5%B8%88-2007_BD%E6%97%A5%E7%B2%A4%E5%9B%BD%E4%B8%89%E8%AF%AD%E4%B8%AD%E5%AD%97.mp4&xl=1754739449\",\n              \"single_file\": true,\n              \"files_count\": 1,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"[电影天堂www.dytt89.com]哆啦A梦：大雄的新魔界大冒险之7个魔法师-2007_BD日粤国三语中字.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"1754739449\"\n                  }\n              ],\n              \"created_at\": 1719214794,\n              \"updated_at\": 1719214794\n          },\n          {\n              \"hash\": \"9dd169d115cd13a90aa036b43cd1eb25c87f215e\",\n              \"name\": \"哆啦A梦：大雄的南极冰冰凉大冒险.Doraemon.Nobita'S.Great.Adventure.in.the.Antarctic.Kachi.Kochi.2017.HK.BluRay.1080p.AVC.TrueHD5.1-HDHome\",\n              \"size\": \"24471978770\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:9dd169d115cd13a90aa036b43cd1eb25c87f215e&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E7%9A%84%E5%8D%97%E6%9E%81%E5%86%B0%E5%86%B0%E5%87%89%E5%A4%A7%E5%86%92%E9%99%A9.Doraemon.Nobita'S.Great.Adventure.in.the.Antarctic.Kachi.Kochi.2017.HK.BluRay.1080p.AVC.TrueHD5.1-HDHome&xl=24471978770\",\n              \"single_file\": false,\n              \"files_count\": 6,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"Doraemon.Nobita'S.Great.Adventure.in.the.Antarctic.Kachi.Kochi.2017.HK.BluRay.1080p.AVC.TrueHD5.1-HDHome.iso\",\n                      \"extension\": \"iso\",\n                      \"size\": \"24468193280\"\n                  },\n                  {\n                      \"index\": 1,\n                      \"path\": \"上海硬盘之家～专业代拷高清电影片库.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"311\"\n                  },\n                  {\n                      \"index\": 2,\n                      \"path\": \"上海硬盘之家～专业销售高清硬盘,提供海量高清资源代拷！.url\",\n                      \"extension\": \"url\",\n                      \"size\": \"125\"\n                  },\n                  {\n                      \"index\": 3,\n                      \"path\": \"上海硬盘之家～微信及公众号二维码.png\",\n                      \"extension\": \"png\",\n                      \"size\": \"45084\"\n                  },\n                  {\n                      \"index\": 4,\n                      \"path\": \"关于BT种子资源库服务及硬盘租赁共享服务的说明.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"1717\"\n                  },\n                  {\n                      \"index\": 5,\n                      \"path\": \"本店有海量高清片库可供代拷.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"3738253\"\n                  }\n              ],\n              \"created_at\": 1719192535,\n              \"updated_at\": 1719192535\n          },\n          {\n              \"hash\": \"1e9db18e52bd838183e6558be605e3748127e12e\",\n              \"name\": \"哆啦A梦：伴我同行.Stand.by.Me.Doraemon.2014.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba\",\n              \"size\": \"1898971258\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:1e9db18e52bd838183e6558be605e3748127e12e&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E4%BC%B4%E6%88%91%E5%90%8C%E8%A1%8C.Stand.by.Me.Doraemon.2014.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba&xl=1898971258\",\n              \"single_file\": false,\n              \"files_count\": 5,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"哆啦A梦：伴我同行.Stand.by.Me.Doraemon.2014.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"1897954225\"\n                  },\n                  {\n                      \"index\": 2,\n                      \"path\": \"更多高清请访问www.mp4ba.com.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"27\"\n                  },\n                  {\n                      \"index\": 4,\n                      \"path\": \"点击进入高清MP4ba.url\",\n                      \"extension\": \"url\",\n                      \"size\": \"122\"\n                  },\n                  {\n                      \"index\": 1,\n                      \"path\": \"_____padding_file_0_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"492623\"\n                  },\n                  {\n                      \"index\": 3,\n                      \"path\": \"_____padding_file_1_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"524261\"\n                  }\n              ],\n              \"created_at\": 1719184658,\n              \"updated_at\": 1719184658\n          },\n          {\n              \"hash\": \"80ddf0bff59baedee4f461adb05fb085954003fd\",\n              \"name\": \"哆啦A梦：大雄的宇宙英雄记.Doraemon.Nobita's.Space.Heros.2015.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba\",\n              \"size\": \"2035810426\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:80ddf0bff59baedee4f461adb05fb085954003fd&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E7%9A%84%E5%AE%87%E5%AE%99%E8%8B%B1%E9%9B%84%E8%AE%B0.Doraemon.Nobita's.Space.Heros.2015.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba&xl=2035810426\",\n              \"single_file\": false,\n              \"files_count\": 7,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"哆啦A梦：大雄的宇宙英雄记.Doraemon.Nobita's.Space.Heros.2015.BD720P.X264.AAC.Japanese.CHS-JPN.Mp4Ba.mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"2034695488\"\n                  },\n                  {\n                      \"index\": 2,\n                      \"path\": \"更多高清请访问www.mp4ba.com.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"27\"\n                  },\n                  {\n                      \"index\": 4,\n                      \"path\": \"本站唯一域名www.mp4ba.com.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"50\"\n                  },\n                  {\n                      \"index\": 6,\n                      \"path\": \"点击进入高清MP4ba.url\",\n                      \"extension\": \"url\",\n                      \"size\": \"122\"\n                  },\n                  {\n                      \"index\": 1,\n                      \"path\": \"_____padding_file_0_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"66240\"\n                  },\n                  {\n                      \"index\": 3,\n                      \"path\": \"_____padding_file_1_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"524261\"\n                  },\n                  {\n                      \"index\": 5,\n                      \"path\": \"_____padding_file_2_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"524238\"\n                  }\n              ],\n              \"created_at\": 1719177553,\n              \"updated_at\": 1719177553\n          },\n          {\n              \"hash\": \"e26f61e220e604d1530ae3f3d9ee136096f751f5\",\n              \"name\": \"[4kii.net]哆啦A梦：大雄与天空的理想乡.Doraemon.Nobita's.Sky.Utopia.2023.2160p.HQ.WEB-DL.H265.DDP5.1.2Audio-[简中]-蓝光高清网\",\n              \"size\": \"16905142890\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:e26f61e220e604d1530ae3f3d9ee136096f751f5&dn=%5B4kii.net%5D%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7%E9%9B%84%E4%B8%8E%E5%A4%A9%E7%A9%BA%E7%9A%84%E7%90%86%E6%83%B3%E4%B9%A1.Doraemon.Nobita's.Sky.Utopia.2023.2160p.HQ.WEB-DL.H265.DDP5.1.2Audio-%5B%E7%AE%80%E4%B8%AD%5D-%E8%93%9D%E5%85%89%E9%AB%98%E6%B8%85%E7%BD%91&xl=16905142890\",\n              \"single_file\": false,\n              \"files_count\": 5,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"4KII.NET.txt\",\n                      \"extension\": \"txt\",\n                      \"size\": \"546\"\n                  },\n                  {\n                      \"index\": 2,\n                      \"path\": \"[4kii.net]哆啦A梦：大雄与天空的理想乡.Doraemon.Nobita's.Sky.Utopia.2023.2160p.HQ.WEB-DL.H265.DDP5.1.2Audio-[简中]-蓝光高清网.mkv\",\n                      \"extension\": \"mkv\",\n                      \"size\": \"16902174938\"\n                  },\n                  {\n                      \"index\": 4,\n                      \"path\": \"【蓝光高清网】全球所有4K资源 第一时间发布.html\",\n                      \"extension\": \"html\",\n                      \"size\": \"618\"\n                  },\n                  {\n                      \"index\": 1,\n                      \"path\": \"_____padding_file_0_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"2096606\"\n                  },\n                  {\n                      \"index\": 3,\n                      \"path\": \"_____padding_file_1_如果您看到此文件，请升级到BitComet(比特彗星)0.85或以上版本____\",\n                      \"extension\": null,\n                      \"size\": \"870182\"\n                  }\n              ],\n              \"created_at\": 1719173721,\n              \"updated_at\": 1719173721\n          },\n          {\n              \"hash\": \"1f24a17e853bf80b2637a87e9e70e6b7759b5780\",\n              \"name\": \"哆啦A梦：大x的jy岛.1080p.国日粤三语.BD中字[最新电影www.66ys.tv].mp4\",\n              \"size\": \"1862845913\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:1f24a17e853bf80b2637a87e9e70e6b7759b5780&dn=%E5%93%86%E5%95%A6A%E6%A2%A6%EF%BC%9A%E5%A4%A7x%E7%9A%84jy%E5%B2%9B.1080p.%E5%9B%BD%E6%97%A5%E7%B2%A4%E4%B8%89%E8%AF%AD.BD%E4%B8%AD%E5%AD%97%5B%E6%9C%80%E6%96%B0%E7%94%B5%E5%BD%B1www.66ys.tv%5D.mp4&xl=1862845913\",\n              \"single_file\": true,\n              \"files_count\": 1,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"哆啦A梦：大x的jy岛.1080p.国日粤三语.BD中字[最新电影www.66ys.tv].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"1862845913\"\n                  }\n              ],\n              \"created_at\": 1719134227,\n              \"updated_at\": 1719134227\n          },\n          {\n              \"hash\": \"3b222a510e1477c5144c34ca92111e7dd7ff26d2\",\n              \"name\": \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[777][2023.09.23][AVC][1080P][GB_JP][MP4].mp4\",\n              \"size\": \"573393407\",\n              \"magnet_uri\": \"magnet:?xt=urn:btih:3b222a510e1477c5144c34ca92111e7dd7ff26d2&dn=%5B%E6%A2%A6%E8%93%9D%E5%AD%97%E5%B9%95%E7%BB%84%5DNew%20Doraemon%20%E5%93%86%E5%95%A6A%E6%A2%A6%E6%96%B0%E7%95%AA%5B777%5D%5B2023.09.23%5D%5BAVC%5D%5B1080P%5D%5BGB_JP%5D%5BMP4%5D.mp4&xl=573393407\",\n              \"single_file\": true,\n              \"files_count\": 1,\n              \"files\": [\n                  {\n                      \"index\": 0,\n                      \"path\": \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[777][2023.09.23][AVC][1080P][GB_JP][MP4].mp4\",\n                      \"extension\": \"mp4\",\n                      \"size\": \"573393407\"\n                  }\n              ],\n              \"created_at\": 1719116467,\n              \"updated_at\": 1719116467\n          }\n      ],\n      \"total_count\": 163,\n      \"has_more\": true\n  },\n  \"message\": \"success\",\n  \"status\": 200\n}"
  },
  {
    "path": "moke/stats.json",
    "content": "{\n  \"data\": {\n      \"size\": \"26710807011\",\n      \"total_count\": 3447565,\n      \"updated_at\": 1719015251,\n      \"latest_torrent_hash\": \"3648baf850d5930510c1f172b534200ebb5496e6\",\n      \"latest_torrent\": {\n          \"hash\": \"3648baf850d5930510c1f172b534200ebb5496e6\",\n          \"name\": \"Ubuntu 24.04\",\n          \"size\": \"8869638144\",\n          \"created_at\": 1719015251,\n          \"updated_at\": 1719015251\n      }\n  },\n  \"message\": \"success\",\n  \"status\": 200\n}"
  },
  {
    "path": "next.config.js",
    "content": "const createNextIntlPlugin = require('next-intl/plugin');\n\nconst withNextIntl = createNextIntlPlugin('./i18n');\n\nconst mode = process.env.BUILD_MODE ?? 'standalone';\nconsole.log(\"[Next] build mode:\", mode);\n\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  output: mode,\n  experimental: {\n    serverComponentsExternalPackages: [\n      '@node-rs/jieba'\n    ]\n  }\n}\n\nmodule.exports = withNextIntl(nextConfig);\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"bitmagnet-next-web\",\n\t\"private\": true,\n\t\"scripts\": {\n\t\t\"dev\": \"next dev\",\n\t\t\"build\": \"cross-env BUILD_MODE=standalone next build\",\n\t\t\"start\": \"next start\",\n\t\t\"lint\": \"eslint . --ext .ts,.tsx -c .eslintrc.json --fix\"\n\t},\n\t\"dependencies\": {\n\t\t\"@apollo/client\": \"^3.10.4\",\n\t\t\"@apollo/server\": \"^4.10.4\",\n\t\t\"@as-integrations/next\": \"^3.0.0\",\n\t\t\"@nextui-org/react\": \"^2.4.1\",\n\t\t\"@nextui-org/system\": \"2.2.1\",\n\t\t\"@nextui-org/theme\": \"2.2.5\",\n\t\t\"@node-rs/jieba\": \"^1.10.3\",\n\t\t\"@react-aria/ssr\": \"3.9.4\",\n\t\t\"@react-aria/visually-hidden\": \"3.8.12\",\n\t\t\"@tsparticles/react\": \"^3.0.0\",\n\t\t\"@tsparticles/slim\": \"^3.4.0\",\n\t\t\"autoprefixer\": \"10.4.19\",\n\t\t\"clsx\": \"2.1.1\",\n\t\t\"dayjs\": \"^1.11.11\",\n\t\t\"embla-carousel\": \"^8.1.5\",\n\t\t\"embla-carousel-react\": \"^8.1.5\",\n\t\t\"framer-motion\": \"~11.1.1\",\n\t\t\"graphql\": \"^16.8.1\",\n\t\t\"graphql-tag\": \"^2.12.6\",\n\t\t\"intl-messageformat\": \"^10.5.0\",\n\t\t\"js-cookie\": \"^3.0.5\",\n\t\t\"next\": \"14.2.3\",\n\t\t\"next-intl\": \"^3.14.1\",\n\t\t\"next-themes\": \"^0.2.1\",\n\t\t\"pg\": \"^8.12.0\",\n\t\t\"postcss\": \"8.4.38\",\n\t\t\"react\": \"18.3.1\",\n\t\t\"react-dom\": \"18.3.1\",\n\t\t\"react-responsive\": \"^10.0.0\",\n\t\t\"tailwind-variants\": \"0.1.20\",\n\t\t\"tailwindcss\": \"3.4.3\",\n\t\t\"zod\": \"^3.23.8\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/js-cookie\": \"^3.0.6\",\n\t\t\"@types/node\": \"20.5.7\",\n\t\t\"@types/pg\": \"^8.11.6\",\n\t\t\"@types/react\": \"18.3.2\",\n\t\t\"@types/react-dom\": \"18.3.0\",\n\t\t\"@typescript-eslint/eslint-plugin\": \"^7.10.0\",\n\t\t\"@typescript-eslint/parser\": \"^7.10.0\",\n\t\t\"cross-env\": \"^7.0.3\",\n\t\t\"eslint\": \"^8.56.0\",\n\t\t\"eslint-config-next\": \"14.2.1\",\n\t\t\"eslint-config-prettier\": \"^8.2.0\",\n\t\t\"eslint-plugin-import\": \"^2.26.0\",\n\t\t\"eslint-plugin-jsx-a11y\": \"^6.4.1\",\n\t\t\"eslint-plugin-node\": \"^11.1.0\",\n\t\t\"eslint-plugin-prettier\": \"^5.1.3\",\n\t\t\"eslint-plugin-react\": \"^7.23.2\",\n\t\t\"eslint-plugin-react-hooks\": \"^4.6.0\",\n\t\t\"eslint-plugin-unused-imports\": \"^3.2.0\",\n\t\t\"typescript\": \"5.4.5\"\n\t}\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "styles/file-type-icon/icons.css",
    "content": ".file-type-icon {\n  display: inline-block;\n  width: 1em;\n  height: 18px;\n  font-size: 1.3em;\n  flex-shrink: 0;\n  align-self: flex-start;\n  vertical-align: middle;\n  margin-right: 4px;\n  background-image: var(--icon);\n  background-repeat: no-repeat;\n  background-size: 100% auto;\n  background-position: center;\n}\n\n:root {\n  --icon-folder: url(./file-folder.svg);\n  --icon-file: url(./page-facing-up.svg);\n  --icon-image: url(./framed-picture.svg);\n  --icon-video: url(./film-frames.svg);\n  --icon-audio: url(./musical-note.svg);\n  --icon-book: url(./books.svg);\n  --icon-web: url(./globe-with-meridians.svg);\n  --icon-archive: url(./package.svg);\n  --icon-disk: url(./optical-disk.svg);\n  --icon-executable: url(./gear.svg);\n  --icon-subtitle: url(./scroll.svg);\n}\n\n.file-type-icon[data-icon=\"folder\"] {\n  --icon: var(--icon-folder);\n}\n\n.file-type-icon[data-icon=\"file\"] {\n  --icon: var(--icon-file);\n}\n\n.file-type-icon[data-icon=\"image\"] {\n  --icon: var(--icon-image);\n}\n\n.file-type-icon[data-icon=\"video\"] {\n  --icon: var(--icon-video);\n}\n\n.file-type-icon[data-icon=\"audio\"] {\n  --icon: var(--icon-audio);\n}\n\n.file-type-icon[data-icon=\"book\"] {\n  --icon: var(--icon-book);\n}\n\n.file-type-icon[data-icon=\"web\"] {\n  --icon: var(--icon-web);\n}\n\n.file-type-icon[data-icon=\"archive\"] {\n  --icon: var(--icon-archive);\n}\n\n.file-type-icon[data-icon=\"disk\"] {\n  --icon: var(--icon-disk);\n}\n\n.file-type-icon[data-icon=\"executable\"] {\n  --icon: var(--icon-executable);\n}\n\n.file-type-icon[data-icon=\"subtitle\"] {\n  --icon: var(--icon-subtitle);\n}\n"
  },
  {
    "path": "styles/globals.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@import url('./file-type-icon/icons.css');\n@import url('./style.css');"
  },
  {
    "path": "styles/style.css",
    "content": "html ,body {\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  overscroll-behavior: unset;\n  scroll-behavior: smooth;\n  touch-action: manipulation;\n}\n\ninput:-internal-autofill-selected,\ninput:-webkit-autofill,\ninput:-webkit-autofill:hover,\ninput:-webkit-autofill:focus,\ninput:-webkit-autofill:active {\n  -webkit-box-shadow: 0 0 2em rgba(255, 255, 255, 0) inset !important;\n  background-color: transparent !important;\n  background-clip: text;\n}"
  },
  {
    "path": "tailwind.config.js",
    "content": "import {nextui} from '@nextui-org/theme'\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\n    './utils/**/*.{js,ts,jsx,tsx,mdx}',\n    './components/**/*.{js,ts,jsx,tsx,mdx}',\n    './app/**/*.{js,ts,jsx,tsx,mdx}',\n    './node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}'\n  ],\n  safelist: [\n    {\n      pattern: /(bg|text)-(red|green|blue|gray)-\\d+/,\n    },\n  ],\n  theme: {\n    extend: {\n      fontFamily: {\n        sans: [\"var(--font-sans)\"],\n        mono: [\"var(--font-mono)\"],\n      },\n      screens: {\n        'xs': '400px',\n        'sm': '540px',\n      },\n      keyframes: {\n        'fade-in': {\n          '0%': { opacity: 0 },\n          '100%': { opacity: 1 },\n        },\n        'fade-out': {\n          '0%': { opacity: 1 },\n          '100%': { opacity: 0 },\n        },\n        'fade-in-up': {\n          '0%': { opacity: 0, transform: 'translateY(-10px)' },\n          '100%': { opacity: 1, transform: 'translateY(0)' },\n        },\n        'pop': {\n          '0%': { transform: 'scale(1)' },\n          '50%': { transform: 'scale(1.05)' },\n          '100%': { transform: 'scale(1)' },\n        },\n      },\n      animation: {\n        'fade-in': 'fade-in 0.3s ease-in-out',\n        'fade-out': 'fade-out 0.3s ease-out forwards',\n        'fade-in-up': 'fade-in-up 0.3s ease-in-out',\n        'pop': 'pop 0.4s cubic-bezier(0.4, 0, 0.2, 1)',\n      },\n    },\n  },\n  future: {\n    hoverOnlyWhenSupported: true,\n  },\n  darkMode: \"class\",\n  plugins: [nextui()],\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\"./*\"]\n    }\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "types/index.ts",
    "content": "import { SVGProps } from \"react\";\n\nexport type IconSvgProps = SVGProps<SVGSVGElement> & {\n  size?: number;\n};\n\nexport type SearchResultsListProps = {\n  torrents: TorrentItemProps[];\n  total_count: number;\n  has_more: boolean;\n};\n\nexport type TorrentItemProps = {\n  hash: string;\n  name: string;\n  size: number;\n  magnet_uri: string;\n  single_file: boolean;\n  files_count: number;\n  files: {\n    index: number;\n    path: string;\n    size: number;\n    extension: string;\n  }[];\n  created_at: number;\n  updated_at: number;\n};\n"
  },
  {
    "path": "utils/Toast.tsx",
    "content": "\"use client\";\n\nimport React, { useState, useEffect } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport clsx from \"clsx\";\n\nimport { $env } from \"@/utils\";\n\ntype ToastType = \"info\" | \"success\" | \"warn\" | \"error\";\n\ninterface ToastMessage {\n  id: number;\n  type: ToastType;\n  content: string;\n  duration: number;\n}\n\ninterface ToastProps {\n  messages: ToastMessage[];\n  removeMessage: (id: number) => void;\n}\n\nconst iconMap = {\n  info: (\n    <div className=\"inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-blue-500 bg-blue-100 rounded-lg dark:bg-blue-800 dark:text-blue-200\">\n      <svg\n        aria-hidden=\"true\"\n        className=\"w-5 h-5\"\n        fill=\"currentColor\"\n        viewBox=\"0 0 20 20\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <path d=\"M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z\" />\n      </svg>\n    </div>\n  ),\n  success: (\n    <div className=\"inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200\">\n      <svg\n        aria-hidden=\"true\"\n        className=\"w-5 h-5\"\n        fill=\"currentColor\"\n        viewBox=\"0 0 20 20\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <path d=\"M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z\" />\n      </svg>\n    </div>\n  ),\n  warn: (\n    <div className=\"inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-orange-300 bg-orange-100 rounded-lg dark:bg-orange-700 dark:text-orange-200\">\n      <svg\n        aria-hidden=\"true\"\n        className=\"w-5 h-5\"\n        fill=\"currentColor\"\n        viewBox=\"0 0 20 20\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <path d=\"M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z\" />\n      </svg>\n    </div>\n  ),\n  error: (\n    <div className=\"inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-red-500 bg-red-100 rounded-lg dark:bg-red-800 dark:text-red-200\">\n      <svg\n        aria-hidden=\"true\"\n        className=\"w-5 h-5\"\n        fill=\"currentColor\"\n        viewBox=\"0 0 20 20\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <path d=\"M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 11.793a1 1 0 1 1-1.414 1.414L10 11.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L8.586 10 6.293 7.707a1 1 0 0 1 1.414-1.414L10 8.586l2.293-2.293a1 1 0 0 1 1.414 1.414L11.414 10l2.293 2.293Z\" />\n      </svg>\n    </div>\n  ),\n};\n\nconst ToastContainer: React.FC<ToastProps> = ({ messages, removeMessage }) => {\n  const [removingMessages, setRemovingMessages] = useState<number[]>([]);\n\n  const handleRemove = (id: number) => {\n    setRemovingMessages((prev) => [...prev, id]);\n    setTimeout(() => removeMessage(id), 300); // 300ms matches the fade-out duration\n  };\n\n  useEffect(() => {\n    messages.forEach((message) => {\n      setTimeout(() => handleRemove(message.id), message.duration);\n    });\n  }, [messages]);\n\n  return (\n    <div className=\"fixed top-[5vh] left-0 right-0 flex flex-col items-center p-4 mb-4 pointer-events-none z-20\">\n      {messages.map((message) => (\n        <div\n          key={message.id}\n          className={clsx(\n            \"flex items-center w-full max-w-xs px-3 py-2 mb-3 text-gray-500 bg-white rounded-lg shadow bg-opacity-90 dark:text-gray-400 dark:bg-gray-800\",\n            {\n              \"animate-fade-out\": removingMessages.includes(message.id),\n              \"animate-fade-in-up\": !removingMessages.includes(message.id),\n            },\n          )}\n          role=\"alert\"\n        >\n          {iconMap[message.type]}\n          <div className=\"ps-3 text-sm font-normal ml-3 border-l border-l-gray-200 dark:border-l-gray-700\">\n            {message.content}\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n};\n\nlet toastId = 0;\nlet toastRoot: HTMLDivElement;\nlet root: ReturnType<typeof createRoot>;\n\nsetTimeout(() => {\n  if ($env.isServer) return;\n\n  toastRoot =\n    document.querySelector(\".__toast-container\") ||\n    document.createElement(\"div\");\n  toastRoot.className = \"__toast-container\";\n  toastRoot.style.zIndex = \"10001\";\n\n  document.body.appendChild(toastRoot);\n  root = createRoot(toastRoot);\n}, 0);\n\nexport const Toast = {\n  messages: [] as ToastMessage[],\n  setMessages(messages: ToastMessage[]) {\n    this.messages = messages;\n    root.render(\n      <ToastContainer\n        messages={this.messages}\n        removeMessage={this.removeMessage.bind(this)}\n      />,\n    );\n  },\n  show(type: ToastType, content: string, duration = 2000) {\n    const id = toastId++;\n    const newMessage: ToastMessage = { id, type, content, duration };\n\n    const list = [newMessage, ...this.messages].splice(0, 5);\n\n    this.setMessages(list);\n  },\n  removeMessage(id: number) {\n    this.setMessages(this.messages.filter((message) => message.id !== id));\n  },\n  info(content: string, duration?: number) {\n    this.show(\"info\", content, duration);\n  },\n  success(content: string, duration?: number) {\n    this.show(\"success\", content, duration);\n  },\n  warn(content: string, duration?: number) {\n    this.show(\"warn\", content, duration);\n  },\n  error(content: string, duration?: number) {\n    this.show(\"error\", content, duration);\n  },\n};\n"
  },
  {
    "path": "utils/api.ts",
    "content": "export function getBaseUrl() {\n  // Check if NEXT_PUBLIC_BASE_URL is set\n  if (process.env.NEXT_PUBLIC_BASE_URL) {\n    return process.env.NEXT_PUBLIC_BASE_URL;\n  }\n\n  // Fallback to localhost with port 3000\n  const host = process.env.NEXT_PUBLIC_HOST || \"localhost\";\n  const port = parseInt(process.env.PORT || \"3000\", 10);\n\n  return `http://${host}:${port}`;\n}\n\nasync function apiFetch(endpoint: string, options?: RequestInit): Promise<any> {\n  try {\n    const baseUrl = getBaseUrl();\n    const url = `${baseUrl}${endpoint}`;\n    const response = await fetch(url, options);\n\n    if (!response.ok) {\n      throw new Error(`Network response was not ok: ${response.statusText}`);\n    }\n\n    return await response.json();\n  } catch (error: any) {\n    console.error(`Failed to fetch: ${error.message}`);\n    throw error;\n  }\n}\n\nexport default apiFetch;\n"
  },
  {
    "path": "utils/index.ts",
    "content": "import dayjs from \"dayjs\";\nimport Cookie from \"js-cookie\";\n\nimport { SEARCH_KEYWORD_SPLIT_REGEX } from \"@/config/constant\";\n\nexport function hexToBase64(hexString: string) {\n  const binary = Buffer.from(hexString, \"hex\");\n  let base64 = binary.toString(\"base64\");\n\n  base64 = base64.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n\n  return base64;\n}\n\nexport function base64ToHex(base64String: string) {\n  let base64 = base64String.replace(/-/g, \"+\").replace(/_/g, \"/\");\n  const padding = \"=\".repeat((4 - (base64.length % 4)) % 4);\n\n  base64 = base64 + padding;\n  const binary = Buffer.from(base64, \"base64\");\n\n  return binary.toString(\"hex\");\n}\n\nexport function formatByteSize(bytes: number | string) {\n  bytes = Number(bytes);\n\n  if (bytes === 0) return \"0 B\";\n\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const base = 1024;\n  const digitGroups = Math.floor(Math.log(bytes) / Math.log(base));\n  const convertedSize = (bytes / Math.pow(base, digitGroups)).toFixed(2);\n\n  return `${convertedSize} ${units[digitGroups]}`;\n}\n\nexport function formatDate(\n  ts: number,\n  format = \"YYYY-MM-DD HH:mm:ss\",\n  utc = false,\n) {\n  let dateStr = dayjs.unix(ts).format(format);\n\n  if (utc) dateStr += \" (UTC)\";\n\n  return dateStr;\n}\n\nexport function getSizeColor(size: number | string) {\n  size = Number(size);\n\n  if (size < 1024 * 1024 * 2) {\n    // < 2MB\n    return \"text-gray-400 bg-gray-100\";\n  } else if (size < 1024 * 1024 * 50) {\n    // < 50MB\n    return \"text-gray-600 bg-gray-100\";\n  } else if (size < 1024 * 1024 * 200) {\n    // < 200MB\n    return \"text-green-600 bg-green-100 opacity-90\";\n  } else if (size < 1024 * 1024 * 1024) {\n    // < 1GB\n    return \"text-green-600 bg-green-100\";\n  } else {\n    // > 1GB\n    return \"text-red-600 bg-red-100\";\n  }\n}\n\nexport function parseHighlight(text: string, highlight: string | string[]) {\n  if (!text || !highlight) {\n    return text;\n  }\n  const keywords =\n    typeof highlight === \"string\"\n      ? [highlight, ...highlight.split(SEARCH_KEYWORD_SPLIT_REGEX)].filter(\n          (k: string) => k.trim().length >= 2,\n        )\n      : highlight;\n\n  // Function to escape HTML special characters to avoid interference\n  function escapeHtml(unsafe: string) {\n    return unsafe.replace(/[&<>\"'`=\\/]/g, (match) => {\n      return (\n        {\n          \"&\": \"&amp;\",\n          \"<\": \"&lt;\",\n          \">\": \"&gt;\",\n          '\"': \"&quot;\",\n          \"'\": \"&#39;\",\n          \"`\": \"&#96;\",\n          \"/\": \"&#x2F;\",\n          \"=\": \"&#x3D;\",\n        }[match] || match\n      );\n    });\n  }\n\n  // Function to highlight the keywords\n  function highlightKeywords(text: string, keyword: string) {\n    const regex = new RegExp(`(${escapeHtml(keyword)})(?![^<>]*>)`, \"gi\");\n\n    return text.replace(\n      regex,\n      `<span class=\"text-red-400 font-bold\">$1</span>`,\n    );\n  }\n\n  let highlightedText = text;\n\n  keywords.forEach((keyword) => {\n    highlightedText = highlightKeywords(highlightedText, keyword);\n  });\n\n  return highlightedText;\n}\n\nexport function setClipboard(text: string) {\n  if (navigator.clipboard) {\n    navigator.clipboard.writeText(text);\n  } else {\n    const textarea = document.createElement(\"textarea\");\n\n    textarea.value = text;\n    document.body.appendChild(textarea);\n    textarea.select();\n    document.execCommand(\"copy\");\n    document.body.removeChild(textarea);\n  }\n\n  return true;\n}\n\n/* \n  get resource link from \"WhatsLinks\"\n  https://whatslink.info/\n*/\nexport interface GetLinkInfoFromWhatsLinkResponse {\n  error: string;\n  type: string; // The content type for the link\n  // The type of the content corresponding to the link, Possible values: unknown, folder, video, text, image, audio, archive, font, document\n  file_type:\n    | \"unknown\"\n    | \"folder\"\n    | \"video\"\n    | \"text\"\n    | \"image\"\n    | \"audio\"\n    | \"archive\"\n    | \"font\"\n    | \"document\";\n  name: string; // The name of the content corresponding to the link\n  size: number; // The total size of the content corresponding to the link\n  count: number; // The number of included files corresponding to the link\n  screenshots:\n    | null\n    | {\n        time: number; // Position of the screenshot within the content\n        screenshot: string; // The URL of the screenshot image\n      }[]; // List of content screenshots corresponding to the link\n}\nexport async function getLinkInfoFromWhatsLink(\n  link: string,\n): Promise<GetLinkInfoFromWhatsLinkResponse> {\n  const res = await fetch(`https://whatslink.info/api/v1/link?url=${link}`, {\n    method: \"GET\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n\n  return res.json();\n}\n\nexport const $env = {\n  get isServer() {\n    return typeof window === \"undefined\";\n  },\n  get isMobile() {\n    return (\n      !this.isServer &&\n      /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(\n        navigator.userAgent,\n      )\n    );\n  },\n\n  get isDesktop() {\n    return !this.isServer && !this.isMobile;\n  },\n};\n\nexport { Cookie };\nexport { Toast } from \"./Toast\";\n"
  }
]