diff --git a/CHANGELOG b/CHANGELOG index 775f56a..3b27182 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,31 @@ +## [221.0.0] - 2026-05-24 +### Added +- 电子书馆引入 Legado 书源支持 +- 漫画和电子书馆搜索增加流式输出功能 +- 增加 Emby 格式 SxxExx 分组功能 +- 登录增加 fail2ban 机制,防止暴力破解 +- 夸克网盘增加直链优先 / 转码优先切换 +- 夸克网盘增加多线程播放功能 +- 网盘搜索磁链类型增加存到私人影库功能 +- 存至私人影库增加下载方式选择 +- OpenList 离线下载增加额外源选择 +- 用户管理增加用户名搜索功能 +- 增加轮播图高度配置 +- 搜索增加部分缓存快速启动机制 + +### Changed +- 音乐封面改为黑胶留声机样式 +- 播放记录保存时不再保存相同秒数的记录 +- 优化切集性能 +- 优化 episode_progress 渲染时机 +- AI 问片手动渲染表格以修复渲染表格报错 + +### Fixed +- 修复当前播放的音乐记录置底问题 +- 修复无网盘权限仍可直链播放网盘链接的权限漏洞 +- 修复播放记录面板不显示当前集数 +- 修复音乐分页问题 + ## [220.0.0] - 2026-05-14 ### Added - 增加视频源过多提示 diff --git a/VERSION.txt b/VERSION.txt index f8b15e4..5fb7969 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -220.0.0 +221.0.0 diff --git a/package.json b/package.json index 0ae2a53..3518e0b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@upstash/redis": "^1.25.0", "@vercel/postgres": "^0.10.0", "@vidstack/react": "^1.12.13", + "@xmldom/xmldom": "^0.9.10", "anime4k-webgpu": "^1.0.0", "artplayer": "^5.3.0", "artplayer-plugin-danmuku": "^5.2.0", @@ -67,7 +68,6 @@ "react-icons": "^5.4.0", "react-markdown": "^10.1.0", "redis": "^4.6.7", - "remark-gfm": "^3.0.1", "server-only": "^0.0.1", "sharp": "^0.34.5", "socket.io": "^4.8.1", @@ -76,6 +76,7 @@ "tailwind-merge": "^2.6.0", "vidstack": "^0.6.15", "xml2js": "^0.6.2", + "xpath": "^0.0.34", "zod": "^3.24.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6f3f65..9c1f076 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@vidstack/react': specifier: ^1.12.13 version: 1.12.13(@types/react@18.3.27)(react@18.3.1) + '@xmldom/xmldom': + specifier: ^0.9.10 + version: 0.9.10 anime4k-webgpu: specifier: ^1.0.0 version: 1.0.0(@webgpu/types@0.1.68) @@ -134,9 +137,6 @@ importers: redis: specifier: ^4.6.7 version: 4.7.1 - remark-gfm: - specifier: ^3.0.1 - version: 3.0.1 server-only: specifier: ^0.0.1 version: 0.0.1 @@ -161,6 +161,9 @@ importers: xml2js: specifier: ^0.6.2 version: 0.6.2 + xpath: + specifier: ^0.0.34 + version: 0.0.34 zod: specifier: ^3.24.1 version: 3.25.76 @@ -3144,9 +3147,6 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/mdast@3.0.15': - resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3552,6 +3552,10 @@ packages: '@webgpu/types@0.1.68': resolution: {integrity: sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==} + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4440,10 +4444,6 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} - diff@5.2.0: - resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} - engines: {node: '>=0.3.1'} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -4661,10 +4661,6 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -5938,9 +5934,6 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5952,33 +5945,9 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} - mdast-util-find-and-replace@2.2.2: - resolution: {integrity: sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==} - - mdast-util-from-markdown@1.3.1: - resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==} - mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} - mdast-util-gfm-autolink-literal@1.0.3: - resolution: {integrity: sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==} - - mdast-util-gfm-footnote@1.0.2: - resolution: {integrity: sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==} - - mdast-util-gfm-strikethrough@1.0.3: - resolution: {integrity: sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==} - - mdast-util-gfm-table@1.0.7: - resolution: {integrity: sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==} - - mdast-util-gfm-task-list-item@1.0.2: - resolution: {integrity: sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==} - - mdast-util-gfm@2.0.2: - resolution: {integrity: sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==} - mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -5988,24 +5957,15 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - mdast-util-phrasing@3.0.1: - resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==} - mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - mdast-util-to-markdown@1.5.0: - resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==} - mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - mdast-util-to-string@3.2.0: - resolution: {integrity: sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==} - mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} @@ -6047,150 +6007,66 @@ packages: engines: {node: '>= 8.0.0'} hasBin: true - micromark-core-commonmark@1.1.0: - resolution: {integrity: sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==} - micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - micromark-extension-gfm-autolink-literal@1.0.5: - resolution: {integrity: sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==} - - micromark-extension-gfm-footnote@1.1.2: - resolution: {integrity: sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==} - - micromark-extension-gfm-strikethrough@1.0.7: - resolution: {integrity: sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==} - - micromark-extension-gfm-table@1.0.7: - resolution: {integrity: sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==} - - micromark-extension-gfm-tagfilter@1.0.2: - resolution: {integrity: sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==} - - micromark-extension-gfm-task-list-item@1.0.5: - resolution: {integrity: sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==} - - micromark-extension-gfm@2.0.3: - resolution: {integrity: sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==} - - micromark-factory-destination@1.1.0: - resolution: {integrity: sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==} - micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - micromark-factory-label@1.1.0: - resolution: {integrity: sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==} - micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - micromark-factory-space@1.1.0: - resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} - micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - micromark-factory-title@1.1.0: - resolution: {integrity: sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==} - micromark-factory-title@2.0.1: resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - micromark-factory-whitespace@1.1.0: - resolution: {integrity: sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==} - micromark-factory-whitespace@2.0.1: resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - micromark-util-character@1.2.0: - resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==} - micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - micromark-util-chunked@1.1.0: - resolution: {integrity: sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==} - micromark-util-chunked@2.0.1: resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - micromark-util-classify-character@1.1.0: - resolution: {integrity: sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==} - micromark-util-classify-character@2.0.1: resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - micromark-util-combine-extensions@1.1.0: - resolution: {integrity: sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==} - micromark-util-combine-extensions@2.0.1: resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - micromark-util-decode-numeric-character-reference@1.1.0: - resolution: {integrity: sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==} - micromark-util-decode-numeric-character-reference@2.0.2: resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - micromark-util-decode-string@1.1.0: - resolution: {integrity: sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==} - micromark-util-decode-string@2.0.1: resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - micromark-util-encode@1.1.0: - resolution: {integrity: sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==} - micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - micromark-util-html-tag-name@1.2.0: - resolution: {integrity: sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==} - micromark-util-html-tag-name@2.0.1: resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - micromark-util-normalize-identifier@1.1.0: - resolution: {integrity: sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==} - micromark-util-normalize-identifier@2.0.1: resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - micromark-util-resolve-all@1.1.0: - resolution: {integrity: sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==} - micromark-util-resolve-all@2.0.1: resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - micromark-util-sanitize-uri@1.2.0: - resolution: {integrity: sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==} - micromark-util-sanitize-uri@2.0.1: resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - micromark-util-subtokenize@1.1.0: - resolution: {integrity: sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==} - micromark-util-subtokenize@2.1.0: resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - micromark-util-symbol@1.1.0: - resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==} - micromark-util-symbol@2.0.1: resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - micromark-util-types@1.1.0: - resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==} - micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - micromark@3.2.0: - resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} - micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} @@ -6840,6 +6716,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.1.2: @@ -7067,9 +6944,6 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true - remark-gfm@3.0.1: - resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==} - remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -7156,10 +7030,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} - safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -7878,9 +7748,6 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} - unified@10.1.2: - resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} - unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7888,30 +7755,18 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} - unist-util-is@5.2.1: - resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} - unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - unist-util-stringify-position@3.0.3: - resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} - unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - unist-util-visit-parents@5.1.3: - resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} - unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - unist-util-visit@4.1.2: - resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} - unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} @@ -7968,11 +7823,6 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true - uvu@0.5.6: - resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} - engines: {node: '>=8'} - hasBin: true - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -7993,15 +7843,9 @@ packages: engines: {node: '>= 18'} hasBin: true - vfile-message@3.1.4: - resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} - vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - vfile@5.3.7: - resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} - vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} @@ -8073,10 +7917,12 @@ packages: whatwg-encoding@1.0.5: resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@2.3.0: resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} @@ -8283,6 +8129,10 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -12568,10 +12418,6 @@ snapshots: '@types/json5@0.0.29': {} - '@types/mdast@3.0.15': - dependencies: - '@types/unist': 2.0.11 - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -13192,6 +13038,8 @@ snapshots: '@webgpu/types@0.1.68': {} + '@xmldom/xmldom@0.9.10': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -14080,8 +13928,6 @@ snapshots: diff@4.0.2: {} - diff@5.2.0: {} - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -14435,8 +14281,6 @@ snapshots: escape-string-regexp@4.0.0: {} - escape-string-regexp@5.0.0: {} - escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -16133,8 +15977,6 @@ snapshots: dependencies: tmpl: 1.0.5 - markdown-table@3.0.4: {} - math-intrinsics@1.1.0: {} maverick.js@0.37.0: @@ -16148,30 +15990,6 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 - mdast-util-find-and-replace@2.2.2: - dependencies: - '@types/mdast': 3.0.15 - escape-string-regexp: 5.0.0 - unist-util-is: 5.2.1 - unist-util-visit-parents: 5.1.3 - - mdast-util-from-markdown@1.3.1: - dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - decode-named-character-reference: 1.2.0 - mdast-util-to-string: 3.2.0 - micromark: 3.2.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-decode-string: 1.1.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - unist-util-stringify-position: 3.0.3 - uvu: 0.5.6 - transitivePeerDependencies: - - supports-color - mdast-util-from-markdown@2.0.2: dependencies: '@types/mdast': 4.0.4 @@ -16189,50 +16007,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-autolink-literal@1.0.3: - dependencies: - '@types/mdast': 3.0.15 - ccount: 2.0.1 - mdast-util-find-and-replace: 2.2.2 - micromark-util-character: 1.2.0 - - mdast-util-gfm-footnote@1.0.2: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-to-markdown: 1.5.0 - micromark-util-normalize-identifier: 1.1.0 - - mdast-util-gfm-strikethrough@1.0.3: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-to-markdown: 1.5.0 - - mdast-util-gfm-table@1.0.7: - dependencies: - '@types/mdast': 3.0.15 - markdown-table: 3.0.4 - mdast-util-from-markdown: 1.3.1 - mdast-util-to-markdown: 1.5.0 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@1.0.2: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-to-markdown: 1.5.0 - - mdast-util-gfm@2.0.2: - dependencies: - mdast-util-from-markdown: 1.3.1 - mdast-util-gfm-autolink-literal: 1.0.3 - mdast-util-gfm-footnote: 1.0.2 - mdast-util-gfm-strikethrough: 1.0.3 - mdast-util-gfm-table: 1.0.7 - mdast-util-gfm-task-list-item: 1.0.2 - mdast-util-to-markdown: 1.5.0 - transitivePeerDependencies: - - supports-color - mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -16272,11 +16046,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-phrasing@3.0.1: - dependencies: - '@types/mdast': 3.0.15 - unist-util-is: 5.2.1 - mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -16294,17 +16063,6 @@ snapshots: unist-util-visit: 5.0.0 vfile: 6.0.3 - mdast-util-to-markdown@1.5.0: - dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - longest-streak: 3.1.0 - mdast-util-phrasing: 3.0.1 - mdast-util-to-string: 3.2.0 - micromark-util-decode-string: 1.1.0 - unist-util-visit: 4.1.2 - zwitch: 2.0.4 - mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -16317,10 +16075,6 @@ snapshots: unist-util-visit: 5.0.0 zwitch: 2.0.4 - mdast-util-to-string@3.2.0: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -16349,25 +16103,6 @@ snapshots: content-type: 1.0.4 raw-body: 2.4.1 - micromark-core-commonmark@1.1.0: - dependencies: - decode-named-character-reference: 1.2.0 - micromark-factory-destination: 1.1.0 - micromark-factory-label: 1.1.0 - micromark-factory-space: 1.1.0 - micromark-factory-title: 1.1.0 - micromark-factory-whitespace: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-chunked: 1.1.0 - micromark-util-classify-character: 1.1.0 - micromark-util-html-tag-name: 1.2.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-resolve-all: 1.1.0 - micromark-util-subtokenize: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -16387,83 +16122,12 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-gfm-autolink-literal@1.0.5: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-sanitize-uri: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-extension-gfm-footnote@1.1.2: - dependencies: - micromark-core-commonmark: 1.1.0 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-sanitize-uri: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-extension-gfm-strikethrough@1.0.7: - dependencies: - micromark-util-chunked: 1.1.0 - micromark-util-classify-character: 1.1.0 - micromark-util-resolve-all: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-extension-gfm-table@1.0.7: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-extension-gfm-tagfilter@1.0.2: - dependencies: - micromark-util-types: 1.1.0 - - micromark-extension-gfm-task-list-item@1.0.5: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-extension-gfm@2.0.3: - dependencies: - micromark-extension-gfm-autolink-literal: 1.0.5 - micromark-extension-gfm-footnote: 1.1.2 - micromark-extension-gfm-strikethrough: 1.0.7 - micromark-extension-gfm-table: 1.0.7 - micromark-extension-gfm-tagfilter: 1.0.2 - micromark-extension-gfm-task-list-item: 1.0.5 - micromark-util-combine-extensions: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-factory-destination@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-factory-label@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 @@ -16471,23 +16135,11 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-factory-space@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-types: 1.1.0 - micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-types: 2.0.2 - micromark-factory-title@1.1.0: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-factory-title@2.0.1: dependencies: micromark-factory-space: 2.0.1 @@ -16495,13 +16147,6 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-factory-whitespace@1.1.0: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-factory-whitespace@2.0.1: dependencies: micromark-factory-space: 2.0.1 @@ -16509,61 +16154,30 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-character@1.2.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-chunked@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-chunked@2.0.1: dependencies: micromark-util-symbol: 2.0.1 - micromark-util-classify-character@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-util-classify-character@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-combine-extensions@1.1.0: - dependencies: - micromark-util-chunked: 1.1.0 - micromark-util-types: 1.1.0 - micromark-util-combine-extensions@2.0.1: dependencies: micromark-util-chunked: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-decode-numeric-character-reference@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-decode-numeric-character-reference@2.0.2: dependencies: micromark-util-symbol: 2.0.1 - micromark-util-decode-string@1.1.0: - dependencies: - decode-named-character-reference: 1.2.0 - micromark-util-character: 1.2.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-decode-string@2.0.1: dependencies: decode-named-character-reference: 1.2.0 @@ -16571,49 +16185,24 @@ snapshots: micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.1 - micromark-util-encode@1.1.0: {} - micromark-util-encode@2.0.1: {} - micromark-util-html-tag-name@1.2.0: {} - micromark-util-html-tag-name@2.0.1: {} - micromark-util-normalize-identifier@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-normalize-identifier@2.0.1: dependencies: micromark-util-symbol: 2.0.1 - micromark-util-resolve-all@1.1.0: - dependencies: - micromark-util-types: 1.1.0 - micromark-util-resolve-all@2.0.1: dependencies: micromark-util-types: 2.0.2 - micromark-util-sanitize-uri@1.2.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-encode: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-sanitize-uri@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-encode: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-subtokenize@1.1.0: - dependencies: - micromark-util-chunked: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - micromark-util-subtokenize@2.1.0: dependencies: devlop: 1.1.0 @@ -16621,36 +16210,10 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-util-symbol@1.1.0: {} - micromark-util-symbol@2.0.1: {} - micromark-util-types@1.1.0: {} - micromark-util-types@2.0.2: {} - micromark@3.2.0: - dependencies: - '@types/debug': 4.1.12 - debug: 4.4.3 - decode-named-character-reference: 1.2.0 - micromark-core-commonmark: 1.1.0 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-chunked: 1.1.0 - micromark-util-combine-extensions: 1.1.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-encode: 1.1.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-resolve-all: 1.1.0 - micromark-util-sanitize-uri: 1.2.0 - micromark-util-subtokenize: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - transitivePeerDependencies: - - supports-color - micromark@4.0.2: dependencies: '@types/debug': 4.1.12 @@ -17476,15 +17039,6 @@ snapshots: dependencies: jsesc: 3.1.0 - remark-gfm@3.0.1: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-gfm: 2.0.2 - micromark-extension-gfm: 2.0.3 - unified: 10.1.2 - transitivePeerDependencies: - - supports-color - remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -17589,10 +17143,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - sade@1.8.1: - dependencies: - mri: 1.2.0 - safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -18440,16 +17990,6 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} - unified@10.1.2: - dependencies: - '@types/unist': 2.0.11 - bail: 2.0.2 - extend: 3.0.2 - is-buffer: 2.0.5 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 5.3.7 - unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -18464,10 +18004,6 @@ snapshots: dependencies: crypto-random-string: 2.0.0 - unist-util-is@5.2.1: - dependencies: - '@types/unist': 2.0.11 - unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -18476,30 +18012,15 @@ snapshots: dependencies: '@types/unist': 3.0.3 - unist-util-stringify-position@3.0.3: - dependencies: - '@types/unist': 2.0.11 - unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 - unist-util-visit-parents@5.1.3: - dependencies: - '@types/unist': 2.0.11 - unist-util-is: 5.2.1 - unist-util-visit-parents@6.0.2: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - unist-util-visit@4.1.2: - dependencies: - '@types/unist': 2.0.11 - unist-util-is: 5.2.1 - unist-util-visit-parents: 5.1.3 - unist-util-visit@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -18573,13 +18094,6 @@ snapshots: uuid@9.0.1: {} - uvu@0.5.6: - dependencies: - dequal: 2.0.3 - diff: 5.2.0 - kleur: 4.1.5 - sade: 1.8.1 - v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@8.1.1: @@ -18628,23 +18142,11 @@ snapshots: - supports-color - typescript - vfile-message@3.1.4: - dependencies: - '@types/unist': 2.0.11 - unist-util-stringify-position: 3.0.3 - vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 - vfile@5.3.7: - dependencies: - '@types/unist': 2.0.11 - is-buffer: 2.0.5 - unist-util-stringify-position: 3.0.3 - vfile-message: 3.1.4 - vfile@6.0.3: dependencies: '@types/unist': 3.0.3 @@ -19026,6 +18528,8 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} + xpath@0.0.34: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d52179f..39de0e5 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -41,12 +41,14 @@ import { Mail, Palette, Plus, + Search, Settings, Trash2, Tv, UserPlus, Users, Video, + X, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; import { @@ -512,8 +514,10 @@ interface UserConfigProps { userPage: number; userTotalPages: number; userTotal: number; - fetchUsersV2: (page: number) => Promise; + fetchUsersV2: (page: number, search?: string) => Promise; userListLoading: boolean; + userSearch: string; + setUserSearch: (value: string) => void; } const UserConfig = ({ @@ -526,6 +530,8 @@ const UserConfig = ({ userTotal, fetchUsersV2, userListLoading, + userSearch, + setUserSearch, }: UserConfigProps) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); @@ -582,6 +588,7 @@ const UserConfig = ({ } | null>(null); const [showDeleteUserModal, setShowDeleteUserModal] = useState(false); const [deletingUser, setDeletingUser] = useState(null); + const trimmedUserSearch = userSearch.trim(); // 当前登录用户名 const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; @@ -887,7 +894,7 @@ const UserConfig = ({ if (checked) { // 只选择自己有权限操作的用户 const selectableUsernames = - config?.UserConfig?.Users?.filter( + displayUsers?.filter( (user) => role === 'owner' || (role === 'admin' && @@ -898,7 +905,7 @@ const UserConfig = ({ setSelectedUsers(new Set()); } }, - [config?.UserConfig?.Users, role, currentUsername] + [displayUsers, role, currentUsername] ); // 批量设置用户组 @@ -1240,15 +1247,87 @@ const UserConfig = ({ {/* 用户列表 */}
-
-

- 用户列表 -

-
+
+
+

+ 用户列表 +

+ +
+
+ {!hasOldUserData && usersV2 && ( +
{ + e.preventDefault(); + setSelectedUsers(new Set()); + fetchUsersV2(1, trimmedUserSearch); + }} + className='ml-auto flex min-w-0 items-center gap-2' + > +
+ + setUserSearch(e.target.value)} + placeholder='按用户名搜索' + className='w-full pl-9 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' + /> +
+ {trimmedUserSearch && ( + + )} + +
+ )} {/* 批量操作按钮 */} {selectedUsers.size > 0 && ( <> -
+
已选择 {selectedUsers.size} 个用户 @@ -1259,23 +1338,9 @@ const UserConfig = ({ 批量设置用户组
-
+
)} -
@@ -1448,7 +1513,7 @@ const UserConfig = ({ {(() => { // 检查是否有权限操作任何用户 - const hasAnyPermission = config?.UserConfig?.Users?.some( + const hasAnyPermission = displayUsers?.some( (user) => role === 'owner' || (role === 'admin' && @@ -1516,7 +1581,7 @@ const UserConfig = ({ 加载中... @@ -1536,6 +1601,23 @@ const UserConfig = ({ }; return priority(a) - priority(b); }); + if (sortedUsers.length === 0) { + return ( + + + + {trimmedUserSearch + ? `未找到用户名包含“${trimmedUserSearch}”的用户` + : '暂无用户'} + + + + ); + } + return ( {sortedUsers.map((user) => { @@ -1778,11 +1860,14 @@ const UserConfig = ({ {!hasOldUserData && usersV2 && userTotalPages > 1 && (
- 共 {userTotal} 个用户,第 {userPage} / {userTotalPages} 页 + {trimmedUserSearch + ? `搜索结果 ${userTotal} 个用户` + : `共 ${userTotal} 个用户`} + ,第 {userPage} / {userTotalPages} 页
+
+
+
+

+ 离线下载使用独立 OpenList 源 +

+

+ 开启后,存到私人影库和追番订阅会把任务提交到下方 OpenList,扫描和播放仍使用上方主 OpenList +

+
+ +
+ + {offlineDownloadUseCustomSource && ( +
+
+ + setOfflineDownloadUrl(e.target.value)} + disabled={!enabled} + placeholder='https://download-openlist-server.com' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+ +
+
+ + + setOfflineDownloadUsername(e.target.value) + } + disabled={!enabled} + placeholder='admin' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+
+ + + setOfflineDownloadPassword(e.target.value) + } + disabled={!enabled} + placeholder='password' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+
+
+ )} +
+
+
+ + +

+ 直链优先会优先使用原画下载地址;转码优先会优先使用夸克转码播放地址。 +

+
+ +
+
+

+ 多线程播放 +

+

+ 开启后,代理会把播放器请求的 Range 拆分并发拉取。 +

+
+ +
+
- - - setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000) - } - className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' - /> + + setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> +
+ +
+
+
+

Legado 订阅

+

目前处于实验性阶段,仅支持部分简单订阅。

+
+ +
+
+ setLegadoSubscriptionName(e.target.value)} placeholder='订阅名称(可选)' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' /> + setLegadoSubscriptionUrl(e.target.value)} placeholder='https://example.com/bookSource.json' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' /> +
+
+ {(legadoSubscriptions || []).length === 0 ?
暂无 Legado 订阅。
: (legadoSubscriptions || []).map((sub) => ( +
+
+
+
{sub.name}
+
{sub.url}
+
源数量:{sub.sourceCount || 0} · 上次同步:{sub.lastSuccessAt ? new Date(sub.lastSuccessAt).toLocaleString() : '-'}
+ {sub.lastError ?
{sub.lastError}
: null} +
+
+ + + +
+
+
+ ))} +
-

- 书源列表 -

- +

OPDS 书源列表

+
- - {sources.length === 0 && ( -
- 暂无 OPDS 书源,点击“添加书源”开始配置。 -
- )} - - {sources.length > 0 && ( - <> -
- {sources.map((source, index) => { - const isEditing = editingIndex === index; - return ( -
-
-
-
-
- {source.name || `书源 ${index + 1}`} -
-
- {source.id || '未设置 ID'} -
-
- -
- -
-
- - 地址 - - - {source.url || '-'} - -
-
- - 认证 - - - {source.authMode === 'none' - ? '无认证' - : source.authMode === 'basic' - ? 'Basic Auth' - : '自定义 Header'} - -
-
- - 搜索 - - - {source.searchTemplate?.trim() - ? '已配置' - : '未配置'} - -
-
- - 格式 - - {source.preferFormat?.join(', ') || '-'} -
-
- -
- - - -
-
- - {isEditing && ( -
-
- 编辑书源 #{index + 1} -
- -
-
- - - updateSource(index, { id: e.target.value }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { name: e.target.value }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- -
- - - updateSource(index, { url: e.target.value }) - } - placeholder='https://example.com/opds' - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
- -
-
- - -
-
- - - updateSource(index, { - language: e.target.value, - }) - } - placeholder='zh / en' - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { - searchTemplate: e.target.value, - }) - } - placeholder='https://...{searchTerms}' - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - {source.authMode === 'basic' && ( -
-
- - - updateSource(index, { - username: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { - password: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- )} - - {source.authMode === 'header' && ( -
-
- - - updateSource(index, { - headerName: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { - headerValue: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- )} -
- )} + {sources.length === 0 ?
暂无 OPDS 书源。
: null} +
+ {sources.map((source, index) => { + const isEditing = editingIndex === index; + return ( +
+
+
+
{source.name || `书源 ${index + 1}`}
+
{source.url || '-'}
- ); - })} -
- -
-
- - - - - - - - - - - - - - - {sources.map((source, index) => { - const isEditing = editingIndex === index; - return ( - - - - - - - - - - - - - {isEditing && ( - - - - )} - - ); - })} - -
- 启用 - - 名称 - - ID - - 地址 - - 认证 - - 搜索 - - 格式偏好 - - 操作 -
- - -
- {source.name || `书源 ${index + 1}`} -
-
- {source.language || '未设置语言'} -
-
- {source.id || '-'} - -
- {source.url || '-'} -
-
- {source.authMode === 'none' - ? '无认证' - : source.authMode === 'basic' - ? 'Basic Auth' - : '自定义 Header'} - - {source.searchTemplate?.trim() - ? '已配置' - : '未配置'} - - {source.preferFormat?.join(', ') || '-'} - -
- - - -
-
-
-
-
-
- 编辑书源 #{index + 1} -
-
- 仅展开当前书源,保存时统一提交。 -
-
- -
- -
-
- - - updateSource(index, { - id: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { - name: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- -
- - - updateSource(index, { - url: e.target.value, - }) - } - placeholder='https://example.com/opds' - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
- -
-
- - -
-
- - - updateSource(index, { - language: e.target.value, - }) - } - placeholder='zh / en' - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { - searchTemplate: e.target.value, - }) - } - placeholder='https://...{searchTerms}' - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - {source.authMode === 'basic' && ( -
-
- - - updateSource(index, { - username: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { - password: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- )} - - {source.authMode === 'header' && ( -
-
- - - updateSource(index, { - headerName: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- - - updateSource(index, { - headerValue: e.target.value, - }) - } - className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' - /> -
-
- )} -
-
+
+ + + + +
+
+ {isEditing ? ( +
+ updateSource(index, { id: e.target.value })} placeholder='书源 ID' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> + updateSource(index, { name: e.target.value })} placeholder='书源名称' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> + updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' /> + + updateSource(index, { language: e.target.value })} placeholder='语言 zh / en' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> + updateSource(index, { searchTemplate: e.target.value })} placeholder='搜索模板 https://...{searchTerms}' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' /> + {source.authMode === 'basic' ? <> updateSource(index, { username: e.target.value })} placeholder='用户名' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> updateSource(index, { password: e.target.value })} placeholder='密码' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> : null} + {source.authMode === 'header' ? <> updateSource(index, { headerName: e.target.value })} placeholder='Header 名称' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> updateSource(index, { headerValue: e.target.value })} placeholder='Header 值' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> : null} +
+ ) : null}
-
- - )} + ); + })} +
- +
- +
); }; @@ -16125,14 +15713,23 @@ function AdminPageClient() { const [userTotalPages, setUserTotalPages] = useState(1); const [userTotal, setUserTotal] = useState(0); const [userListLoading, setUserListLoading] = useState(false); + const [userSearch, setUserSearch] = useState(''); const userLimit = 10; // 获取新版本用户列表 - const fetchUsersV2 = useCallback(async (page = 1) => { + const fetchUsersV2 = useCallback(async (page = 1, search = userSearch) => { try { setUserListLoading(true); + const params = new URLSearchParams({ + page: String(page), + limit: String(userLimit), + }); + const trimmedSearch = search.trim(); + if (trimmedSearch) { + params.set('search', trimmedSearch); + } const response = await fetch( - `/api/admin/users?page=${page}&limit=${userLimit}` + `/api/admin/users?${params.toString()}` ); if (response.ok) { const data = await response.json(); @@ -16146,7 +15743,7 @@ function AdminPageClient() { } finally { setUserListLoading(false); } - }, []); + }, [userSearch]); // 刷新配置和用户列表 const refreshConfigAndUsers = useCallback(async () => { @@ -16418,9 +16015,9 @@ function AdminPageClient() {
- {/* 用户配置标签 */} + {/* 用户管理标签 */} } @@ -16437,6 +16034,8 @@ function AdminPageClient() { userTotal={userTotal} fetchUsersV2={fetchUsersV2} userListLoading={userListLoading} + userSearch={userSearch} + setUserSearch={setUserSearch} /> diff --git a/src/app/api/acg/download/route.ts b/src/app/api/acg/download/route.ts index 7304fda..61d56ef 100644 --- a/src/app/api/acg/download/route.ts +++ b/src/app/api/acg/download/route.ts @@ -3,11 +3,22 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getConfig } from '@/lib/config'; -import { OpenListClient } from '@/lib/openlist.client'; +import { + addOpenListOfflineDownload, + getOfflineDownloadBasePath, + joinOpenListPath, +} from '@/lib/openlist-offline-download'; import { hasFeaturePermission } from '@/lib/permissions'; export const runtime = 'nodejs'; +const downloadTools = ['aria2', 'Transmission', 'qBittorrent'] as const; +type DownloadTool = typeof downloadTools[number]; + +function isDownloadTool(tool: unknown): tool is DownloadTool { + return typeof tool === 'string' && downloadTools.includes(tool as DownloadTool); +} + /** * POST /api/acg/download * 添加 ACG 资源到 OpenList 离线下载(仅管理员和站长可用) @@ -23,7 +34,7 @@ export async function POST(req: NextRequest) { ); } - const { url, name } = await req.json(); + const { url, name, tool = 'aria2' } = await req.json(); if (!url || typeof url !== 'string') { return NextResponse.json( @@ -39,58 +50,22 @@ export async function POST(req: NextRequest) { ); } + if (!isDownloadTool(tool)) { + return NextResponse.json( + { error: '下载方式不支持' }, + { status: 400 } + ); + } + // 获取 OpenList 配置 const config = await getConfig(); - const openlistConfig = config.OpenListConfig; - - if (!openlistConfig?.Enabled) { - return NextResponse.json( - { error: '私人影库功能未启用' }, - { status: 400 } - ); - } - - if (!openlistConfig.URL || !openlistConfig.Username || !openlistConfig.Password) { - return NextResponse.json( - { error: 'OpenList 配置不完整' }, - { status: 400 } - ); - } // 构建下载路径(使用离线下载目录) - const offlineDownloadPath = openlistConfig.OfflineDownloadPath || '/'; - const downloadPath = `${offlineDownloadPath.replace(/\/$/, '')}/${name}`; - - // 使用 OpenListClient 添加离线下载任务 - const client = new OpenListClient( - openlistConfig.URL, - openlistConfig.Username, - openlistConfig.Password + const downloadPath = joinOpenListPath( + getOfflineDownloadBasePath(config), + name ); - - // 获取 Token 并调用 API - const token = await (client as any).getToken(); - const openlistUrl = `${openlistConfig.URL.replace(/\/$/, '')}/api/fs/add_offline_download`; - - const response = await fetch(openlistUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': token, - }, - body: JSON.stringify({ - path: downloadPath, - urls: [url], - tool: 'aria2', - }), - }); - - const data = await response.json(); - - // 检查响应状态 - if (!response.ok || data.code !== 200) { - throw new Error(data.message || '添加离线下载任务失败'); - } + await addOpenListOfflineDownload(config, downloadPath, url, tool); return NextResponse.json({ success: true, diff --git a/src/app/api/admin/anime-subscription/route.ts b/src/app/api/admin/anime-subscription/route.ts index 501cfe1..2b4c9a0 100644 --- a/src/app/api/admin/anime-subscription/route.ts +++ b/src/app/api/admin/anime-subscription/route.ts @@ -23,10 +23,14 @@ export async function GET(req: NextRequest) { const config = await getConfig(); const animeConfig = config.AnimeSubscriptionConfig || { Enabled: false, + DownloadTool: 'aria2', Subscriptions: [], }; - return NextResponse.json(animeConfig); + return NextResponse.json({ + ...animeConfig, + DownloadTool: animeConfig.DownloadTool || 'aria2', + }); } catch (error: any) { console.error('获取追番订阅配置失败:', error); return NextResponse.json( @@ -63,7 +67,13 @@ export async function POST(req: NextRequest) { const config = await getConfig(); if (!config.AnimeSubscriptionConfig) { - config.AnimeSubscriptionConfig = { Enabled: false, Subscriptions: [] }; + config.AnimeSubscriptionConfig = { + Enabled: false, + DownloadTool: 'aria2', + Subscriptions: [], + }; + } else if (!config.AnimeSubscriptionConfig.DownloadTool) { + config.AnimeSubscriptionConfig.DownloadTool = 'aria2'; } // 验证集数 diff --git a/src/app/api/admin/anime-subscription/toggle/route.ts b/src/app/api/admin/anime-subscription/toggle/route.ts index 6dc2fee..3190746 100644 --- a/src/app/api/admin/anime-subscription/toggle/route.ts +++ b/src/app/api/admin/anime-subscription/toggle/route.ts @@ -7,6 +7,13 @@ import { db } from '@/lib/db'; export const runtime = 'nodejs'; +const downloadTools = ['aria2', 'qBittorrent', 'Transmission'] as const; +type DownloadTool = typeof downloadTools[number]; + +function isDownloadTool(tool: unknown): tool is DownloadTool { + return typeof tool === 'string' && downloadTools.includes(tool as DownloadTool); +} + /** * PUT /api/admin/anime-subscription/toggle * 切换追番功能启用状态 @@ -19,7 +26,7 @@ export async function PUT(req: NextRequest) { return NextResponse.json({ error: '无权限访问' }, { status: 403 }); } - const { enabled } = await req.json(); + const { enabled, downloadTool } = await req.json(); if (typeof enabled !== 'boolean') { return NextResponse.json( @@ -28,15 +35,31 @@ export async function PUT(req: NextRequest) { ); } + if (downloadTool !== undefined && !isDownloadTool(downloadTool)) { + return NextResponse.json( + { error: '下载方式不支持' }, + { status: 400 } + ); + } + const config = await getConfig(); if (!config.AnimeSubscriptionConfig) { config.AnimeSubscriptionConfig = { Enabled: false, Subscriptions: [] }; } config.AnimeSubscriptionConfig.Enabled = enabled; + if (downloadTool !== undefined) { + config.AnimeSubscriptionConfig.DownloadTool = downloadTool; + } else if (!config.AnimeSubscriptionConfig.DownloadTool) { + config.AnimeSubscriptionConfig.DownloadTool = 'aria2'; + } await db.saveAdminConfig(config); - return NextResponse.json({ success: true, enabled }); + return NextResponse.json({ + success: true, + enabled, + downloadTool: config.AnimeSubscriptionConfig.DownloadTool, + }); } catch (error: any) { console.error('切换追番功能状态失败:', error); return NextResponse.json( diff --git a/src/app/api/admin/legado-subscriptions/[id]/refresh/route.ts b/src/app/api/admin/legado-subscriptions/[id]/refresh/route.ts new file mode 100644 index 0000000..9c406b6 --- /dev/null +++ b/src/app/api/admin/legado-subscriptions/[id]/refresh/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig, setCachedConfig } from '@/lib/config'; +import { db } from '@/lib/db'; +import { legadoSubscriptionStore } from '@/lib/legado/subscription-store'; + +export const runtime = 'nodejs'; + +async function ensureAdmin(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (authInfo.username !== process.env.USERNAME) { + const userInfo = await db.getUserInfoV2(authInfo.username); + if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + return authInfo.username; +} + +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const ensured = await ensureAdmin(request); + if (ensured instanceof NextResponse) return ensured; + const { id } = await context.params; + try { + const config = await getConfig(); + const opds = config.OPDSConfig || { Enabled: false, Sources: [], LegadoSubscriptions: [], CacheTTL: 10 * 60 * 1000 }; + const current = (opds.LegadoSubscriptions || []).find((item) => item.id === id); + if (!current) return NextResponse.json({ success: false, error: '订阅不存在' }, { status: 404 }); + const meta = await legadoSubscriptionStore.sync({ id: current.id, name: current.name, url: current.url }); + const nextConfig = legadoSubscriptionStore.mergeMeta(config, { ...current, ...meta, enabled: current.enabled !== false }); + await db.saveAdminConfig(nextConfig); + await setCachedConfig(nextConfig); + return NextResponse.json({ success: true, subscription: { ...current, ...meta, enabled: current.enabled !== false } }); + } catch (error) { + const config = await getConfig(); + const opds = config.OPDSConfig || { Enabled: false, Sources: [], LegadoSubscriptions: [], CacheTTL: 10 * 60 * 1000 }; + const now = Date.now(); + const message = error instanceof Error ? error.message : '刷新 Legado 订阅失败'; + const nextSubscriptions = (opds.LegadoSubscriptions || []).map((item) => item.id === id ? { ...item, lastSyncAt: now, lastError: message } : item); + const nextConfig = { ...config, OPDSConfig: { ...opds, LegadoSubscriptions: nextSubscriptions } }; + await db.saveAdminConfig(nextConfig); + await setCachedConfig(nextConfig); + return NextResponse.json({ success: false, error: message }, { status: 400 }); + } +} diff --git a/src/app/api/admin/legado-subscriptions/[id]/route.ts b/src/app/api/admin/legado-subscriptions/[id]/route.ts new file mode 100644 index 0000000..02dce99 --- /dev/null +++ b/src/app/api/admin/legado-subscriptions/[id]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig, setCachedConfig } from '@/lib/config'; +import { db } from '@/lib/db'; +import { legadoSubscriptionStore } from '@/lib/legado/subscription-store'; + +export const runtime = 'nodejs'; + +async function ensureAdmin(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (authInfo.username !== process.env.USERNAME) { + const userInfo = await db.getUserInfoV2(authInfo.username); + if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + return authInfo.username; +} + +export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) { + const ensured = await ensureAdmin(request); + if (ensured instanceof NextResponse) return ensured; + const { id } = await context.params; + try { + const config = await getConfig(); + const opds = config.OPDSConfig || { Enabled: false, Sources: [], LegadoSubscriptions: [], CacheTTL: 10 * 60 * 1000 }; + const nextConfig = { ...config, OPDSConfig: { ...opds, LegadoSubscriptions: (opds.LegadoSubscriptions || []).filter((item) => item.id !== id) } }; + await legadoSubscriptionStore.delete(id); + await db.saveAdminConfig(nextConfig); + await setCachedConfig(nextConfig); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ success: false, error: error instanceof Error ? error.message : '删除 Legado 订阅失败' }, { status: 400 }); + } +} diff --git a/src/app/api/admin/legado-subscriptions/import/route.ts b/src/app/api/admin/legado-subscriptions/import/route.ts new file mode 100644 index 0000000..884e5da --- /dev/null +++ b/src/app/api/admin/legado-subscriptions/import/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig, setCachedConfig } from '@/lib/config'; +import { db } from '@/lib/db'; +import { legadoSubscriptionStore } from '@/lib/legado/subscription-store'; + +export const runtime = 'nodejs'; + +async function ensureAdmin(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (authInfo.username !== process.env.USERNAME) { + const userInfo = await db.getUserInfoV2(authInfo.username); + if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + return authInfo.username; +} + +export async function POST(request: NextRequest) { + const ensured = await ensureAdmin(request); + if (ensured instanceof NextResponse) return ensured; + try { + const body = await request.json(); + const url = String(body?.url || '').trim(); + const name = String(body?.name || '').trim() || 'Legado 订阅'; + const meta = await legadoSubscriptionStore.sync({ name, url }); + const config = await getConfig(); + const nextConfig = legadoSubscriptionStore.mergeMeta(config, meta); + await db.saveAdminConfig(nextConfig); + await setCachedConfig(nextConfig); + return NextResponse.json({ success: true, subscription: meta }); + } catch (error) { + return NextResponse.json({ success: false, error: error instanceof Error ? error.message : '导入 Legado 订阅失败' }, { status: 400 }); + } +} diff --git a/src/app/api/admin/netdisk/route.ts b/src/app/api/admin/netdisk/route.ts index 4230490..d9d84bd 100644 --- a/src/app/api/admin/netdisk/route.ts +++ b/src/app/api/admin/netdisk/route.ts @@ -79,6 +79,8 @@ export async function POST(request: NextRequest) { Enabled: Boolean(Quark?.Enabled), Cookie: normalizedCookie, SavePath: Quark?.SavePath || '/', + PlayMode: Quark?.PlayMode === 'direct_first' ? 'direct_first' : 'transcode_first', + MultiThreadPlayback: Boolean(Quark?.MultiThreadPlayback), }; adminConfig.NetDiskConfig.Mobile = { Enabled: Boolean(Mobile?.Enabled), diff --git a/src/app/api/admin/opds/route.ts b/src/app/api/admin/opds/route.ts index f45cd1b..6f15ff7 100644 --- a/src/app/api/admin/opds/route.ts +++ b/src/app/api/admin/opds/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { BookSource, BookSourceCapabilities } from '@/lib/book.types'; import { db } from '@/lib/db'; +import { legadoClient } from '@/lib/legado.client'; import { opdsClient } from '@/lib/opds.client'; export const runtime = 'nodejs'; @@ -10,6 +11,7 @@ export const runtime = 'nodejs'; interface TestSourceInput { id?: string; name?: string; + type?: 'opds' | 'legado'; url?: string; enabled?: boolean; authMode?: 'none' | 'basic' | 'header'; @@ -20,6 +22,7 @@ interface TestSourceInput { searchTemplate?: string; preferFormat?: Array<'epub' | 'pdf'>; language?: string; + legado?: BookSource['legado']; } async function ensureAdmin(request: NextRequest) { @@ -39,6 +42,7 @@ async function ensureAdmin(request: NextRequest) { } async function detectCapabilitiesFromSource(source: BookSource): Promise { + if (source.type === 'legado') return legadoClient.detectCapabilitiesFromSource(source); try { const result = await opdsClient.getCatalogFromSource(source); return { @@ -70,7 +74,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const inputSources = (body?.Sources || []) as TestSourceInput[]; if (!Array.isArray(inputSources) || inputSources.length === 0) { - return NextResponse.json({ success: false, message: '请至少填写一个 OPDS 书源' }, { status: 400 }); + return NextResponse.json({ success: false, message: '请至少填写一个电子书源' }, { status: 400 }); } const sources: BookSource[] = inputSources @@ -78,6 +82,7 @@ export async function POST(request: NextRequest) { .map((item, index) => ({ id: item.id?.trim() || `source_${index + 1}`, name: item.name?.trim() || `书源 ${index + 1}`, + type: item.type || 'opds', url: (item.url || '').trim(), enabled: item.enabled !== false, authMode: item.authMode || 'none', @@ -88,6 +93,7 @@ export async function POST(request: NextRequest) { searchTemplate: item.searchTemplate?.trim() || '', preferFormat: item.preferFormat || ['epub', 'pdf'], language: item.language?.trim() || '', + legado: item.legado, })); if (sources.length === 0) { diff --git a/src/app/api/admin/openlist/route.ts b/src/app/api/admin/openlist/route.ts index b70afe5..8c9c434 100644 --- a/src/app/api/admin/openlist/route.ts +++ b/src/app/api/admin/openlist/route.ts @@ -45,7 +45,22 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - const { action, Enabled, URL, Username, Password, RootPaths, OfflineDownloadPath, ScanInterval, ScanMode, DisableVideoPreview } = body; + const { + action, + Enabled, + URL, + Username, + Password, + RootPaths, + OfflineDownloadPath, + OfflineDownloadUseCustomSource, + OfflineDownloadURL, + OfflineDownloadUsername, + OfflineDownloadPassword, + ScanInterval, + ScanMode, + DisableVideoPreview, + } = body; const authInfo = getAuthInfoFromCookie(request); if (!authInfo || !authInfo.username) { @@ -74,6 +89,10 @@ export async function POST(request: NextRequest) { Password: Password || '', RootPaths: RootPaths || ['/'], OfflineDownloadPath: OfflineDownloadPath || '/', + OfflineDownloadUseCustomSource: OfflineDownloadUseCustomSource || false, + OfflineDownloadURL: OfflineDownloadURL || '', + OfflineDownloadUsername: OfflineDownloadUsername || '', + OfflineDownloadPassword: OfflineDownloadPassword || '', LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, ResourceCount: adminConfig.OpenListConfig?.ResourceCount, ScanInterval: 0, @@ -97,6 +116,16 @@ export async function POST(request: NextRequest) { ); } + if ( + OfflineDownloadUseCustomSource && + (!OfflineDownloadURL || !OfflineDownloadUsername || !OfflineDownloadPassword) + ) { + return NextResponse.json( + { error: '请提供离线下载 OpenList URL、账号和密码' }, + { status: 400 } + ); + } + // 验证 RootPaths if (!Array.isArray(RootPaths) || RootPaths.length === 0) { return NextResponse.json( @@ -130,6 +159,24 @@ export async function POST(request: NextRequest) { ); } + if (OfflineDownloadUseCustomSource) { + try { + console.log('[OpenList Config] 验证离线下载 OpenList 账号密码'); + await OpenListClient.login( + OfflineDownloadURL, + OfflineDownloadUsername, + OfflineDownloadPassword + ); + console.log('[OpenList Config] 离线下载 OpenList 账号密码验证成功'); + } catch (error) { + console.error('[OpenList Config] 离线下载 OpenList 账号密码验证失败:', error); + return NextResponse.json( + { error: '离线下载 OpenList 账号密码验证失败: ' + (error as Error).message }, + { status: 400 } + ); + } + } + adminConfig.OpenListConfig = { Enabled: true, URL, @@ -137,6 +184,10 @@ export async function POST(request: NextRequest) { Password, RootPaths: cleanedRootPaths, OfflineDownloadPath: OfflineDownloadPath || '/', + OfflineDownloadUseCustomSource: OfflineDownloadUseCustomSource || false, + OfflineDownloadURL: OfflineDownloadURL || '', + OfflineDownloadUsername: OfflineDownloadUsername || '', + OfflineDownloadPassword: OfflineDownloadPassword || '', LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, ResourceCount: adminConfig.OpenListConfig?.ResourceCount, ScanInterval: scanInterval, diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 84a5811..90fe69e 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -44,10 +44,11 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page') || '1', 10); const limit = parseInt(searchParams.get('limit') || '10', 10); + const search = (searchParams.get('search') || '').trim(); const offset = (page - 1) * limit; // 获取用户列表(优先使用新版本) - const result = await db.getUserListV2(offset, limit, process.env.USERNAME); + const result = await db.getUserListV2(offset, limit, process.env.USERNAME, search); if (result.users.length > 0) { // 使用新版本数据 diff --git a/src/app/api/books/catalog/route.ts b/src/app/api/books/catalog/route.ts index b0d9cd7..3ddad6c 100644 --- a/src/app/api/books/catalog/route.ts +++ b/src/app/api/books/catalog/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { opdsClient } from '@/lib/opds.client'; +import { bookProvider } from '@/lib/book-provider'; import { getAuthorizedBooksUsername } from '../_utils'; @@ -17,7 +17,7 @@ export async function GET(request: NextRequest) { if (!sourceId) { return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 }); } - const result = await opdsClient.getCatalog(sourceId, href); + const result = await bookProvider.getCatalog(sourceId, href); return NextResponse.json(result); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 500 }); diff --git a/src/app/api/books/detail/route.ts b/src/app/api/books/detail/route.ts index 7314d8b..630e663 100644 --- a/src/app/api/books/detail/route.ts +++ b/src/app/api/books/detail/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { BookAcquisitionLink } from '@/lib/book.types'; import { db } from '@/lib/db'; -import { opdsClient } from '@/lib/opds.client'; +import { bookProvider } from '@/lib/book-provider'; import { getAuthorizedBooksUsername } from '../_utils'; @@ -49,7 +49,7 @@ async function resolveDetail(username: string, payload: DetailPayload) { }] : undefined; - const detail = await opdsClient.getBookDetail(sourceId, href, { + const detail = await bookProvider.getBookDetail(sourceId, href, { id: bookId, title: payload.title || shelfItem?.title || readRecord?.title || undefined, author: payload.author || shelfItem?.author || readRecord?.author || undefined, diff --git a/src/app/api/books/file/route.ts b/src/app/api/books/file/route.ts index 944dacf..5c35f71 100644 --- a/src/app/api/books/file/route.ts +++ b/src/app/api/books/file/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { opdsClient } from '@/lib/opds.client'; +import { bookProvider } from '@/lib/book-provider'; import { getAuthorizedBooksUsername } from '../_utils'; @@ -11,12 +11,13 @@ type FilePayload = { sourceId?: string; bookId?: string; href?: string; - format?: 'epub' | 'pdf' | null; + format?: 'epub' | 'pdf' | 'chapters' | null; }; async function resolveFileHref(username: string, payload: FilePayload): Promise<{ sourceId: string; href: string }> { const sourceId = payload.sourceId?.trim(); if (!sourceId) throw new Error('缺少 sourceId'); + if (payload.format === 'chapters') throw new Error('章节型书源不提供文件下载'); if (payload.href?.trim()) { return { sourceId, href: payload.href.trim() }; @@ -35,9 +36,10 @@ async function resolveFileHref(username: string, payload: FilePayload): Promise< const detailHref = shelfItem?.detailHref || readRecord?.detailHref; if (!detailHref) throw new Error('找不到可下载文件'); - const preferred = await opdsClient.getPreferredAcquisition(sourceId, detailHref); + const preferred = await bookProvider.getPreferredAcquisition(sourceId, detailHref); + if (preferred.format === 'chapters') throw new Error('章节型书源不提供文件下载'); if (payload.format && preferred.format !== payload.format) { - const detail = await opdsClient.getBookDetail(sourceId, detailHref); + const detail = await bookProvider.getBookDetail(sourceId, detailHref); const matched = detail.acquisitionLinks.find((item) => (payload.format === 'pdf' ? item.type.toLowerCase().includes('pdf') : item.type.toLowerCase().includes('epub'))); if (!matched?.href) throw new Error('找不到对应格式文件'); return { sourceId, href: matched.href }; @@ -47,7 +49,7 @@ async function resolveFileHref(username: string, payload: FilePayload): Promise< } async function proxyFile(request: NextRequest, sourceId: string, href: string) { - const source = await opdsClient.getSourceById(sourceId); + const source = await bookProvider.getSourceById(sourceId); const headers = new Headers(); if (source.authMode === 'basic' && source.username) { headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`); @@ -98,7 +100,7 @@ export async function GET(request: NextRequest) { sourceId: searchParams.get('sourceId') || undefined, bookId: searchParams.get('bookId') || undefined, href: searchParams.get('href') || undefined, - format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | null) || null, + format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | 'chapters' | null) || null, }); return await proxyFile(request, resolved.sourceId, resolved.href); } catch (error) { diff --git a/src/app/api/books/image/route.ts b/src/app/api/books/image/route.ts new file mode 100644 index 0000000..17435c3 --- /dev/null +++ b/src/app/api/books/image/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { legadoClient } from '@/lib/legado.client'; +import crypto from 'crypto'; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; + +import { getAuthorizedBooksUsername } from '../_utils'; + +export const runtime = 'nodejs'; + +const imageCache = new Map(); + +function asObjectHeader(value?: string | Record): Record { + if (!value) return {}; + if (typeof value === 'object') return value; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return value.split('\n').reduce>((headers, line) => { + const index = line.indexOf(':'); + if (index > 0) headers[line.slice(0, index).trim()] = line.slice(index + 1).trim(); + return headers; + }, {}); + } +} + +export async function GET(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const { searchParams } = new URL(request.url); + const sourceId = searchParams.get('sourceId') || ''; + const url = searchParams.get('url') || ''; + if (!sourceId || !url) return NextResponse.json({ error: '缺少 sourceId 或 url' }, { status: 400 }); + if (!(await validateProxyUrlServerSide(url))) return NextResponse.json({ error: '图片地址未通过安全校验' }, { status: 400 }); + const source = await legadoClient.getSourceById(sourceId); + let imageOptions: any = {}; + try { + const rawOptions = searchParams.get('options') || ''; + imageOptions = rawOptions ? JSON.parse(rawOptions) : {}; + } catch { + imageOptions = {}; + } + const headers: Record = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36', + Referer: source.legado?.bookSourceUrl || source.url, + ...asObjectHeader(source.legado?.header), + ...(imageOptions.headers && typeof imageOptions.headers === 'object' ? imageOptions.headers : {}), + }; + delete headers.Host; + delete headers.host; + const cacheKey = crypto.createHash('sha1').update(`${sourceId}|${url}|${JSON.stringify(imageOptions)}`).digest('hex'); + const cached = imageCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now() && !request.headers.get('range')) { + return new NextResponse(cached.data, { headers: { 'Content-Type': cached.contentType, 'Cache-Control': 'public, max-age=86400' } }); + } + const range = request.headers.get('range'); + if (range) headers.Range = range; + const res = await fetch(url, { method: imageOptions.method || 'GET', headers, body: imageOptions.body, cache: 'no-store', redirect: 'manual' }); + if ([301, 302, 303, 307, 308].includes(res.status)) { + const location = res.headers.get('location'); + if (!location) return NextResponse.json({ error: '图片重定向缺少地址' }, { status: 502 }); + const redirected = new URL(location, url).toString(); + if (!(await validateProxyUrlServerSide(redirected))) return NextResponse.json({ error: '图片重定向地址未通过安全校验' }, { status: 400 }); + const second = await fetch(redirected, { method: imageOptions.method || 'GET', headers, body: imageOptions.body, cache: 'no-store' }); + if (!second.ok) return NextResponse.json({ error: `图片请求失败: ${second.status}` }, { status: second.status }); + const data = new Uint8Array(await second.arrayBuffer()); + const contentType = second.headers.get('content-type') || 'image/jpeg'; + if (!range) imageCache.set(cacheKey, { data, contentType, expiresAt: Date.now() + 86400_000 }); + return new NextResponse(data, { status: second.status, headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400' } }); + } + if (!res.ok) return NextResponse.json({ error: `图片请求失败: ${res.status}` }, { status: res.status }); + const data = new Uint8Array(await res.arrayBuffer()); + const contentType = res.headers.get('content-type') || 'image/jpeg'; + if (!range) imageCache.set(cacheKey, { data, contentType, expiresAt: Date.now() + 86400_000 }); + return new NextResponse(data, { + status: res.status, + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400', + ...(res.headers.get('content-range') ? { 'Content-Range': res.headers.get('content-range') as string } : {}), + ...(res.headers.get('accept-ranges') ? { 'Accept-Ranges': res.headers.get('accept-ranges') as string } : {}), + }, + }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : '图片代理失败' }, { status: 500 }); + } +} diff --git a/src/app/api/books/read/chapter/route.ts b/src/app/api/books/read/chapter/route.ts new file mode 100644 index 0000000..7f41e6f --- /dev/null +++ b/src/app/api/books/read/chapter/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { legadoClient } from '@/lib/legado.client'; + +import { getAuthorizedBooksUsername } from '../../_utils'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const { searchParams } = new URL(request.url); + const sourceId = searchParams.get('sourceId')?.trim(); + const href = searchParams.get('href')?.trim(); + const tocHref = searchParams.get('tocHref')?.trim() || undefined; + if (!sourceId || !href) return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 }); + const chapter = await legadoClient.getChapterContent(sourceId, href, tocHref); + return NextResponse.json(chapter); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + } +} diff --git a/src/app/api/books/read/chapters/route.ts b/src/app/api/books/read/chapters/route.ts new file mode 100644 index 0000000..eba2ab9 --- /dev/null +++ b/src/app/api/books/read/chapters/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { legadoClient } from '@/lib/legado.client'; + +import { getAuthorizedBooksUsername } from '../../_utils'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + try { + const { searchParams } = new URL(request.url); + const sourceId = searchParams.get('sourceId')?.trim(); + const bookId = searchParams.get('bookId')?.trim(); + const href = searchParams.get('href')?.trim() || ''; + + if (!sourceId) return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 }); + + const chapters = bookId + ? await legadoClient.getChaptersByBookId(sourceId, bookId) + : href + ? await legadoClient.getChapters(sourceId, href) + : null; + if (!chapters) return NextResponse.json({ error: '缺少 bookId 或 href,无法定位章节目录' }, { status: 400 }); + return NextResponse.json({ chapters }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + } +} diff --git a/src/app/api/books/read/manifest/route.ts b/src/app/api/books/read/manifest/route.ts index d1c89f7..96052b6 100644 --- a/src/app/api/books/read/manifest/route.ts +++ b/src/app/api/books/read/manifest/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { BookAcquisitionLink } from '@/lib/book.types'; import { db } from '@/lib/db'; -import { opdsClient } from '@/lib/opds.client'; +import { bookProvider } from '@/lib/book-provider'; import { getAuthorizedBooksUsername } from '../../_utils'; @@ -13,7 +13,7 @@ type ManifestPayload = { bookId?: string; href?: string; acquisitionHref?: string; - format?: 'epub' | 'pdf' | null; + format?: 'epub' | 'pdf' | 'chapters' | null; title?: string; author?: string; cover?: string; @@ -45,12 +45,12 @@ async function resolveManifest(username: string, payload: ManifestPayload) { const fallbackAcquisitionLinks: BookAcquisitionLink[] = resolvedAcquisitionHref ? [{ rel: 'http://opds-spec.org/acquisition', - type: resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip', + type: resolvedFormat === 'chapters' ? 'application/x-legado-chapters+json' : resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip', href: resolvedAcquisitionHref, }] : []; - const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', { + const detail = await bookProvider.getBookDetail(sourceId, resolvedHref || '', { id: bookId || resolvedAcquisitionHref || undefined, title: payload.title || existingRecord?.title || shelfItem?.title || undefined, author: payload.author || existingRecord?.author || shelfItem?.author || undefined, @@ -60,9 +60,9 @@ async function resolveManifest(username: string, payload: ManifestPayload) { acquisitionLinks: fallbackAcquisitionLinks, }); const preferred = resolvedHref - ? await opdsClient.getPreferredAcquisition(sourceId, resolvedHref) + ? await bookProvider.getPreferredAcquisition(sourceId, resolvedHref) : { - format: resolvedFormat === 'pdf' ? 'pdf' : 'epub', + format: resolvedFormat === 'chapters' ? 'chapters' : resolvedFormat === 'pdf' ? 'pdf' : 'epub', href: resolvedAcquisitionHref || '', }; const lastRecord = await db.getBookReadRecord(username, sourceId, detail.id); @@ -70,7 +70,8 @@ async function resolveManifest(username: string, payload: ManifestPayload) { return NextResponse.json({ book: detail, format: preferred.format, - fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`, + fileUrl: preferred.format === 'chapters' ? undefined : `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`, + chaptersUrl: preferred.format === 'chapters' ? `/api/books/read/chapters?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}` : undefined, acquisitionHref: preferred.href, cacheKey: `${sourceId}::${detail.id}::${preferred.format}`, coverUrl: detail.cover, diff --git a/src/app/api/books/search/route.ts b/src/app/api/books/search/route.ts index 879fe1f..3226b8c 100644 --- a/src/app/api/books/search/route.ts +++ b/src/app/api/books/search/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { opdsClient } from '@/lib/opds.client'; +import { bookProvider } from '@/lib/book-provider'; import { getAuthorizedBooksUsername } from '../_utils'; @@ -17,7 +17,7 @@ export async function GET(request: NextRequest) { if (!q) { return NextResponse.json({ results: [], failedSources: [] }); } - const result = await opdsClient.searchBooks(q, sourceId); + const result = await bookProvider.searchBooks(q, sourceId); return NextResponse.json(result); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 500 }); diff --git a/src/app/api/books/search/ws/route.ts b/src/app/api/books/search/ws/route.ts new file mode 100644 index 0000000..719bb94 --- /dev/null +++ b/src/app/api/books/search/ws/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { bookProvider } from '@/lib/book-provider'; + +import { getAuthorizedBooksUsername } from '../../_utils'; + +export const runtime = 'nodejs'; + +function sse(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +export async function GET(request: NextRequest) { + const username = await getAuthorizedBooksUsername(request); + if (username instanceof NextResponse) return username; + + const { searchParams } = new URL(request.url); + const q = searchParams.get('q')?.trim(); + const sourceId = searchParams.get('sourceId')?.trim() || undefined; + + if (!q) { + return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 }); + } + + const encoder = new TextEncoder(); + let closed = false; + + const stream = new ReadableStream({ + async start(controller) { + const send = (payload: unknown) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(sse(payload))); + } catch { + closed = true; + } + }; + + try { + const sources = await bookProvider.getSearchSources(sourceId); + let completedSources = 0; + let totalResults = 0; + const failedSources: Array<{ sourceId: string; sourceName: string; error: string }> = []; + + send({ type: 'start', totalSources: sources.length }); + + await Promise.all( + sources.map(async (source) => { + try { + const result = await bookProvider.searchBooksSource(q, source); + completedSources += 1; + totalResults += result.results.length; + send({ + type: 'source_result', + sourceId: source.id, + sourceName: source.name, + results: result.results, + completedSources, + totalSources: sources.length, + }); + } catch (error) { + const failure = { + sourceId: source.id, + sourceName: source.name, + error: error instanceof Error ? error.message : '未知错误', + }; + completedSources += 1; + failedSources.push(failure); + send({ + type: 'source_error', + ...failure, + completedSources, + totalSources: sources.length, + }); + } + }) + ); + + send({ + type: 'complete', + completedSources, + totalSources: sources.length, + totalResults, + failedSources, + }); + } catch (error) { + send({ type: 'error', error: error instanceof Error ? error.message : '搜索失败' }); + } finally { + closed = true; + try { + controller.close(); + } catch { + // ignore client disconnect races + } + } + }, + cancel() { + closed = true; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/src/app/api/books/sources/route.ts b/src/app/api/books/sources/route.ts index da29e4d..c30145c 100644 --- a/src/app/api/books/sources/route.ts +++ b/src/app/api/books/sources/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { opdsClient } from '@/lib/opds.client'; +import { bookProvider } from '@/lib/book-provider'; import { getAuthorizedBooksUsername } from '../_utils'; @@ -11,7 +11,7 @@ export async function GET(request: NextRequest) { if (username instanceof NextResponse) return username; try { - const sources = await opdsClient.getSources(); + const sources = await bookProvider.getSources(); return NextResponse.json({ sources }); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 500 }); diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index aad2a47..41bbc91 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -4,6 +4,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { parseAuthInfo } from '@/lib/auth'; import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; +import { + checkLoginBan, + getLoginClientIp, + recordLoginFailure, + recordLoginSuccess, +} from '@/lib/login-fail2ban'; import { generateRefreshToken, generateTokenId, @@ -177,6 +183,20 @@ function getDeviceInfo(request: NextRequest): string { export async function POST(req: NextRequest) { try { + const clientIp = getLoginClientIp(req); + const banStatus = checkLoginBan(clientIp); + if (banStatus.banned) { + return NextResponse.json( + { error: '登录失败次数过多,请稍后再试' }, + { + status: 429, + headers: banStatus.retryAfterSeconds + ? { 'Retry-After': String(banStatus.retryAfterSeconds) } + : undefined, + } + ); + } + // 获取站点配置 const adminConfig = await getConfig(); const siteConfig = adminConfig.SiteConfig; @@ -206,12 +226,15 @@ export async function POST(req: NextRequest) { } if (password !== envPassword) { + recordLoginFailure(clientIp); return NextResponse.json( { ok: false, error: '密码错误' }, { status: 401 } ); } + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const username = process.env.USERNAME || 'default'; const deviceInfo = getDeviceInfo(req); @@ -279,6 +302,8 @@ export async function POST(req: NextRequest) { username === process.env.USERNAME && password === process.env.PASSWORD ) { + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const deviceInfo = getDeviceInfo(req); const cookieValue = await generateAuthCookie( @@ -302,6 +327,7 @@ export async function POST(req: NextRequest) { return response; } else if (username === process.env.USERNAME) { + recordLoginFailure(clientIp); return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }); } @@ -326,12 +352,15 @@ export async function POST(req: NextRequest) { } if (!pass) { + recordLoginFailure(clientIp); return NextResponse.json( { error: '用户名或密码错误' }, { status: 401 } ); } + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const deviceInfo = getDeviceInfo(req); const cookieValue = await generateAuthCookie( diff --git a/src/app/api/manga/search/ws/route.ts b/src/app/api/manga/search/ws/route.ts new file mode 100644 index 0000000..e79a8e2 --- /dev/null +++ b/src/app/api/manga/search/ws/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { suwayomiClient } from '@/lib/suwayomi.client'; + +import { getAuthorizedUsername } from '../../_utils'; + +export const runtime = 'nodejs'; + +function sse(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +export async function GET(request: NextRequest) { + const username = await getAuthorizedUsername(request); + if (username instanceof NextResponse) return username; + + const { searchParams } = new URL(request.url); + const q = searchParams.get('q')?.trim(); + const sourceId = searchParams.get('sourceId')?.trim() || undefined; + const page = Number(searchParams.get('page') || '1'); + + if (!q) { + return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 }); + } + + const encoder = new TextEncoder(); + let closed = false; + + const stream = new ReadableStream({ + async start(controller) { + const send = (payload: unknown) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(sse(payload))); + } catch { + closed = true; + } + }; + + try { + const sources = await suwayomiClient.getSearchSources(sourceId); + let completedSources = 0; + let totalResults = 0; + const failedSources: Array<{ sourceId: string; sourceName: string; error: string }> = []; + + send({ type: 'start', totalSources: sources.length }); + + await Promise.all( + sources.map(async (source) => { + try { + const result = await suwayomiClient.searchMangaSource(q, source, page); + completedSources += 1; + totalResults += result.results.length; + send({ + type: 'source_result', + sourceId: String(source.id), + sourceName: source.displayName || source.name || String(source.id), + results: result.results, + completedSources, + totalSources: sources.length, + }); + } catch (error) { + const message = error instanceof Error ? error.message : '未知错误'; + completedSources += 1; + const failure = { + sourceId: String(source.id), + sourceName: source.displayName || source.name || String(source.id), + error: message, + }; + failedSources.push(failure); + send({ + type: 'source_error', + ...failure, + completedSources, + totalSources: sources.length, + }); + } + }) + ); + + send({ + type: 'complete', + completedSources, + totalSources: sources.length, + totalResults, + failedSources, + }); + } catch (error) { + send({ + type: 'error', + error: error instanceof Error ? error.message : '搜索失败', + }); + } finally { + closed = true; + try { + controller.close(); + } catch { + // ignore close races when the client disconnects + } + } + }, + cancel() { + closed = true; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/src/app/api/music/v2/history/route.ts b/src/app/api/music/v2/history/route.ts index 68de61d..c500af2 100644 --- a/src/app/api/music/v2/history/route.ts +++ b/src/app/api/music/v2/history/route.ts @@ -15,7 +15,7 @@ function toHistoryRecord(input: any, previous?: MusicV2HistoryRecord): MusicV2Hi lastPlayedAt: Number(input.lastPlayedAt ?? input.last_played_at ?? now), playCount: Number(input.playCount ?? input.play_count ?? ((previous?.playCount || 0) + 1)), lastQuality: input.lastQuality || input.last_quality || previous?.lastQuality, - createdAt: Number(input.createdAt ?? input.created_at ?? previous?.createdAt ?? now), + createdAt: Number(previous?.createdAt ?? input.createdAt ?? input.created_at ?? now), updatedAt: now, }; } diff --git a/src/app/api/netdisk/quark/proxy/route.ts b/src/app/api/netdisk/quark/proxy/route.ts index 7cf7ceb..7424b73 100644 --- a/src/app/api/netdisk/quark/proxy/route.ts +++ b/src/app/api/netdisk/quark/proxy/route.ts @@ -1,12 +1,149 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { ensureQuarkPlayFolder, getQuarkPlayHeaders, getQuarkPlayUrls, saveQuarkShareFile } from '@/lib/netdisk/quark.client'; +import { createQuarkMultiThreadStream } from '@/lib/netdisk/quark-multithread-proxy'; +import { + ensureQuarkPlayFolder, + getQuarkPlayHeaders, + getQuarkPlayUrls, + probeQuarkPlayRange, + saveQuarkShareFile, +} from '@/lib/netdisk/quark.client'; import { refreshQuarkNetdiskSession } from '@/lib/netdisk/quark-session-cache'; import { resolveQuarkSession } from '@/lib/netdisk/quark-session-resolver'; export const runtime = 'nodejs'; +const QUARK_PC_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch'; + +async function pipeUpstream(response: Response, range: string | null) { + const responseHeaders = new Headers(); + const copyHeaders = ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']; + copyHeaders.forEach((name) => { + const value = response.headers.get(name); + if (value) responseHeaders.set(name, value); + }); + responseHeaders.set('Cache-Control', 'private, no-store'); + + const { readable, writable } = new TransformStream(); + const reader = response.body!.getReader(); + + void (async () => { + const writer = writable.getWriter(); + try { + let streamDone = false; + while (!streamDone) { + const { done, value } = await reader.read(); + if (done) { + streamDone = true; + } else { + await writer.write(value); + } + } + } catch { + try { + await reader.cancel(); + } catch { + void 0; + } + } finally { + try { + reader.releaseLock(); + } catch { + void 0; + } + try { + await writer.close(); + } catch { + void 0; + } + } + })(); + + return new Response(readable, { + status: range && response.headers.get('content-range') ? 206 : response.status, + headers: responseHeaders, + }); +} + +function getPassthroughHeaders(request: NextRequest) { + const passthroughHeaderNames = [ + 'accept', + 'accept-language', + 'accept-encoding', + 'connection', + 'sec-fetch-dest', + 'sec-fetch-mode', + 'sec-fetch-site', + ]; + const passthroughHeaders: Record = {}; + for (const name of passthroughHeaderNames) { + const value = request.headers.get(name); + if (value) passthroughHeaders[name] = value; + } + return passthroughHeaders; +} + +function buildHeaderProfiles(request: NextRequest, cookie: string) { + const passthroughHeaders = getPassthroughHeaders(request); + return [ + { + name: 'quark-empty-ua', + headers: { + ...passthroughHeaders, + ...getQuarkPlayHeaders(cookie), + }, + }, + { + name: 'quark-api-ua', + headers: { + ...passthroughHeaders, + cookie, + referer: 'https://pan.quark.cn/', + 'user-agent': QUARK_PC_UA, + }, + }, + { + name: 'quark-no-ua', + headers: { + ...passthroughHeaders, + cookie, + referer: 'https://pan.quark.cn/', + }, + }, + { + name: 'browser-origin', + headers: { + ...passthroughHeaders, + cookie, + origin: 'https://pan.quark.cn', + referer: 'https://pan.quark.cn/', + 'user-agent': + request.headers.get('user-agent') || + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36', + }, + }, + ]; +} + +function parseRequestedRange(range: string | null): { start: number; end?: number } | null { + if (!range) return null; + const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim()); + if (!match) return null; + return { + start: Number(match[1]), + end: match[2] ? Number(match[2]) : undefined, + }; +} + +function buildRangeHeaders(start: number, end: number, total: number) { + return { + 'Content-Range': `bytes ${start}-${end}/${total}`, + 'Content-Length': String(end - start + 1), + }; +} + export async function GET(request: NextRequest) { try { const authInfo = getAuthInfoFromCookie(request); @@ -27,7 +164,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '无效的 episodeIndex' }, { status: 400 }); } - const { session, cookie, savePath } = await resolveQuarkSession(id); + const { session, cookie, savePath, playMode, multiThreadPlayback } = await resolveQuarkSession(id); const file = session.files[episodeIndex]; if (!file) { return NextResponse.json({ error: '播放文件不存在' }, { status: 404 }); @@ -45,6 +182,8 @@ export async function GET(request: NextRequest) { shareId: session.shareId, shareToken: session.shareToken, fileId: file.fid, + fileName: file.name, + size: file.size, shareFileToken: file.shareFidToken, playFolderFid: session.playFolderFid, }); @@ -52,89 +191,94 @@ export async function GET(request: NextRequest) { } refreshQuarkNetdiskSession(id); - const playUrls = await getQuarkPlayUrls(cookie, savedFileId); + session.playUrlCaches = session.playUrlCaches || {}; + const playUrlCacheKey = `${savedFileId}:${playMode}`; + const cachedPlayUrls = session.playUrlCaches[playUrlCacheKey]; + const playUrls = + cachedPlayUrls && cachedPlayUrls.expiresAt > Date.now() + ? cachedPlayUrls.urls + : await getQuarkPlayUrls(cookie, savedFileId, playMode); + if (!cachedPlayUrls || cachedPlayUrls.expiresAt <= Date.now()) { + session.playUrlCaches[playUrlCacheKey] = { + urls: playUrls, + expiresAt: Date.now() + 5 * 60 * 1000, + }; + } const selected = playUrls.find((item) => item.name === quality) || playUrls[0]; - if (!selected) { + const candidates = selected + ? [ + selected, + ...playUrls.filter((item) => item.url !== selected.url), + ] + : []; + if (candidates.length === 0) { return NextResponse.json({ error: '未获取到夸克播放地址' }, { status: 500 }); } const range = request.headers.get('range'); - const abortController = new AbortController(); - const timeoutId = setTimeout(() => abortController.abort(), 300000); + let lastStatus = 500; - try { - const upstream = await fetch(selected.url, { - headers: { - ...getQuarkPlayHeaders(cookie), - ...(range ? { Range: range } : {}), - }, - cache: 'no-store', - signal: abortController.signal, - }); - - clearTimeout(timeoutId); - - if (!upstream.ok || !upstream.body) { - return NextResponse.json( - { error: `夸克视频代理失败 (${upstream.status})` }, - { status: upstream.status || 500 } - ); - } - - const responseHeaders = new Headers(); - const copyHeaders = ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']; - copyHeaders.forEach((name) => { - const value = upstream.headers.get(name); - if (value) responseHeaders.set(name, value); - }); - responseHeaders.set('Cache-Control', 'private, no-store'); - - const { readable, writable } = new TransformStream(); - const reader = upstream.body.getReader(); - - void (async () => { - const writer = writable.getWriter(); + for (const candidate of candidates) { + for (const profile of buildHeaderProfiles(request, cookie)) { try { - let streamDone = false; - while (!streamDone) { - const { done, value } = await reader.read(); - if (done) { - streamDone = true; - } else { - await writer.write(value); + const requestedRange = parseRequestedRange(range); + const probeRange = + multiThreadPlayback && requestedRange + ? `bytes=${requestedRange.start}-${requestedRange.start}` + : range || undefined; + const probed = await probeQuarkPlayRange(candidate.url, profile.headers, probeRange); + if (probed?.response.body) { + if (multiThreadPlayback && requestedRange && probed.window) { + const trunkEnd = Math.min( + requestedRange.end ?? requestedRange.start + 8 * 1024 * 1024 - 1, + requestedRange.start + 8 * 1024 * 1024 - 1, + probed.window.total - 1 + ); + const window = { + start: requestedRange.start, + end: trunkEnd, + total: probed.window.total, + }; + const responseHeaders = new Headers(); + const copyHeaders = ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']; + copyHeaders.forEach((name) => { + const value = probed.response.headers.get(name); + if (value) responseHeaders.set(name, value); + }); + responseHeaders.set('Cache-Control', 'private, no-store'); + const rangeHeaders = buildRangeHeaders(window.start, window.end, window.total); + responseHeaders.set('Content-Range', rangeHeaders['Content-Range']); + responseHeaders.set('Content-Length', rangeHeaders['Content-Length']); + try { + await probed.response.body.cancel(); + } catch { + void 0; + } + return createQuarkMultiThreadStream({ + url: candidate.url, + headers: profile.headers, + window, + contentHeaders: responseHeaders, + status: 206, + }); } + + return pipeUpstream(probed.response, range); } - } catch { - try { - await reader.cancel(); - } catch { - void 0; - } - } finally { - try { - reader.releaseLock(); - } catch { - void 0; - } - try { - await writer.close(); - } catch { - void 0; + + lastStatus = probed?.response.status || lastStatus; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return NextResponse.json({ error: '夸克网盘代理超时' }, { status: 504 }); } } - })(); - - return new Response(readable, { - status: range && upstream.headers.get('content-range') ? 206 : upstream.status, - headers: responseHeaders, - }); - } catch (error) { - clearTimeout(timeoutId); - if (error instanceof Error && error.name === 'AbortError') { - return NextResponse.json({ error: '夸克网盘代理超时' }, { status: 504 }); } - throw error; } + + return NextResponse.json( + { error: `夸克视频代理失败 (${lastStatus})` }, + { status: lastStatus } + ); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : '夸克网盘代理失败' }, diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 4318b03..e9198e9 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -6,6 +6,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { getDetailFromApiV2 } from '@/lib/downstream'; import { getProxyToken } from '@/lib/emby-token'; +import { hasFeaturePermission } from '@/lib/permissions'; import { createBaiduNetdiskSession, getBaiduNetdiskSession, @@ -45,6 +46,7 @@ import { NETDISK_QUARK_SOURCE, NETDISK_TIANYI_SOURCE, NETDISK_UC_SOURCE, + isNetdiskSource, normalizeNetdiskSource, } from '@/lib/netdisk/source'; import { @@ -68,17 +70,19 @@ import { export const runtime = 'nodejs'; -function formatNetdiskEpisodeTitle(parsed: { - season?: number; - episode?: number; -}, fallback: string) { +function formatNetdiskEpisodeTitle( + parsed: { + season?: number; + episode?: number; + }, + fallback: string +) { if (parsed.season && parsed.episode) { const season = String(Math.trunc(parsed.season)).padStart(2, '0'); const episodeValue = parsed.episode; - const episode = - Number.isInteger(episodeValue) - ? String(Math.trunc(episodeValue)).padStart(2, '0') - : String(episodeValue); + const episode = Number.isInteger(episodeValue) + ? String(Math.trunc(episodeValue)).padStart(2, '0') + : String(episodeValue); return `S${season}E${episode}`; } @@ -112,6 +116,19 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + if (isNetdiskSource(sourceCode)) { + const allowed = await hasFeaturePermission( + authInfo.username, + 'netdisk_temp_play' + ); + if (!allowed) { + return NextResponse.json( + { error: '无权限使用临时播放' }, + { status: 403 } + ); + } + } + const parsedScriptSource = parseScriptSourceValue(sourceCode); if (parsedScriptSource) { try { @@ -121,11 +138,12 @@ export async function GET(request: NextRequest) { payload: {}, }); const sources = normalizeScriptSources(sourcesExecution.result); - const sourceInfo = - sources.find((item) => item.id === parsedScriptSource.sourceId) || { - id: parsedScriptSource.sourceId, - name: parsedScriptSource.sourceId, - }; + const sourceInfo = sources.find( + (item) => item.id === parsedScriptSource.sourceId + ) || { + id: parsedScriptSource.sourceId, + name: parsedScriptSource.sourceId, + }; const detailExecution = await executeSavedSourceScript({ key: parsedScriptSource.scriptKey, @@ -161,7 +179,10 @@ export async function GET(request: NextRequest) { const config = await getConfig(); // 检查是否有启用的 Emby 源 - if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { + if ( + !config.EmbyConfig?.Sources || + config.EmbyConfig.Sources.length === 0 + ) { throw new Error('Emby 未配置或未启用'); } @@ -174,13 +195,15 @@ export async function GET(request: NextRequest) { // 使用 EmbyManager 获取客户端和配置 const { embyManager } = await import('@/lib/emby-manager'); const sources = await embyManager.getEnabledSources(); - const sourceConfig = sources.find(s => s.key === embyKey); + const sourceConfig = sources.find((s) => s.key === embyKey); const sourceName = sourceConfig?.name || 'Emby'; const client = await embyManager.getClient(embyKey); // 获取代理 token(如果启用了代理) - const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null; + const proxyToken = client.isProxyEnabled() + ? await getProxyToken(request) + : null; // 获取媒体详情 const item = await client.getItem(id); @@ -195,7 +218,12 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), + poster: client.getImageUrl( + item.Id, + 'Primary', + undefined, + proxyToken || undefined + ), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', @@ -229,15 +257,24 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), + poster: client.getImageUrl( + item.Id, + 'Primary', + undefined, + proxyToken || undefined + ), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', - episodes: await Promise.all(allEpisodes.map((ep) => client.getStreamUrl(ep.Id))), + episodes: await Promise.all( + allEpisodes.map((ep) => client.getStreamUrl(ep.Id)) + ), episodes_titles: allEpisodes.map((ep) => { const seasonNum = ep.ParentIndexNumber || 1; const episodeNum = ep.IndexNumber || 1; - return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; + return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum + .toString() + .padStart(2, '0')}`; }), subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)), proxyMode: false, @@ -261,16 +298,14 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const xiaoyaConfig = config.XiaoyaConfig; - if ( - !xiaoyaConfig || - !xiaoyaConfig.Enabled || - !xiaoyaConfig.ServerURL - ) { + if (!xiaoyaConfig || !xiaoyaConfig.Enabled || !xiaoyaConfig.ServerURL) { throw new Error('小雅未配置或未启用'); } const { XiaoyaClient } = await import('@/lib/xiaoya.client'); - const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import('@/lib/xiaoya-metadata'); + const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import( + '@/lib/xiaoya-metadata' + ); const { base58Decode, base58Encode } = await import('@/lib/utils'); const client = new XiaoyaClient( @@ -299,7 +334,9 @@ export async function GET(request: NextRequest) { let clickedFilePath: string | undefined; if (fileName) { // 拼接目录路径和文件名 - clickedFilePath = `${decodedDirPath}${decodedDirPath.endsWith('/') ? '' : '/'}${fileName}`; + clickedFilePath = `${decodedDirPath}${ + decodedDirPath.endsWith('/') ? '' : '/' + }${fileName}`; console.log('[xiaoya] 用户点击的文件路径:', clickedFilePath); } @@ -319,7 +356,9 @@ export async function GET(request: NextRequest) { // 如果有点击的文件路径,找到对应的集数索引 let clickedFileIndex = -1; if (clickedFilePath) { - clickedFileIndex = episodes.findIndex(ep => ep.path === clickedFilePath); + clickedFileIndex = episodes.findIndex( + (ep) => ep.path === clickedFilePath + ); console.log('[xiaoya] 文件在集数列表中的索引:', clickedFileIndex); } @@ -332,12 +371,16 @@ export async function GET(request: NextRequest) { year: metadata.year || '', douban_id: 0, desc: metadata.plot || '', - episodes: episodes.map(ep => `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}`), - episodes_titles: episodes.map(ep => ep.title), + episodes: episodes.map( + (ep) => + `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}` + ), + episodes_titles: episodes.map((ep) => ep.title), subtitles: [], proxyMode: false, // 返回用户点击的文件索引(如果找到的话) - initialEpisodeIndex: clickedFileIndex >= 0 ? clickedFileIndex : undefined, + initialEpisodeIndex: + clickedFileIndex >= 0 ? clickedFileIndex : undefined, // 返回元数据来源 metadataSource: metadata.source, }; @@ -360,11 +403,17 @@ export async function GET(request: NextRequest) { throw new Error('移动云盘未配置或未启用'); } - let session = refreshMobileNetdiskSession(id) || getMobileNetdiskSession(id); + let session = + refreshMobileNetdiskSession(id) || getMobileNetdiskSession(id); if (!session) { const payload = parseMobileNetdiskId(id); - const { listMobileShareVideos } = await import('@/lib/netdisk/mobile.client'); - const result = await listMobileShareVideos(payload.shareUrl, mobileConfig.Authorization); + const { listMobileShareVideos } = await import( + '@/lib/netdisk/mobile.client' + ); + const result = await listMobileShareVideos( + payload.shareUrl, + mobileConfig.Authorization + ); session = createMobileNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -377,8 +426,9 @@ export async function GET(request: NextRequest) { } const mobileSession = session; const { parseVideoFileName } = await import('@/lib/video-parser'); - const parsedFiles = mobileSession.files.map((file, index) => { - const parsed = parseVideoFileName(file.name); + const parsedFiles = mobileSession.files + .map((file, index) => { + const parsed = parseVideoFileName(file.name); return { ...file, originalIndex: index, @@ -386,20 +436,24 @@ export async function GET(request: NextRequest) { isOVA: parsed.isOVA, displayTitle: formatNetdiskEpisodeTitle(parsed, file.name), }; - }).sort((a, b) => { - if (a.isOVA && !b.isOVA) return 1; - if (!a.isOVA && b.isOVA) return -1; - return a.sortEpisode !== b.sortEpisode - ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { - numeric: true, - sensitivity: 'base', - }); - }); + }) + .sort((a, b) => { + if (a.isOVA && !b.isOVA) return 1; + if (!a.isOVA && b.isOVA) return -1; + return a.sortEpisode !== b.sortEpisode + ? a.sortEpisode - b.sortEpisode + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); + }); - const episodes = parsedFiles.map((file) => ( - `/api/netdisk/mobile/play?id=${encodeURIComponent(mobileSession.id)}&episodeIndex=${file.originalIndex}` - )); + const episodes = parsedFiles.map( + (file) => + `/api/netdisk/mobile/play?id=${encodeURIComponent( + mobileSession.id + )}&episodeIndex=${file.originalIndex}` + ); return NextResponse.json({ source: NETDISK_MOBILE_SOURCE, @@ -430,11 +484,18 @@ export async function GET(request: NextRequest) { throw new Error('百度网盘未配置或未启用'); } - let session = refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id); + let session = + refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id); if (!session) { const payload = parseBaiduNetdiskId(id); - const { listBaiduShareVideos } = await import('@/lib/netdisk/baidu.client'); - const result = await listBaiduShareVideos(payload.shareUrl, baiduConfig.Cookie, payload.passcode || ''); + const { listBaiduShareVideos } = await import( + '@/lib/netdisk/baidu.client' + ); + const result = await listBaiduShareVideos( + payload.shareUrl, + baiduConfig.Cookie, + payload.passcode || '' + ); session = createBaiduNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -465,7 +526,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -477,9 +541,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `百度网盘分享:${baiduSession.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/baidu/play?id=${encodeURIComponent(baiduSession.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/baidu/play?id=${encodeURIComponent( + baiduSession.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -495,14 +562,21 @@ export async function GET(request: NextRequest) { try { const config = await getConfig(); const tianyiConfig = config.NetDiskConfig?.Tianyi; - if (!tianyiConfig?.Enabled || !tianyiConfig.Account || !tianyiConfig.Password) { + if ( + !tianyiConfig?.Enabled || + !tianyiConfig.Account || + !tianyiConfig.Password + ) { throw new Error('天翼云盘未配置或未启用'); } - let session = refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id); + let session = + refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id); if (!session) { const payload = parseTianyiNetdiskId(id); - const { listTianyiShareVideos } = await import('@/lib/netdisk/tianyi.client'); + const { listTianyiShareVideos } = await import( + '@/lib/netdisk/tianyi.client' + ); const result = await listTianyiShareVideos( payload.shareUrl, tianyiConfig.Account, @@ -542,7 +616,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -554,9 +631,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `天翼云盘分享:${tianyiSession.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/tianyi/play?id=${encodeURIComponent(tianyiSession.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/tianyi/play?id=${encodeURIComponent( + tianyiSession.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -572,15 +652,25 @@ export async function GET(request: NextRequest) { try { const config = await getConfig(); const pan123Config = config.NetDiskConfig?.Pan123; - if (!pan123Config?.Enabled || !pan123Config.Account || !pan123Config.Password) { + if ( + !pan123Config?.Enabled || + !pan123Config.Account || + !pan123Config.Password + ) { throw new Error('123网盘未配置或未启用'); } - let session = refreshPan123NetdiskSession(id) || getPan123NetdiskSession(id); + let session = + refreshPan123NetdiskSession(id) || getPan123NetdiskSession(id); if (!session) { const payload = parsePan123NetdiskId(id); - const { listPan123ShareVideos } = await import('@/lib/netdisk/pan123.client'); - const result = await listPan123ShareVideos(payload.shareUrl, payload.passcode || ''); + const { listPan123ShareVideos } = await import( + '@/lib/netdisk/pan123.client' + ); + const result = await listPan123ShareVideos( + payload.shareUrl, + payload.passcode || '' + ); session = createPan123NetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -610,7 +700,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.fileName.localeCompare(b.fileName, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.fileName.localeCompare(b.fileName, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -622,9 +715,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `123网盘分享:${pan123Session.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/123/play?id=${encodeURIComponent(pan123Session.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/123/play?id=${encodeURIComponent( + pan123Session.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -645,11 +741,17 @@ export async function GET(request: NextRequest) { throw new Error('115网盘未配置或未启用'); } - let session = refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id); + let session = + refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id); if (!session) { const payload = parsePan115NetdiskId(id); - const { listPan115ShareVideos } = await import('@/lib/netdisk/pan115.client'); - const result = await listPan115ShareVideos(payload.shareUrl, payload.passcode || ''); + const { listPan115ShareVideos } = await import( + '@/lib/netdisk/pan115.client' + ); + const result = await listPan115ShareVideos( + payload.shareUrl, + payload.passcode || '' + ); session = createPan115NetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -679,7 +781,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -691,9 +796,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `115网盘分享:${pan115Session.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/115/play?id=${encodeURIComponent(pan115Session.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/115/play?id=${encodeURIComponent( + pan115Session.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -705,7 +813,10 @@ export async function GET(request: NextRequest) { } } - if (sourceCode === NETDISK_QUARK_SOURCE || sourceCode === LEGACY_QUARK_TEMP_SOURCE) { + if ( + sourceCode === NETDISK_QUARK_SOURCE || + sourceCode === LEGACY_QUARK_TEMP_SOURCE + ) { try { const config = await getConfig(); const quarkConfig = config.NetDiskConfig?.Quark; @@ -714,11 +825,18 @@ export async function GET(request: NextRequest) { } const { parseVideoFileName } = await import('@/lib/video-parser'); - let session = refreshQuarkNetdiskSession(id) || getQuarkNetdiskSession(id); + let session = + refreshQuarkNetdiskSession(id) || getQuarkNetdiskSession(id); if (!session) { const payload = parseQuarkNetdiskId(id); - const { listQuarkShareVideos } = await import('@/lib/netdisk/quark.client'); - const result = await listQuarkShareVideos(payload.shareUrl, quarkConfig.Cookie, payload.passcode || ''); + const { listQuarkShareVideos } = await import( + '@/lib/netdisk/quark.client' + ); + const result = await listQuarkShareVideos( + payload.shareUrl, + quarkConfig.Cookie, + payload.passcode || '' + ); session = createQuarkNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -761,9 +879,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `夸克网盘分享:${quarkSession.shareUrl}`, - episodes: episodes.map((ep) => ( - `/api/netdisk/quark/play?id=${encodeURIComponent(quarkSession.id)}&episodeIndex=${ep.originalIndex}` - )), + episodes: episodes.map( + (ep) => + `/api/netdisk/quark/play?id=${encodeURIComponent( + quarkSession.id + )}&episodeIndex=${ep.originalIndex}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, }); @@ -788,7 +909,11 @@ export async function GET(request: NextRequest) { if (!session) { const payload = parseUCNetdiskId(id); const { listUCShareVideos } = await import('@/lib/netdisk/uc.client'); - const result = await listUCShareVideos(payload.shareUrl, ucConfig.Cookie, payload.passcode || ''); + const result = await listUCShareVideos( + payload.shareUrl, + ucConfig.Cookie, + payload.passcode || '' + ); session = createUCNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -831,9 +956,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `UC网盘分享:${ucSession.shareUrl}`, - episodes: episodes.map((ep) => ( - `/api/netdisk/uc/play?id=${encodeURIComponent(ucSession.id)}&episodeIndex=${ep.originalIndex}` - )), + episodes: episodes.map( + (ep) => + `/api/netdisk/uc/play?id=${encodeURIComponent( + ucSession.id + )}&episodeIndex=${ep.originalIndex}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, }); @@ -867,7 +995,9 @@ export async function GET(request: NextRequest) { let metaInfo: any = null; let folderMeta: any = null; try { - const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getCachedMetaInfo, setCachedMetaInfo } = await import( + '@/lib/openlist-cache' + ); const { db } = await import('@/lib/db'); metaInfo = getCachedMetaInfo(); @@ -891,11 +1021,15 @@ export async function GET(request: NextRequest) { // 使用 folderName 构建实际路径 const folderName = folderMeta.folderName; - const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + const folderPath = `${rootPath}${ + rootPath.endsWith('/') ? '' : '/' + }${folderName}`; // 2. 直接调用 OpenList 客户端获取视频列表 const { OpenListClient } = await import('@/lib/openlist.client'); - const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache'); + const { getCachedVideoInfo, setCachedVideoInfo } = await import( + '@/lib/openlist-cache' + ); const { parseVideoFileName } = await import('@/lib/video-parser'); const client = new OpenListClient( @@ -914,7 +1048,11 @@ export async function GET(request: NextRequest) { let hasMore = true; while (hasMore) { - const listResponse = await client.listDirectory(folderPath, currentPage, pageSize); + const listResponse = await client.listDirectory( + folderPath, + currentPage, + pageSize + ); if (listResponse.code !== 200) { throw new Error('OpenList 列表获取失败4'); @@ -927,10 +1065,35 @@ export async function GET(request: NextRequest) { currentPage++; } - const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob']; + const videoExtensions = [ + '.mp4', + '.mkv', + '.avi', + '.m3u8', + '.flv', + '.ts', + '.mov', + '.wmv', + '.webm', + '.rmvb', + '.rm', + '.mpg', + '.mpeg', + '.3gp', + '.f4v', + '.m4v', + '.vob', + ]; const videoFiles = allFiles.filter((item) => { - if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false; - return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)); + if ( + item.is_dir || + item.name.startsWith('.') || + item.name.endsWith('.json') + ) + return false; + return videoExtensions.some((ext) => + item.name.toLowerCase().endsWith(ext) + ); }); if (!videoInfo) { @@ -940,7 +1103,7 @@ export async function GET(request: NextRequest) { const file = videoFiles[i]; const parsed = parseVideoFileName(file.name); videoInfo.episodes[file.name] = { - episode: parsed.episode || (i + 1), + episode: parsed.episode || i + 1, season: parsed.season, title: parsed.title, parsed_from: 'filename', @@ -955,25 +1118,46 @@ export async function GET(request: NextRequest) { const parsed = parseVideoFileName(file.name); let episodeInfo; if (parsed.episode) { - episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA }; + episodeInfo = { + episode: parsed.episode, + season: parsed.season, + title: parsed.title, + parsed_from: 'filename', + isOVA: parsed.isOVA, + }; } else { - episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' }; + episodeInfo = videoInfo!.episodes[file.name] || { + episode: index + 1, + season: undefined, + title: undefined, + parsed_from: 'filename', + }; } let displayTitle = episodeInfo.title; if (!displayTitle && episodeInfo.episode) { - displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`; + displayTitle = episodeInfo.isOVA + ? `OVA ${episodeInfo.episode}` + : `第${episodeInfo.episode}集`; } if (!displayTitle) { displayTitle = file.name; } - return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA }; + return { + fileName: file.name, + episode: episodeInfo.episode || 0, + season: episodeInfo.season, + title: displayTitle, + isOVA: episodeInfo.isOVA, + }; }) .sort((a, b) => { // OVA 排在最后 if (a.isOVA && !b.isOVA) return 1; if (!a.isOVA && b.isOVA) return -1; // 都是 OVA 或都不是 OVA,按集数排序 - return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName); + return a.episode !== b.episode + ? a.episode - b.episode + : a.fileName.localeCompare(b.fileName); }); // 3. 从 metainfo 中获取元数据 @@ -984,11 +1168,20 @@ export async function GET(request: NextRequest) { source_name: '私人影库', id: id, title: folderMeta?.title || folderName, - poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '', - year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '', + poster: folderMeta?.poster_path + ? getTMDBImageUrl(folderMeta.poster_path) + : '', + year: folderMeta?.release_date + ? folderMeta.release_date.split('-')[0] + : '', douban_id: 0, desc: folderMeta?.overview || '', - episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`), + episodes: episodes.map( + (ep) => + `/api/openlist/play?folder=${encodeURIComponent( + folderName + )}&fileName=${encodeURIComponent(ep.fileName)}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, // openlist 源不使用代理模式 }; diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx index b1c9faa..6f06ea5 100644 --- a/src/app/books/catalog/page.tsx +++ b/src/app/books/catalog/page.tsx @@ -72,9 +72,12 @@ export default function BooksCatalogPage() { const [error, setError] = useState(''); const [loadingMore, setLoadingMore] = useState(false); const loaderRef = useRef(null); + const sourceScrollerRef = useRef(null); const navScrollerRef = useRef(null); const loadedPageHrefsRef = useRef>(new Set()); const failedPageHrefsRef = useRef>(new Set()); + const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); + const suppressSourceClickRef = useRef(false); const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); const suppressNavClickRef = useRef(false); @@ -157,6 +160,64 @@ export default function BooksCatalogPage() { return () => observer.disconnect(); }, [data, nextHref, loadingMore, loadCatalog]); + const handleSourcePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + const node = sourceScrollerRef.current; + if (!node) return; + sourceDragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: node.scrollLeft, + moved: false, + pointerType: event.pointerType, + }; + suppressSourceClickRef.current = false; + if (event.pointerType !== 'mouse') { + node.setPointerCapture?.(event.pointerId); + } + }, []); + + const handleSourcePointerMove = useCallback((event: ReactPointerEvent) => { + const node = sourceScrollerRef.current; + const dragState = sourceDragStateRef.current; + if (!node || !dragState || dragState.pointerId !== event.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; + if (Math.abs(deltaX) > moveThreshold) { + dragState.moved = true; + suppressSourceClickRef.current = true; + } + node.scrollLeft = dragState.startScrollLeft - deltaX; + }, []); + + const handleSourcePointerUp = useCallback((event: ReactPointerEvent) => { + const node = sourceScrollerRef.current; + const dragState = sourceDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + if (dragState.moved) { + event.preventDefault(); + window.setTimeout(() => { + suppressSourceClickRef.current = false; + }, 0); + } + sourceDragStateRef.current = null; + if (dragState.pointerType !== 'mouse') { + node?.releasePointerCapture?.(event.pointerId); + } + }, []); + + const handleSourcePointerLeave = useCallback((event: ReactPointerEvent) => { + if (event.pointerType === 'mouse') return; + handleSourcePointerUp(event); + }, [handleSourcePointerUp]); + + const handleSourceWheel = useCallback((event: ReactWheelEvent) => { + const node = sourceScrollerRef.current; + if (!node) return; + const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (!delta) return; + node.scrollLeft += delta; + }, []); const handleNavPointerDown = useCallback((event: ReactPointerEvent) => { if (event.pointerType === 'mouse' && event.button !== 0) return; @@ -235,9 +296,30 @@ export default function BooksCatalogPage() { return (
-
+
{sources.map((source) => ( - + event.preventDefault()} + onClick={(event) => { + if (suppressSourceClickRef.current) { + event.preventDefault(); + suppressSourceClickRef.current = false; + } + }} + className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`} + > {source.name} ))} diff --git a/src/app/books/detail/page.tsx b/src/app/books/detail/page.tsx index aabb25d..70352fe 100644 --- a/src/app/books/detail/page.tsx +++ b/src/app/books/detail/page.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react'; import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client'; -import { BookDetail, BookShelfItem } from '@/lib/book.types'; +import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types'; function DetailSkeleton() { return ( @@ -47,7 +47,7 @@ function sanitizeFilename(name: string) { return name.replace(/[\/:*?"<>|]/g, '_').trim(); } -async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string, title?: string) { +async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf' | 'chapters', download = false, href?: string, title?: string) { const response = await fetch('/api/books/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -86,6 +86,9 @@ export default function BookDetailPage() { const bookId = searchParams.get('bookId') || ''; const [detail, setDetail] = useState(null); const [shelf, setShelf] = useState>({}); + const [chapters, setChapters] = useState([]); + const [chaptersLoading, setChaptersLoading] = useState(false); + const [chaptersError, setChaptersError] = useState(''); const [error, setError] = useState(''); const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>(''); @@ -122,8 +125,50 @@ export default function BookDetailPage() { .catch((err) => setError(err.message || '获取详情失败')); }, [sourceId, bookId, cached]); - const readable = detail?.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf')); - const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub'; + const readable = detail?.acquisitionLinks.find((item) => { + const type = item.type.toLowerCase(); + return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters'; + }); + const readableFormat = readable?.type.toLowerCase().includes('pdf') + ? 'pdf' + : readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters' + ? 'chapters' + : 'epub'; + + useEffect(() => { + if (!detail || !readable || readableFormat !== 'chapters') { + setChapters([]); + setChaptersError(''); + setChaptersLoading(false); + return; + } + let cancelled = false; + setChapters([]); + setChaptersLoading(true); + setChaptersError(''); + const params = new URLSearchParams({ + sourceId: detail.sourceId, + bookId: detail.id, + }); + fetch(`/api/books/read/chapters?${params.toString()}`, { cache: 'no-store' }) + .then(async (res) => { + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取章节失败'); + if (cancelled) return; + setChapters((json.chapters || []) as BookChapter[]); + }) + .catch((err) => { + if (cancelled) return; + setChapters([]); + setChaptersError(err.message || '获取章节失败'); + }) + .finally(() => { + if (!cancelled) setChaptersLoading(false); + }); + return () => { + cancelled = true; + }; + }, [detail, readable, readableFormat]); const toggleShelf = async () => { if (!detail) return; @@ -176,7 +221,7 @@ export default function BookDetailPage() {
{readable ? cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读 : null} - {readable ? : null} + {readable && readableFormat !== 'chapters' ? : null}
@@ -184,7 +229,8 @@ export default function BookDetailPage() {

可用格式

{detail.acquisitionLinks.map((item) => { - const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined; + const type = item.type.toLowerCase(); + const format = type.includes('pdf') ? 'pdf' : type.includes('epub') ? 'epub' : type.includes('legado-chapters') || item.rel === 'legado:chapters' ? 'chapters' : undefined; return (
@@ -193,7 +239,7 @@ export default function BookDetailPage() {
+ {readableFormat === 'chapters' ? ( +
+
+

章节目录

+
{chaptersLoading ? '加载中...' : `${chapters.length} 章`}
+
+ {chaptersError ?
{chaptersError}
: null} + {!chaptersLoading && !chaptersError && chapters.length === 0 ? ( +
+ 源站当前没有返回章节,这不是 EPUB 文件缺失;请换有章节的搜索结果。 +
+ ) : null} + {chapters.length > 0 ? ( +
+ {chapters.slice(0, 60).map((chapter) => ( + cacheBookDetail(detail)} + className='truncate rounded-2xl bg-gray-50 px-4 py-3 text-sm hover:bg-sky-50 hover:text-sky-600 dark:bg-gray-900 dark:hover:bg-sky-950/40' + title={chapter.title} + > + {chapter.title} + + ))} +
+ ) : null} + {chapters.length > 60 ?
仅预览前 60 章,完整目录请进入阅读页侧边栏查看。
: null} +
+ ) : null}
); } diff --git a/src/app/books/page.tsx b/src/app/books/page.tsx index 151fae2..53084cd 100644 --- a/src/app/books/page.tsx +++ b/src/app/books/page.tsx @@ -45,8 +45,7 @@ export default function BooksHomePage() { return (
-

OPDS 电子书源

-

支持分类浏览、搜索、书架与 EPUB 在线阅读。

+

电子书源

{loading ? : null} @@ -56,6 +55,7 @@ export default function BooksHomePage() { {sources.map((source) => (
{source.name}
+
{source.type === 'legado' ? 'Legado' : 'OPDS'}
分类{source.capabilities?.catalogSupported ? '可用' : '不可用'} 搜索{source.capabilities?.searchSupported ? '可用' : '不可用'} diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index adee9ea..7f39aef 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { saveBookReadRecord } from '@/lib/book.db.client'; -import { BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types'; +import { BookChapter, BookChapterContent, BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types'; import { buildBookCacheKey, enforceBookCacheLimit, @@ -129,6 +129,7 @@ const DEFAULT_TTS_SETTINGS: TtsSettings = { autoPlayNext: true, }; const SAVE_INTERVAL_MS = 10000; +const CHAPTER_SAVE_INTERVAL_MS = 20000; const TTS_RATE_STEPS = [-20, -10, 0, 10, 20, 35]; const TTS_PITCH_STEPS = [-10, 0, 10, 20]; const TTS_VOLUME_STEPS = [-10, 0, 10, 20]; @@ -310,6 +311,21 @@ function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, curre return ratio * maxCurrent; } +function encodeChapterScrollLocator(href: string, scrollTop: number, scrollHeight: number, clientHeight: number) { + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const ratio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; + return `${href}#scroll=${ratio.toFixed(6)}`; +} + +function parseChapterScrollLocator(value?: string) { + if (!value) return null; + const match = value.match(/(?:^|[#&?])scroll=([0-9.]+)/); + if (!match) return null; + const ratio = Number(match[1]); + if (!Number.isFinite(ratio)) return null; + return Math.max(0, Math.min(1, ratio)); +} + function flattenToc(items: TocItem[]): TocItem[] { return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]); } @@ -327,6 +343,24 @@ function findTocLabelByHref(items: TocItem[], currentHref: string): string { return ''; } +async function fetchJsonWithRetry(url: string, init?: RequestInit, retries = 2): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const res = await fetch(url, init); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || `请求失败: ${res.status}`); + return json as T; + } catch (error) { + lastError = error; + if (attempt < retries) { + await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))); + } + } + } + throw lastError instanceof Error ? lastError : new Error('请求失败'); +} + async function downloadBookWithProgress( manifest: Pick, onProgress: (received: number, total: number | null) => void @@ -370,6 +404,542 @@ async function downloadBookWithProgress( return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' }); } +function ChapterReader({ manifest }: { manifest: BookReadManifest }) { + const searchParams = useSearchParams(); + const initialChapterHref = searchParams.get('chapterHref') || ''; + const [chapters, setChapters] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [chapter, setChapter] = useState(null); + const [tocOpen, setTocOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const [chaptersLoaded, setChaptersLoaded] = useState(false); + const [error, setError] = useState(''); + const [ttsVoices, setTtsVoices] = useState([]); + const [ttsAvailable, setTtsAvailable] = useState(false); + const [ttsSettings, setTtsSettings] = useState(DEFAULT_TTS_SETTINGS); + const [ttsStatus, setTtsStatus] = useState('idle'); + const [ttsError, setTtsError] = useState(''); + const [ttsChunks, setTtsChunks] = useState([]); + const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); + const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState(null); + const [ttsBarVisible, setTtsBarVisible] = useState(false); + const [ttsPanelOpen, setTtsPanelOpen] = useState(false); + const [ttsCurrentTime, setTtsCurrentTime] = useState(0); + const [ttsDuration, setTtsDuration] = useState(0); + const [ttsSeekValue, setTtsSeekValue] = useState(0); + const [ttsSeeking, setTtsSeeking] = useState(false); + const scrollRef = useRef(null); + const restoredChapterPositionRef = useRef(false); + const chapterSaveTimerRef = useRef(null); + const pendingChapterRestoreRatioRef = useRef(null); + const currentIndexRef = useRef(0); + const lastChapterSavedAtRef = useRef(0); + const lastChapterSavedLocatorValueRef = useRef(manifest.lastRecord?.locator?.value || ''); + const audioRef = useRef(null); + const ttsChunksRef = useRef([]); + const ttsCurrentChunkIndexRef = useRef(0); + const ttsStatusRef = useRef('idle'); + const ttsSettingsRef = useRef(DEFAULT_TTS_SETTINGS); + const ttsSeekingRef = useRef(false); + const ttsResumeTimeRef = useRef(0); + const currentChapterHref = chapters[currentIndex]?.href || ''; + const currentChapterTitle = chapters[currentIndex]?.title || chapter?.title || ''; + + useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); + + useEffect(() => { + setSettings(loadReaderSettings()); + setTtsSettings(loadTtsSettings()); + }, []); + + useEffect(() => { + if (typeof window !== 'undefined') localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + }, [settings]); + + useEffect(() => { + if (typeof window !== 'undefined') localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings)); + ttsSettingsRef.current = ttsSettings; + }, [ttsSettings]); + + useEffect(() => { ttsChunksRef.current = ttsChunks; }, [ttsChunks]); + useEffect(() => { ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; }, [ttsCurrentChunkIndex]); + useEffect(() => { ttsStatusRef.current = ttsStatus; }, [ttsStatus]); + useEffect(() => { ttsSeekingRef.current = ttsSeeking; if (!ttsSeeking) setTtsSeekValue(ttsCurrentTime); }, [ttsCurrentTime, ttsSeeking]); + + const stopTts = useCallback((clearQueue = false) => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + setTtsCurrentTime(0); + setTtsDuration(0); + setTtsSeekValue(0); + setTtsSeeking(false); + setTtsLoadingChunkIndex(null); + setTtsStatus('idle'); + if (clearQueue) { + setTtsChunks([]); + ttsChunksRef.current = []; + setTtsCurrentChunkIndex(0); + ttsCurrentChunkIndexRef.current = 0; + } + }, []); + + useEffect(() => { + const handleToggleChapters = () => { setTocOpen((prev) => !prev); setSettingsOpen(false); setTtsPanelOpen(false); }; + const handleToggleSettings = () => { setSettingsOpen((prev) => !prev); setTocOpen(false); setTtsPanelOpen(false); }; + const handleToggleTts = () => { setTtsBarVisible((prev) => !prev); setTocOpen(false); setSettingsOpen(false); }; + window.addEventListener('books-read-toggle-chapters', handleToggleChapters); + window.addEventListener('books-read-toggle-settings', handleToggleSettings); + window.addEventListener('books-read-toggle-tts', handleToggleTts); + return () => { + window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); + window.removeEventListener('books-read-toggle-settings', handleToggleSettings); + window.removeEventListener('books-read-toggle-tts', handleToggleTts); + }; + }, []); + + useEffect(() => { + let cancelled = false; + fetch('/api/books/tts/voices') + .then(async (res) => { + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '获取朗读配置失败'); + if (cancelled) return; + setTtsAvailable(true); + setTtsVoices(json.voices || []); + setTtsSettings((prev) => ({ ...prev, voice: prev.voice || json.defaults?.voice || '' })); + }) + .catch((err) => { + if (!cancelled) { + setTtsAvailable(false); + setTtsVoices([]); + setTtsError(err.message || '朗读能力不可用'); + } + }); + return () => { cancelled = true; }; + }, []); + + const buildChapterReadRecord = useCallback((item: BookChapter, index: number): BookReadRecord => { + const node = scrollRef.current; + const scrollTop = node?.scrollTop || 0; + const scrollHeight = node?.scrollHeight || 0; + const clientHeight = node?.clientHeight || 0; + const chapterCount = Math.max(1, chapters.length); + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const chapterRatio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; + const progressPercent = chapters.length > 0 + ? Math.max(0, Math.min(100, ((index + chapterRatio) / chapterCount) * 100)) + : 0; + + return { + sourceId: manifest.book.sourceId, + sourceName: manifest.book.sourceName, + bookId: manifest.book.id, + title: manifest.book.title, + author: manifest.book.author, + cover: manifest.book.cover, + detailHref: manifest.book.detailHref, + acquisitionHref: manifest.acquisitionHref, + format: 'chapters', + locator: { + type: 'chapter', + value: encodeChapterScrollLocator(item.href, scrollTop, scrollHeight, clientHeight), + href: item.href, + chapterTitle: item.title, + }, + chapterTitle: item.title, + chapterHref: item.href, + progressPercent, + saveTime: Date.now(), + }; + }, [chapters.length, manifest]); + + const persistChapterProgress = useCallback((index = currentIndexRef.current) => { + const item = chapters[index]; + if (!item) return; + const record = buildChapterReadRecord(item, index); + if (record.locator.value === lastChapterSavedLocatorValueRef.current) return; + lastChapterSavedLocatorValueRef.current = record.locator.value; + lastChapterSavedAtRef.current = Date.now(); + void saveBookReadRecord(record.sourceId, record.bookId, record); + }, [buildChapterReadRecord, chapters]); + + const scheduleChapterProgressSave = useCallback(() => { + if (chapterSaveTimerRef.current) return; + const elapsed = Date.now() - lastChapterSavedAtRef.current; + const delay = Math.max(0, CHAPTER_SAVE_INTERVAL_MS - elapsed); + chapterSaveTimerRef.current = window.setTimeout(() => { + chapterSaveTimerRef.current = null; + persistChapterProgress(); + }, delay); + }, [persistChapterProgress]); + + useEffect(() => { + if (!manifest.chaptersUrl && !manifest.book.id) return; + let cancelled = false; + setChapters([]); + setChaptersLoaded(false); + setChapter(null); + setCurrentIndex(0); + restoredChapterPositionRef.current = false; + pendingChapterRestoreRatioRef.current = null; + lastChapterSavedAtRef.current = 0; + lastChapterSavedLocatorValueRef.current = manifest.lastRecord?.locator?.value || ''; + setLoading(true); + setError(''); + const url = manifest.chaptersUrl || `/api/books/read/chapters?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`; + fetchJsonWithRetry<{ chapters?: BookChapter[] }>(url, { cache: 'no-store' }) + .then((json) => { + if (cancelled) return; + const list = (json.chapters || []) as BookChapter[]; + setChapters(list); + setChaptersLoaded(true); + const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value?.split('#scroll=')[0] || ''; + const savedIndex = list.findIndex((item) => item.href === savedHref); + setCurrentIndex(savedIndex >= 0 ? savedIndex : 0); + }) + .catch((err) => { if (!cancelled) { setError(err.message || '获取目录失败'); setChaptersLoaded(true); } }); + return () => { cancelled = true; }; + }, [initialChapterHref, manifest]); + + useEffect(() => { + const item = chapters[currentIndex]; + if (!item) { + if (chaptersLoaded && chapters.length === 0) setLoading(false); + return; + } + stopTts(true); + setLoading(true); + setError(''); + scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' }); + pendingChapterRestoreRatioRef.current = null; + const params = new URLSearchParams({ sourceId: manifest.book.sourceId, href: item.href }); + if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref); + fetchJsonWithRetry(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' }) + .then((json) => { + const shouldRestore = !restoredChapterPositionRef.current + && !initialChapterHref + && (manifest.lastRecord?.chapterHref === item.href || manifest.lastRecord?.locator?.href === item.href); + pendingChapterRestoreRatioRef.current = shouldRestore ? parseChapterScrollLocator(manifest.lastRecord?.locator?.value) : null; + setChapter({ ...(json as BookChapterContent), title: (json as BookChapterContent).title || item.title }); + }) + .catch((err) => setError(err.message || '获取章节失败')) + .finally(() => setLoading(false)); + }, [chapters, chaptersLoaded, currentIndex, initialChapterHref, manifest, persistChapterProgress, stopTts]); + + useEffect(() => { + window.dispatchEvent(new CustomEvent('books-read-update-header', { + detail: { + title: manifest.book.title, + subtitle: currentChapterTitle || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'), + backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, + }, + })); + }, [manifest, currentChapterTitle, settings.mode]); + + const goPrevChapter = useCallback(() => { + persistChapterProgress(); + setCurrentIndex((prev) => Math.max(0, prev - 1)); + }, [persistChapterProgress]); + const goNextChapter = useCallback(() => { + persistChapterProgress(); + setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1)); + }, [chapters.length, persistChapterProgress]); + + const turnPage = useCallback((direction: 1 | -1) => { + const node = scrollRef.current; + if (!node) return; + if (settings.mode === 'scrolled') return; + const delta = Math.max(240, node.clientHeight * 0.88) * direction; + const maxTop = Math.max(0, node.scrollHeight - node.clientHeight); + const nextTop = Math.max(0, Math.min(maxTop, node.scrollTop + delta)); + if (direction > 0 && node.scrollTop >= maxTop - 8) { + if (currentIndex < chapters.length - 1) goNextChapter(); + return; + } + if (direction < 0 && node.scrollTop <= 8) { + if (currentIndex > 0) goPrevChapter(); + return; + } + node.scrollTo({ top: nextTop, behavior: 'smooth' }); + }, [chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode]); + + const getChapterPlainText = useCallback(() => { + const html = chapter?.content || ''; + if (!html) return ''; + if (typeof document === 'undefined') return sanitizeTtsText(html.replace(/<[^>]*>/g, ' ')); + const div = document.createElement('div'); + div.innerHTML = html; + div.querySelectorAll('script,style,img').forEach((node) => node.remove()); + return sanitizeTtsText(div.innerText || div.textContent || ''); + }, [chapter]); + + const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => { + if (!manifest) throw new Error('书籍信息未准备好'); + const response = await fetch('/api/books/tts/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceId: manifest.book.sourceId, + bookId: manifest.book.id, + chapterHref, + text: chunk.text, + voice: ttsSettingsRef.current.voice, + rate: ttsSettingsRef.current.rate, + pitch: ttsSettingsRef.current.pitch, + volume: ttsSettingsRef.current.volume, + }), + }); + const json = await response.json(); + if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); + return URL.createObjectURL(decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg')); + }, [manifest]); + + const playTtsChunk = useCallback(async (index: number) => { + const chunks = ttsChunksRef.current; + const chunk = chunks[index]; + if (!chunk || !currentChapterHref) return; + try { + setTtsError(''); + setTtsLoadingChunkIndex(index); + setTtsStatus('loading'); + const url = await fetchTtsChunkAudioUrl(chunk, currentChapterHref); + if (!audioRef.current) audioRef.current = new Audio(); + audioRef.current.src = url; + await audioRef.current.play(); + ttsCurrentChunkIndexRef.current = index; + setTtsCurrentChunkIndex(index); + setTtsStatus('playing'); + setTtsLoadingChunkIndex(null); + } catch (err) { + setTtsStatus('error'); + setTtsLoadingChunkIndex(null); + setTtsError((err as Error).message || '朗读失败'); + } + }, [currentChapterHref, fetchTtsChunkAudioUrl]); + + const bootstrapTts = useCallback(async () => { + if (!ttsAvailable) return; + if (!currentChapterHref) { + setTtsError('当前章节尚未定位,稍后再试'); + setTtsStatus('error'); + return; + } + const text = getChapterPlainText(); + if (!text) { + setTtsError('当前章节没有可朗读文本'); + setTtsStatus('error'); + return; + } + const chunks = chunkTtsText(text, 1200); + setTtsChunks(chunks); + ttsChunksRef.current = chunks; + setTtsCurrentChunkIndex(0); + ttsCurrentChunkIndexRef.current = 0; + await playTtsChunk(0); + }, [currentChapterHref, getChapterPlainText, playTtsChunk, ttsAvailable]); + + const toggleTtsPlayback = useCallback(async () => { + if (!ttsAvailable) return; + if (ttsStatus === 'playing') { + audioRef.current?.pause(); + setTtsStatus('paused'); + return; + } + if (ttsStatus === 'paused' && audioRef.current) { + await audioRef.current.play(); + setTtsStatus('playing'); + return; + } + await bootstrapTts(); + }, [bootstrapTts, ttsAvailable, ttsStatus]); + + useEffect(() => { + if (loading || !chapter || restoredChapterPositionRef.current) return; + const savedRatio = pendingChapterRestoreRatioRef.current; + if (savedRatio === null) { + restoredChapterPositionRef.current = true; + persistChapterProgress(); + return; + } + + let cancelled = false; + const restore = (attempt = 0) => { + if (cancelled) return; + const node = scrollRef.current; + if (!node) { + if (attempt < 20) window.setTimeout(() => restore(attempt + 1), 100); + return; + } + const maxScrollTop = Math.max(0, node.scrollHeight - node.clientHeight); + if (maxScrollTop <= 0 && attempt < 20) { + window.setTimeout(() => restore(attempt + 1), 100); + return; + } + node.scrollTo({ top: maxScrollTop * savedRatio, behavior: 'auto' }); + restoredChapterPositionRef.current = true; + pendingChapterRestoreRatioRef.current = null; + window.setTimeout(() => persistChapterProgress(), 0); + }; + + window.requestAnimationFrame(() => restore()); + return () => { + cancelled = true; + }; + }, [chapter, loading, persistChapterProgress]); + + useEffect(() => { + const node = scrollRef.current; + if (!node) return; + node.addEventListener('scroll', scheduleChapterProgressSave, { passive: true }); + return () => { + node.removeEventListener('scroll', scheduleChapterProgressSave); + if (chapterSaveTimerRef.current) { + window.clearTimeout(chapterSaveTimerRef.current); + chapterSaveTimerRef.current = null; + } + persistChapterProgress(); + }; + }, [chapter, currentChapterHref, loading, persistChapterProgress, scheduleChapterProgressSave]); + + useEffect(() => { + const flush = () => persistChapterProgress(); + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') flush(); + }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + flush(); + window.removeEventListener('pagehide', flush); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [persistChapterProgress]); + + useEffect(() => { + if (!audioRef.current) audioRef.current = new Audio(); + const audio = audioRef.current; + const handleEnded = () => { + setTtsCurrentTime(0); + setTtsSeekValue(0); + const next = ttsCurrentChunkIndexRef.current + 1; + if (next < ttsChunksRef.current.length) void playTtsChunk(next); + else setTtsStatus('idle'); + }; + const handleTimeUpdate = () => { + setTtsCurrentTime(audio.currentTime || 0); + setTtsDuration(audio.duration || 0); + if (!ttsSeekingRef.current) setTtsSeekValue(audio.currentTime || 0); + }; + const handlePause = () => { if (!audio.ended && ttsStatusRef.current === 'playing') setTtsStatus('paused'); }; + const handleLoadedMetadata = () => { + if (ttsResumeTimeRef.current > 0 && audio.duration > 0) audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, audio.duration - 0.25)); + ttsResumeTimeRef.current = 0; + setTtsDuration(audio.duration || 0); + }; + audio.addEventListener('ended', handleEnded); + audio.addEventListener('timeupdate', handleTimeUpdate); + audio.addEventListener('pause', handlePause); + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + return () => { + audio.removeEventListener('ended', handleEnded); + audio.removeEventListener('timeupdate', handleTimeUpdate); + audio.removeEventListener('pause', handlePause); + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + stopTts(true); + }; + }, [playTtsChunk, stopTts]); + + const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice); + const currentChunk = ttsChunks[ttsCurrentChunkIndex]; + const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%'); + const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz'); + const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%'); + const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime; + const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0; + const palette = THEME_STYLES[settings.theme]; + + if (error) return
{error}
; + if (!chaptersLoaded || (loading && !chapter)) { + return ( +
+
+
+ +
+
章节加载中...
+
+
+ ); + } + if (!chapters.length) { + return ( +
+
+ 暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB 文件;如果详情接口显示章节数为 0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。 +
+
+ ); + } + + return ( +
+ {tocOpen && typeof document !== 'undefined' ? createPortal( +
setTocOpen(false)}> +
event.stopPropagation()}> +
+ {chapters.map((item, index) => { + const active = index === currentIndex; + return ; + })} +
+
+
, document.body + ) : null} + + {settingsOpen && typeof document !== 'undefined' ? createPortal( +
setSettingsOpen(false)}> +
event.stopPropagation()}> +
阅读设置
Legado 源支持翻页和滚动阅读
+
+
阅读模式
{([{ key: 'paginated', label: '翻页模式', desc: '左右点击翻页/章节' }, { key: 'scrolled', label: '滚动模式', desc: '上下连续滚动' }] as { key: ReaderMode; label: string; desc: string }[]).map((mode) => )}
+
主题
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => )}
+
字号 {settings.fontSize}%
setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' />
+
行距 {settings.lineHeight.toFixed(1)}
setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' />
+
+
+
+
, document.body + ) : null} + + {settings.mode === 'paginated' && !tocOpen && !settingsOpen ? <>
: null} +
+ + {ttsBarVisible ? <> +
+
+
setTtsSeeking(true)} onChange={(e) => setTtsSeekValue(Number(e.target.value))} onPointerUp={(e) => { const next = Number((e.target as HTMLInputElement).value); if (audioRef.current && Number.isFinite(next)) audioRef.current.currentTime = next; setTtsCurrentTime(next); setTtsSeeking(false); }} className='w-full accent-sky-500' />
+
{currentChapterTitle || '语音朗读'}
{!ttsAvailable ? '服务异常' : ttsStatus === 'playing' ? '正在播放' : ttsStatus === 'paused' ? '已暂停' : ttsLoadingChunkIndex !== null ? '生成语音中...' : '待播放'}{ttsChunks.length > 0 ? {ttsCurrentChunkIndex + 1}/{ttsChunks.length} : null}
{selectedVoice?.displayName || '默认音色'}{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}
+
+
+ {ttsPanelOpen ?
听书控制
{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}{Math.round(ttsChunkPercent)}%
{ttsError ?
{ttsError}
: null}
: null} + : null} +
+ ); +} function normalizeHrefForMatch(href?: string) { if (!href) return ''; @@ -638,6 +1208,7 @@ export default function BookReadPage() { useEffect(() => { const handleToggleSettings = () => { + if (manifest?.format === 'chapters') return; setSettingsOpen((prev) => !prev); setTocOpen(false); }; @@ -646,10 +1217,11 @@ export default function BookReadPage() { return () => { window.removeEventListener('books-read-toggle-settings', handleToggleSettings); }; - }, []); + }, [manifest?.format]); useEffect(() => { const handleToggleChapters = () => { + if (manifest?.format === 'chapters') return; setTocOpen((prev) => !prev); setSettingsOpen(false); }; @@ -658,10 +1230,11 @@ export default function BookReadPage() { return () => { window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); }; - }, []); + }, [manifest?.format]); useEffect(() => { const handleToggleTts = () => { + if (manifest?.format === 'chapters') return; setTtsBarVisible((prev) => { const next = !prev; if (!next) { @@ -677,7 +1250,7 @@ export default function BookReadPage() { return () => { window.removeEventListener('books-read-toggle-tts', handleToggleTts); }; - }, []); + }, [manifest?.format]); useEffect(() => { @@ -1174,7 +1747,7 @@ export default function BookReadPage() { sourceId: manifest.book.sourceId, bookId: manifest.book.id, title: manifest.book.title, - format: manifest.format, + format: 'epub', acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`, blob, size: blob.size, @@ -1655,6 +2228,10 @@ export default function BookReadPage() { ); } + if (manifest.format === 'chapters') { + return ; + } + if (manifest.format === 'pdf') { if (!pdfBlobUrl) return
PDF 加载中... {progressLabel}
; return