Merge branch 'dev'
This commit is contained in:
@@ -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
|
||||
- 增加视频源过多提示
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
220.0.0
|
||||
221.0.0
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
Generated
+21
-517
@@ -41,6 +41,9 @@ importers:
|
||||
'@vidstack/react':
|
||||
specifier: ^1.12.13
|
||||
version: 1.12.13(@types/[email protected])([email protected])
|
||||
'@xmldom/xmldom':
|
||||
specifier: ^0.9.10
|
||||
version: 0.9.10
|
||||
anime4k-webgpu:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(@webgpu/[email protected])
|
||||
@@ -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/[email protected]':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
@@ -3552,6 +3552,10 @@ packages:
|
||||
'@webgpu/[email protected]':
|
||||
resolution: {integrity: sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==}
|
||||
|
||||
'@xmldom/[email protected]':
|
||||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
'@xtuc/[email protected]':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
|
||||
@@ -4440,10 +4444,6 @@ packages:
|
||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4661,10 +4661,6 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -5938,9 +5934,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5952,33 +5945,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
|
||||
|
||||
@@ -5988,24 +5957,15 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
|
||||
|
||||
@@ -6047,150 +6007,66 @@ packages:
|
||||
engines: {node: '>= 8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
|
||||
|
||||
@@ -6840,6 +6716,7 @@ packages:
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
@@ -7067,9 +6944,6 @@ packages:
|
||||
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
||||
|
||||
@@ -7156,10 +7030,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -7878,9 +7748,6 @@ packages:
|
||||
resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -7888,30 +7755,18 @@ packages:
|
||||
resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
|
||||
|
||||
@@ -7968,11 +7823,6 @@ packages:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
@@ -7993,15 +7843,9 @@ packages:
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
@@ -8073,10 +7917,12 @@ packages:
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==}
|
||||
@@ -8283,6 +8129,10 @@ packages:
|
||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==}
|
||||
engines: {node: '>=0.6.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -12568,10 +12418,6 @@ snapshots:
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -13192,6 +13038,8 @@ snapshots:
|
||||
|
||||
'@webgpu/[email protected]': {}
|
||||
|
||||
'@xmldom/[email protected]': {}
|
||||
|
||||
'@xtuc/[email protected]': {}
|
||||
|
||||
'@xtuc/[email protected]': {}
|
||||
@@ -14080,8 +13928,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
@@ -14435,8 +14281,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
esprima: 4.0.1
|
||||
@@ -16133,8 +15977,6 @@ snapshots:
|
||||
dependencies:
|
||||
tmpl: 1.0.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -16148,30 +15990,6 @@ snapshots:
|
||||
crypt: 0.0.2
|
||||
is-buffer: 1.1.6
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -16189,50 +16007,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 3.0.15
|
||||
ccount: 2.0.1
|
||||
mdast-util-find-and-replace: 2.2.2
|
||||
micromark-util-character: 1.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 3.0.15
|
||||
mdast-util-to-markdown: 1.5.0
|
||||
micromark-util-normalize-identifier: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 3.0.15
|
||||
mdast-util-to-markdown: 1.5.0
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 3.0.15
|
||||
mdast-util-to-markdown: 1.5.0
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
@@ -16272,11 +16046,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 3.0.15
|
||||
unist-util-is: 5.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -16294,17 +16063,6 @@ snapshots:
|
||||
unist-util-visit: 5.0.0
|
||||
vfile: 6.0.3
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -16317,10 +16075,6 @@ snapshots:
|
||||
unist-util-visit: 5.0.0
|
||||
zwitch: 2.0.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 3.0.15
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -16349,25 +16103,6 @@ snapshots:
|
||||
content-type: 1.0.4
|
||||
raw-body: 2.4.1
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-types: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 1.2.0
|
||||
micromark-util-symbol: 1.1.0
|
||||
micromark-util-types: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 1.2.0
|
||||
micromark-util-symbol: 1.1.0
|
||||
micromark-util-types: 1.1.0
|
||||
uvu: 0.5.6
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
@@ -16471,23 +16135,11 @@ snapshots:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 1.2.0
|
||||
micromark-util-types: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-factory-space: 2.0.1
|
||||
@@ -16495,13 +16147,6 @@ snapshots:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-factory-space: 2.0.1
|
||||
@@ -16509,61 +16154,30 @@ snapshots:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 1.1.0
|
||||
micromark-util-types: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 1.2.0
|
||||
micromark-util-symbol: 1.1.0
|
||||
micromark-util-types: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-chunked: 1.1.0
|
||||
micromark-util-types: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-chunked: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-types: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 1.2.0
|
||||
micromark-util-encode: 1.1.0
|
||||
micromark-util-symbol: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-encode: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
micromark-util-chunked: 1.1.0
|
||||
micromark-util-symbol: 1.1.0
|
||||
micromark-util-types: 1.1.0
|
||||
uvu: 0.5.6
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
@@ -16621,36 +16210,10 @@ snapshots:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
@@ -17476,15 +17039,6 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.1.0
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -17589,10 +17143,6 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mri: 1.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -18440,16 +17990,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -18464,10 +18004,6 @@ snapshots:
|
||||
dependencies:
|
||||
crypto-random-string: 2.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -18476,30 +18012,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-is: 5.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-is: 5.2.1
|
||||
unist-util-visit-parents: 5.1.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -18573,13 +18094,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
diff: 5.2.0
|
||||
kleur: 4.1.5
|
||||
sade: 1.8.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -18628,23 +18142,11 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-stringify-position: 3.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-stringify-position: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
is-buffer: 2.0.5
|
||||
unist-util-stringify-position: 3.0.3
|
||||
vfile-message: 3.1.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -19026,6 +18528,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
+470
-871
@@ -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<void>;
|
||||
fetchUsersV2: (page: number, search?: string) => Promise<void>;
|
||||
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<string | null>(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 = ({
|
||||
|
||||
{/* 用户列表 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
用户列表
|
||||
</h4>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='mb-3 space-y-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
用户列表
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddUserForm(!showAddUserForm);
|
||||
if (showChangePasswordForm) {
|
||||
setShowChangePasswordForm(false);
|
||||
setChangePasswordUser({ username: '', password: '' });
|
||||
}
|
||||
}}
|
||||
className={
|
||||
showAddUserForm
|
||||
? buttonStyles.secondary
|
||||
: buttonStyles.success
|
||||
}
|
||||
>
|
||||
{showAddUserForm ? '取消' : '添加用户'}
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex w-full flex-wrap items-center justify-end gap-2'>
|
||||
{!hasOldUserData && usersV2 && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedUsers(new Set());
|
||||
fetchUsersV2(1, trimmedUserSearch);
|
||||
}}
|
||||
className='ml-auto flex min-w-0 items-center gap-2'
|
||||
>
|
||||
<div className='relative w-44 sm:w-56'>
|
||||
<Search className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400' />
|
||||
<input
|
||||
type='text'
|
||||
value={userSearch}
|
||||
onChange={(e) => 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'
|
||||
/>
|
||||
</div>
|
||||
{trimmedUserSearch && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setUserSearch('');
|
||||
setSelectedUsers(new Set());
|
||||
fetchUsersV2(1, '');
|
||||
}}
|
||||
disabled={userListLoading}
|
||||
aria-label='清空搜索'
|
||||
title='清空'
|
||||
className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
|
||||
userListLoading
|
||||
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white'
|
||||
: 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white'
|
||||
}`}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='submit'
|
||||
disabled={userListLoading}
|
||||
aria-label='搜索用户'
|
||||
title='搜索'
|
||||
className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
|
||||
userListLoading
|
||||
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
>
|
||||
<Search className='h-4 w-4' />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{/* 批量操作按钮 */}
|
||||
{selectedUsers.size > 0 && (
|
||||
<>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:space-x-3'>
|
||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
已选择 {selectedUsers.size} 个用户
|
||||
</span>
|
||||
@@ -1259,23 +1338,9 @@ const UserConfig = ({
|
||||
批量设置用户组
|
||||
</button>
|
||||
</div>
|
||||
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
|
||||
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddUserForm(!showAddUserForm);
|
||||
if (showChangePasswordForm) {
|
||||
setShowChangePasswordForm(false);
|
||||
setChangePasswordUser({ username: '', password: '' });
|
||||
}
|
||||
}}
|
||||
className={
|
||||
showAddUserForm ? buttonStyles.secondary : buttonStyles.success
|
||||
}
|
||||
>
|
||||
{showAddUserForm ? '取消' : '添加用户'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1448,7 +1513,7 @@ const UserConfig = ({
|
||||
<th className='w-10 px-1 py-3 text-center'>
|
||||
{(() => {
|
||||
// 检查是否有权限操作任何用户
|
||||
const hasAnyPermission = config?.UserConfig?.Users?.some(
|
||||
const hasAnyPermission = displayUsers?.some(
|
||||
(user) =>
|
||||
role === 'owner' ||
|
||||
(role === 'admin' &&
|
||||
@@ -1516,7 +1581,7 @@ const UserConfig = ({
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
colSpan={8}
|
||||
className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'
|
||||
>
|
||||
加载中...
|
||||
@@ -1536,6 +1601,23 @@ const UserConfig = ({
|
||||
};
|
||||
return priority(a) - priority(b);
|
||||
});
|
||||
if (sortedUsers.length === 0) {
|
||||
return (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={8}
|
||||
className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'
|
||||
>
|
||||
{trimmedUserSearch
|
||||
? `未找到用户名包含“${trimmedUserSearch}”的用户`
|
||||
: '暂无用户'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{sortedUsers.map((user) => {
|
||||
@@ -1778,11 +1860,14 @@ const UserConfig = ({
|
||||
{!hasOldUserData && usersV2 && userTotalPages > 1 && (
|
||||
<div className='mt-4 flex items-center justify-between px-4'>
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
共 {userTotal} 个用户,第 {userPage} / {userTotalPages} 页
|
||||
{trimmedUserSearch
|
||||
? `搜索结果 ${userTotal} 个用户`
|
||||
: `共 ${userTotal} 个用户`}
|
||||
,第 {userPage} / {userTotalPages} 页
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<button
|
||||
onClick={() => fetchUsersV2(1)}
|
||||
onClick={() => fetchUsersV2(1, trimmedUserSearch)}
|
||||
disabled={userPage === 1}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
userPage === 1
|
||||
@@ -1793,7 +1878,7 @@ const UserConfig = ({
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchUsersV2(userPage - 1)}
|
||||
onClick={() => fetchUsersV2(userPage - 1, trimmedUserSearch)}
|
||||
disabled={userPage === 1}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
userPage === 1
|
||||
@@ -1804,7 +1889,7 @@ const UserConfig = ({
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchUsersV2(userPage + 1)}
|
||||
onClick={() => fetchUsersV2(userPage + 1, trimmedUserSearch)}
|
||||
disabled={userPage === userTotalPages}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
userPage === userTotalPages
|
||||
@@ -1815,7 +1900,7 @@ const UserConfig = ({
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchUsersV2(userTotalPages)}
|
||||
onClick={() => fetchUsersV2(userTotalPages, trimmedUserSearch)}
|
||||
disabled={userPage === userTotalPages}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
userPage === userTotalPages
|
||||
@@ -3070,6 +3155,11 @@ const OpenListConfigComponent = ({
|
||||
const [password, setPassword] = useState('');
|
||||
const [rootPaths, setRootPaths] = useState<string[]>(['/']);
|
||||
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
|
||||
const [offlineDownloadUseCustomSource, setOfflineDownloadUseCustomSource] =
|
||||
useState(false);
|
||||
const [offlineDownloadUrl, setOfflineDownloadUrl] = useState('');
|
||||
const [offlineDownloadUsername, setOfflineDownloadUsername] = useState('');
|
||||
const [offlineDownloadPassword, setOfflineDownloadPassword] = useState('');
|
||||
const [scanInterval, setScanInterval] = useState(0);
|
||||
const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>(
|
||||
'hybrid'
|
||||
@@ -3098,6 +3188,16 @@ const OpenListConfigComponent = ({
|
||||
: ['/'])
|
||||
);
|
||||
setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
|
||||
setOfflineDownloadUseCustomSource(
|
||||
config.OpenListConfig.OfflineDownloadUseCustomSource || false
|
||||
);
|
||||
setOfflineDownloadUrl(config.OpenListConfig.OfflineDownloadURL || '');
|
||||
setOfflineDownloadUsername(
|
||||
config.OpenListConfig.OfflineDownloadUsername || ''
|
||||
);
|
||||
setOfflineDownloadPassword(
|
||||
config.OpenListConfig.OfflineDownloadPassword || ''
|
||||
);
|
||||
setScanInterval(config.OpenListConfig.ScanInterval || 0);
|
||||
setScanMode(config.OpenListConfig.ScanMode || 'hybrid');
|
||||
setDisableVideoPreview(
|
||||
@@ -3148,6 +3248,10 @@ const OpenListConfigComponent = ({
|
||||
Password: password,
|
||||
RootPaths: rootPaths,
|
||||
OfflineDownloadPath: offlineDownloadPath,
|
||||
OfflineDownloadUseCustomSource: offlineDownloadUseCustomSource,
|
||||
OfflineDownloadURL: offlineDownloadUrl,
|
||||
OfflineDownloadUsername: offlineDownloadUsername,
|
||||
OfflineDownloadPassword: offlineDownloadPassword,
|
||||
ScanInterval: scanInterval,
|
||||
ScanMode: scanMode,
|
||||
DisableVideoPreview: disableVideoPreview,
|
||||
@@ -3507,6 +3611,92 @@ const OpenListConfigComponent = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
离线下载使用独立 OpenList 源
|
||||
</h3>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后,存到私人影库和追番订阅会把任务提交到下方 OpenList,扫描和播放仍使用上方主 OpenList
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setOfflineDownloadUseCustomSource(
|
||||
!offlineDownloadUseCustomSource
|
||||
)
|
||||
}
|
||||
disabled={!enabled}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||
offlineDownloadUseCustomSource
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
} ${!enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
offlineDownloadUseCustomSource
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{offlineDownloadUseCustomSource && (
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
离线下载 OpenList URL
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={offlineDownloadUrl}
|
||||
onChange={(e) => 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'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
离线下载账号
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={offlineDownloadUsername}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
离线下载密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={offlineDownloadPassword}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
定时扫描间隔(分钟)
|
||||
@@ -3851,6 +4041,8 @@ const NetDiskConfigComponent = ({
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [cookie, setCookie] = useState('');
|
||||
const [savePath, setSavePath] = useState('/');
|
||||
const [quarkPlayMode, setQuarkPlayMode] = useState<'direct_first' | 'transcode_first'>('transcode_first');
|
||||
const [quarkMultiThreadPlayback, setQuarkMultiThreadPlayback] = useState(false);
|
||||
const [mobileEnabled, setMobileEnabled] = useState(false);
|
||||
const [mobileAuthorization, setMobileAuthorization] = useState('');
|
||||
const [baiduEnabled, setBaiduEnabled] = useState(false);
|
||||
@@ -3874,6 +4066,8 @@ const NetDiskConfigComponent = ({
|
||||
setEnabled(quark?.Enabled || false);
|
||||
setCookie(quark?.Cookie || '');
|
||||
setSavePath(quark?.SavePath || '/');
|
||||
setQuarkPlayMode(quark?.PlayMode === 'direct_first' ? 'direct_first' : 'transcode_first');
|
||||
setQuarkMultiThreadPlayback(Boolean(quark?.MultiThreadPlayback));
|
||||
setMobileEnabled(mobile?.Enabled || false);
|
||||
setMobileAuthorization(mobile?.Authorization || '');
|
||||
setBaiduEnabled(config?.NetDiskConfig?.Baidu?.Enabled || false);
|
||||
@@ -3903,6 +4097,8 @@ const NetDiskConfigComponent = ({
|
||||
Enabled: enabled,
|
||||
Cookie: cookie,
|
||||
SavePath: savePath,
|
||||
PlayMode: quarkPlayMode,
|
||||
MultiThreadPlayback: quarkMultiThreadPlayback,
|
||||
},
|
||||
Mobile: {
|
||||
Enabled: mobileEnabled,
|
||||
@@ -4221,6 +4417,45 @@ const NetDiskConfigComponent = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
播放方式
|
||||
</label>
|
||||
<select
|
||||
value={quarkPlayMode}
|
||||
onChange={(e) => setQuarkPlayMode(e.target.value === 'transcode_first' ? 'transcode_first' : 'direct_first')}
|
||||
disabled={!enabled}
|
||||
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'
|
||||
>
|
||||
<option value='direct_first'>直链优先</option>
|
||||
<option value='transcode_first'>转码优先</option>
|
||||
</select>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
直链优先会优先使用原画下载地址;转码优先会优先使用夸克转码播放地址。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
多线程播放
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
开启后,代理会把播放器请求的 Range 拆分并发拉取。
|
||||
</p>
|
||||
</div>
|
||||
<label className='relative inline-flex items-center cursor-pointer'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={quarkMultiThreadPlayback}
|
||||
onChange={(e) => setQuarkMultiThreadPlayback(e.target.checked)}
|
||||
disabled={!enabled}
|
||||
className='sr-only peer'
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-disabled:opacity-50 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
@@ -12087,92 +12322,77 @@ const OPDSConfigComponent = ({
|
||||
const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000);
|
||||
const [sources, setSources] = useState<BookSource[]>([]);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [legadoSubscriptionName, setLegadoSubscriptionName] = useState('');
|
||||
const [legadoSubscriptionUrl, setLegadoSubscriptionUrl] = useState('');
|
||||
const [legadoSubscriptions, setLegadoSubscriptions] = useState<NonNullable<AdminConfig['OPDSConfig']>['LegadoSubscriptions']>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.OPDSConfig) {
|
||||
setEnabled(config.OPDSConfig.Enabled || false);
|
||||
setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000);
|
||||
setSources(
|
||||
(config.OPDSConfig.Sources || []).map((item, index) => ({
|
||||
id: item.id || `source_${index + 1}`,
|
||||
name: item.name || `书源 ${index + 1}`,
|
||||
url: item.url || '',
|
||||
enabled: item.enabled !== false,
|
||||
authMode: item.authMode || 'none',
|
||||
username: item.username || '',
|
||||
password: item.password || '',
|
||||
headerName: item.headerName || '',
|
||||
headerValue: item.headerValue || '',
|
||||
searchTemplate: item.searchTemplate || '',
|
||||
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
||||
language: item.language || '',
|
||||
}))
|
||||
);
|
||||
setEditingIndex(null);
|
||||
}
|
||||
if (!config?.OPDSConfig) return;
|
||||
setEnabled(config.OPDSConfig.Enabled || false);
|
||||
setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000);
|
||||
setSources((config.OPDSConfig.Sources || []).map((item, index) => ({
|
||||
id: item.id || `source_${index + 1}`,
|
||||
name: item.name || `书源 ${index + 1}`,
|
||||
type: 'opds' as const,
|
||||
url: item.url || '',
|
||||
enabled: item.enabled !== false,
|
||||
authMode: item.authMode || 'none',
|
||||
username: item.username || '',
|
||||
password: item.password || '',
|
||||
headerName: item.headerName || '',
|
||||
headerValue: item.headerValue || '',
|
||||
searchTemplate: item.searchTemplate || '',
|
||||
preferFormat: item.preferFormat || ['epub', 'pdf'],
|
||||
language: item.language || '',
|
||||
})));
|
||||
setLegadoSubscriptions(config.OPDSConfig.LegadoSubscriptions || []);
|
||||
setEditingIndex(null);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingIndex((prev) => {
|
||||
if (prev === null) return prev;
|
||||
return prev >= sources.length ? null : prev;
|
||||
});
|
||||
}, [sources.length]);
|
||||
|
||||
const updateSource = (index: number, patch: Partial<BookSource>) => {
|
||||
setSources((prev) =>
|
||||
prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))
|
||||
);
|
||||
setSources((prev) => prev.map((item, idx) => idx === index ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const addSource = () => {
|
||||
setSources((prev) => {
|
||||
const nextIndex = prev.length;
|
||||
setEditingIndex(nextIndex);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `source_${prev.length + 1}`,
|
||||
name: `书源 ${prev.length + 1}`,
|
||||
url: '',
|
||||
enabled: true,
|
||||
authMode: 'none',
|
||||
username: '',
|
||||
password: '',
|
||||
headerName: '',
|
||||
headerValue: '',
|
||||
searchTemplate: '',
|
||||
preferFormat: ['epub', 'pdf'],
|
||||
language: '',
|
||||
},
|
||||
];
|
||||
return [...prev, {
|
||||
id: `source_${nextIndex + 1}`,
|
||||
name: `书源 ${nextIndex + 1}`,
|
||||
type: 'opds' as const,
|
||||
url: '',
|
||||
enabled: true,
|
||||
authMode: 'none' as const,
|
||||
username: '',
|
||||
password: '',
|
||||
headerName: '',
|
||||
headerValue: '',
|
||||
searchTemplate: '',
|
||||
preferFormat: ['epub' as const, 'pdf' as const],
|
||||
language: '',
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const removeSource = (index: number) => {
|
||||
setSources((prev) => prev.filter((_, idx) => idx !== index));
|
||||
setEditingIndex((prev) => {
|
||||
if (prev === null) return prev;
|
||||
if (prev === index) return null;
|
||||
return prev > index ? prev - 1 : prev;
|
||||
});
|
||||
setEditingIndex((prev) => prev === index ? null : prev !== null && prev > index ? prev - 1 : prev);
|
||||
};
|
||||
|
||||
const normalizeSource = (source: BookSource, index: number) => ({
|
||||
id: source.id?.trim() || `source_${index + 1}`,
|
||||
name: source.name?.trim() || `书源 ${index + 1}`,
|
||||
type: 'opds' as const,
|
||||
url: source.url?.trim() || '',
|
||||
enabled: source.enabled !== false,
|
||||
authMode: source.authMode || 'none',
|
||||
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
|
||||
password: source.authMode === 'none' ? '' : source.password || '',
|
||||
headerName:
|
||||
source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
||||
headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '',
|
||||
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
|
||||
searchTemplate: source.searchTemplate?.trim() || '',
|
||||
preferFormat: source.preferFormat?.length
|
||||
? source.preferFormat
|
||||
: ['epub', 'pdf'],
|
||||
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'],
|
||||
language: source.language?.trim() || '',
|
||||
});
|
||||
|
||||
@@ -12180,6 +12400,7 @@ const OPDSConfigComponent = ({
|
||||
Enabled: enabled,
|
||||
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
||||
Sources: sources.map(normalizeSource).filter((source) => !!source.url),
|
||||
LegadoSubscriptions: legadoSubscriptions || [],
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -12189,22 +12410,14 @@ const OPDSConfigComponent = ({
|
||||
const response = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...config,
|
||||
OPDSConfig: buildConfig(),
|
||||
}),
|
||||
body: JSON.stringify({ ...config, OPDSConfig: buildConfig() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || '保存失败');
|
||||
}
|
||||
showSuccess('电子书 OPDS 配置已保存', showAlert);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || '保存失败');
|
||||
showSuccess('电子书源配置已保存', showAlert);
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
showError(
|
||||
error instanceof Error ? error.message : '保存失败',
|
||||
showAlert
|
||||
);
|
||||
showError(error instanceof Error ? error.message : '保存失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -12214,38 +12427,69 @@ const OPDSConfigComponent = ({
|
||||
await withLoading(`testOPDSConfig-${index}`, async () => {
|
||||
try {
|
||||
const source = normalizeSource(sources[index], index);
|
||||
if (!source?.url) {
|
||||
throw new Error('请先填写书源地址');
|
||||
}
|
||||
if (!source.url) throw new Error('请先填写书源地址');
|
||||
const response = await fetch('/api/admin/opds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Enabled: true,
|
||||
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
|
||||
Sources: [source],
|
||||
}),
|
||||
body: JSON.stringify({ Enabled: true, CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), Sources: [source] }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || data.error || '测试连接失败');
|
||||
}
|
||||
if (!response.ok || !data.success) throw new Error(data.message || data.error || '测试连接失败');
|
||||
const result = Array.isArray(data.results) ? data.results[0] : null;
|
||||
const summary = result
|
||||
? `${result.name}: 分类${
|
||||
result.capability.catalogSupported ? '√' : '×'
|
||||
} / 搜索${result.capability.searchSupported ? '√' : '×'}${
|
||||
result.capability.lastError
|
||||
? ` (${result.capability.lastError})`
|
||||
: ''
|
||||
}`
|
||||
: data.message || '测试成功';
|
||||
showSuccess(summary, showAlert);
|
||||
showSuccess(result ? `${result.name}: 分类${result.capability.catalogSupported ? '√' : '×'} / 搜索${result.capability.searchSupported ? '√' : '×'}` : '测试成功', showAlert);
|
||||
} catch (error) {
|
||||
showError(
|
||||
error instanceof Error ? error.message : '测试连接失败',
|
||||
showAlert
|
||||
);
|
||||
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const importLegadoSubscription = async () => {
|
||||
await withLoading('importLegadoSubscription', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/legado-subscriptions/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: legadoSubscriptionName, url: legadoSubscriptionUrl }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || '导入 Legado 订阅失败');
|
||||
setLegadoSubscriptionName('');
|
||||
setLegadoSubscriptionUrl('');
|
||||
showSuccess(`已导入 ${data.subscription?.sourceCount || 0} 个 Legado 书源`, showAlert);
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '导入 Legado 订阅失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const refreshLegadoSubscription = async (id: string) => {
|
||||
await withLoading(`refreshLegadoSubscription-${id}`, async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/legado-subscriptions/${encodeURIComponent(id)}/refresh`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || '刷新 Legado 订阅失败');
|
||||
showSuccess(`已同步 ${data.subscription?.sourceCount || 0} 个 Legado 书源`, showAlert);
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '刷新 Legado 订阅失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deleteLegadoSubscription = async (id: string) => {
|
||||
await withLoading(`deleteLegadoSubscription-${id}`, async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/legado-subscriptions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || '删除 Legado 订阅失败');
|
||||
showSuccess('Legado 订阅已删除', showAlert);
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '删除 Legado 订阅失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -12253,766 +12497,110 @@ const OPDSConfigComponent = ({
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'>
|
||||
<h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'>
|
||||
关于电子书馆 / OPDS
|
||||
</h3>
|
||||
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
|
||||
<p>• 支持多书源,每个源可独立配置认证、搜索模板与默认格式偏好。</p>
|
||||
<p>
|
||||
• 有些源只支持分类浏览,有些源只支持搜索,测试连接会自动探测能力。
|
||||
</p>
|
||||
<p>• 目前前台优先支持 EPUB 在线阅读,PDF 走内嵌预览。</p>
|
||||
<div className='rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
||||
<h3 className='mb-2 text-sm font-medium text-amber-900 dark:text-amber-100'>关于电子书馆 / OPDS / Legado</h3>
|
||||
<div className='space-y-1 text-sm text-amber-800 dark:text-amber-200'>
|
||||
<p>• OPDS 源手动配置。</p>
|
||||
<p>• Legado 通过订阅 URL 导入。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center justify-between border-b border-gray-200 py-3 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||
启用电子书馆
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
关闭后不会展示 OPDS 电子书入口。
|
||||
</p>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>启用电子书馆</h3>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>关闭后不会展示电子书入口。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
<button onClick={() => setEnabled(!enabled)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'}`}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Feed 缓存时长(毫秒)
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
min='60000'
|
||||
value={cacheTTL}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>Feed 缓存时长(毫秒)</label>
|
||||
<input type='number' min='60000' value={cacheTTL} onChange={(e) => 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' />
|
||||
</div>
|
||||
|
||||
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20'>
|
||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-amber-900 dark:text-amber-100'>Legado 订阅</h4>
|
||||
<p className='mt-1 text-xs text-amber-800 dark:text-amber-200'>目前处于实验性阶段,仅支持部分简单订阅。</p>
|
||||
</div>
|
||||
<button type='button' onClick={importLegadoSubscription} disabled={!legadoSubscriptionUrl.trim() || isLoading('importLegadoSubscription')} className={buttonStyles.primarySmall}>{isLoading('importLegadoSubscription') ? '导入中...' : '导入订阅'}</button>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
<input type='text' value={legadoSubscriptionName} onChange={(e) => 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' />
|
||||
<input type='text' value={legadoSubscriptionUrl} onChange={(e) => 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' />
|
||||
</div>
|
||||
<div className='mt-4 space-y-2'>
|
||||
{(legadoSubscriptions || []).length === 0 ? <div className='text-xs text-amber-800 dark:text-amber-200'>暂无 Legado 订阅。</div> : (legadoSubscriptions || []).map((sub) => (
|
||||
<div key={sub.id} className='rounded-lg border border-amber-200 bg-white p-3 text-sm dark:border-amber-800 dark:bg-gray-900'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='font-medium text-gray-900 dark:text-gray-100'>{sub.name}</div>
|
||||
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>{sub.url}</div>
|
||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>源数量:{sub.sourceCount || 0} · 上次同步:{sub.lastSuccessAt ? new Date(sub.lastSuccessAt).toLocaleString() : '-'}</div>
|
||||
{sub.lastError ? <div className='mt-1 text-xs text-red-500'>{sub.lastError}</div> : null}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button type='button' onClick={() => setLegadoSubscriptions((prev) => (prev || []).map((item) => item.id === sub.id ? { ...item, enabled: item.enabled === false } : item))} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${sub.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}><span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${sub.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} /></button>
|
||||
<button type='button' onClick={() => refreshLegadoSubscription(sub.id)} disabled={isLoading(`refreshLegadoSubscription-${sub.id}`)} className={buttonStyles.secondarySmall}>{isLoading(`refreshLegadoSubscription-${sub.id}`) ? '同步中...' : '同步'}</button>
|
||||
<button type='button' onClick={() => deleteLegadoSubscription(sub.id)} disabled={isLoading(`deleteLegadoSubscription-${sub.id}`)} className={buttonStyles.dangerSmall}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||
书源列表
|
||||
</h3>
|
||||
<button
|
||||
type='button'
|
||||
onClick={addSource}
|
||||
className={buttonStyles.primary}
|
||||
>
|
||||
<Plus size={16} className='inline mr-1' />
|
||||
添加书源
|
||||
</button>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>OPDS 书源列表</h3>
|
||||
<button type='button' onClick={addSource} className={buttonStyles.primary}><Plus size={16} className='mr-1 inline' />添加 OPDS</button>
|
||||
</div>
|
||||
|
||||
{sources.length === 0 && (
|
||||
<div className='rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-500 dark:text-gray-400'>
|
||||
暂无 OPDS 书源,点击“添加书源”开始配置。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length > 0 && (
|
||||
<>
|
||||
<div className='space-y-3 md:hidden'>
|
||||
{sources.map((source, index) => {
|
||||
const isEditing = editingIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={`opds-source-${index}`}
|
||||
className='overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'
|
||||
>
|
||||
<div className='space-y-3 p-4'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
{source.name || `书源 ${index + 1}`}
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{source.id || '未设置 ID'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
updateSource(index, {
|
||||
enabled: source.enabled === false,
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||
source.enabled !== false
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
source.enabled !== false
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 text-xs text-gray-600 dark:text-gray-300'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<span className='shrink-0 text-gray-500 dark:text-gray-400'>
|
||||
地址
|
||||
</span>
|
||||
<span className='min-w-0 text-right break-all'>
|
||||
{source.url || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-gray-500 dark:text-gray-400'>
|
||||
认证
|
||||
</span>
|
||||
<span>
|
||||
{source.authMode === 'none'
|
||||
? '无认证'
|
||||
: source.authMode === 'basic'
|
||||
? 'Basic Auth'
|
||||
: '自定义 Header'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-gray-500 dark:text-gray-400'>
|
||||
搜索
|
||||
</span>
|
||||
<span>
|
||||
{source.searchTemplate?.trim()
|
||||
? '已配置'
|
||||
: '未配置'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-gray-500 dark:text-gray-400'>
|
||||
格式
|
||||
</span>
|
||||
<span>{source.preferFormat?.join(', ') || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleTest(index)}
|
||||
disabled={isLoading(`testOPDSConfig-${index}`)}
|
||||
className={buttonStyles.primarySmall}
|
||||
>
|
||||
{isLoading(`testOPDSConfig-${index}`)
|
||||
? '测试中...'
|
||||
: '测试'}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setEditingIndex(isEditing ? null : index)
|
||||
}
|
||||
className={buttonStyles.secondarySmall}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<ChevronUp size={14} className='inline mr-1' />
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Settings size={14} className='inline mr-1' />
|
||||
编辑
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => removeSource(index)}
|
||||
className={buttonStyles.dangerSmall}
|
||||
>
|
||||
<Trash2 size={14} className='inline mr-1' />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className='space-y-4 border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/40'>
|
||||
<div className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||
编辑书源 #{index + 1}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
书源 ID
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.id}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
书源名称
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.name}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
根地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.url}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
认证方式
|
||||
</label>
|
||||
<select
|
||||
value={source.authMode || 'none'}
|
||||
onChange={(e) =>
|
||||
updateSource(index, {
|
||||
authMode: e.target
|
||||
.value as BookSource['authMode'],
|
||||
})
|
||||
}
|
||||
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'
|
||||
>
|
||||
<option value='none'>无认证</option>
|
||||
<option value='basic'>Basic Auth</option>
|
||||
<option value='header'>自定义 Header</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
语言
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.language || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
搜索模板
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.searchTemplate || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source.authMode === 'basic' && (
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.username || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={source.password || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source.authMode === 'header' && (
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Header 名称
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.headerName || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Header 值
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={source.headerValue || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sources.length === 0 ? <div className='rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400'>暂无 OPDS 书源。</div> : null}
|
||||
<div className='space-y-3'>
|
||||
{sources.map((source, index) => {
|
||||
const isEditing = editingIndex === index;
|
||||
return (
|
||||
<div key={`opds-source-${index}`} className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='font-medium text-gray-900 dark:text-gray-100'>{source.name || `书源 ${index + 1}`}</div>
|
||||
<div className='mt-1 break-all text-xs text-gray-500 dark:text-gray-400'>{source.url || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className='hidden overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 md:block'>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-800/70'>
|
||||
<tr>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
启用
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
名称
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
ID
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
地址
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
认证
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
搜索
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
格式偏好
|
||||
</th>
|
||||
<th className='px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400'>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'>
|
||||
{sources.map((source, index) => {
|
||||
const isEditing = editingIndex === index;
|
||||
return (
|
||||
<Fragment key={`opds-source-${index}`}>
|
||||
<tr className='align-top'>
|
||||
<td className='px-4 py-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
updateSource(index, {
|
||||
enabled: source.enabled === false,
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
source.enabled !== false
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
source.enabled !== false
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className='px-4 py-3 text-sm text-gray-900 dark:text-gray-100'>
|
||||
<div className='font-medium'>
|
||||
{source.name || `书源 ${index + 1}`}
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{source.language || '未设置语言'}
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||
{source.id || '-'}
|
||||
</td>
|
||||
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||
<div
|
||||
className='max-w-[320px] truncate'
|
||||
title={source.url || ''}
|
||||
>
|
||||
{source.url || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||
{source.authMode === 'none'
|
||||
? '无认证'
|
||||
: source.authMode === 'basic'
|
||||
? 'Basic Auth'
|
||||
: '自定义 Header'}
|
||||
</td>
|
||||
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||
{source.searchTemplate?.trim()
|
||||
? '已配置'
|
||||
: '未配置'}
|
||||
</td>
|
||||
<td className='px-4 py-3 text-sm text-gray-600 dark:text-gray-300'>
|
||||
{source.preferFormat?.join(', ') || '-'}
|
||||
</td>
|
||||
<td className='px-4 py-3'>
|
||||
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleTest(index)}
|
||||
disabled={isLoading(
|
||||
`testOPDSConfig-${index}`
|
||||
)}
|
||||
className={buttonStyles.primarySmall}
|
||||
>
|
||||
{isLoading(`testOPDSConfig-${index}`)
|
||||
? '测试中...'
|
||||
: '测试'}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setEditingIndex(isEditing ? null : index)
|
||||
}
|
||||
className={buttonStyles.secondarySmall}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className='inline mr-1'
|
||||
/>
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Settings
|
||||
size={14}
|
||||
className='inline mr-1'
|
||||
/>
|
||||
编辑
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => removeSource(index)}
|
||||
className={buttonStyles.dangerSmall}
|
||||
>
|
||||
<Trash2 size={14} className='inline mr-1' />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{isEditing && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={8}
|
||||
className='bg-gray-50 px-4 py-4 dark:bg-gray-800/40'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<div className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||
编辑书源 #{index + 1}
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
仅展开当前书源,保存时统一提交。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setEditingIndex(null)}
|
||||
className={buttonStyles.secondarySmall}
|
||||
>
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className='inline mr-1'
|
||||
/>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
书源 ID
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.id}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
书源名称
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.name}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
根地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.url}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
认证方式
|
||||
</label>
|
||||
<select
|
||||
value={source.authMode || 'none'}
|
||||
onChange={(e) =>
|
||||
updateSource(index, {
|
||||
authMode: e.target
|
||||
.value as BookSource['authMode'],
|
||||
})
|
||||
}
|
||||
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'
|
||||
>
|
||||
<option value='none'>无认证</option>
|
||||
<option value='basic'>
|
||||
Basic Auth
|
||||
</option>
|
||||
<option value='header'>
|
||||
自定义 Header
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
语言
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.language || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
搜索模板
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.searchTemplate || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source.authMode === 'basic' && (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.username || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={source.password || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source.authMode === 'header' && (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Header 名称
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={source.headerName || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Header 值
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={source.headerValue || ''}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button type='button' onClick={() => updateSource(index, { enabled: source.enabled === false })} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}><span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} /></button>
|
||||
<button type='button' onClick={() => handleTest(index)} disabled={isLoading(`testOPDSConfig-${index}`)} className={buttonStyles.primarySmall}>{isLoading(`testOPDSConfig-${index}`) ? '测试中...' : '测试'}</button>
|
||||
<button type='button' onClick={() => setEditingIndex(isEditing ? null : index)} className={buttonStyles.secondarySmall}>{isEditing ? '收起' : '编辑'}</button>
|
||||
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.dangerSmall}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className='mt-4 grid grid-cols-1 gap-4 border-t border-gray-200 pt-4 dark:border-gray-700 md:grid-cols-2'>
|
||||
<input type='text' value={source.id} onChange={(e) => 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' />
|
||||
<input type='text' value={source.name} onChange={(e) => 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' />
|
||||
<input type='text' value={source.url} onChange={(e) => 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' />
|
||||
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} 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'>
|
||||
<option value='none'>无认证</option><option value='basic'>Basic Auth</option><option value='header'>自定义 Header</option>
|
||||
</select>
|
||||
<input type='text' value={source.language || ''} onChange={(e) => 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' />
|
||||
<input type='text' value={source.searchTemplate || ''} onChange={(e) => 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' ? <><input type='text' value={source.username || ''} onChange={(e) => 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' /><input type='password' value={source.password || ''} onChange={(e) => 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' ? <><input type='text' value={source.headerName || ''} onChange={(e) => 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' /><input type='password' value={source.headerValue || ''} onChange={(e) => 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}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveOPDSConfig')}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}>{isLoading('saveOPDSConfig') ? '保存中...' : '保存电子书源配置'}</button>
|
||||
</div>
|
||||
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
type={alertModal.type}
|
||||
title={alertModal.title}
|
||||
message={alertModal.message}
|
||||
timer={alertModal.timer}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
/>
|
||||
<AlertModal isOpen={alertModal.isOpen} onClose={hideAlert} type={alertModal.type} title={alertModal.title} message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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() {
|
||||
</CollapsibleTab>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* 用户配置标签 */}
|
||||
{/* 用户管理标签 */}
|
||||
<CollapsibleTab
|
||||
title='用户配置'
|
||||
title='用户管理'
|
||||
icon={
|
||||
<Users size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
}
|
||||
@@ -16437,6 +16034,8 @@ function AdminPageClient() {
|
||||
userTotal={userTotal}
|
||||
fetchUsersV2={fetchUsersV2}
|
||||
userListLoading={userListLoading}
|
||||
userSearch={userSearch}
|
||||
setUserSearch={setUserSearch}
|
||||
/>
|
||||
</CollapsibleTab>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
// 验证集数
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<BookSourceCapabilities> {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
// 使用新版本数据
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, { expiresAt: number; contentType: string; data: Uint8Array }>();
|
||||
|
||||
function asObjectHeader(value?: string | Record<string, string>): Record<string, string> {
|
||||
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<Record<string, string>>((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<string, string> = {
|
||||
'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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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 : '夸克网盘代理失败' },
|
||||
|
||||
+298
-105
@@ -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 源不使用代理模式
|
||||
};
|
||||
|
||||
@@ -72,9 +72,12 @@ export default function BooksCatalogPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||
const sourceScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const navScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadedPageHrefsRef = useRef<Set<string>>(new Set());
|
||||
const failedPageHrefsRef = useRef<Set<string>>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (event.pointerType === 'mouse') return;
|
||||
handleSourcePointerUp(event);
|
||||
}, [handleSourcePointerUp]);
|
||||
|
||||
const handleSourceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
@@ -235,9 +296,30 @@ export default function BooksCatalogPage() {
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<div
|
||||
ref={sourceScrollerRef}
|
||||
className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
||||
onPointerDown={handleSourcePointerDown}
|
||||
onPointerMove={handleSourcePointerMove}
|
||||
onPointerUp={handleSourcePointerUp}
|
||||
onPointerCancel={handleSourcePointerUp}
|
||||
onPointerLeave={handleSourcePointerLeave}
|
||||
onWheel={handleSourceWheel}
|
||||
>
|
||||
{sources.map((source) => (
|
||||
<Link key={source.id} href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className={`rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}>
|
||||
<Link
|
||||
key={source.id}
|
||||
href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`}
|
||||
draggable={false}
|
||||
onDragStart={(event) => 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}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -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<BookDetail | null>(null);
|
||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||
const [chapters, setChapters] = useState<BookChapter[]>([]);
|
||||
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() {
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
||||
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
|
||||
{readable ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
||||
{readable && readableFormat !== 'chapters' ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -184,7 +229,8 @@ export default function BookDetailPage() {
|
||||
<h2 className='text-lg font-semibold'>可用格式</h2>
|
||||
<div className='mt-4 space-y-3'>
|
||||
{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 (
|
||||
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
|
||||
<div>
|
||||
@@ -193,7 +239,7 @@ export default function BookDetailPage() {
|
||||
</div>
|
||||
<button disabled={!format || fileBusy !== ''} onClick={async () => {
|
||||
if (!format) return;
|
||||
if (format === 'epub') {
|
||||
if (format === 'epub' || format === 'chapters') {
|
||||
cacheBookDetail(detail);
|
||||
window.location.href = buildBookReadPath(detail.sourceId, detail.id);
|
||||
return;
|
||||
@@ -212,6 +258,36 @@ export default function BookDetailPage() {
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{readableFormat === 'chapters' ? (
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<h2 className='text-lg font-semibold'>章节目录</h2>
|
||||
<div className='text-sm text-gray-500'>{chaptersLoading ? '加载中...' : `${chapters.length} 章`}</div>
|
||||
</div>
|
||||
{chaptersError ? <div className='mt-4 text-sm text-red-500'>{chaptersError}</div> : null}
|
||||
{!chaptersLoading && !chaptersError && chapters.length === 0 ? (
|
||||
<div className='mt-4 rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
||||
源站当前没有返回章节,这不是 EPUB 文件缺失;请换有章节的搜索结果。
|
||||
</div>
|
||||
) : null}
|
||||
{chapters.length > 0 ? (
|
||||
<div className='mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{chapters.slice(0, 60).map((chapter) => (
|
||||
<Link
|
||||
key={`${chapter.href}-${chapter.order}`}
|
||||
href={buildBookReadPath(detail.sourceId, detail.id, chapter.href)}
|
||||
onClick={() => 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}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{chapters.length > 60 ? <div className='mt-3 text-xs text-gray-500'>仅预览前 60 章,完整目录请进入阅读页侧边栏查看。</div> : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ export default function BooksHomePage() {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<h1 className='text-lg font-semibold'>OPDS 电子书源</h1>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>支持分类浏览、搜索、书架与 EPUB 在线阅读。</p>
|
||||
<h1 className='text-lg font-semibold'>电子书源</h1>
|
||||
</section>
|
||||
|
||||
{loading ? <BooksHomeSkeleton /> : null}
|
||||
@@ -56,6 +55,7 @@ export default function BooksHomePage() {
|
||||
{sources.map((source) => (
|
||||
<div key={source.id} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='text-base font-semibold'>{source.name}</div>
|
||||
<div className='mt-1 text-xs text-gray-400'>{source.type === 'legado' ? 'Legado' : 'OPDS'}</div>
|
||||
<div className='mt-2 flex flex-wrap gap-2 text-xs'>
|
||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.catalogSupported ? 'bg-sky-100 text-sky-700 dark:bg-sky-950/50 dark:text-sky-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>分类{source.capabilities?.catalogSupported ? '可用' : '不可用'}</span>
|
||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.searchSupported ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>搜索{source.capabilities?.searchSupported ? '可用' : '不可用'}</span>
|
||||
|
||||
+582
-5
@@ -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<T>(url: string, init?: RequestInit, retries = 2): Promise<T> {
|
||||
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<BookReadManifest, 'book' | 'format' | 'acquisitionHref'>,
|
||||
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<BookChapter[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [chapter, setChapter] = useState<BookChapterContent | null>(null);
|
||||
const [tocOpen, setTocOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [chaptersLoaded, setChaptersLoaded] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [ttsVoices, setTtsVoices] = useState<BookTtsVoice[]>([]);
|
||||
const [ttsAvailable, setTtsAvailable] = useState(false);
|
||||
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(DEFAULT_TTS_SETTINGS);
|
||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>('idle');
|
||||
const [ttsError, setTtsError] = useState('');
|
||||
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
|
||||
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
|
||||
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<number | null>(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<HTMLDivElement | null>(null);
|
||||
const restoredChapterPositionRef = useRef(false);
|
||||
const chapterSaveTimerRef = useRef<number | null>(null);
|
||||
const pendingChapterRestoreRatioRef = useRef<number | null>(null);
|
||||
const currentIndexRef = useRef(0);
|
||||
const lastChapterSavedAtRef = useRef(0);
|
||||
const lastChapterSavedLocatorValueRef = useRef(manifest.lastRecord?.locator?.value || '');
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const ttsChunksRef = useRef<TtsChunk[]>([]);
|
||||
const ttsCurrentChunkIndexRef = useRef(0);
|
||||
const ttsStatusRef = useRef<TtsStatus>('idle');
|
||||
const ttsSettingsRef = useRef<TtsSettings>(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<BookChapterContent>(`/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 <div className='p-4 text-sm text-red-500'>{error}</div>;
|
||||
if (!chaptersLoaded || (loading && !chapter)) {
|
||||
return (
|
||||
<div className='flex h-[calc(100vh-3.5rem)] items-center justify-center bg-white px-4 dark:bg-gray-950'>
|
||||
<div className='flex flex-col items-center gap-4 text-center'>
|
||||
<div className='reader-book-loader'>
|
||||
<BookOpen className='h-10 w-10' strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 dark:text-gray-400'>章节加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!chapters.length) {
|
||||
return (
|
||||
<div className='mx-auto max-w-2xl p-4'>
|
||||
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
||||
暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB 文件;如果详情接口显示章节数为 0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative h-[calc(100vh-3.5rem)] overflow-hidden' style={{ backgroundColor: palette.panelBg, color: palette.bodyColor }}>
|
||||
{tocOpen && typeof document !== 'undefined' ? createPortal(
|
||||
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
||||
<div className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}>
|
||||
<div className='space-y-2 p-4'>
|
||||
{chapters.map((item, index) => {
|
||||
const active = index === currentIndex;
|
||||
return <button key={`${item.href}-${item.order}-${index}`} onClick={() => { persistChapterProgress(); setCurrentIndex(index); setTocOpen(false); }} className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`} title={item.title}>{item.title}</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body
|
||||
) : null}
|
||||
|
||||
{settingsOpen && typeof document !== 'undefined' ? createPortal(
|
||||
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
|
||||
<div className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}>
|
||||
<div className='mb-4'><div className='text-base font-semibold text-gray-900 dark:text-gray-100'>阅读设置</div><div className='mt-1 text-xs text-gray-500'>Legado 源支持翻页和滚动阅读</div></div>
|
||||
<div className='space-y-6 p-1 text-sm'>
|
||||
<div><div className='mb-2 font-medium'>阅读模式</div><div className='grid grid-cols-2 gap-2'>{([{ key: 'paginated', label: '翻页模式', desc: '左右点击翻页/章节' }, { key: 'scrolled', label: '滚动模式', desc: '上下连续滚动' }] as { key: ReaderMode; label: string; desc: string }[]).map((mode) => <button key={mode.key} onClick={() => setSettings((prev) => ({ ...prev, mode: mode.key }))} className={`rounded-2xl border px-3 py-3 text-left ${settings.mode === mode.key ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}><div className='font-medium'>{mode.label}</div><div className='mt-1 text-xs opacity-70'>{mode.desc}</div></button>)}</div></div>
|
||||
<div><div className='mb-2 font-medium'>主题</div><div className='grid grid-cols-3 gap-2'>{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => <button key={theme} onClick={() => setSettings((prev) => ({ ...prev, theme }))} className={`rounded-2xl border px-3 py-2 ${settings.theme === theme ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}>{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'}</button>)}</div></div>
|
||||
<div><div className='mb-2 flex items-center justify-between font-medium'>字号 <span>{settings.fontSize}%</span></div><input type='range' min='85' max='140' step='5' value={settings.fontSize} onChange={(e) => setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' /></div>
|
||||
<div><div className='mb-2 flex items-center justify-between font-medium'>行距 <span>{settings.lineHeight.toFixed(1)}</span></div><input type='range' min='1.4' max='2.2' step='0.1' value={settings.lineHeight} onChange={(e) => setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' /></div>
|
||||
<div className='flex justify-end'><button type='button' className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white' onClick={() => setSettingsOpen(false)}>完成</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body
|
||||
) : null}
|
||||
|
||||
{settings.mode === 'paginated' && !tocOpen && !settingsOpen ? <><button aria-label='上一页' className='absolute inset-y-0 left-0 z-10 w-[28%] cursor-pointer bg-transparent' onClick={() => turnPage(-1)} /><button aria-label='下一页' className='absolute inset-y-0 right-0 z-10 w-[28%] cursor-pointer bg-transparent' onClick={() => turnPage(1)} /></> : null}
|
||||
|
||||
<div ref={scrollRef} className='h-full overflow-y-auto px-4 py-6' style={{ scrollSnapType: settings.mode === 'paginated' ? 'y mandatory' : undefined }}>
|
||||
<article className='mx-auto max-w-3xl text-gray-800 dark:text-gray-100' style={{ fontSize: `${settings.fontSize}%`, lineHeight: settings.lineHeight, color: palette.bodyColor }}>
|
||||
{loading ? '加载中...' : chapter?.content?.includes('<img')
|
||||
? <div className='space-y-2 [&_img]:mx-auto [&_img]:block [&_img]:max-w-full' dangerouslySetInnerHTML={{ __html: chapter.content }} />
|
||||
: <div className='whitespace-pre-wrap'>{chapter?.content || '本章暂无内容'}</div>}
|
||||
</article>
|
||||
{settings.mode === 'scrolled' ? <div className='mx-auto mt-5 flex max-w-3xl justify-between gap-3 pb-8'><button disabled={currentIndex <= 0} onClick={goPrevChapter} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'>上一章</button><button disabled={currentIndex >= chapters.length - 1} onClick={goNextChapter} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'>下一章</button></div> : null}
|
||||
</div>
|
||||
|
||||
{ttsBarVisible ? <>
|
||||
<div className='absolute inset-x-0 bottom-3 z-20 mx-auto w-[min(94vw,34rem)]'>
|
||||
<div className='overflow-hidden rounded-3xl border border-gray-200 bg-white/95 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
|
||||
<div className='px-2 pt-2'><input type='range' min={0} max={Math.max(ttsDuration, 0)} step={0.1} value={Math.min(ttsSeekValue, Math.max(ttsDuration, 0))} disabled={!ttsAvailable || ttsDuration <= 0} onPointerDown={() => 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' /></div>
|
||||
<div className='px-3 py-2.5'><div className='flex items-center gap-2'><button type='button' onClick={() => void toggleTtsPlayback()} disabled={!ttsAvailable || ttsLoadingChunkIndex !== null} className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-600 text-white disabled:opacity-50'>{ttsLoadingChunkIndex !== null ? <Loader2 className='h-4 w-4 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-4 w-4' /> : <Play className='h-4 w-4' />}</button><div className='min-w-0 flex-1'><div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>{currentChapterTitle || '语音朗读'}</div><div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'><span>{!ttsAvailable ? '服务异常' : ttsStatus === 'playing' ? '正在播放' : ttsStatus === 'paused' ? '已暂停' : ttsLoadingChunkIndex !== null ? '生成语音中...' : '待播放'}</span>{ttsChunks.length > 0 ? <span>{ttsCurrentChunkIndex + 1}/{ttsChunks.length}</span> : null}</div></div><button type='button' onClick={() => setTtsPanelOpen((prev) => !prev)} className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'><ChevronUp className={`h-4 w-4 transition-transform ${ttsPanelOpen ? 'rotate-180' : ''}`} /></button></div><div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'><span>{selectedVoice?.displayName || '默认音色'}</span><span>{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{ttsPanelOpen ? <div className='absolute inset-x-0 bottom-20 z-30 mx-auto w-[min(94vw,34rem)]'><div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'><div className='mb-3 flex items-center justify-between'><div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><Headphones className='h-4 w-4 text-sky-500' />听书控制</div><button type='button' onClick={() => setTtsPanelOpen(false)} className='flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-900 dark:text-gray-300'><X className='h-4 w-4' /></button></div><div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'><span className='truncate'>{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}</span><span className='ml-2 shrink-0'>{Math.round(ttsChunkPercent)}%</span></div><select value={ttsSettings.voice} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, voice: e.target.value })); }} className='mb-4 w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'>{ttsVoices.map((voice) => <option key={voice.shortName} value={voice.shortName}>{voice.displayName || voice.shortName}</option>)}</select><label className='mb-3 block text-xs text-gray-500'>语速 {ttsSettings.rate}<input type='range' min={0} max={TTS_RATE_STEPS.length - 1} step={1} value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, rate: formatSignedValue(TTS_RATE_STEPS[Number(e.target.value)] ?? 0, '%') })); }} className='w-full' /></label><label className='mb-3 block text-xs text-gray-500'>音调 {ttsSettings.pitch}<input type='range' min={0} max={TTS_PITCH_STEPS.length - 1} step={1} value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, pitch: formatSignedValue(TTS_PITCH_STEPS[Number(e.target.value)] ?? 0, 'Hz') })); }} className='w-full' /></label><label className='block text-xs text-gray-500'>音量 {ttsSettings.volume}<input type='range' min={0} max={TTS_VOLUME_STEPS.length - 1} step={1} value={Math.max(0, TTS_VOLUME_STEPS.indexOf(ttsVolumeValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, volume: formatSignedValue(TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0, '%') })); }} className='w-full' /></label>{ttsError ? <div className='mt-3 text-xs text-red-500'>{ttsError}</div> : null}</div></div> : null}
|
||||
</> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <ChapterReader manifest={manifest} />;
|
||||
}
|
||||
|
||||
if (manifest.format === 'pdf') {
|
||||
if (!pdfBlobUrl) return <div className='p-4 text-sm text-gray-500'>PDF 加载中... {progressLabel}</div>;
|
||||
return <iframe src={pdfBlobUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
|
||||
|
||||
+217
-24
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import BookCard from '@/components/books/BookCard';
|
||||
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
||||
@@ -26,18 +26,31 @@ function SearchSkeleton() {
|
||||
}
|
||||
|
||||
const BOOK_SEARCH_STATE_KEY = 'book_search_state';
|
||||
const EMPTY_RESULT: BookSearchResult = { results: [], failedSources: [] };
|
||||
|
||||
export default function BooksSearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [q, setQ] = useState(searchParams.get('q') || '');
|
||||
const [sourceId, setSourceId] = useState(searchParams.get('sourceId') || '');
|
||||
const urlQuery = searchParams.get('q') || '';
|
||||
const urlSourceId = searchParams.get('sourceId') || '';
|
||||
|
||||
const [q, setQ] = useState(urlQuery);
|
||||
const [sourceId, setSourceId] = useState(urlSourceId);
|
||||
const [sources, setSources] = useState<BookSource[]>([]);
|
||||
const [result, setResult] = useState<BookSearchResult>({ results: [], failedSources: [] });
|
||||
const [result, setResult] = useState<BookSearchResult>(EMPTY_RESULT);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [totalSources, setTotalSources] = useState(0);
|
||||
const [completedSources, setCompletedSources] = useState(0);
|
||||
const [useFluidSearch, setUseFluidSearch] = useState(true);
|
||||
|
||||
const restoredRef = useRef(false);
|
||||
const forceNextUrlSearchRef = useRef(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const currentSearchKeyRef = useRef('');
|
||||
const pendingResultsRef = useRef<BookListItem[]>([]);
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
|
||||
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => `book_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`, []);
|
||||
|
||||
@@ -58,6 +71,47 @@ export default function BooksSearchPage() {
|
||||
} catch {}
|
||||
}, [getCacheKey]);
|
||||
|
||||
const readFluidSearchSetting = useCallback(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
try {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
if (savedFluidSearch !== null) return JSON.parse(savedFluidSearch) !== false;
|
||||
} catch {}
|
||||
return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
}, []);
|
||||
|
||||
const closeEventSource = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
try {
|
||||
eventSourceRef.current.close();
|
||||
} catch {}
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearPendingResults = useCallback(() => {
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
window.clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const appendBufferedResults = useCallback((nextResults: BookListItem[]) => {
|
||||
if (nextResults.length === 0) return;
|
||||
pendingResultsRef.current.push(...nextResults);
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = window.setTimeout(() => {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
startTransition(() => {
|
||||
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) }));
|
||||
});
|
||||
flushTimerRef.current = null;
|
||||
}, 80);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveSearchState = useCallback((nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
@@ -78,44 +132,152 @@ export default function BooksSearchPage() {
|
||||
const performSearch = useCallback(async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) return;
|
||||
const normalizedSourceId = selectedSourceId || '';
|
||||
const searchKey = `${normalizedSourceId}::${trimmed}`;
|
||||
const forceRefresh = options?.forceRefresh === true;
|
||||
|
||||
closeEventSource();
|
||||
clearPendingResults();
|
||||
currentSearchKeyRef.current = searchKey;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setHasSearched(true);
|
||||
setResult({ results: [], failedSources: [] });
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
|
||||
const cached = forceRefresh ? null : getCachedResult(trimmed, selectedSourceId);
|
||||
const cached = forceRefresh ? null : getCachedResult(trimmed, normalizedSourceId);
|
||||
if (cached) {
|
||||
setResult(cached);
|
||||
saveSearchState({ q: trimmed, sourceId: selectedSourceId, result: cached });
|
||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: cached });
|
||||
setLoading(false);
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
return;
|
||||
}
|
||||
|
||||
setResult(EMPTY_RESULT);
|
||||
|
||||
const currentFluidSearch = readFluidSearchSetting();
|
||||
setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch));
|
||||
|
||||
const params = new URLSearchParams({ q: trimmed });
|
||||
if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
|
||||
|
||||
if (currentFluidSearch) {
|
||||
const es = new EventSource(`/api/books/search/ws?${params.toString()}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (!event.data || currentSearchKeyRef.current !== searchKey) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
switch (payload.type) {
|
||||
case 'start':
|
||||
setTotalSources(payload.totalSources || 0);
|
||||
setCompletedSources(0);
|
||||
break;
|
||||
case 'source_result':
|
||||
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
appendBufferedResults(payload.results as BookListItem[]);
|
||||
}
|
||||
break;
|
||||
case 'source_error': {
|
||||
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
setError(payload.error || '搜索失败');
|
||||
setLoading(false);
|
||||
closeEventSource();
|
||||
break;
|
||||
case 'complete': {
|
||||
const finalFailedSources: BookSearchResult['failedSources'] = [];
|
||||
setCompletedSources(payload.completedSources || payload.totalSources || 0);
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
window.clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setResult((prev) => {
|
||||
const nextResult = { results: prev.results.concat(toAppend), failedSources: finalFailedSources };
|
||||
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
|
||||
return nextResult;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setResult((prev) => {
|
||||
const nextResult = { results: prev.results, failedSources: finalFailedSources };
|
||||
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
|
||||
return nextResult;
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
closeEventSource();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
if (currentSearchKeyRef.current !== searchKey) return;
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
window.clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) }));
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
closeEventSource();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmed, ...(selectedSourceId ? { sourceId: selectedSourceId } : {}) });
|
||||
const res = await fetch(`/api/books/search?${params.toString()}`);
|
||||
const json = await res.json();
|
||||
if (currentSearchKeyRef.current !== searchKey) return;
|
||||
if (!res.ok) throw new Error(json.error || '搜索失败');
|
||||
const nextResult: BookSearchResult = { results: json.results || [], failedSources: json.failedSources || [] };
|
||||
const nextResult: BookSearchResult = { results: json.results || [], failedSources: [] };
|
||||
setResult(nextResult);
|
||||
setCachedResult(trimmed, selectedSourceId, nextResult);
|
||||
saveSearchState({ q: trimmed, sourceId: selectedSourceId, result: nextResult });
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
|
||||
} catch (err) {
|
||||
if (currentSearchKeyRef.current !== searchKey) return;
|
||||
setError((err as Error).message || '搜索失败');
|
||||
setResult({ results: [], failedSources: [] });
|
||||
setResult(EMPTY_RESULT);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (currentSearchKeyRef.current === searchKey) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [getCachedResult, saveSearchState, setCachedResult]);
|
||||
}, [appendBufferedResults, clearPendingResults, closeEventSource, getCachedResult, readFluidSearchSetting, saveSearchState, setCachedResult]);
|
||||
|
||||
useEffect(() => {
|
||||
setUseFluidSearch(readFluidSearchSetting());
|
||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])).catch(() => undefined);
|
||||
}, []);
|
||||
return () => {
|
||||
closeEventSource();
|
||||
clearPendingResults();
|
||||
};
|
||||
}, [clearPendingResults, closeEventSource, readFluidSearchSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = searchParams.get('q') || '';
|
||||
const source = searchParams.get('sourceId') || '';
|
||||
const keyword = urlQuery;
|
||||
const source = urlSourceId;
|
||||
|
||||
if (!restoredRef.current) {
|
||||
restoredRef.current = true;
|
||||
@@ -124,7 +286,7 @@ export default function BooksSearchPage() {
|
||||
if (cachedState?.q?.trim()) {
|
||||
setQ(cachedState.q);
|
||||
setSourceId(cachedState.sourceId || '');
|
||||
setResult(cachedState.result || { results: [], failedSources: [] });
|
||||
setResult(cachedState.result || EMPTY_RESULT);
|
||||
setHasSearched(true);
|
||||
}
|
||||
return;
|
||||
@@ -134,18 +296,42 @@ export default function BooksSearchPage() {
|
||||
setQ(keyword);
|
||||
setSourceId(source);
|
||||
if (!keyword) {
|
||||
setResult({ results: [], failedSources: [] });
|
||||
closeEventSource();
|
||||
clearPendingResults();
|
||||
setResult(EMPTY_RESULT);
|
||||
setLoading(false);
|
||||
setHasSearched(false);
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
void performSearch(keyword, source);
|
||||
}, [performSearch, restoreSearchState, searchParams]);
|
||||
|
||||
const forceRefresh = forceNextUrlSearchRef.current;
|
||||
forceNextUrlSearchRef.current = false;
|
||||
void performSearch(keyword, source, { forceRefresh });
|
||||
}, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', trimmed);
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
const nextUrl = `/books/search?${params.toString()}`;
|
||||
if (urlQuery === trimmed && urlSourceId === sourceId) {
|
||||
await performSearch(trimmed, sourceId, { forceRefresh: true });
|
||||
} else {
|
||||
forceNextUrlSearchRef.current = true;
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<form onSubmit={async (e) => { e.preventDefault(); const trimmed = q.trim(); if (!trimmed) return; const params = new URLSearchParams(); params.set('q', trimmed); if (sourceId) params.set('sourceId', sourceId); router.replace(`/books/search?${params.toString()}`); await performSearch(trimmed, sourceId, { forceRefresh: true }); }} className='space-y-3'>
|
||||
<form onSubmit={handleSubmit} className='space-y-3'>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
|
||||
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
|
||||
<option value=''>全部书源</option>
|
||||
@@ -154,9 +340,16 @@ export default function BooksSearchPage() {
|
||||
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>搜索</button>
|
||||
</form>
|
||||
</section>
|
||||
{loading ? <SearchSkeleton /> : null}
|
||||
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<h2 className='text-lg font-semibold'>搜索结果{result.results.length > 0 ? `(${result.results.length})` : ''}</h2>
|
||||
{loading && useFluidSearch && totalSources > 0 ? (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>搜索中 {completedSources}/{totalSources}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading && result.results.length === 0 ? <SearchSkeleton /> : null}
|
||||
{error ? <div className='rounded-2xl bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/20 dark:text-red-300'>{error}</div> : null}
|
||||
{result.failedSources.length > 0 ? <div className='rounded-2xl bg-amber-50 p-4 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300'>{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join(';')}</div> : null}
|
||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
|
||||
</section>
|
||||
|
||||
+206
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
||||
import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types';
|
||||
@@ -43,6 +43,14 @@ export default function MangaSearchPage() {
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
||||
const [lastSearchedSourceId, setLastSearchedSourceId] = useState('');
|
||||
const restoredRef = useRef(false);
|
||||
const forceNextUrlSearchRef = useRef(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const currentSearchKeyRef = useRef('');
|
||||
const pendingResultsRef = useRef<MangaSearchItem[]>([]);
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
const [totalSources, setTotalSources] = useState(0);
|
||||
const [completedSources, setCompletedSources] = useState(0);
|
||||
const [useFluidSearch, setUseFluidSearch] = useState(true);
|
||||
|
||||
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => {
|
||||
return `manga_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`;
|
||||
@@ -73,6 +81,52 @@ export default function MangaSearchPage() {
|
||||
[getCacheKey]
|
||||
);
|
||||
|
||||
|
||||
const readFluidSearchSetting = useCallback(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
try {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
if (savedFluidSearch !== null) return JSON.parse(savedFluidSearch) !== false;
|
||||
} catch {
|
||||
// ignore invalid localStorage values
|
||||
}
|
||||
return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
}, []);
|
||||
|
||||
const closeEventSource = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
try {
|
||||
eventSourceRef.current.close();
|
||||
} catch {
|
||||
// ignore close failures
|
||||
}
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearPendingResults = useCallback(() => {
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
window.clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const appendBufferedResults = useCallback((nextResults: MangaSearchItem[]) => {
|
||||
if (nextResults.length === 0) return;
|
||||
pendingResultsRef.current.push(...nextResults);
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = window.setTimeout(() => {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
startTransition(() => {
|
||||
setResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
flushTimerRef.current = null;
|
||||
}, 80);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveSearchState = useCallback((nextState: { query: string; sourceId: string; results: MangaSearchItem[] }) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
@@ -99,52 +153,169 @@ export default function MangaSearchPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setUseFluidSearch(readFluidSearchSetting());
|
||||
|
||||
fetch('/api/manga/sources')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSources(data.sources || []))
|
||||
.catch(() => undefined);
|
||||
|
||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
closeEventSource();
|
||||
clearPendingResults();
|
||||
};
|
||||
}, [clearPendingResults, closeEventSource, readFluidSearchSetting]);
|
||||
|
||||
const performSearch = useCallback(
|
||||
async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
|
||||
const trimmedQuery = keyword.trim();
|
||||
if (!trimmedQuery) return;
|
||||
const normalizedSourceId = selectedSourceId || '';
|
||||
const searchKey = `${normalizedSourceId}::${trimmedQuery}`;
|
||||
const forceRefresh = options?.forceRefresh === true;
|
||||
|
||||
closeEventSource();
|
||||
clearPendingResults();
|
||||
currentSearchKeyRef.current = searchKey;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setHasSearched(true);
|
||||
setLastSearchedQuery(trimmedQuery);
|
||||
setLastSearchedSourceId(selectedSourceId);
|
||||
setLastSearchedSourceId(normalizedSourceId);
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
|
||||
const cached = forceRefresh ? null : getCachedResults(trimmedQuery, selectedSourceId);
|
||||
const cached = forceRefresh ? null : getCachedResults(trimmedQuery, normalizedSourceId);
|
||||
if (cached) {
|
||||
setResults(cached);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached });
|
||||
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: cached });
|
||||
setLoading(false);
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
return;
|
||||
}
|
||||
|
||||
setResults([]);
|
||||
|
||||
const currentFluidSearch = readFluidSearchSetting();
|
||||
setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch));
|
||||
|
||||
const params = new URLSearchParams({ q: trimmedQuery });
|
||||
if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
|
||||
|
||||
if (currentFluidSearch) {
|
||||
const es = new EventSource(`/api/manga/search/ws?${params.toString()}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (!event.data || currentSearchKeyRef.current !== searchKey) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
switch (payload.type) {
|
||||
case 'start':
|
||||
setTotalSources(payload.totalSources || 0);
|
||||
setCompletedSources(0);
|
||||
break;
|
||||
case 'source_result':
|
||||
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
appendBufferedResults(payload.results as MangaSearchItem[]);
|
||||
}
|
||||
break;
|
||||
case 'source_error':
|
||||
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
||||
break;
|
||||
case 'error':
|
||||
setError(payload.error || '搜索失败');
|
||||
setLoading(false);
|
||||
closeEventSource();
|
||||
break;
|
||||
case 'complete': {
|
||||
setCompletedSources(payload.completedSources || payload.totalSources || 0);
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
window.clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setResults((prev) => {
|
||||
const nextResults = prev.concat(toAppend);
|
||||
setCachedResults(trimmedQuery, normalizedSourceId, nextResults);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: nextResults });
|
||||
return nextResults;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setResults((prev) => {
|
||||
setCachedResults(trimmedQuery, normalizedSourceId, prev);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: prev });
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
closeEventSource();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed SSE payloads
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
if (currentSearchKeyRef.current !== searchKey) return;
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
window.clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
closeEventSource();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmedQuery });
|
||||
if (selectedSourceId) params.set('sourceId', selectedSourceId);
|
||||
const res = await fetch(`/api/manga/search?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (currentSearchKeyRef.current !== searchKey) return;
|
||||
if (!res.ok) throw new Error(data.error || '搜索失败');
|
||||
const nextResults = data.results || [];
|
||||
setResults(nextResults);
|
||||
setCachedResults(trimmedQuery, selectedSourceId, nextResults);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: nextResults });
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
setCachedResults(trimmedQuery, normalizedSourceId, nextResults);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: normalizedSourceId, results: nextResults });
|
||||
} catch (err) {
|
||||
if (currentSearchKeyRef.current !== searchKey) return;
|
||||
setError((err as Error).message);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (currentSearchKeyRef.current === searchKey) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[getCachedResults, saveSearchState, setCachedResults]
|
||||
[
|
||||
appendBufferedResults,
|
||||
clearPendingResults,
|
||||
closeEventSource,
|
||||
getCachedResults,
|
||||
readFluidSearchSetting,
|
||||
saveSearchState,
|
||||
setCachedResults,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -169,16 +340,23 @@ export default function MangaSearchPage() {
|
||||
setSourceId(urlSourceId);
|
||||
|
||||
if (!urlQuery) {
|
||||
closeEventSource();
|
||||
clearPendingResults();
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
setHasSearched(false);
|
||||
setLastSearchedQuery('');
|
||||
setLastSearchedSourceId('');
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
|
||||
void performSearch(urlQuery, urlSourceId);
|
||||
}, [performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
||||
const forceRefresh = forceNextUrlSearchRef.current;
|
||||
forceNextUrlSearchRef.current = false;
|
||||
void performSearch(urlQuery, urlSourceId, { forceRefresh });
|
||||
}, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -187,8 +365,13 @@ export default function MangaSearchPage() {
|
||||
|
||||
const params = new URLSearchParams({ q: trimmedQuery });
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
router.replace(`/manga/search?${params.toString()}`);
|
||||
await performSearch(trimmedQuery, sourceId, { forceRefresh: true });
|
||||
const nextUrl = `/manga/search?${params.toString()}`;
|
||||
if (urlQuery === trimmedQuery && urlSourceId === sourceId) {
|
||||
await performSearch(trimmedQuery, sourceId, { forceRefresh: true });
|
||||
} else {
|
||||
forceNextUrlSearchRef.current = true;
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const returnTo = useMemo(() => {
|
||||
@@ -257,11 +440,16 @@ export default function MangaSearchPage() {
|
||||
</form>
|
||||
|
||||
<section>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-lg font-semibold'>搜索结果</h2>
|
||||
<div className='mb-4 flex items-center justify-between gap-3'>
|
||||
<h2 className='text-lg font-semibold'>搜索结果{results.length > 0 ? `(${results.length})` : ''}</h2>
|
||||
{loading && useFluidSearch && totalSources > 0 && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
搜索中 {completedSources}/{totalSources}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
|
||||
{loading ? (
|
||||
{loading && results.length === 0 ? (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<MangaCardSkeleton key={index} withButton />
|
||||
|
||||
+300
-14
@@ -183,6 +183,74 @@ function AudioSpectrumCanvas({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const VINYL_NEEDLE_SVG = `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 130"><path d="M21,21 C21,65 86,70 86,100" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="6" stroke-linecap="round"/><path d="M20,20 C20,65 85,70 85,100" fill="none" stroke="%23e0e0e0" stroke-width="4.5" stroke-linecap="round"/><path d="M19,20 C19,65 84,70 84,100" fill="none" stroke="%23fff" stroke-width="1.5" stroke-linecap="round"/><g transform="translate(85, 100) rotate(25)"><rect x="-6" y="0" width="12" height="18" rx="2" fill="%23ccc"/><rect x="-4" y="5" width="8" height="14" rx="1" fill="%23333"/><rect x="-2" y="16" width="4" height="5" rx="1" fill="%23d43c33"/></g><circle cx="20" cy="20" r="10" fill="%23f0f0f0" stroke="%23ccc" stroke-width="1"/><circle cx="20" cy="20" r="4" fill="%23fff"/><circle cx="20" cy="20" r="1.5" fill="%23999"/></svg>')`;
|
||||
|
||||
function VinylTurntable({
|
||||
cover,
|
||||
title,
|
||||
isPlaying,
|
||||
className = '',
|
||||
}: {
|
||||
cover?: string;
|
||||
title: string;
|
||||
isPlaying: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative mx-auto mt-12 mb-5 flex h-[280px] w-[280px] items-center justify-center md:mt-16 md:mb-8 md:h-[340px] md:w-[340px] ${className}`}
|
||||
aria-label="黑胶唱片机封面"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-[-54px] z-20 h-[140px] w-[108px] drop-shadow-xl transition-transform duration-500"
|
||||
style={{
|
||||
marginLeft: '-20px',
|
||||
backgroundImage: VINYL_NEEDLE_SVG,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
transformOrigin: '20px 20px',
|
||||
transform: isPlaying ? 'rotate(0deg)' : 'rotate(-30deg)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative flex h-[246px] w-[246px] items-center justify-center overflow-hidden rounded-full md:h-[300px] md:w-[300px]"
|
||||
style={{
|
||||
background: 'conic-gradient(from 45deg, #070707 0%, #2b2b2b 10%, #101010 20%, #080808 32%, #242424 42%, #0b0b0b 55%, #1e1e1e 68%, #050505 80%, #2c2c2c 90%, #070707 100%)',
|
||||
boxShadow: '0 0 0 8px rgba(255,255,255,0.055), 0 20px 42px rgba(0,0,0,0.65), inset 0 0 28px rgba(255,255,255,0.045)',
|
||||
animation: 'vinyl-spin 20s linear infinite',
|
||||
animationPlayState: isPlaying ? 'running' : 'paused',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'repeating-radial-gradient(circle, transparent 0, transparent 3px, rgba(255,255,255,0.055) 3px, rgba(255,255,255,0.055) 4px)',
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute left-[18%] top-[10%] h-[42%] w-[22%] rotate-[-28deg] rounded-full bg-white/10 blur-md" />
|
||||
<div className="relative z-10 flex h-[158px] w-[158px] items-center justify-center overflow-hidden rounded-full border-[5px] border-black bg-zinc-800 md:h-[192px] md:w-[192px]">
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt={title}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<svg className="h-14 w-14 text-zinc-500 md:h-16 md:w-16" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute z-20 h-3 w-3 rounded-full border border-white/30 bg-zinc-950" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 扩展 Window 接口以支持 Document PiP API
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -201,6 +269,10 @@ export default function MusicPage() {
|
||||
const [currentView, setCurrentView] = useState<'playlists' | 'songs' | 'myPlaylists'>('playlists');
|
||||
const [currentPlaylistTitle, setCurrentPlaylistTitle] = useState('');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [searchHasMore, setSearchHasMore] = useState(false);
|
||||
const [loadingMoreSearch, setLoadingMoreSearch] = useState(false);
|
||||
const [currentSong, setCurrentSong] = useState<Song | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -212,6 +284,7 @@ export default function MusicPage() {
|
||||
const [showPlayer, setShowPlayer] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
const [mobileLyricsView, setMobileLyricsView] = useState<'lyrics' | 'vinyl'>('lyrics');
|
||||
const [musicProxyEnabled, setMusicProxyEnabled] = useState(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
return (window as any).RUNTIME_CONFIG?.MUSIC_PROXY_ENABLED !== false;
|
||||
@@ -277,6 +350,7 @@ export default function MusicPage() {
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const lyricsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const searchLoadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
const restoredTimeRef = useRef<number>(0);
|
||||
const songStartTimeRef = useRef<number>(0); // 歌曲开始播放的时间戳
|
||||
@@ -407,6 +481,7 @@ export default function MusicPage() {
|
||||
playProgressSec: playTime,
|
||||
lastPlayedAt,
|
||||
lastQuality: quality,
|
||||
createdAt: record.timestamp,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -701,6 +776,9 @@ export default function MusicPage() {
|
||||
const data = await response.json();
|
||||
setSongs((data.data?.list || []).map(mapSong));
|
||||
setCurrentPlaylistTitle(playlistName);
|
||||
setActiveSearchKeyword('');
|
||||
setSearchPage(1);
|
||||
setSearchHasMore(false);
|
||||
setCurrentView('songs');
|
||||
} catch (error) {
|
||||
console.error('加载歌单失败:', error);
|
||||
@@ -743,6 +821,7 @@ export default function MusicPage() {
|
||||
lastPlayedAt: baseTime + i,
|
||||
playCount: 1,
|
||||
lastQuality: quality,
|
||||
createdAt: baseTime + i,
|
||||
}));
|
||||
|
||||
await fetch('/api/music/v2/history', {
|
||||
@@ -783,25 +862,68 @@ export default function MusicPage() {
|
||||
|
||||
// 搜索歌曲
|
||||
const searchSongs = async () => {
|
||||
if (!searchKeyword.trim()) return;
|
||||
const keyword = searchKeyword.trim();
|
||||
if (!keyword) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(searchKeyword)}&page=1&limit=20`
|
||||
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=1&limit=20`
|
||||
);
|
||||
const data = await response.json();
|
||||
setSongs((data.data?.list || []).map(mapSong));
|
||||
setCurrentPlaylistTitle(`搜索: ${searchKeyword}`);
|
||||
setActiveSearchKeyword(keyword);
|
||||
setSearchPage(1);
|
||||
setSearchHasMore(Boolean(data.data?.hasMore));
|
||||
setCurrentPlaylistTitle(`搜索: ${keyword}`);
|
||||
setCurrentView('songs');
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
setSongs([]);
|
||||
setActiveSearchKeyword('');
|
||||
setSearchPage(1);
|
||||
setSearchHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreSearchSongs = async () => {
|
||||
const keyword = activeSearchKeyword.trim();
|
||||
if (!keyword || loadingMoreSearch || !searchHasMore) return;
|
||||
|
||||
const nextPage = searchPage + 1;
|
||||
setLoadingMoreSearch(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=${nextPage}&limit=20`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
const nextSongs = (data.data?.list || []).map(mapSong);
|
||||
setSongs((prev) => [...prev, ...nextSongs]);
|
||||
setSearchPage(nextPage);
|
||||
setSearchHasMore(Boolean(data.data?.hasMore));
|
||||
} else {
|
||||
setToast({
|
||||
message: data.error?.message || '加载更多失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载更多搜索结果失败:', error);
|
||||
setToast({
|
||||
message: '加载更多失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} finally {
|
||||
setLoadingMoreSearch(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开添加到歌单弹窗
|
||||
const handleAddToPlaylist = (song: Song, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发播放
|
||||
@@ -930,6 +1052,7 @@ export default function MusicPage() {
|
||||
lastPlayedAt: baseTime + i,
|
||||
playCount: 1,
|
||||
lastQuality: quality,
|
||||
createdAt: baseTime + i,
|
||||
}));
|
||||
|
||||
// 一次性批量添加所有歌曲
|
||||
@@ -1131,8 +1254,10 @@ export default function MusicPage() {
|
||||
setShowPlayer(true);
|
||||
setLyrics([]); // 清空旧歌词
|
||||
|
||||
// 添加到播放记录和播放列表
|
||||
const record: PlayRecord = {
|
||||
// 添加到播放记录和播放列表。timestamp 表示入队时间,不能在再次播放时刷新,
|
||||
// 否则会破坏按 createdAt/timestamp 维护的播放队列顺序。
|
||||
const existingRecord = playRecords.find(r => r.platform === platform && r.id === song.id);
|
||||
const record: PlayRecord = existingRecord || {
|
||||
platform: platform,
|
||||
id: song.id,
|
||||
playTime: 0, // 初始播放时间
|
||||
@@ -1146,11 +1271,11 @@ export default function MusicPage() {
|
||||
setPlayRecords(prev => {
|
||||
const existingIndex = prev.findIndex(r => r.platform === record.platform && r.id === record.id);
|
||||
if (existingIndex >= 0) {
|
||||
// 记录已存在,更新时间戳但不重置播放时间
|
||||
// 记录已存在:保持原位置和原 timestamp,只补齐可能变化的时长信息。
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = {
|
||||
...updated[existingIndex],
|
||||
timestamp: Date.now(),
|
||||
duration: updated[existingIndex].duration || song.duration || 0,
|
||||
};
|
||||
return updated;
|
||||
} else {
|
||||
@@ -1411,6 +1536,9 @@ export default function MusicPage() {
|
||||
if (currentView === 'songs') {
|
||||
setCurrentView('playlists');
|
||||
setSongs([]);
|
||||
setActiveSearchKeyword('');
|
||||
setSearchPage(1);
|
||||
setSearchHasMore(false);
|
||||
} else if (currentView === 'myPlaylists') {
|
||||
setCurrentView('playlists');
|
||||
setSelectedUserPlaylist(null);
|
||||
@@ -1439,6 +1567,9 @@ export default function MusicPage() {
|
||||
setCurrentView('playlists');
|
||||
setSongs([]);
|
||||
setSearchKeyword('');
|
||||
setActiveSearchKeyword('');
|
||||
setSearchPage(1);
|
||||
setSearchHasMore(false);
|
||||
};
|
||||
|
||||
// 音频事件监听
|
||||
@@ -1597,6 +1728,27 @@ export default function MusicPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = searchLoadMoreRef.current;
|
||||
if (!sentinel || !activeSearchKeyword || !searchHasMore) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMoreSearchSongs();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '240px 0px 240px 0px',
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [activeSearchKeyword, searchHasMore, loadingMoreSearch, searchPage]);
|
||||
|
||||
// 进度条拖动
|
||||
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = (parseFloat(e.target.value) / 100) * duration;
|
||||
@@ -1845,7 +1997,99 @@ export default function MusicPage() {
|
||||
return (
|
||||
<div className="music-theme min-h-screen bg-zinc-950 text-white">
|
||||
<>
|
||||
<style jsx>{`
|
||||
<style jsx global>{`
|
||||
@keyframes vinyl-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.vinyl-player {
|
||||
position: relative;
|
||||
width: min(72vw, 300px);
|
||||
height: min(72vw, 300px);
|
||||
margin: 42px auto 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.vinyl-player {
|
||||
width: clamp(260px, 30vw, 360px);
|
||||
height: clamp(260px, 30vw, 360px);
|
||||
margin: 54px auto 28px;
|
||||
}
|
||||
}
|
||||
.vinyl-needle {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: 50%;
|
||||
margin-left: -20px;
|
||||
width: 108px;
|
||||
height: 140px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 130"><path d="M21,21 C21,65 86,70 86,100" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="6" stroke-linecap="round"/><path d="M20,20 C20,65 85,70 85,100" fill="none" stroke="%23e0e0e0" stroke-width="4.5" stroke-linecap="round"/><path d="M19,20 C19,65 84,70 84,100" fill="none" stroke="%23fff" stroke-width="1.5" stroke-linecap="round"/><g transform="translate(85, 100) rotate(25)"><rect x="-6" y="0" width="12" height="18" rx="2" fill="%23ccc"/><rect x="-4" y="5" width="8" height="14" rx="1" fill="%23333"/><rect x="-2" y="16" width="4" height="5" rx="1" fill="%23d43c33"/></g><circle cx="20" cy="20" r="10" fill="%23f0f0f0" stroke="%23ccc" stroke-width="1"/><circle cx="20" cy="20" r="4" fill="%23fff"/><circle cx="20" cy="20" r="1.5" fill="%23999"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
transform-origin: 20px 20px;
|
||||
transform: rotate(-30deg);
|
||||
transition: transform 0.5s cubic-bezier(0.3, 0, 0.1, 1);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 8px 8px rgba(0,0,0,0.35));
|
||||
}
|
||||
.vinyl-player.playing .vinyl-needle {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
.vinyl-disc {
|
||||
position: relative;
|
||||
width: 88%;
|
||||
height: 88%;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: conic-gradient(from 45deg, #080808 0%, #2a2a2a 10%, #0f0f0f 20%, #111 40%, #303030 50%, #111 60%, #090909 80%, #2a2a2a 90%, #080808 100%);
|
||||
box-shadow: 0 0 0 8px rgba(255,255,255,0.045), 0 18px 36px rgba(0,0,0,0.55), inset 0 0 24px rgba(255,255,255,0.04);
|
||||
animation: vinyl-spin 20s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.vinyl-player.playing .vinyl-disc {
|
||||
animation-play-state: running;
|
||||
}
|
||||
.vinyl-disc::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: repeating-radial-gradient(transparent 0, transparent 3px, rgba(255,255,255,0.045) 3px, rgba(255,255,255,0.045) 4px), radial-gradient(circle at 35% 25%, rgba(255,255,255,0.14), transparent 28%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.vinyl-disc::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 9999px;
|
||||
background: #111;
|
||||
border: 2px solid rgba(255,255,255,0.25);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vinyl-cover {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 64%;
|
||||
height: 64%;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 5px solid #0a0a0a;
|
||||
user-select: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06);
|
||||
}
|
||||
.vinyl-cover-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #27272a;
|
||||
}
|
||||
@keyframes music-note-bounce {
|
||||
0%,
|
||||
100% {
|
||||
@@ -2117,9 +2361,25 @@ export default function MusicPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{activeSearchKeyword && (
|
||||
<div ref={searchLoadMoreRef} className="mt-6 flex min-h-10 justify-center">
|
||||
{searchHasMore ? (
|
||||
loadingMoreSearch ? (
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-5 py-2.5">
|
||||
<MusicLoadingIndicator size="sm" className="gap-2 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">
|
||||
继续向下滚动加载更多
|
||||
</div>
|
||||
)
|
||||
) : songs.length > 0 ? (
|
||||
<div className="text-xs text-zinc-500">没有更多搜索结果了</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Playlists View */}
|
||||
{currentView === 'myPlaylists' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@@ -2465,7 +2725,7 @@ export default function MusicPage() {
|
||||
</button>
|
||||
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
||||
{/* Cover / Meta */}
|
||||
<div className="relative h-32 md:h-auto md:w-[320px] lg:w-[360px] xl:w-[420px] bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0">
|
||||
<div className="relative h-32 md:h-auto md:w-[380px] lg:w-[430px] xl:w-[480px] bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0 overflow-hidden">
|
||||
{currentSong.pic && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
@@ -2476,7 +2736,13 @@ export default function MusicPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-full flex flex-col items-center justify-center p-4 md:p-6 lg:p-8">
|
||||
<div className="w-16 h-16 md:w-40 md:h-40 lg:w-56 lg:h-56 rounded-xl lg:rounded-2xl overflow-hidden shadow-2xl mb-2 md:mb-4 lg:mb-6">
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMobileLyricsView((view) => (view === 'lyrics' ? 'vinyl' : 'lyrics'));
|
||||
}}
|
||||
className="w-16 h-16 md:hidden rounded-xl overflow-hidden shadow-2xl mb-2"
|
||||
>
|
||||
{currentSong.pic ? (
|
||||
<img
|
||||
src={currentSong.pic}
|
||||
@@ -2485,19 +2751,39 @@ export default function MusicPage() {
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 md:w-16 md:h-16 lg:w-20 lg:h-20 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg className="w-8 h-8 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-base md:text-lg lg:text-2xl font-bold text-white text-center mb-1 line-clamp-2">{currentSong.name}</h2>
|
||||
<div className="hidden md:block w-full">
|
||||
<VinylTurntable cover={currentSong.pic} title={currentSong.name} isPlaying={isPlaying} />
|
||||
</div>
|
||||
<h2 className="text-base md:text-xl lg:text-2xl font-bold text-white text-center mb-1 line-clamp-2">{currentSong.name}</h2>
|
||||
<p className="text-xs md:text-sm lg:text-base text-zinc-400 line-clamp-1 text-center">{currentSong.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileLyricsView === 'vinyl' && (
|
||||
<div className="relative md:hidden flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-zinc-800 to-zinc-900">
|
||||
{currentSong.pic && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={currentSong.pic}
|
||||
alt={currentSong.name}
|
||||
className="w-full h-full object-cover opacity-30 blur-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-full flex items-center justify-center p-4">
|
||||
<VinylTurntable cover={currentSong.pic} title={currentSong.name} isPlaying={isPlaying} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lyrics Content */}
|
||||
<div ref={lyricsContainerRef} className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
|
||||
<div ref={lyricsContainerRef} className={`${mobileLyricsView === 'vinyl' ? 'hidden md:block' : 'block'} flex-1 overflow-y-auto p-4 md:p-6 lg:p-8`}>
|
||||
{lyrics.length > 0 ? (
|
||||
<div className="space-y-4 md:space-y-5">
|
||||
{lyrics.map((line, index) => (
|
||||
|
||||
+156
-51
@@ -2,7 +2,15 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { BookMarked, BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
|
||||
import {
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Link as LinkIcon,
|
||||
ListVideo,
|
||||
Music,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
@@ -56,23 +64,29 @@ function HomeClient() {
|
||||
{ id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 },
|
||||
]);
|
||||
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
|
||||
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true);
|
||||
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] =
|
||||
useState(true);
|
||||
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
const [showHttpWarning, setShowHttpWarning] = useState(true);
|
||||
const [showAIChat, setShowAIChat] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState(
|
||||
'你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'
|
||||
);
|
||||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||||
const [musicEnabled, setMusicEnabled] = useState(false);
|
||||
const [mangaEnabled, setMangaEnabled] = useState(false);
|
||||
const [booksEnabled, setBooksEnabled] = useState(false);
|
||||
const [netdiskTempPlayEnabled, setNetdiskTempPlayEnabled] = useState(false);
|
||||
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
||||
const [directPlayUrl, setDirectPlayUrl] = useState('');
|
||||
const [directPlaySubmitting, setDirectPlaySubmitting] = useState(false);
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
|
||||
const detectNetdiskLink = (url: string): {
|
||||
const detectNetdiskLink = (
|
||||
url: string
|
||||
): {
|
||||
provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc' | '115';
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
@@ -84,11 +98,17 @@ function HomeClient() {
|
||||
|
||||
const inlinePasscode = (text: string) =>
|
||||
pickPasscode(
|
||||
text.match(/(?:提取码|访问码|密码)\s*[::=]?\s*([a-zA-Z0-9]{4,8})/i)?.[1],
|
||||
text.match(
|
||||
/(?:提取码|访问码|密码)\s*[::=]?\s*([a-zA-Z0-9]{4,8})/i
|
||||
)?.[1],
|
||||
text.match(/[?&](?:pwd|passcode|accessCode)=([^&\s]+)/i)?.[1]
|
||||
);
|
||||
|
||||
if (/https:\/\/(?:www\.)?123(?:684|865|912|pan)\.(?:com|cn)\/s\//i.test(trimmed)) {
|
||||
if (
|
||||
/https:\/\/(?:www\.)?123(?:684|865|912|pan)\.(?:com|cn)\/s\//i.test(
|
||||
trimmed
|
||||
)
|
||||
) {
|
||||
return {
|
||||
provider: '123',
|
||||
shareUrl: trimmed,
|
||||
@@ -99,7 +119,10 @@ function HomeClient() {
|
||||
};
|
||||
}
|
||||
|
||||
if (/https:\/\/cloud\.189\.cn\/(web\/share\?code=|t\/)/i.test(trimmed) || /https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\//i.test(trimmed)) {
|
||||
if (
|
||||
/https:\/\/cloud\.189\.cn\/(web\/share\?code=|t\/)/i.test(trimmed) ||
|
||||
/https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\//i.test(trimmed)
|
||||
) {
|
||||
return {
|
||||
provider: 'tianyi',
|
||||
shareUrl: trimmed,
|
||||
@@ -172,21 +195,25 @@ function HomeClient() {
|
||||
setDirectPlaySubmitting(true);
|
||||
try {
|
||||
const netdisk = detectNetdiskLink(trimmed);
|
||||
if (netdisk && !netdiskTempPlayEnabled) {
|
||||
throw new Error('无权限使用临时播放');
|
||||
}
|
||||
|
||||
if (netdisk) {
|
||||
const source =
|
||||
netdisk.provider === 'mobile'
|
||||
? 'netdisk-mobile'
|
||||
: netdisk.provider === 'baidu'
|
||||
? 'netdisk-baidu'
|
||||
: netdisk.provider === 'tianyi'
|
||||
? 'netdisk-tianyi'
|
||||
: netdisk.provider === '115'
|
||||
? 'netdisk-115'
|
||||
: netdisk.provider === 'uc'
|
||||
? 'netdisk-uc'
|
||||
: netdisk.provider === '123'
|
||||
? 'netdisk-123'
|
||||
: 'netdisk-quark';
|
||||
? 'netdisk-baidu'
|
||||
: netdisk.provider === 'tianyi'
|
||||
? 'netdisk-tianyi'
|
||||
: netdisk.provider === '115'
|
||||
? 'netdisk-115'
|
||||
: netdisk.provider === 'uc'
|
||||
? 'netdisk-uc'
|
||||
: netdisk.provider === '123'
|
||||
? 'netdisk-123'
|
||||
: 'netdisk-quark';
|
||||
const id = base58Encode(
|
||||
JSON.stringify({
|
||||
shareUrl: netdisk.shareUrl,
|
||||
@@ -196,7 +223,11 @@ function HomeClient() {
|
||||
if (!id) {
|
||||
throw new Error('网盘链接编码失败');
|
||||
}
|
||||
const targetUrl = `/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(id)}&title=${encodeURIComponent('网盘直链播放')}`;
|
||||
const targetUrl = `/play?source=${encodeURIComponent(
|
||||
source
|
||||
)}&id=${encodeURIComponent(id)}&title=${encodeURIComponent(
|
||||
'网盘直链播放'
|
||||
)}`;
|
||||
setShowDirectPlayDialog(false);
|
||||
setDirectPlayUrl('');
|
||||
window.location.assign(targetUrl);
|
||||
@@ -205,7 +236,9 @@ function HomeClient() {
|
||||
|
||||
const encoded = base58Encode(trimmed);
|
||||
if (!encoded) return;
|
||||
const targetUrl = `/play?source=directplay&id=${encodeURIComponent(encoded)}`;
|
||||
const targetUrl = `/play?source=directplay&id=${encodeURIComponent(
|
||||
encoded
|
||||
)}`;
|
||||
setShowDirectPlayDialog(false);
|
||||
setDirectPlayUrl('');
|
||||
window.location.assign(targetUrl);
|
||||
@@ -237,9 +270,13 @@ function HomeClient() {
|
||||
setHomeBannerEnabled(savedHomeBannerEnabled === 'true');
|
||||
}
|
||||
|
||||
const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled');
|
||||
const savedHomeContinueWatchingEnabled = localStorage.getItem(
|
||||
'homeContinueWatchingEnabled'
|
||||
);
|
||||
if (savedHomeContinueWatchingEnabled !== null) {
|
||||
setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true');
|
||||
setHomeContinueWatchingEnabled(
|
||||
savedHomeContinueWatchingEnabled === 'true'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,7 +293,10 @@ function HomeClient() {
|
||||
|
||||
window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated);
|
||||
return () => {
|
||||
window.removeEventListener('homeModulesUpdated', handleHomeModulesUpdated);
|
||||
window.removeEventListener(
|
||||
'homeModulesUpdated',
|
||||
handleHomeModulesUpdated
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -269,7 +309,8 @@ function HomeClient() {
|
||||
setAiEnabled(enabled);
|
||||
|
||||
// 加载AI默认消息配置
|
||||
const defaultMsg = (window as any).RUNTIME_CONFIG?.AI_DEFAULT_MESSAGE_NO_VIDEO;
|
||||
const defaultMsg = (window as any).RUNTIME_CONFIG
|
||||
?.AI_DEFAULT_MESSAGE_NO_VIDEO;
|
||||
if (defaultMsg) {
|
||||
setAiDefaultMessageNoVideo(defaultMsg);
|
||||
}
|
||||
@@ -279,7 +320,8 @@ function HomeClient() {
|
||||
// 检查源站寻片功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled = (window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false;
|
||||
const enabled =
|
||||
(window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false;
|
||||
setSourceSearchEnabled(enabled);
|
||||
}
|
||||
}, []);
|
||||
@@ -308,6 +350,15 @@ function HomeClient() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查网盘临时播放权限,仅有权限时在直链播放弹窗展示网盘在线播放提示
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled = !!(window as any).RUNTIME_CONFIG
|
||||
?.NETDISK_TEMP_PLAY_ENABLED;
|
||||
setNetdiskTempPlayEnabled(enabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查公告弹窗状态
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && announcement) {
|
||||
@@ -336,7 +387,10 @@ function HomeClient() {
|
||||
|
||||
const setCache = (key: string, data: any) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() }));
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ data, timestamp: Date.now() })
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
@@ -356,18 +410,43 @@ function HomeClient() {
|
||||
if (duanjuCache?.data) setHotDuanju(duanjuCache.data);
|
||||
if (upcomingCache?.data) setUpcomingContent(upcomingCache.data);
|
||||
|
||||
const hasCache = moviesCache || tvShowsCache || varietyCache || bangumiCache || duanjuCache || upcomingCache;
|
||||
const hasCache =
|
||||
moviesCache ||
|
||||
tvShowsCache ||
|
||||
varietyCache ||
|
||||
bangumiCache ||
|
||||
duanjuCache ||
|
||||
upcomingCache;
|
||||
if (hasCache) setLoading(false);
|
||||
|
||||
const needsRefresh = !moviesCache || moviesCache.expired || !tvShowsCache || tvShowsCache.expired ||
|
||||
!varietyCache || varietyCache.expired || !bangumiCache || bangumiCache.expired ||
|
||||
!duanjuCache || duanjuCache.expired || !upcomingCache || upcomingCache.expired;
|
||||
const needsRefresh =
|
||||
!moviesCache ||
|
||||
moviesCache.expired ||
|
||||
!tvShowsCache ||
|
||||
tvShowsCache.expired ||
|
||||
!varietyCache ||
|
||||
varietyCache.expired ||
|
||||
!bangumiCache ||
|
||||
bangumiCache.expired ||
|
||||
!duanjuCache ||
|
||||
duanjuCache.expired ||
|
||||
!upcomingCache ||
|
||||
upcomingCache.expired;
|
||||
|
||||
if (needsRefresh) {
|
||||
(async () => {
|
||||
try {
|
||||
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] = await Promise.all([
|
||||
getDoubanCategories({ kind: 'movie', category: '热门', type: '全部' }),
|
||||
const [
|
||||
moviesData,
|
||||
tvShowsData,
|
||||
varietyShowsData,
|
||||
bangumiCalendarData,
|
||||
] = await Promise.all([
|
||||
getDoubanCategories({
|
||||
kind: 'movie',
|
||||
category: '热门',
|
||||
type: '全部',
|
||||
}),
|
||||
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
|
||||
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
|
||||
GetBangumiCalendarData(),
|
||||
@@ -400,7 +479,11 @@ function HomeClient() {
|
||||
const duanjuResponse = await fetch('/api/duanju/recommends');
|
||||
if (duanjuResponse.ok) {
|
||||
const duanjuResult = await duanjuResponse.json();
|
||||
if (duanjuResult.code === 200 && duanjuResult.data && duanjuResult.data.length > 0) {
|
||||
if (
|
||||
duanjuResult.code === 200 &&
|
||||
duanjuResult.data &&
|
||||
duanjuResult.data.length > 0
|
||||
) {
|
||||
setHotDuanju(duanjuResult.data);
|
||||
setCache('homepage_duanju', duanjuResult.data);
|
||||
}
|
||||
@@ -413,10 +496,18 @@ function HomeClient() {
|
||||
const response = await fetch('/api/tmdb/upcoming');
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.code === 200 && result.data && result.data.length > 0) {
|
||||
if (
|
||||
result.code === 200 &&
|
||||
result.data &&
|
||||
result.data.length > 0
|
||||
) {
|
||||
const sorted = [...result.data].sort((a, b) => {
|
||||
const dateA = new Date(a.release_date || '9999-12-31').getTime();
|
||||
const dateB = new Date(b.release_date || '9999-12-31').getTime();
|
||||
const dateA = new Date(
|
||||
a.release_date || '9999-12-31'
|
||||
).getTime();
|
||||
const dateB = new Date(
|
||||
b.release_date || '9999-12-31'
|
||||
).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
setUpcomingContent(sorted);
|
||||
@@ -436,8 +527,6 @@ function HomeClient() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const handleCloseAnnouncement = (announcement: string) => {
|
||||
setShowAnnouncement(false);
|
||||
localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗
|
||||
@@ -448,7 +537,7 @@ function HomeClient() {
|
||||
switch (moduleId) {
|
||||
case 'hotMovies':
|
||||
return (
|
||||
<section key="hotMovies" className='mb-8'>
|
||||
<section key='hotMovies' className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门电影
|
||||
@@ -496,7 +585,7 @@ function HomeClient() {
|
||||
case 'hotDuanju':
|
||||
if (hotDuanju.length === 0) return null;
|
||||
return (
|
||||
<section key="hotDuanju" className='mb-8'>
|
||||
<section key='hotDuanju' className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热播短剧
|
||||
@@ -550,7 +639,7 @@ function HomeClient() {
|
||||
|
||||
case 'bangumiCalendar':
|
||||
return (
|
||||
<section key="bangumiCalendar" className='mb-8'>
|
||||
<section key='bangumiCalendar' className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
新番放送
|
||||
@@ -578,7 +667,15 @@ function HomeClient() {
|
||||
))
|
||||
: (() => {
|
||||
const today = new Date();
|
||||
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const weekdays = [
|
||||
'Sun',
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
];
|
||||
const currentWeekday = weekdays[today.getDay()];
|
||||
const todayAnimes =
|
||||
bangumiCalendarData
|
||||
@@ -615,7 +712,7 @@ function HomeClient() {
|
||||
|
||||
case 'hotTvShows':
|
||||
return (
|
||||
<section key="hotTvShows" className='mb-8'>
|
||||
<section key='hotTvShows' className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门剧集
|
||||
@@ -662,7 +759,7 @@ function HomeClient() {
|
||||
|
||||
case 'hotVarietyShows':
|
||||
return (
|
||||
<section key="hotVarietyShows" className='mb-8'>
|
||||
<section key='hotVarietyShows' className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门综艺
|
||||
@@ -699,7 +796,9 @@ function HomeClient() {
|
||||
rate={varietyShow.rate}
|
||||
type='tv'
|
||||
from='douban'
|
||||
douban_id={varietyShow.id ? parseInt(varietyShow.id) : undefined}
|
||||
douban_id={
|
||||
varietyShow.id ? parseInt(varietyShow.id) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -710,7 +809,7 @@ function HomeClient() {
|
||||
case 'upcomingContent':
|
||||
if (upcomingContent.length === 0) return null;
|
||||
return (
|
||||
<section key="upcomingContent" className='mb-8'>
|
||||
<section key='upcomingContent' className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
即将上映
|
||||
@@ -762,7 +861,11 @@ function HomeClient() {
|
||||
{/* 首页内容 */}
|
||||
<>
|
||||
{/* 源站寻片和AI问片入口 */}
|
||||
<div className={`flex items-center justify-end gap-2 mb-4 ${homeBannerEnabled ? '' : 'mt-[30px]'}`}>
|
||||
<div
|
||||
className={`flex items-center justify-end gap-2 mb-4 ${
|
||||
homeBannerEnabled ? '' : 'mt-[30px]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={handleDirectPlay}
|
||||
className='p-1.5 rounded-lg text-blue-500 hover:text-blue-600 transition-colors'
|
||||
@@ -833,9 +936,9 @@ function HomeClient() {
|
||||
|
||||
{/* 根据配置动态渲染首页模块 */}
|
||||
{homeModules
|
||||
.filter(module => module.enabled)
|
||||
.filter((module) => module.enabled)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(module => renderModule(module.id))}
|
||||
.map((module) => renderModule(module.id))}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
@@ -899,9 +1002,11 @@ function HomeClient() {
|
||||
<div className='text-sm text-gray-600 dark:text-gray-300'>
|
||||
请输入可直接播放的视频链接。
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。
|
||||
</div>
|
||||
{netdiskTempPlayEnabled && (
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={directPlayUrl}
|
||||
onChange={(event) => setDirectPlayUrl(event.target.value)}
|
||||
|
||||
+146
-117
@@ -109,6 +109,13 @@ interface PlayFallbackRecommendation {
|
||||
doubanId?: number;
|
||||
}
|
||||
|
||||
interface SearchCachePayload {
|
||||
status: 'complete' | 'partial';
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function PlayPageClient() {
|
||||
const LOCAL_TRANSCODER_BASE_URL = 'http://localhost:19080';
|
||||
const router = useRouter();
|
||||
@@ -730,7 +737,15 @@ function PlayPageClient() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切到新的一集后,重新允许检查该集是否存在播放记录。
|
||||
// 用户主动切集/自动下一集时不再弹“上次播放到 xx”。
|
||||
// 首次进入页面仍保留检查能力,用于展示继续播放提示。
|
||||
if (suppressPlayRecordJumpOnNextEpisodeChangeRef.current) {
|
||||
playRecordJumpInitialCheckRef.current = false;
|
||||
playRecordJumpDismissedRef.current = true;
|
||||
suppressPlayRecordJumpOnNextEpisodeChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
playRecordJumpInitialCheckRef.current = true;
|
||||
playRecordJumpDismissedRef.current = false;
|
||||
}, [currentEpisodeIndex]);
|
||||
@@ -1444,6 +1459,7 @@ function PlayPageClient() {
|
||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null); // 14分钟定时器
|
||||
const currentXiaoyaUrlRef = useRef<string>(''); // 当前xiaoya原始URL(用于刷新)
|
||||
const isInitialLoadRef = useRef(true); // 标记是否为首次加载
|
||||
const suppressPlayRecordJumpOnNextEpisodeChangeRef = useRef(false); // 主动切集时不显示播放记录跳转提示
|
||||
|
||||
// 视频源代理模式状态
|
||||
const [sourceProxyMode, setSourceProxyMode] = useState(false);
|
||||
@@ -1757,6 +1773,7 @@ function PlayPageClient() {
|
||||
// 播放进度保存相关
|
||||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
const lastSavedPlayTimeRef = useRef<number | null>(null);
|
||||
|
||||
// 下集预缓存相关
|
||||
const nextEpisodePreCacheTriggeredRef = useRef<boolean>(false);
|
||||
@@ -3764,53 +3781,88 @@ function PlayPageClient() {
|
||||
.slice(0, 12);
|
||||
};
|
||||
|
||||
const readSearchCache = (query: string): SearchCachePayload | null => {
|
||||
if (typeof window === 'undefined' || !query.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `search_cache_${query.trim()}`;
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (!cached) return null;
|
||||
|
||||
const parsed = JSON.parse(cached) as SearchCachePayload;
|
||||
if (
|
||||
(parsed?.status === 'complete' || parsed?.status === 'partial') &&
|
||||
Array.isArray(parsed.results)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Play] 读取缓存失败:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const writeCompleteSearchCache = (query: string, results: SearchResult[]) => {
|
||||
if (typeof window === 'undefined' || !query.trim()) return;
|
||||
|
||||
try {
|
||||
const cacheKey = `search_cache_${query.trim()}`;
|
||||
const payload: SearchCachePayload = {
|
||||
status: 'complete',
|
||||
results,
|
||||
query: query.trim(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.error('[Play] 写入缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filterSourcesForCurrentVideo = (items: SearchResult[]): SearchResult[] => {
|
||||
return items.filter(
|
||||
(result: SearchResult) =>
|
||||
normalizeTitle(result.title).toLowerCase() ===
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
(videoYearRef.current
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
(searchType
|
||||
? getType(result) === searchType
|
||||
: true)
|
||||
);
|
||||
};
|
||||
|
||||
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
|
||||
// 根据搜索词获取全部源信息
|
||||
setHasCompletedSearchRequest(false);
|
||||
setFallbackRecommendations([]);
|
||||
|
||||
let fallbackCachedResults: SearchResult[] = [];
|
||||
|
||||
try {
|
||||
// 先检查 sessionStorage 中是否有缓存
|
||||
const cacheKey = `search_cache_${query.trim()}`;
|
||||
let results: SearchResult[] = [];
|
||||
const cachedPayload = readSearchCache(query);
|
||||
if (cachedPayload) {
|
||||
console.log(`[Play] 使用 sessionStorage ${cachedPayload.status === 'partial' ? '临时' : '完整'}缓存的搜索结果`);
|
||||
setFallbackRecommendations(buildFallbackRecommendations(cachedPayload.results, query));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
console.log('[Play] 使用 sessionStorage 缓存的搜索结果');
|
||||
const cachedData = JSON.parse(cached) as SearchResult[];
|
||||
const cachedResults = filterSourcesForCurrentVideo(cachedPayload.results);
|
||||
fallbackCachedResults = cachedResults;
|
||||
setAvailableSources(applyCorrectionsToSources(cachedResults));
|
||||
|
||||
setHasCompletedSearchRequest(true);
|
||||
setFallbackRecommendations(buildFallbackRecommendations(cachedData, query));
|
||||
|
||||
// 处理缓存的搜索结果,根据规则过滤
|
||||
results = cachedData.filter(
|
||||
(result: SearchResult) =>
|
||||
normalizeTitle(result.title).toLowerCase() ===
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
(videoYearRef.current
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
(searchType
|
||||
? getType(result) === searchType
|
||||
: true)
|
||||
);
|
||||
|
||||
setAvailableSources(applyCorrectionsToSources(results));
|
||||
return results;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Play] 读取缓存失败:', error);
|
||||
// 继续执行 API 调用
|
||||
if (cachedPayload.status === 'complete') {
|
||||
setHasCompletedSearchRequest(true);
|
||||
return cachedResults;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有缓存,调用 API
|
||||
// 没有缓存或只有 partial 缓存时,重新请求完整搜索结果
|
||||
const response = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||
);
|
||||
@@ -3820,29 +3872,18 @@ function PlayPageClient() {
|
||||
const data = await response.json();
|
||||
const allResults = (data.results || []) as SearchResult[];
|
||||
|
||||
writeCompleteSearchCache(query, allResults);
|
||||
setHasCompletedSearchRequest(true);
|
||||
setFallbackRecommendations(buildFallbackRecommendations(allResults, query));
|
||||
|
||||
// 处理搜索结果,根据规则过滤
|
||||
results = allResults.filter(
|
||||
(result: SearchResult) =>
|
||||
normalizeTitle(result.title).toLowerCase() ===
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
(videoYearRef.current
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
(searchType
|
||||
? getType(result) === searchType
|
||||
: true)
|
||||
);
|
||||
const results = filterSourcesForCurrentVideo(allResults);
|
||||
setAvailableSources(applyCorrectionsToSources(results));
|
||||
return results;
|
||||
} catch (err) {
|
||||
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
|
||||
if (fallbackCachedResults.length > 0) {
|
||||
return fallbackCachedResults;
|
||||
}
|
||||
setAvailableSources([]);
|
||||
return [];
|
||||
} finally {
|
||||
@@ -3851,39 +3892,14 @@ function PlayPageClient() {
|
||||
};
|
||||
|
||||
const getCachedSourcesData = (query: string): SearchResult[] => {
|
||||
if (typeof window === 'undefined' || !query.trim()) {
|
||||
const cachedPayload = readSearchCache(query);
|
||||
if (!cachedPayload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `search_cache_${query.trim()}`;
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (!cached) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cachedData = JSON.parse(cached);
|
||||
const results = cachedData.filter(
|
||||
(result: SearchResult) =>
|
||||
normalizeTitle(result.title).toLowerCase() ===
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
(videoYearRef.current
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
(searchType
|
||||
? getType(result) === searchType
|
||||
: true)
|
||||
);
|
||||
|
||||
return applyCorrectionsToSources(results);
|
||||
} catch (error) {
|
||||
console.error('[Play] 读取缓存失败:', error);
|
||||
return [];
|
||||
}
|
||||
return applyCorrectionsToSources(
|
||||
filterSourcesForCurrentVideo(cachedPayload.results)
|
||||
);
|
||||
};
|
||||
|
||||
const initAll = async () => {
|
||||
@@ -4626,39 +4642,45 @@ function PlayPageClient() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 集数切换
|
||||
// ---------------------------------------------------------------------------
|
||||
const primeEpisodeResumeState = async (targetEpisodeIndex: number) => {
|
||||
const saveCurrentEpisodeLocalProgressOnly = () => {
|
||||
if (!artPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = artPlayerRef.current.currentTime || 0;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
|
||||
if (currentTime < 1 || !duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saveLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
currentEpisodeIndexRef.current,
|
||||
currentTime,
|
||||
duration
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[Play] Failed to save local episode progress before episode switch:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const primeEpisodeResumeState = (targetEpisodeIndex: number) => {
|
||||
if (!currentSourceRef.current || !currentIdRef.current) {
|
||||
resumeTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const key = generateStorageKey(currentSourceRef.current, currentIdRef.current);
|
||||
const record = allRecords[key];
|
||||
|
||||
if (record && record.index - 1 === targetEpisodeIndex && record.play_time > 1) {
|
||||
resumeTimeRef.current = record.play_time;
|
||||
} else {
|
||||
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
targetEpisodeIndex
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Play] Failed to prime episode resume state:', error);
|
||||
if (currentSourceRef.current && currentIdRef.current) {
|
||||
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
targetEpisodeIndex
|
||||
);
|
||||
} else {
|
||||
resumeTimeRef.current = null;
|
||||
}
|
||||
}
|
||||
// 切集路径只读取本地单集进度,避免阻塞式读取全局播放记录/远端数据库。
|
||||
// 首次进入页面的全局播放记录恢复逻辑保持不变。
|
||||
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
targetEpisodeIndex
|
||||
);
|
||||
};
|
||||
|
||||
const prepareEpisodeSwitch = async () => {
|
||||
const prepareEpisodeSwitch = () => {
|
||||
if (artPlayerRef.current) {
|
||||
lastPlaybackRateRef.current =
|
||||
artPlayerRef.current.playbackRate || lastPlaybackRateRef.current;
|
||||
@@ -4666,9 +4688,10 @@ function PlayPageClient() {
|
||||
artPlayerRef.current.volume || lastVolumeRef.current;
|
||||
playbackRateRestoreWindowUntilRef.current = Date.now() + 8000;
|
||||
|
||||
await saveCurrentPlayProgress();
|
||||
saveCurrentEpisodeLocalProgressOnly();
|
||||
}
|
||||
|
||||
suppressPlayRecordJumpOnNextEpisodeChangeRef.current = true;
|
||||
setVideoLoadingStage('episodeChanging');
|
||||
setIsVideoLoading(true);
|
||||
setVideoError(null);
|
||||
@@ -4684,8 +4707,8 @@ function PlayPageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
await prepareEpisodeSwitch();
|
||||
await primeEpisodeResumeState(episodeNumber);
|
||||
prepareEpisodeSwitch();
|
||||
primeEpisodeResumeState(episodeNumber);
|
||||
setCurrentEpisodeIndex(episodeNumber);
|
||||
};
|
||||
|
||||
@@ -4693,9 +4716,9 @@ function PlayPageClient() {
|
||||
const d = detailRef.current;
|
||||
const idx = currentEpisodeIndexRef.current;
|
||||
if (d && d.episodes && idx > 0) {
|
||||
await prepareEpisodeSwitch();
|
||||
const targetIndex = idx - 1;
|
||||
await primeEpisodeResumeState(targetIndex);
|
||||
prepareEpisodeSwitch();
|
||||
primeEpisodeResumeState(targetIndex);
|
||||
setCurrentEpisodeIndex(targetIndex);
|
||||
}
|
||||
};
|
||||
@@ -4720,8 +4743,8 @@ function PlayPageClient() {
|
||||
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
|
||||
|
||||
if (!isFiltered) {
|
||||
await prepareEpisodeSwitch();
|
||||
await primeEpisodeResumeState(nextIdx);
|
||||
prepareEpisodeSwitch();
|
||||
primeEpisodeResumeState(nextIdx);
|
||||
setCurrentEpisodeIndex(nextIdx);
|
||||
return;
|
||||
}
|
||||
@@ -5681,12 +5704,17 @@ function PlayPageClient() {
|
||||
const player = artPlayerRef.current;
|
||||
const currentTime = player.currentTime || 0;
|
||||
const duration = player.duration || 0;
|
||||
const playTime = Math.floor(currentTime);
|
||||
|
||||
// 如果播放时间太短(少于5秒)或者视频时长无效,不保存
|
||||
if (currentTime < 1 || !duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastSavedPlayTimeRef.current === playTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saveLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
@@ -5702,12 +5730,13 @@ function PlayPageClient() {
|
||||
cover: detailRef.current?.poster || '',
|
||||
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
|
||||
total_episodes: detailRef.current?.episodes.length || 1,
|
||||
play_time: Math.floor(currentTime),
|
||||
play_time: playTime,
|
||||
total_time: Math.floor(duration),
|
||||
save_time: Date.now(),
|
||||
search_title: searchTitle,
|
||||
});
|
||||
|
||||
lastSavedPlayTimeRef.current = playTime;
|
||||
lastSaveTimeRef.current = Date.now();
|
||||
console.log('播放进度已保存:', {
|
||||
title: videoTitleRef.current,
|
||||
|
||||
+41
-6
@@ -46,6 +46,13 @@ import SearchSuggestions from '@/components/SearchSuggestions';
|
||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
|
||||
|
||||
type SearchCachePayload = {
|
||||
status: 'complete' | 'partial';
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function SearchPageClient() {
|
||||
// 搜索历史
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
@@ -107,14 +114,17 @@ function SearchPageClient() {
|
||||
return `search_cache_${query.trim()}`;
|
||||
};
|
||||
|
||||
// 从 sessionStorage 获取缓存的搜索结果
|
||||
// 从 sessionStorage 获取完整缓存的搜索结果(partial 只给播放页快速启动使用)
|
||||
const getCachedResults = (query: string): SearchResult[] | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const cacheKey = getCacheKey(query);
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
if (!cached) return null;
|
||||
|
||||
const parsed = JSON.parse(cached) as SearchCachePayload;
|
||||
if (parsed?.status === 'complete' && Array.isArray(parsed.results)) {
|
||||
return parsed.results;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached results:', error);
|
||||
@@ -123,16 +133,36 @@ function SearchPageClient() {
|
||||
};
|
||||
|
||||
// 保存搜索结果到 sessionStorage
|
||||
const setCachedResults = (query: string, results: SearchResult[]) => {
|
||||
const setCachedResults = (
|
||||
query: string,
|
||||
results: SearchResult[],
|
||||
status: SearchCachePayload['status'] = 'complete'
|
||||
) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const cacheKey = getCacheKey(query);
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(results));
|
||||
const payload: SearchCachePayload = {
|
||||
status,
|
||||
results,
|
||||
query: query.trim(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.error('Failed to cache results:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const savePartialCacheForPlayback = () => {
|
||||
const query = currentQueryRef.current.trim();
|
||||
if (!query || !eventSourceRef.current || !isLoading) return;
|
||||
|
||||
const snapshot = searchResults.concat(pendingResultsRef.current);
|
||||
if (snapshot.length > 20) {
|
||||
setCachedResults(query, snapshot, 'partial');
|
||||
}
|
||||
};
|
||||
|
||||
// 清除指定查询的缓存
|
||||
const clearCachedResults = (query: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -847,7 +877,10 @@ function SearchPageClient() {
|
||||
<button
|
||||
key={item.key}
|
||||
type='button'
|
||||
onClick={() => router.push(itemUrl)}
|
||||
onClick={() => {
|
||||
savePartialCacheForPlayback();
|
||||
router.push(itemUrl);
|
||||
}}
|
||||
className='group w-full rounded-2xl border border-gray-200/80 bg-white/90 p-3 text-left shadow-sm transition-all hover:border-green-300 hover:shadow-md dark:border-gray-700 dark:bg-gray-900/70 dark:hover:border-green-700'
|
||||
>
|
||||
<div className='flex items-start gap-4'>
|
||||
@@ -1818,6 +1851,7 @@ function SearchPageClient() {
|
||||
<VideoCard
|
||||
ref={getGroupRef(mapKey)}
|
||||
from='search'
|
||||
onBeforeNavigate={savePartialCacheForPlayback}
|
||||
isAggregate={true}
|
||||
title={title}
|
||||
poster={poster}
|
||||
@@ -1867,6 +1901,7 @@ function SearchPageClient() {
|
||||
>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
onBeforeNavigate={savePartialCacheForPlayback}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
episodes={item.episodes.length}
|
||||
|
||||
+287
-47
@@ -7,7 +7,6 @@ import { usePathname } from 'next/navigation';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import { VideoContext } from '@/lib/ai-orchestrator';
|
||||
@@ -27,6 +26,199 @@ interface AIChatPanelProps {
|
||||
drawerWidth?: string;
|
||||
}
|
||||
|
||||
type MarkdownSegment =
|
||||
| { type: 'markdown'; content: string }
|
||||
| {
|
||||
type: 'table';
|
||||
header: string[];
|
||||
align: Array<'left' | 'center' | 'right' | undefined>;
|
||||
rows: string[][];
|
||||
};
|
||||
|
||||
const splitMarkdownTableRow = (line: string): string[] => {
|
||||
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const char = trimmed[i];
|
||||
if (char === '|' && trimmed[i - 1] !== '\\') {
|
||||
cells.push(current.replace(/\\\|/g, '|').trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
cells.push(current.replace(/\\\|/g, '|').trim());
|
||||
return cells;
|
||||
};
|
||||
|
||||
const getTableAlign = (cell: string): 'left' | 'center' | 'right' | undefined => {
|
||||
const trimmed = cell.trim();
|
||||
if (!/^:?-{3,}:?$/.test(trimmed)) return undefined;
|
||||
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
|
||||
if (trimmed.endsWith(':')) return 'right';
|
||||
return 'left';
|
||||
};
|
||||
|
||||
const isTableDelimiterRow = (line: string): boolean => {
|
||||
const cells = splitMarkdownTableRow(line);
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
||||
};
|
||||
|
||||
const normalizeTableRow = (cells: string[], length: number): string[] => {
|
||||
if (cells.length === length) return cells;
|
||||
if (cells.length > length) return cells.slice(0, length);
|
||||
return [...cells, ...Array.from({ length: length - cells.length }, () => '')];
|
||||
};
|
||||
|
||||
const splitMarkdownByTables = (content: string): MarkdownSegment[] => {
|
||||
const lines = content.split('\n');
|
||||
const segments: MarkdownSegment[] = [];
|
||||
const markdownBuffer: string[] = [];
|
||||
let inFence = false;
|
||||
let i = 0;
|
||||
|
||||
const flushMarkdown = () => {
|
||||
const markdown = markdownBuffer.join('\n');
|
||||
if (markdown.trim()) {
|
||||
segments.push({ type: 'markdown', content: markdown });
|
||||
}
|
||||
markdownBuffer.length = 0;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (/^(```|~~~)/.test(trimmed)) {
|
||||
inFence = !inFence;
|
||||
markdownBuffer.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!inFence &&
|
||||
line.includes('|') &&
|
||||
i + 1 < lines.length &&
|
||||
isTableDelimiterRow(lines[i + 1])
|
||||
) {
|
||||
const header = splitMarkdownTableRow(line);
|
||||
const delimiter = splitMarkdownTableRow(lines[i + 1]);
|
||||
|
||||
if (header.length === delimiter.length) {
|
||||
flushMarkdown();
|
||||
|
||||
const rows: string[][] = [];
|
||||
i += 2;
|
||||
|
||||
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
||||
rows.push(normalizeTableRow(splitMarkdownTableRow(lines[i]), header.length));
|
||||
i++;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: 'table',
|
||||
header,
|
||||
align: delimiter.map(getTableAlign),
|
||||
rows,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
markdownBuffer.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
flushMarkdown();
|
||||
return segments;
|
||||
};
|
||||
|
||||
const transformStrikethrough = (line: string): string => {
|
||||
return line;
|
||||
};
|
||||
|
||||
const transformTaskList = (line: string): string => {
|
||||
return line.replace(/^(\s*[-*+]\s+)\[(x|X| )\]\s+/g, (_match, prefix: string, checked: string) => {
|
||||
return `${prefix}${checked.trim() ? '☑' : '☐'} `;
|
||||
});
|
||||
};
|
||||
|
||||
const transformBareLinks = (line: string): string => {
|
||||
return line.replace(/(https?:\/\/[^\s<>()]+|www\.[^\s<>()]+)/g, (match, _url: string, offset: number, source: string) => {
|
||||
const before = source.slice(Math.max(0, offset - 2), offset);
|
||||
const previousChar = source[offset - 1];
|
||||
const lastOpenBracket = source.lastIndexOf('[', offset);
|
||||
const lastCloseBracket = source.lastIndexOf(']', offset);
|
||||
const nextCloseBracket = source.indexOf(']', offset + match.length);
|
||||
const nextOpenParen = nextCloseBracket >= 0 ? source.slice(nextCloseBracket, nextCloseBracket + 2) : '';
|
||||
|
||||
// 已经是 Markdown 链接目标或链接文本时不重复转换。
|
||||
if (before === '](' || previousChar === '<' || (lastOpenBracket > lastCloseBracket && nextOpenParen === '](')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const trailing = match.match(/[.,!?;:,。!?;:]+$/)?.[0] || '';
|
||||
const cleanUrl = trailing ? match.slice(0, -trailing.length) : match;
|
||||
const href = cleanUrl.startsWith('www.') ? `https://${cleanUrl}` : cleanUrl;
|
||||
|
||||
return `[${cleanUrl}](${href})${trailing}`;
|
||||
});
|
||||
};
|
||||
|
||||
const transformLightweightGfm = (content: string): string => {
|
||||
const lines = content.split('\n');
|
||||
let inFence = false;
|
||||
|
||||
return lines.map((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
return line;
|
||||
}
|
||||
|
||||
if (inFence) return line;
|
||||
|
||||
return transformBareLinks(transformStrikethrough(transformTaskList(line)));
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
const renderStrikethroughNodes = (children: React.ReactNode): React.ReactNode => {
|
||||
return React.Children.map(children, (child) => {
|
||||
if (typeof child === 'string') {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const regex = /~~([^~\n]+)~~/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(child)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(child.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
parts.push(<del key={`${match.index}-${match[1]}`}>{match[1]}</del>);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < child.length) {
|
||||
parts.push(child.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : child;
|
||||
}
|
||||
|
||||
if (React.isValidElement(child) && (child as any).props?.children) {
|
||||
return React.cloneElement(child as any, {
|
||||
children: renderStrikethroughNodes((child as any).props.children),
|
||||
});
|
||||
}
|
||||
|
||||
return child;
|
||||
});
|
||||
};
|
||||
|
||||
export default function AIChatPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -67,6 +259,98 @@ export default function AIChatPanel({
|
||||
});
|
||||
};
|
||||
|
||||
const markdownComponents = useMemo(() => ({
|
||||
del: ({ children }: any) => <del>{children}</del>,
|
||||
p: ({ children }: any) => <p>{renderStrikethroughNodes(children)}</p>,
|
||||
li: ({ children }: any) => <li>{renderStrikethroughNodes(children)}</li>,
|
||||
h1: ({ children }: any) => <h1>{renderStrikethroughNodes(children)}</h1>,
|
||||
h2: ({ children }: any) => <h2>{renderStrikethroughNodes(children)}</h2>,
|
||||
h3: ({ children }: any) => <h3>{renderStrikethroughNodes(children)}</h3>,
|
||||
h4: ({ children }: any) => <h4>{renderStrikethroughNodes(children)}</h4>,
|
||||
h5: ({ children }: any) => <h5>{renderStrikethroughNodes(children)}</h5>,
|
||||
h6: ({ children }: any) => <h6>{renderStrikethroughNodes(children)}</h6>,
|
||||
a: ({ href, children, ...props }: any) => {
|
||||
// 如果是内部链接(以 / 开头),使用 Next.js Link
|
||||
if (href?.startsWith('/')) {
|
||||
// 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本)
|
||||
if (pathname === '/play' && href.startsWith('/play')) {
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// 外部链接使用普通 a 标签
|
||||
return <a href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
|
||||
},
|
||||
}), [pathname]);
|
||||
|
||||
const inlineMarkdownComponents = useMemo(() => ({
|
||||
...markdownComponents,
|
||||
p: ({ children }: any) => <span>{renderStrikethroughNodes(children)}</span>,
|
||||
}), [markdownComponents]);
|
||||
|
||||
const renderAssistantContent = (content: string) => {
|
||||
return splitMarkdownByTables(content).map((segment, segmentIndex) => {
|
||||
if (segment.type === 'markdown') {
|
||||
return (
|
||||
<ReactMarkdown key={segmentIndex} components={markdownComponents}>
|
||||
{transformLightweightGfm(convertTitleToLink(segment.content))}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={segmentIndex}
|
||||
className='not-prose overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm ring-1 ring-black/5 dark:border-gray-700 dark:bg-gray-900 dark:ring-white/10'
|
||||
>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='m-0 min-w-full border-separate border-spacing-0 text-left text-sm'>
|
||||
<thead>
|
||||
<tr className='bg-gradient-to-r from-purple-50 to-blue-50 dark:from-purple-950/40 dark:to-blue-950/40'>
|
||||
{segment.header.map((cell, cellIndex) => (
|
||||
<th
|
||||
key={cellIndex}
|
||||
className='whitespace-nowrap border-b border-gray-200 px-4 py-3 font-semibold text-gray-800 first:rounded-tl-xl last:rounded-tr-xl dark:border-gray-700 dark:text-gray-100'
|
||||
style={{ textAlign: segment.align[cellIndex] }}
|
||||
>
|
||||
<ReactMarkdown components={inlineMarkdownComponents}>
|
||||
{transformLightweightGfm(convertTitleToLink(cell))}
|
||||
</ReactMarkdown>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-gray-100 dark:divide-gray-800'>
|
||||
{segment.rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className='transition-colors odd:bg-white even:bg-gray-50/70 hover:bg-purple-50/70 dark:odd:bg-gray-900 dark:even:bg-gray-800/40 dark:hover:bg-purple-950/25'
|
||||
>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className='px-4 py-3 align-top leading-relaxed text-gray-700 dark:text-gray-200'
|
||||
style={{ textAlign: segment.align[cellIndex] }}
|
||||
>
|
||||
<ReactMarkdown components={inlineMarkdownComponents}>
|
||||
{transformLightweightGfm(convertTitleToLink(cell))}
|
||||
</ReactMarkdown>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
@@ -446,29 +730,7 @@ export default function AIChatPanel({
|
||||
</p>
|
||||
) : (
|
||||
<div className='prose prose-sm max-w-none dark:prose-invert prose-p:my-2 prose-p:leading-relaxed prose-pre:bg-gray-800 prose-pre:text-gray-100 dark:prose-pre:bg-gray-900 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:bg-purple-50 dark:prose-code:bg-purple-900/20 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-a:text-inherit dark:prose-a:text-inherit prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 dark:prose-strong:text-white prose-ul:my-2 prose-ol:my-2 prose-li:my-1'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm as any]}
|
||||
components={{
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
// 如果是内部链接(以 / 开头),使用 Next.js Link
|
||||
if (href?.startsWith('/')) {
|
||||
// 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本)
|
||||
if (pathname === '/play' && href.startsWith('/play')) {
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// 外部链接使用普通 a 标签
|
||||
return <a href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{convertTitleToLink(message.content)}
|
||||
</ReactMarkdown>
|
||||
{renderAssistantContent(message.content)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -652,29 +914,7 @@ export default function AIChatPanel({
|
||||
</p>
|
||||
) : (
|
||||
<div className='prose prose-sm max-w-none dark:prose-invert prose-p:my-2 prose-p:leading-relaxed prose-pre:bg-gray-800 prose-pre:text-gray-100 dark:prose-pre:bg-gray-900 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:bg-purple-50 dark:prose-code:bg-purple-900/20 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-a:text-inherit dark:prose-a:text-inherit prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 dark:prose-strong:text-white prose-ul:my-2 prose-ol:my-2 prose-li:my-1'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm as any]}
|
||||
components={{
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
// 如果是内部链接(以 / 开头),使用 Next.js Link
|
||||
if (href?.startsWith('/')) {
|
||||
// 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本)
|
||||
if (pathname === '/play' && href.startsWith('/play')) {
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// 外部链接使用普通 a 标签
|
||||
return <a href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{convertTitleToLink(message.content)}
|
||||
</ReactMarkdown>
|
||||
{renderAssistantContent(message.content)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,13 @@ interface AcgSearchProps {
|
||||
}
|
||||
|
||||
type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy';
|
||||
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
|
||||
|
||||
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
||||
{ value: 'aria2', label: 'aria2' },
|
||||
{ value: 'qBittorrent', label: 'qBittorrent' },
|
||||
{ value: 'Transmission', label: 'Transmission' },
|
||||
];
|
||||
|
||||
export default function AcgSearch({
|
||||
keyword,
|
||||
@@ -47,6 +54,7 @@ export default function AcgSearch({
|
||||
const [showNameDialog, setShowNameDialog] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<AcgSearchItem | null>(null);
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [downloadTool, setDownloadTool] = useState<DownloadTool>('aria2');
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
@@ -204,6 +212,7 @@ export default function AcgSearch({
|
||||
body: JSON.stringify({
|
||||
url: selectedItem.torrentUrl,
|
||||
name: customName.trim(),
|
||||
tool: downloadTool,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -228,6 +237,7 @@ export default function AcgSearch({
|
||||
setDownloadingId(null);
|
||||
setSelectedItem(null);
|
||||
setCustomName('');
|
||||
setDownloadTool('aria2');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -365,12 +375,27 @@ export default function AcgSearch({
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
|
||||
autoFocus
|
||||
/>
|
||||
<label className='mt-4 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
下载方式
|
||||
</label>
|
||||
<select
|
||||
value={downloadTool}
|
||||
onChange={(e) => setDownloadTool(e.target.value as DownloadTool)}
|
||||
className='mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
|
||||
>
|
||||
{downloadToolOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className='mt-4 flex gap-2 justify-end'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNameDialog(false);
|
||||
setSelectedItem(null);
|
||||
setCustomName('');
|
||||
setDownloadTool('aria2');
|
||||
}}
|
||||
className='px-4 py-2 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors'
|
||||
>
|
||||
|
||||
@@ -6,13 +6,19 @@ import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { AnimeSubscription } from '@/types/anime-subscription';
|
||||
import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription';
|
||||
|
||||
interface AnimeSubscriptionComponentProps {
|
||||
config: AdminConfig | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
}
|
||||
|
||||
const downloadToolOptions: Array<{ value: AnimeSubscriptionDownloadTool; label: string }> = [
|
||||
{ value: 'aria2', label: 'aria2' },
|
||||
{ value: 'qBittorrent', label: 'qBittorrent' },
|
||||
{ value: 'Transmission', label: 'Transmission' },
|
||||
];
|
||||
|
||||
// Switch 组件
|
||||
const Switch = ({ checked, onChange, disabled }: { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean }) => (
|
||||
<button
|
||||
@@ -141,6 +147,7 @@ export default function AnimeSubscriptionComponent({
|
||||
refreshConfig,
|
||||
}: AnimeSubscriptionComponentProps) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [downloadTool, setDownloadTool] = useState<AnimeSubscriptionDownloadTool>('aria2');
|
||||
const [subscriptions, setSubscriptions] = useState<AnimeSubscription[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
@@ -181,6 +188,7 @@ export default function AnimeSubscriptionComponent({
|
||||
useEffect(() => {
|
||||
if (config?.AnimeSubscriptionConfig) {
|
||||
setEnabled(config.AnimeSubscriptionConfig.Enabled || false);
|
||||
setDownloadTool(config.AnimeSubscriptionConfig.DownloadTool || 'aria2');
|
||||
setSubscriptions(config.AnimeSubscriptionConfig.Subscriptions || []);
|
||||
}
|
||||
}, [config]);
|
||||
@@ -205,7 +213,7 @@ export default function AnimeSubscriptionComponent({
|
||||
const response = await fetch('/api/admin/anime-subscription/toggle', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: newEnabled }),
|
||||
body: JSON.stringify({ enabled: newEnabled, downloadTool }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -225,6 +233,35 @@ export default function AnimeSubscriptionComponent({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadToolChange = async (newDownloadTool: AnimeSubscriptionDownloadTool) => {
|
||||
const previousDownloadTool = downloadTool;
|
||||
setDownloadTool(newDownloadTool);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/anime-subscription/toggle', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled, downloadTool: newDownloadTool }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('保存下载方式失败');
|
||||
}
|
||||
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
setDownloadTool(previousDownloadTool);
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '保存失败',
|
||||
message: error instanceof Error ? error.message : '保存下载方式失败',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始添加
|
||||
const handleAdd = () => {
|
||||
resetForm();
|
||||
@@ -408,12 +445,31 @@ export default function AnimeSubscriptionComponent({
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* 顶部控制 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
启用追番功能
|
||||
</span>
|
||||
<Switch checked={enabled} onChange={handleToggleEnabled} disabled={loading} />
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
启用追番功能
|
||||
</span>
|
||||
<Switch checked={enabled} onChange={handleToggleEnabled} disabled={loading} />
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
下载方式
|
||||
</label>
|
||||
<select
|
||||
value={downloadTool}
|
||||
onChange={(e) => handleDownloadToolChange(e.target.value as AnimeSubscriptionDownloadTool)}
|
||||
disabled={loading}
|
||||
className='min-w-40 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50'
|
||||
>
|
||||
{downloadToolOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight, Play, Volume2, VolumeX } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef,useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||
import {
|
||||
type TMDBItem,
|
||||
getGenreNames,
|
||||
getTMDBImageUrl,
|
||||
} from '@/lib/tmdb.client';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
@@ -15,6 +25,21 @@ interface BannerCarouselProps {
|
||||
delayLoad?: boolean; // 是否延迟加载(等页面加载完毕后再加载)
|
||||
}
|
||||
|
||||
type HomeBannerHeightScale = '1' | '1.5' | '2';
|
||||
|
||||
const bannerHeightClassMap: Record<HomeBannerHeightScale, string> = {
|
||||
'1': 'h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px]',
|
||||
'1.5': 'h-[300px] sm:h-[450px] md:h-[600px] lg:h-[750px]',
|
||||
'2': 'h-[400px] sm:h-[600px] md:h-[800px] lg:h-[1000px]',
|
||||
};
|
||||
|
||||
const getSavedBannerHeightScale = (): HomeBannerHeightScale => {
|
||||
if (typeof window === 'undefined') return '1';
|
||||
|
||||
const saved = localStorage.getItem('homeBannerHeightScale');
|
||||
return saved === '1.5' || saved === '2' ? saved : '1';
|
||||
};
|
||||
|
||||
// 扩展TMDBItem类型以支持TX数据源的额外字段
|
||||
interface BannerItem extends TMDBItem {
|
||||
subtitle?: string; // TX数据源的子标题
|
||||
@@ -23,7 +48,10 @@ interface BannerItem extends TMDBItem {
|
||||
genres?: string[]; // 豆瓣数据源的类型标签
|
||||
}
|
||||
|
||||
export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = false }: BannerCarouselProps) {
|
||||
export default function BannerCarousel({
|
||||
autoPlayInterval = 5000,
|
||||
delayLoad = false,
|
||||
}: BannerCarouselProps) {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState<BannerItem[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
@@ -36,6 +64,8 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
const [dataSource, setDataSource] = useState<string>(''); // 当前数据源
|
||||
const [trailersLoaded, setTrailersLoaded] = useState(false); // 预告片是否已加载
|
||||
const [isMuted, setIsMuted] = useState(true); // 视频是否静音(默认静音)
|
||||
const [bannerHeightScale, setBannerHeightScale] =
|
||||
useState<HomeBannerHeightScale>('1'); // 轮播图高度倍率
|
||||
const videoRef = useRef<HTMLVideoElement>(null); // 视频元素引用
|
||||
const videoRefs = useRef<Map<number, HTMLVideoElement>>(new Map()); // 所有视频元素的引用
|
||||
const touchStartX = useRef(0);
|
||||
@@ -95,6 +125,20 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
if (setting !== null) {
|
||||
setEnableTrailers(setting === 'true');
|
||||
}
|
||||
|
||||
setBannerHeightScale(getSavedBannerHeightScale());
|
||||
|
||||
const handleHomeModulesUpdated = () => {
|
||||
setBannerHeightScale(getSavedBannerHeightScale());
|
||||
};
|
||||
|
||||
window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'homeModulesUpdated',
|
||||
handleHomeModulesUpdated
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 延迟加载:等待页面加载完毕后再开始加载轮播图数据
|
||||
@@ -204,10 +248,13 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
|
||||
// 保存到 localStorage(使用数据源特定的key)
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({
|
||||
data: result.list,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
localStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify({
|
||||
data: result.list,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
// localStorage 可能已满,忽略错误
|
||||
console.error('保存到 localStorage 失败:', e);
|
||||
@@ -227,7 +274,12 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
// 前端获取豆瓣预告片
|
||||
useEffect(() => {
|
||||
// 只有在启用预告片、数据源是豆瓣、有数据且未加载预告片时才执行
|
||||
if (!enableTrailers || dataSource !== 'Douban' || items.length === 0 || trailersLoaded) {
|
||||
if (
|
||||
!enableTrailers ||
|
||||
dataSource !== 'Douban' ||
|
||||
items.length === 0 ||
|
||||
trailersLoaded
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,9 +293,10 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
const detail = await getDoubanDetail(item.id.toString());
|
||||
|
||||
// 获取预告片链接(取第一个)
|
||||
const trailerUrl = detail.trailers && detail.trailers.length > 0
|
||||
? detail.trailers[0].video_url
|
||||
: null;
|
||||
const trailerUrl =
|
||||
detail.trailers && detail.trailers.length > 0
|
||||
? detail.trailers[0].video_url
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
@@ -298,7 +351,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
setSkipNextAutoPlay(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setCurrentIndex((prev) => (prev + 1) % items.length);
|
||||
}, autoPlayInterval);
|
||||
|
||||
@@ -350,7 +403,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
// 防止在手动切换过程中触发
|
||||
if (isManualChange.current) return;
|
||||
if (!touchStartX.current) return;
|
||||
|
||||
|
||||
// 如果有滑动,则执行滑动逻辑
|
||||
if (touchEndX.current !== 0) {
|
||||
const distance = touchStartX.current - touchEndX.current;
|
||||
@@ -374,13 +427,15 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
|
||||
if (isLoading || !shouldLoad) {
|
||||
return (
|
||||
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 overflow-hidden flex items-center justify-center">
|
||||
<div
|
||||
className={`relative w-full ${bannerHeightClassMap[bannerHeightScale]} bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 overflow-hidden flex items-center justify-center`}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="MoonTVPlus"
|
||||
src='/logo.png'
|
||||
alt='MoonTVPlus'
|
||||
width={120}
|
||||
height={120}
|
||||
className="opacity-50"
|
||||
className='opacity-50'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
@@ -395,7 +450,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] overflow-hidden group"
|
||||
className={`relative w-full ${bannerHeightClassMap[bannerHeightScale]} overflow-hidden group`}
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
onTouchStart={handleTouchStart}
|
||||
@@ -409,7 +464,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
}}
|
||||
>
|
||||
{/* 背景图片或视频 */}
|
||||
<div className="absolute inset-0">
|
||||
<div className='absolute inset-0'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -419,7 +474,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
>
|
||||
{item.trailer_url && enableTrailers ? (
|
||||
/* 显示豆瓣直链视频 */
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className='absolute inset-0 overflow-hidden'>
|
||||
<video
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
@@ -429,20 +484,20 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
}
|
||||
}}
|
||||
src={getVideoUrl(item.trailer_url) || undefined}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-full min-h-full w-auto h-auto object-cover"
|
||||
className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-full min-h-full w-auto h-auto object-cover'
|
||||
muted={isMuted}
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
preload='metadata'
|
||||
/>
|
||||
</div>
|
||||
) : item.video_key && isYouTubeAccessible && enableTrailers ? (
|
||||
/* 显示YouTube视频 */
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className='absolute inset-0 overflow-hidden'>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${item.video_key}?listType=playlist&autoplay=1&mute=1&controls=0&loop=1&playlist=${item.video_key}&modestbranding=1&rel=0&showinfo=0&vq=hd1080&hd=1&disablekb=1&fs=0&iv_load_policy=3`}
|
||||
className="absolute top-1/2 left-1/2 pointer-events-none"
|
||||
allow="autoplay; encrypted-media"
|
||||
className='absolute top-1/2 left-1/2 pointer-events-none'
|
||||
allow='autoplay; encrypted-media'
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '100vw',
|
||||
@@ -456,54 +511,65 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
) : (
|
||||
/* 显示图片 */
|
||||
<ProxyImage
|
||||
originalSrc={getImageUrl(item.backdrop_path || item.poster_path)}
|
||||
originalSrc={getImageUrl(
|
||||
item.backdrop_path || item.poster_path
|
||||
)}
|
||||
alt={item.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
)}
|
||||
{/* 渐变遮罩 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-transparent"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||
<div className='absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-transparent'></div>
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容信息 */}
|
||||
<div className="absolute inset-0 flex items-end p-8 md:p-12 pointer-events-none">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-white drop-shadow-lg">
|
||||
<div className='absolute inset-0 flex items-end p-8 md:p-12 pointer-events-none'>
|
||||
<div className='max-w-2xl space-y-4'>
|
||||
<h2 className='text-3xl md:text-5xl font-bold text-white drop-shadow-lg'>
|
||||
{currentItem.title}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3 text-sm md:text-base text-white/90 flex-wrap">
|
||||
<div className='flex items-center gap-2 md:gap-3 text-sm md:text-base text-white/90 flex-wrap'>
|
||||
{currentItem.vote_average > 0 && (
|
||||
<span className="px-2 py-1 bg-yellow-500 text-black font-semibold rounded">
|
||||
<span className='px-2 py-1 bg-yellow-500 text-black font-semibold rounded'>
|
||||
{currentItem.vote_average.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{/* 显示标签:优先TX的tags,其次豆瓣的genres,最后TMDB的genre_ids */}
|
||||
{currentItem.tags && currentItem.tags.length > 0 ? (
|
||||
currentItem.tags.slice(0, 3).map((tag, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : currentItem.genres && Array.isArray(currentItem.genres) && currentItem.genres.length > 0 ? (
|
||||
/* 显示豆瓣数据源的标签 */
|
||||
currentItem.genres.slice(0, 3).map((genre, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
|
||||
{genre}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
/* 显示TMDB数据源的类型标签 */
|
||||
getGenreNames(currentItem.genre_ids, 3).map(genre => (
|
||||
<span key={genre} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
|
||||
{genre}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
{currentItem.tags && currentItem.tags.length > 0
|
||||
? currentItem.tags.slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className='px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
: currentItem.genres &&
|
||||
Array.isArray(currentItem.genres) &&
|
||||
currentItem.genres.length > 0
|
||||
? /* 显示豆瓣数据源的标签 */
|
||||
currentItem.genres.slice(0, 3).map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className='px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm'
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))
|
||||
: /* 显示TMDB数据源的类型标签 */
|
||||
getGenreNames(currentItem.genre_ids, 3).map((genre) => (
|
||||
<span
|
||||
key={genre}
|
||||
className='px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm'
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
{currentItem.release_date && (
|
||||
<span>{currentItem.release_date}</span>
|
||||
)}
|
||||
@@ -515,14 +581,14 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
e.stopPropagation();
|
||||
handlePlay(currentItem.title);
|
||||
}}
|
||||
className="hidden md:flex items-center gap-2 px-6 py-3 bg-gray-500/30 hover:bg-gray-500/50 backdrop-blur-sm text-white font-semibold rounded-lg transition-all pointer-events-auto"
|
||||
className='hidden md:flex items-center gap-2 px-6 py-3 bg-gray-500/30 hover:bg-gray-500/50 backdrop-blur-sm text-white font-semibold rounded-lg transition-all pointer-events-auto'
|
||||
>
|
||||
<Play className="w-5 h-5 fill-white" />
|
||||
<Play className='w-5 h-5 fill-white' />
|
||||
立即播放
|
||||
</button>
|
||||
|
||||
{currentItem.overview && (
|
||||
<p className="text-sm md:text-base text-white/80 line-clamp-3 drop-shadow-md">
|
||||
<p className='text-sm md:text-base text-white/80 line-clamp-3 drop-shadow-md'>
|
||||
{currentItem.overview}
|
||||
</p>
|
||||
)}
|
||||
@@ -532,36 +598,36 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
|
||||
{/* 左右切换按钮 - 只在桌面端显示 */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="hidden md:flex absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="上一张"
|
||||
className='hidden md:flex absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300'
|
||||
aria-label='上一张'
|
||||
>
|
||||
<ChevronLeft className="w-8 h-8" />
|
||||
<ChevronLeft className='w-8 h-8' />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="hidden md:flex absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="下一张"
|
||||
className='hidden md:flex absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300'
|
||||
aria-label='下一张'
|
||||
>
|
||||
<ChevronRight className="w-8 h-8" />
|
||||
<ChevronRight className='w-8 h-8' />
|
||||
</button>
|
||||
|
||||
{/* 音量控制按钮 - 只在有豆瓣预告片时显示 */}
|
||||
{currentItem.trailer_url && enableTrailers && (
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="absolute top-2 right-2 md:top-4 md:right-4 w-8 h-8 md:w-10 md:h-10 bg-black/30 hover:bg-black/60 text-white rounded-full flex items-center justify-center transition-all duration-300 z-10"
|
||||
aria-label={isMuted ? "开启声音" : "关闭声音"}
|
||||
className='absolute top-2 right-2 md:top-4 md:right-4 w-8 h-8 md:w-10 md:h-10 bg-black/30 hover:bg-black/60 text-white rounded-full flex items-center justify-center transition-all duration-300 z-10'
|
||||
aria-label={isMuted ? '开启声音' : '关闭声音'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-4 h-4 md:w-5 md:h-5" />
|
||||
<VolumeX className='w-4 h-4 md:w-5 md:h-5' />
|
||||
) : (
|
||||
<Volume2 className="w-4 h-4 md:w-5 md:h-5" />
|
||||
<Volume2 className='w-4 h-4 md:w-5 md:h-5' />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 指示器 */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
||||
<div className='absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2'>
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
|
||||
@@ -96,7 +96,68 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
onShowToast,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
|
||||
const parseSxxExxTitle = useCallback((title?: string) => {
|
||||
const match = title?.match(/[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seasonNumber = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(seasonNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { season: seasonNumber };
|
||||
}, []);
|
||||
|
||||
const episodeGroupsAsc = useMemo(() => {
|
||||
const sxxexxMatchCount = episodes_titles.reduce((count, title) => {
|
||||
return count + (parseSxxExxTitle(title) ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
if (sxxexxMatchCount >= 2) {
|
||||
const seasons = new Map<number, number[]>();
|
||||
const otherEpisodes: number[] = [];
|
||||
|
||||
for (let episodeNumber = 1; episodeNumber <= totalEpisodes; episodeNumber += 1) {
|
||||
const parsed = parseSxxExxTitle(episodes_titles?.[episodeNumber - 1]);
|
||||
if (!parsed) {
|
||||
otherEpisodes.push(episodeNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
const episodes = seasons.get(parsed.season) ?? [];
|
||||
episodes.push(episodeNumber);
|
||||
seasons.set(parsed.season, episodes);
|
||||
}
|
||||
|
||||
const seasonGroups = Array.from(seasons.entries())
|
||||
.sort(([seasonA], [seasonB]) => seasonA - seasonB)
|
||||
.map(([season, episodes]) => ({
|
||||
label: `S${String(season).padStart(2, '0')}`,
|
||||
episodes,
|
||||
}));
|
||||
|
||||
if (otherEpisodes.length > 0) {
|
||||
seasonGroups.push({ label: '其他', episodes: otherEpisodes });
|
||||
}
|
||||
|
||||
return seasonGroups;
|
||||
}
|
||||
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
return Array.from({ length: pageCount }, (_, i) => {
|
||||
const start = i * episodesPerPage + 1;
|
||||
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
|
||||
return {
|
||||
label: `${start}-${end}`,
|
||||
episodes: Array.from({ length: end - start + 1 }, (_, idx) => start + idx),
|
||||
};
|
||||
});
|
||||
}, [episodesPerPage, episodes_titles, parseSxxExxTitle, totalEpisodes]);
|
||||
|
||||
const pageCount = episodeGroupsAsc.length;
|
||||
|
||||
// 存储每个源的视频信息
|
||||
const [videoInfoMap, setVideoInfoMap] = useState<Map<string, VideoInfo>>(
|
||||
@@ -141,56 +202,37 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const readWatchedEpisodes = () => {
|
||||
const watched = new Set<number>();
|
||||
const watched = new Set<number>();
|
||||
|
||||
try {
|
||||
const records = getCachedPlayRecordsSnapshot();
|
||||
const record = records[generateStorageKey(currentSource, currentId)];
|
||||
if (record && record.index > 0 && record.play_time > 1) {
|
||||
watched.add(record.index);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[EpisodeSelector] Failed to read cached play records:', error);
|
||||
try {
|
||||
const records = getCachedPlayRecordsSnapshot();
|
||||
const record = records[generateStorageKey(currentSource, currentId)];
|
||||
if (record && record.index > 0 && record.play_time > 1) {
|
||||
watched.add(record.index);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[EpisodeSelector] Failed to read cached play records:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const episodeRecords = loadAllLocalEpisodeProgressRecords(
|
||||
episodeProgressContentKey
|
||||
);
|
||||
try {
|
||||
const episodeRecords = loadAllLocalEpisodeProgressRecords(
|
||||
episodeProgressContentKey
|
||||
);
|
||||
|
||||
for (const [episodeIndex, record] of Object.entries(episodeRecords)) {
|
||||
if (Number(record?.playTime) > 1) {
|
||||
const episodeNumber = Number(episodeIndex) + 1;
|
||||
if (episodeNumber >= 1 && episodeNumber <= totalEpisodes) {
|
||||
watched.add(episodeNumber);
|
||||
}
|
||||
for (const [episodeIndex, record] of Object.entries(episodeRecords)) {
|
||||
if (Number(record?.playTime) > 1) {
|
||||
const episodeNumber = Number(episodeIndex) + 1;
|
||||
if (episodeNumber >= 1 && episodeNumber <= totalEpisodes) {
|
||||
watched.add(episodeNumber);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[EpisodeSelector] Failed to read local episode progress:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[EpisodeSelector] Failed to read local episode progress:', error);
|
||||
}
|
||||
|
||||
setWatchedEpisodes(watched);
|
||||
};
|
||||
|
||||
readWatchedEpisodes();
|
||||
|
||||
const handlePlayRecordsUpdated = () => {
|
||||
readWatchedEpisodes();
|
||||
};
|
||||
|
||||
window.addEventListener('playRecordsUpdated', handlePlayRecordsUpdated as EventListener);
|
||||
window.addEventListener('storage', handlePlayRecordsUpdated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'playRecordsUpdated',
|
||||
handlePlayRecordsUpdated as EventListener
|
||||
);
|
||||
window.removeEventListener('storage', handlePlayRecordsUpdated);
|
||||
};
|
||||
}, [currentSource, currentId, episodeProgressContentKey, totalEpisodes]);
|
||||
setWatchedEpisodes(watched);
|
||||
}, [currentSource, currentId, episodeProgressContentKey, totalEpisodes, value]);
|
||||
|
||||
// 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
|
||||
// 默认显示选集选项卡,但如果是房员则显示弹幕
|
||||
@@ -205,8 +247,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
}
|
||||
}, [isRoomMember, activeTab]);
|
||||
|
||||
// 当前分页索引(0 开始)
|
||||
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
||||
// 当前分组索引(0 开始)
|
||||
const initialPage = Math.max(
|
||||
0,
|
||||
episodeGroupsAsc.findIndex((group) => group.episodes.includes(value))
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||
|
||||
// 是否倒序显示
|
||||
@@ -264,6 +309,17 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
return currentPage;
|
||||
}, [currentPage, descending, pageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentEpisode = Math.max(1, Math.min(value, totalEpisodes));
|
||||
const nextPage = episodeGroupsAsc.findIndex((group) =>
|
||||
group.episodes.includes(currentEpisode)
|
||||
);
|
||||
|
||||
if (nextPage >= 0) {
|
||||
setCurrentPage(nextPage);
|
||||
}
|
||||
}, [episodeGroupsAsc, totalEpisodes, value]);
|
||||
|
||||
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
|
||||
const getVideoInfo = useCallback(async (source: SearchResult) => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
@@ -444,25 +500,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
prevBackgroundLoadingRef.current = backgroundSourcesLoading;
|
||||
}, [backgroundSourcesLoading, activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]);
|
||||
|
||||
// 升序分页标签
|
||||
const categoriesAsc = useMemo(() => {
|
||||
return Array.from({ length: pageCount }, (_, i) => {
|
||||
const start = i * episodesPerPage + 1;
|
||||
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
|
||||
return { start, end };
|
||||
});
|
||||
}, [pageCount, episodesPerPage, totalEpisodes]);
|
||||
|
||||
// 根据 descending 状态决定分页标签的排序和内容
|
||||
// 根据 descending 状态决定分组标签的排序和内容
|
||||
const categories = useMemo(() => {
|
||||
if (descending) {
|
||||
// 倒序时,label 也倒序显示
|
||||
return [...categoriesAsc]
|
||||
.reverse()
|
||||
.map(({ start, end }) => `${end}-${start}`);
|
||||
}
|
||||
return categoriesAsc.map(({ start, end }) => `${start}-${end}`);
|
||||
}, [categoriesAsc, descending]);
|
||||
const groups = descending ? [...episodeGroupsAsc].reverse() : episodeGroupsAsc;
|
||||
return groups.map((group) => group.label);
|
||||
}, [episodeGroupsAsc, descending]);
|
||||
|
||||
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
@@ -624,11 +666,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
[getVideoInfo]
|
||||
);
|
||||
|
||||
const currentStart = currentPage * episodesPerPage + 1;
|
||||
const currentEnd = Math.min(
|
||||
currentStart + episodesPerPage - 1,
|
||||
totalEpisodes
|
||||
);
|
||||
const currentEpisodeGroup = episodeGroupsAsc[currentPage] ?? {
|
||||
label: '',
|
||||
episodes: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
||||
@@ -765,10 +806,9 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
{/* 集数网格 */}
|
||||
<div className='flex flex-wrap gap-3 overflow-y-auto flex-1 content-start pb-4'>
|
||||
{(() => {
|
||||
const len = currentEnd - currentStart + 1;
|
||||
const episodes = Array.from({ length: len }, (_, i) =>
|
||||
descending ? currentEnd - i : currentStart + i
|
||||
);
|
||||
const episodes = descending
|
||||
? [...currentEpisodeGroup.episodes].reverse()
|
||||
: currentEpisodeGroup.episodes;
|
||||
// 过滤掉被屏蔽的集数,但保持原有索引
|
||||
return episodes
|
||||
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
|
||||
@@ -802,12 +842,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
if (title.match(/^OVA\s+\d+/i)) {
|
||||
return title;
|
||||
}
|
||||
// 如果匹配 S01E01 格式,提取并返回
|
||||
const sxxexxMatch = title.match(/[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/);
|
||||
// 如果匹配 S01E01 格式,只显示集数部分(去掉 SxxE)
|
||||
const sxxexxMatch = title.match(/[Ss]\d+[Ee](\d{1,4}(?:\.\d+)?)/);
|
||||
if (sxxexxMatch) {
|
||||
const season = sxxexxMatch[1].padStart(2, '0');
|
||||
const episode = sxxexxMatch[2];
|
||||
return `S${season}E${episode}`;
|
||||
return sxxexxMatch[1];
|
||||
}
|
||||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
|
||||
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
@@ -21,6 +22,14 @@ interface PansouSearchProps {
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
|
||||
|
||||
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
|
||||
{ value: 'aria2', label: 'aria2' },
|
||||
{ value: 'qBittorrent', label: 'qBittorrent' },
|
||||
{ value: 'Transmission', label: 'Transmission' },
|
||||
];
|
||||
|
||||
// 网盘类型映射
|
||||
const CLOUD_TYPE_NAMES: Record<string, string> = {
|
||||
baidu: '百度网盘',
|
||||
@@ -154,6 +163,12 @@ export default function PansouSearch({
|
||||
const typeDragScrollLeftRef = useRef(0);
|
||||
const [transferingUrl, setTransferingUrl] = useState<string | null>(null);
|
||||
const [playingUrl, setPlayingUrl] = useState<string | null>(null);
|
||||
const [downloadingUrl, setDownloadingUrl] = useState<string | null>(null);
|
||||
const [showNameDialog, setShowNameDialog] = useState(false);
|
||||
const [selectedDownloadLink, setSelectedDownloadLink] =
|
||||
useState<PansouLink | null>(null);
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [downloadTool, setDownloadTool] = useState<DownloadTool>('aria2');
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
const [cooldownRemainingMs, setCooldownRemainingMs] = useState(0);
|
||||
const [checkStatesByType, setCheckStatesByType] = useState<
|
||||
@@ -335,6 +350,64 @@ export default function PansouSearch({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDownloadDialog = (link: PansouLink) => {
|
||||
setSelectedDownloadLink(link);
|
||||
setCustomName(keyword.trim() || link.note || '');
|
||||
setShowNameDialog(true);
|
||||
};
|
||||
|
||||
const handleCloseDownloadDialog = () => {
|
||||
setShowNameDialog(false);
|
||||
setSelectedDownloadLink(null);
|
||||
setCustomName('');
|
||||
setDownloadTool('aria2');
|
||||
};
|
||||
|
||||
const handleConfirmDownload = async () => {
|
||||
if (!selectedDownloadLink || !customName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadingUrl(selectedDownloadLink.url);
|
||||
setShowNameDialog(false);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/acg/download', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: selectedDownloadLink.url,
|
||||
name: customName.trim(),
|
||||
tool: downloadTool,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '添加下载任务失败');
|
||||
}
|
||||
|
||||
setToast({
|
||||
message: data.message || '已添加到离线下载队列',
|
||||
type: 'success',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} catch (err: any) {
|
||||
setToast({
|
||||
message: err?.message || '添加下载任务失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} finally {
|
||||
setDownloadingUrl(null);
|
||||
setSelectedDownloadLink(null);
|
||||
setCustomName('');
|
||||
setDownloadTool('aria2');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNetdiskInstantPlay = async (
|
||||
cloudType: string,
|
||||
link: PansouLink
|
||||
@@ -798,6 +871,28 @@ export default function PansouSearch({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{cloudType === 'magnet' && (
|
||||
<button
|
||||
onClick={() => handleOpenDownloadDialog(link)}
|
||||
disabled={downloadingUrl === link.url}
|
||||
className='flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
|
||||
title='存到私人影库'
|
||||
>
|
||||
{downloadingUrl === link.url ? (
|
||||
<>
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
<span className='hidden sm:inline'>下载中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className='h-3.5 w-3.5' />
|
||||
<span className='hidden sm:inline'>
|
||||
存到私人影库
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
@@ -877,6 +972,52 @@ export default function PansouSearch({
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-6'>{renderBody()}</div>
|
||||
{showNameDialog && (
|
||||
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/50'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4'>
|
||||
设置资源名称
|
||||
</h3>
|
||||
<input
|
||||
type='text'
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder='请输入资源名称'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
|
||||
autoFocus
|
||||
/>
|
||||
<label className='mt-4 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
下载方式
|
||||
</label>
|
||||
<select
|
||||
value={downloadTool}
|
||||
onChange={(e) => setDownloadTool(e.target.value as DownloadTool)}
|
||||
className='mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
|
||||
>
|
||||
{downloadToolOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className='mt-4 flex gap-2 justify-end'>
|
||||
<button
|
||||
onClick={handleCloseDownloadDialog}
|
||||
className='px-4 py-2 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDownload}
|
||||
disabled={!customName.trim()}
|
||||
className='px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toast && <Toast {...toast} />}
|
||||
</>
|
||||
);
|
||||
|
||||
+717
-250
@@ -65,17 +65,20 @@ export const UserMenu: React.FC = () => {
|
||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
|
||||
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] =
|
||||
useState(false);
|
||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
|
||||
const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false);
|
||||
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
|
||||
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
|
||||
const [isReportOpen, setIsReportOpen] = useState(false);
|
||||
const [isDownloadManagementOpen, setIsDownloadManagementOpen] = useState(false);
|
||||
const [isDownloadManagementOpen, setIsDownloadManagementOpen] =
|
||||
useState(false);
|
||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||
const [displayStorageType, setDisplayStorageType] = useState<string>('localstorage');
|
||||
const [displayStorageType, setDisplayStorageType] =
|
||||
useState<string>('localstorage');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
@@ -86,12 +89,25 @@ export const UserMenu: React.FC = () => {
|
||||
const [tvboxToken, setTvboxToken] = useState('');
|
||||
const [isResettingToken, setIsResettingToken] = useState(false);
|
||||
const [isLoadingSubscribeUrl, setIsLoadingSubscribeUrl] = useState(false);
|
||||
const [subscribeAdFilterEnabled, setSubscribeAdFilterEnabled] = useState(false);
|
||||
const [subscribeYellowFilterEnabled, setSubscribeYellowFilterEnabled] = useState(false);
|
||||
const [subscribeAdFilterEnabled, setSubscribeAdFilterEnabled] =
|
||||
useState(false);
|
||||
const [subscribeYellowFilterEnabled, setSubscribeYellowFilterEnabled] =
|
||||
useState(false);
|
||||
|
||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
||||
useEffect(() => {
|
||||
if (isProfileCenterOpen || isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen || isReportOpen || isDownloadManagementOpen) {
|
||||
if (
|
||||
isProfileCenterOpen ||
|
||||
isSettingsOpen ||
|
||||
isChangePasswordOpen ||
|
||||
isSubscribeOpen ||
|
||||
isOfflineDownloadPanelOpen ||
|
||||
isEmailSettingsOpen ||
|
||||
isDeviceManagementOpen ||
|
||||
isEcoAppsOpen ||
|
||||
isReportOpen ||
|
||||
isDownloadManagementOpen
|
||||
) {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
@@ -104,13 +120,23 @@ export const UserMenu: React.FC = () => {
|
||||
html.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
|
||||
// 恢复所有原始样式
|
||||
body.style.overflow = originalBodyOverflow;
|
||||
html.style.overflow = originalHtmlOverflow;
|
||||
};
|
||||
}
|
||||
}, [isProfileCenterOpen, isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen, isDeviceManagementOpen, isEcoAppsOpen, isReportOpen, isDownloadManagementOpen]);
|
||||
}, [
|
||||
isProfileCenterOpen,
|
||||
isSettingsOpen,
|
||||
isChangePasswordOpen,
|
||||
isSubscribeOpen,
|
||||
isOfflineDownloadPanelOpen,
|
||||
isEmailSettingsOpen,
|
||||
isDeviceManagementOpen,
|
||||
isEcoAppsOpen,
|
||||
isReportOpen,
|
||||
isDownloadManagementOpen,
|
||||
]);
|
||||
|
||||
// 设置相关状态
|
||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||
@@ -121,30 +147,44 @@ export const UserMenu: React.FC = () => {
|
||||
const [fluidSearch, setFluidSearch] = useState(true);
|
||||
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
||||
const [enableTrailers, setEnableTrailers] = useState(false);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||
const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
||||
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server');
|
||||
const [doubanDataSource, setDoubanDataSource] = useState(
|
||||
'cmliussss-cdn-tencent'
|
||||
);
|
||||
const [doubanDataSourceBackup, setDoubanDataSourceBackup] =
|
||||
useState('direct');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState(
|
||||
'cmliussss-cdn-tencent'
|
||||
);
|
||||
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] =
|
||||
useState('server');
|
||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||
const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
|
||||
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState('');
|
||||
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] =
|
||||
useState('');
|
||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false);
|
||||
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] =
|
||||
useState(false);
|
||||
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
||||
useState(false);
|
||||
const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
isDoubanImageProxyBackupDropdownOpen,
|
||||
setIsDoubanImageProxyBackupDropdownOpen,
|
||||
] = useState(false);
|
||||
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
||||
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
||||
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
|
||||
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] =
|
||||
useState(true);
|
||||
const [disableAutoLoadDanmaku, setDisableAutoLoadDanmaku] = useState(false);
|
||||
const [danmakuMaxCount, setDanmakuMaxCount] = useState(0);
|
||||
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
||||
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
||||
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] =
|
||||
useState(false);
|
||||
const [exactSearch, setExactSearch] = useState(true);
|
||||
const [maxConcurrentDownloads, setMaxConcurrentDownloads] = useState(6);
|
||||
const [downloadThreadsPerTask, setDownloadThreadsPerTask] = useState(6);
|
||||
const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem'>('browser');
|
||||
const [downloadMode, setDownloadMode] = useState<'browser' | 'filesystem'>(
|
||||
'browser'
|
||||
);
|
||||
const [filesystemSavePath, setFilesystemSavePath] = useState<string>('');
|
||||
|
||||
// 邮件通知设置
|
||||
@@ -179,7 +219,9 @@ export const UserMenu: React.FC = () => {
|
||||
const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(false);
|
||||
|
||||
// TMDB 图片设置
|
||||
const [tmdbImageBaseUrl, setTmdbImageBaseUrl] = useState('https://image.tmdb.org');
|
||||
const [tmdbImageBaseUrl, setTmdbImageBaseUrl] = useState(
|
||||
'https://image.tmdb.org'
|
||||
);
|
||||
const [isUsageSectionOpen, setIsUsageSectionOpen] = useState(false);
|
||||
const [isDownloadSectionOpen, setIsDownloadSectionOpen] = useState(false);
|
||||
const [isBufferSectionOpen, setIsBufferSectionOpen] = useState(false);
|
||||
@@ -194,6 +236,8 @@ export const UserMenu: React.FC = () => {
|
||||
order: number;
|
||||
}
|
||||
|
||||
type HomeBannerHeightScale = '1' | '1.5' | '2';
|
||||
|
||||
const defaultHomeModules: HomeModule[] = [
|
||||
{ id: 'hotMovies', name: '热门电影', enabled: true, order: 0 },
|
||||
{ id: 'hotDuanju', name: '热播短剧', enabled: true, order: 1 },
|
||||
@@ -203,9 +247,23 @@ export const UserMenu: React.FC = () => {
|
||||
{ id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 },
|
||||
];
|
||||
|
||||
const [homeModules, setHomeModules] = useState<HomeModule[]>(defaultHomeModules);
|
||||
const [homeModules, setHomeModules] =
|
||||
useState<HomeModule[]>(defaultHomeModules);
|
||||
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
|
||||
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true);
|
||||
const [homeBannerHeightScale, setHomeBannerHeightScale] =
|
||||
useState<HomeBannerHeightScale>('1');
|
||||
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] =
|
||||
useState(true);
|
||||
|
||||
const homeBannerHeightOptions: {
|
||||
value: HomeBannerHeightScale;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ value: '1', label: '标准', description: '1x' },
|
||||
{ value: '1.5', label: '增高', description: '1.5x' },
|
||||
{ value: '2', label: '特高', description: '2x' },
|
||||
];
|
||||
|
||||
// 豆瓣数据源选项
|
||||
const doubanDataSourceOptions = [
|
||||
@@ -229,8 +287,14 @@ export const UserMenu: React.FC = () => {
|
||||
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
|
||||
{ value: 'baidu', label: '百度图片代理' },
|
||||
{ value: 'custom', label: '自定义代理' },
|
||||
{ value: 'direct', label: '直连(浏览器直接请求豆瓣,可能需要浏览器插件才能正常显示)' },
|
||||
{ value: 'img3', label: '豆瓣官方精品 CDN(阿里云,可能需要浏览器插件才能正常显示)' },
|
||||
{
|
||||
value: 'direct',
|
||||
label: '直连(浏览器直接请求豆瓣,可能需要浏览器插件才能正常显示)',
|
||||
},
|
||||
{
|
||||
value: 'img3',
|
||||
label: '豆瓣官方精品 CDN(阿里云,可能需要浏览器插件才能正常显示)',
|
||||
},
|
||||
];
|
||||
|
||||
// 缓冲策略选项
|
||||
@@ -249,7 +313,9 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
// 清除弹幕缓存相关状态
|
||||
const [isClearingCache, setIsClearingCache] = useState(false);
|
||||
const [clearCacheMessage, setClearCacheMessage] = useState<string | null>(null);
|
||||
const [clearCacheMessage, setClearCacheMessage] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [danmakuCacheUsage, setDanmakuCacheUsage] = useState('计算中...');
|
||||
|
||||
// 确保组件已挂载
|
||||
@@ -300,7 +366,10 @@ export const UserMenu: React.FC = () => {
|
||||
if (globalWindow.__loadingNotifications) {
|
||||
// 如果正在加载,等待加载完成后获取结果
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!globalWindow.__loadingNotifications && globalWindow.__unreadNotificationCount !== undefined) {
|
||||
if (
|
||||
!globalWindow.__loadingNotifications &&
|
||||
globalWindow.__unreadNotificationCount !== undefined
|
||||
) {
|
||||
setUnreadCount(globalWindow.__unreadNotificationCount);
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
@@ -340,14 +409,18 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
window.addEventListener('notificationsUpdated', handleNotificationsUpdated);
|
||||
return () => {
|
||||
window.removeEventListener('notificationsUpdated', handleNotificationsUpdated);
|
||||
window.removeEventListener(
|
||||
'notificationsUpdated',
|
||||
handleNotificationsUpdated
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 从运行时配置读取订阅是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled = (window as any).RUNTIME_CONFIG?.ENABLE_TVBOX_SUBSCRIBE || false;
|
||||
const enabled =
|
||||
(window as any).RUNTIME_CONFIG?.ENABLE_TVBOX_SUBSCRIBE || false;
|
||||
setSubscribeEnabled(enabled);
|
||||
}
|
||||
}, []);
|
||||
@@ -363,7 +436,13 @@ export const UserMenu: React.FC = () => {
|
||||
const token = data.token;
|
||||
setTvboxToken(token);
|
||||
|
||||
setSubscribeUrl(buildSubscribeUrl(token, subscribeAdFilterEnabled, subscribeYellowFilterEnabled));
|
||||
setSubscribeUrl(
|
||||
buildSubscribeUrl(
|
||||
token,
|
||||
subscribeAdFilterEnabled,
|
||||
subscribeYellowFilterEnabled
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订阅URL失败:', error);
|
||||
@@ -393,11 +472,18 @@ export const UserMenu: React.FC = () => {
|
||||
const token = data.token;
|
||||
setTvboxToken(token);
|
||||
|
||||
setSubscribeUrl(buildSubscribeUrl(token, subscribeAdFilterEnabled, subscribeYellowFilterEnabled));
|
||||
setSubscribeUrl(
|
||||
buildSubscribeUrl(
|
||||
token,
|
||||
subscribeAdFilterEnabled,
|
||||
subscribeYellowFilterEnabled
|
||||
)
|
||||
);
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.textContent = '订阅token已重置!';
|
||||
messageEl.className = 'text-xs text-center text-green-600 dark:text-green-400 mt-2';
|
||||
messageEl.className =
|
||||
'text-xs text-center text-green-600 dark:text-green-400 mt-2';
|
||||
messageEl.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
messageEl.classList.add('hidden');
|
||||
@@ -407,7 +493,8 @@ export const UserMenu: React.FC = () => {
|
||||
const data = await response.json();
|
||||
if (messageEl) {
|
||||
messageEl.textContent = data.error || '重置失败,请重试';
|
||||
messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400 mt-2';
|
||||
messageEl.className =
|
||||
'text-xs text-center text-red-600 dark:text-red-400 mt-2';
|
||||
messageEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
@@ -416,7 +503,8 @@ export const UserMenu: React.FC = () => {
|
||||
const messageEl = document.getElementById('tvbox-token-message');
|
||||
if (messageEl) {
|
||||
messageEl.textContent = '重置失败,请重试';
|
||||
messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400 mt-2';
|
||||
messageEl.className =
|
||||
'text-xs text-center text-red-600 dark:text-red-400 mt-2';
|
||||
messageEl.classList.remove('hidden');
|
||||
}
|
||||
} finally {
|
||||
@@ -426,7 +514,11 @@ export const UserMenu: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const buildSubscribeUrl = (token: string, adFilter: boolean, yellowFilter: boolean) => {
|
||||
const buildSubscribeUrl = (
|
||||
token: string,
|
||||
adFilter: boolean,
|
||||
yellowFilter: boolean
|
||||
) => {
|
||||
const currentOrigin = window.location.origin;
|
||||
const url = new URL('/api/tvbox/subscribe', currentOrigin);
|
||||
url.searchParams.set('token', token);
|
||||
@@ -465,7 +557,8 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
const savedDoubanDataSource = localStorage.getItem('doubanDataSource');
|
||||
const defaultDoubanProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
|
||||
'cmliussss-cdn-tencent';
|
||||
if (savedDoubanDataSource !== null) {
|
||||
setDoubanDataSource(savedDoubanDataSource);
|
||||
} else if (defaultDoubanProxyType) {
|
||||
@@ -495,7 +588,8 @@ export const UserMenu: React.FC = () => {
|
||||
'doubanImageProxyType'
|
||||
);
|
||||
const defaultDoubanImageProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
|
||||
'cmliussss-cdn-tencent';
|
||||
if (savedDoubanImageProxyType !== null) {
|
||||
setDoubanImageProxyType(savedDoubanImageProxyType);
|
||||
} else if (defaultDoubanImageProxyType) {
|
||||
@@ -516,7 +610,9 @@ export const UserMenu: React.FC = () => {
|
||||
const savedDoubanImageProxyTypeBackup = localStorage.getItem(
|
||||
'doubanImageProxyTypeBackup'
|
||||
);
|
||||
setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server');
|
||||
setDoubanImageProxyTypeBackup(
|
||||
savedDoubanImageProxyTypeBackup || 'server'
|
||||
);
|
||||
|
||||
const savedDoubanImageProxyUrlBackup = localStorage.getItem(
|
||||
'doubanImageProxyUrlBackup'
|
||||
@@ -553,7 +649,9 @@ export const UserMenu: React.FC = () => {
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
|
||||
const savedTmdbBackdropDisabled = localStorage.getItem('tmdb_backdrop_disabled');
|
||||
const savedTmdbBackdropDisabled = localStorage.getItem(
|
||||
'tmdb_backdrop_disabled'
|
||||
);
|
||||
if (savedTmdbBackdropDisabled !== null) {
|
||||
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
|
||||
}
|
||||
@@ -568,17 +666,23 @@ export const UserMenu: React.FC = () => {
|
||||
setBufferStrategy(savedBufferStrategy);
|
||||
}
|
||||
|
||||
const savedNextEpisodePreCache = localStorage.getItem('nextEpisodePreCache');
|
||||
const savedNextEpisodePreCache = localStorage.getItem(
|
||||
'nextEpisodePreCache'
|
||||
);
|
||||
if (savedNextEpisodePreCache !== null) {
|
||||
setNextEpisodePreCache(savedNextEpisodePreCache === 'true');
|
||||
}
|
||||
|
||||
const savedNextEpisodeDanmakuPreload = localStorage.getItem('nextEpisodeDanmakuPreload');
|
||||
const savedNextEpisodeDanmakuPreload = localStorage.getItem(
|
||||
'nextEpisodeDanmakuPreload'
|
||||
);
|
||||
if (savedNextEpisodeDanmakuPreload !== null) {
|
||||
setNextEpisodeDanmakuPreload(savedNextEpisodeDanmakuPreload === 'true');
|
||||
}
|
||||
|
||||
const savedDisableAutoLoadDanmaku = localStorage.getItem('disableAutoLoadDanmaku');
|
||||
const savedDisableAutoLoadDanmaku = localStorage.getItem(
|
||||
'disableAutoLoadDanmaku'
|
||||
);
|
||||
if (savedDisableAutoLoadDanmaku !== null) {
|
||||
setDisableAutoLoadDanmaku(savedDisableAutoLoadDanmaku === 'true');
|
||||
} else {
|
||||
@@ -592,7 +696,9 @@ export const UserMenu: React.FC = () => {
|
||||
setDanmakuMaxCount(parseInt(savedDanmakuMaxCount, 10));
|
||||
}
|
||||
|
||||
const savedDanmakuHeatmapDisabled = localStorage.getItem('danmaku_heatmap_disabled');
|
||||
const savedDanmakuHeatmapDisabled = localStorage.getItem(
|
||||
'danmaku_heatmap_disabled'
|
||||
);
|
||||
if (savedDanmakuHeatmapDisabled !== null) {
|
||||
setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true');
|
||||
}
|
||||
@@ -602,9 +708,24 @@ export const UserMenu: React.FC = () => {
|
||||
setHomeBannerEnabled(savedHomeBannerEnabled === 'true');
|
||||
}
|
||||
|
||||
const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled');
|
||||
const savedHomeBannerHeightScale = localStorage.getItem(
|
||||
'homeBannerHeightScale'
|
||||
);
|
||||
if (
|
||||
savedHomeBannerHeightScale === '1' ||
|
||||
savedHomeBannerHeightScale === '1.5' ||
|
||||
savedHomeBannerHeightScale === '2'
|
||||
) {
|
||||
setHomeBannerHeightScale(savedHomeBannerHeightScale);
|
||||
}
|
||||
|
||||
const savedHomeContinueWatchingEnabled = localStorage.getItem(
|
||||
'homeContinueWatchingEnabled'
|
||||
);
|
||||
if (savedHomeContinueWatchingEnabled !== null) {
|
||||
setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true');
|
||||
setHomeContinueWatchingEnabled(
|
||||
savedHomeContinueWatchingEnabled === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
// 加载首页模块配置
|
||||
@@ -618,9 +739,13 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
|
||||
// 加载搜索繁体转简体设置
|
||||
const savedSearchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||
const savedSearchTraditionalToSimplified = localStorage.getItem(
|
||||
'searchTraditionalToSimplified'
|
||||
);
|
||||
if (savedSearchTraditionalToSimplified !== null) {
|
||||
setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true');
|
||||
setSearchTraditionalToSimplified(
|
||||
savedSearchTraditionalToSimplified === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
// 加载精确搜索设置
|
||||
@@ -630,25 +755,33 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
|
||||
// 加载最大同时下载限制设置
|
||||
const savedMaxConcurrentDownloads = localStorage.getItem('maxConcurrentDownloads');
|
||||
const savedMaxConcurrentDownloads = localStorage.getItem(
|
||||
'maxConcurrentDownloads'
|
||||
);
|
||||
if (savedMaxConcurrentDownloads !== null) {
|
||||
setMaxConcurrentDownloads(Number(savedMaxConcurrentDownloads));
|
||||
}
|
||||
|
||||
// 加载单任务线程数设置
|
||||
const savedDownloadThreadsPerTask = localStorage.getItem('downloadThreadsPerTask');
|
||||
const savedDownloadThreadsPerTask = localStorage.getItem(
|
||||
'downloadThreadsPerTask'
|
||||
);
|
||||
if (savedDownloadThreadsPerTask !== null) {
|
||||
setDownloadThreadsPerTask(Number(savedDownloadThreadsPerTask));
|
||||
}
|
||||
|
||||
// 加载下载模式设置
|
||||
const savedDownloadMode = localStorage.getItem('downloadMode');
|
||||
if (savedDownloadMode === 'browser' || savedDownloadMode === 'filesystem') {
|
||||
if (
|
||||
savedDownloadMode === 'browser' ||
|
||||
savedDownloadMode === 'filesystem'
|
||||
) {
|
||||
setDownloadMode(savedDownloadMode);
|
||||
}
|
||||
|
||||
// 加载保存路径设置
|
||||
const savedFilesystemSavePath = localStorage.getItem('filesystemSavePath');
|
||||
const savedFilesystemSavePath =
|
||||
localStorage.getItem('filesystemSavePath');
|
||||
if (savedFilesystemSavePath !== null) {
|
||||
setFilesystemSavePath(savedFilesystemSavePath);
|
||||
}
|
||||
@@ -763,7 +896,8 @@ export const UserMenu: React.FC = () => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: '登出所有设备',
|
||||
message: '确定要登出所有设备吗?这将清除所有设备的登录状态(包括当前设备)。',
|
||||
message:
|
||||
'确定要登出所有设备吗?这将清除所有设备的登录状态(包括当前设备)。',
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog({ ...confirmDialog, isOpen: false });
|
||||
try {
|
||||
@@ -789,7 +923,11 @@ export const UserMenu: React.FC = () => {
|
||||
const getDeviceIcon = (deviceInfo: string) => {
|
||||
const info = deviceInfo.toLowerCase();
|
||||
|
||||
if (info.includes('mobile') || info.includes('iphone') || info.includes('android')) {
|
||||
if (
|
||||
info.includes('mobile') ||
|
||||
info.includes('iphone') ||
|
||||
info.includes('android')
|
||||
) {
|
||||
return Smartphone;
|
||||
}
|
||||
|
||||
@@ -932,11 +1070,22 @@ export const UserMenu: React.FC = () => {
|
||||
console.error('复制失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!tvboxToken || !isSubscribeOpen) return;
|
||||
setSubscribeUrl(buildSubscribeUrl(tvboxToken, subscribeAdFilterEnabled, subscribeYellowFilterEnabled));
|
||||
}, [tvboxToken, subscribeAdFilterEnabled, subscribeYellowFilterEnabled, isSubscribeOpen]);
|
||||
setSubscribeUrl(
|
||||
buildSubscribeUrl(
|
||||
tvboxToken,
|
||||
subscribeAdFilterEnabled,
|
||||
subscribeYellowFilterEnabled
|
||||
)
|
||||
);
|
||||
}, [
|
||||
tvboxToken,
|
||||
subscribeAdFilterEnabled,
|
||||
subscribeYellowFilterEnabled,
|
||||
isSubscribeOpen,
|
||||
]);
|
||||
|
||||
const handleSubmitChangePassword = async () => {
|
||||
setPasswordError('');
|
||||
@@ -1043,11 +1192,16 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
const handleDownloadModeChange = (mode: 'browser' | 'filesystem') => {
|
||||
// 如果选择 filesystem 模式,先检测浏览器是否支持
|
||||
if (mode === 'filesystem' && typeof window !== 'undefined' && !('showDirectoryPicker' in window)) {
|
||||
if (
|
||||
mode === 'filesystem' &&
|
||||
typeof window !== 'undefined' &&
|
||||
!('showDirectoryPicker' in window)
|
||||
) {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: '浏览器不支持',
|
||||
message: '您的浏览器不支持 File System Access API,请使用 Chrome 86+ 或 Edge 86+',
|
||||
message:
|
||||
'您的浏览器不支持 File System Access API,请使用 Chrome 86+ 或 Edge 86+',
|
||||
onConfirm: () => {
|
||||
setConfirmDialog({ ...confirmDialog, isOpen: false });
|
||||
},
|
||||
@@ -1085,18 +1239,28 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
// 创建 activeTasks 表(如果不存在)
|
||||
if (!db.objectStoreNames.contains('activeTasks')) {
|
||||
const activeStore = db.createObjectStore('activeTasks', { keyPath: 'id' });
|
||||
const activeStore = db.createObjectStore('activeTasks', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
activeStore.createIndex('status', 'status', { unique: false });
|
||||
activeStore.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
activeStore.createIndex('createdAt', 'createdAt', {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 创建 completedTasks 表(如果不存在)
|
||||
if (!db.objectStoreNames.contains('completedTasks')) {
|
||||
const completedStore = db.createObjectStore('completedTasks', { keyPath: 'id' });
|
||||
const completedStore = db.createObjectStore('completedTasks', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
completedStore.createIndex('source', 'source', { unique: false });
|
||||
completedStore.createIndex('videoId', 'videoId', { unique: false });
|
||||
completedStore.createIndex('completedAt', 'completedAt', { unique: false });
|
||||
completedStore.createIndex('sourceVideoId', ['source', 'videoId'], { unique: false });
|
||||
completedStore.createIndex('completedAt', 'completedAt', {
|
||||
unique: false,
|
||||
});
|
||||
completedStore.createIndex('sourceVideoId', ['source', 'videoId'], {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1280,6 +1444,14 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeBannerHeightScaleChange = (value: HomeBannerHeightScale) => {
|
||||
setHomeBannerHeightScale(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('homeBannerHeightScale', value);
|
||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeContinueWatchingToggle = (value: boolean) => {
|
||||
setHomeContinueWatchingEnabled(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1290,7 +1462,7 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
// 首页模块配置处理函数
|
||||
const handleHomeModuleToggle = (id: string, enabled: boolean) => {
|
||||
const updatedModules = homeModules.map(module =>
|
||||
const updatedModules = homeModules.map((module) =>
|
||||
module.id === id ? { ...module, enabled } : module
|
||||
);
|
||||
setHomeModules(updatedModules);
|
||||
@@ -1356,11 +1528,13 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
const handleResetSettings = () => {
|
||||
const defaultDoubanProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
|
||||
'cmliussss-cdn-tencent';
|
||||
const defaultDoubanProxy =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||
const defaultDoubanImageProxyType =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
|
||||
'cmliussss-cdn-tencent';
|
||||
const defaultDoubanImageProxyUrl =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
||||
const defaultFluidSearch =
|
||||
@@ -1390,6 +1564,7 @@ export const UserMenu: React.FC = () => {
|
||||
false;
|
||||
setDisableAutoLoadDanmaku(!defaultDanmakuAutoLoad);
|
||||
setHomeBannerEnabled(true);
|
||||
setHomeBannerHeightScale('1');
|
||||
setHomeContinueWatchingEnabled(true);
|
||||
setHomeModules(defaultHomeModules);
|
||||
setSearchTraditionalToSimplified(false);
|
||||
@@ -1421,6 +1596,7 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('danmakuMaxCount', '0');
|
||||
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||
localStorage.setItem('homeBannerEnabled', 'true');
|
||||
localStorage.setItem('homeBannerHeightScale', '1');
|
||||
localStorage.setItem('homeContinueWatchingEnabled', 'true');
|
||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
||||
@@ -1495,8 +1671,8 @@ export const UserMenu: React.FC = () => {
|
||||
currentRole === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: currentRole === 'admin'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
|
||||
const handleOpenProfileCenter = () => {
|
||||
setIsOpen(false);
|
||||
@@ -1542,7 +1718,9 @@ export const UserMenu: React.FC = () => {
|
||||
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
|
||||
<div>数据存储</div>
|
||||
<div className='mt-0.5'>
|
||||
{displayStorageType === 'localstorage' ? '本地' : displayStorageType}
|
||||
{displayStorageType === 'localstorage'
|
||||
? '本地'
|
||||
: displayStorageType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1666,12 +1844,13 @@ export const UserMenu: React.FC = () => {
|
||||
updateStatus &&
|
||||
updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full -translate-y-2 ${updateStatus === UpdateStatus.HAS_UPDATE
|
||||
? 'bg-yellow-500'
|
||||
: updateStatus === UpdateStatus.NO_UPDATE
|
||||
className={`w-2 h-2 rounded-full -translate-y-2 ${
|
||||
updateStatus === UpdateStatus.HAS_UPDATE
|
||||
? 'bg-yellow-500'
|
||||
: updateStatus === UpdateStatus.NO_UPDATE
|
||||
? 'bg-green-400'
|
||||
: ''
|
||||
}`}
|
||||
}`}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1702,9 +1881,7 @@ export const UserMenu: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 设置面板 */}
|
||||
<div
|
||||
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col'
|
||||
>
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col'>
|
||||
{/* 内容容器 - 独立的滚动区域 */}
|
||||
<div
|
||||
className='flex-1 px-4 py-6 md:p-6 overflow-y-auto'
|
||||
@@ -1773,7 +1950,9 @@ export const UserMenu: React.FC = () => {
|
||||
{/* 自定义下拉选择框 */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
|
||||
onClick={() =>
|
||||
setIsDoubanDropdownOpen(!isDoubanDropdownOpen)
|
||||
}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
@@ -1786,8 +1965,9 @@ export const UserMenu: React.FC = () => {
|
||||
{/* 下拉箭头 */}
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1802,10 +1982,11 @@ export const UserMenu: React.FC = () => {
|
||||
handleDoubanDataSourceChange(option.value);
|
||||
setIsDoubanDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSource === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
doubanDataSource === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanDataSource === option.value && (
|
||||
@@ -1823,7 +2004,10 @@ export const UserMenu: React.FC = () => {
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
window.open(getThanksInfo(doubanDataSource)!.url, '_blank')
|
||||
window.open(
|
||||
getThanksInfo(doubanDataSource)!.url,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
|
||||
>
|
||||
@@ -1852,7 +2036,9 @@ export const UserMenu: React.FC = () => {
|
||||
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||
value={doubanProxyUrl}
|
||||
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleDoubanProxyUrlChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{!doubanProxyUrl.trim() && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||
@@ -1878,7 +2064,9 @@ export const UserMenu: React.FC = () => {
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setIsDoubanBackupDropdownOpen(!isDoubanBackupDropdownOpen)
|
||||
setIsDoubanBackupDropdownOpen(
|
||||
!isDoubanBackupDropdownOpen
|
||||
)
|
||||
}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
@@ -1890,8 +2078,9 @@ export const UserMenu: React.FC = () => {
|
||||
</button>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanBackupDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanBackupDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{isDoubanBackupDropdownOpen && (
|
||||
@@ -1901,13 +2090,16 @@ export const UserMenu: React.FC = () => {
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleDoubanDataSourceBackupChange(option.value);
|
||||
handleDoubanDataSourceBackupChange(
|
||||
option.value
|
||||
);
|
||||
setIsDoubanBackupDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSourceBackup === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
doubanDataSourceBackup === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanDataSourceBackup === option.value && (
|
||||
@@ -1960,7 +2152,10 @@ export const UserMenu: React.FC = () => {
|
||||
选择获取豆瓣图片的方式
|
||||
</p>
|
||||
</div>
|
||||
<div className='relative' data-dropdown='douban-image-proxy'>
|
||||
<div
|
||||
className='relative'
|
||||
data-dropdown='douban-image-proxy'
|
||||
>
|
||||
{/* 自定义下拉选择框 */}
|
||||
<button
|
||||
type='button'
|
||||
@@ -1981,8 +2176,9 @@ export const UserMenu: React.FC = () => {
|
||||
{/* 下拉箭头 */}
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1997,10 +2193,11 @@ export const UserMenu: React.FC = () => {
|
||||
handleDoubanImageProxyTypeChange(option.value);
|
||||
setIsDoubanImageProxyDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyType === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
doubanImageProxyType === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanImageProxyType === option.value && (
|
||||
@@ -2086,14 +2283,18 @@ export const UserMenu: React.FC = () => {
|
||||
>
|
||||
{
|
||||
doubanImageProxyTypeOptions.find(
|
||||
(option) => option.value === doubanImageProxyTypeBackup
|
||||
(option) =>
|
||||
option.value === doubanImageProxyTypeBackup
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyBackupDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanImageProxyBackupDropdownOpen
|
||||
? 'rotate-180'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{isDoubanImageProxyBackupDropdownOpen && (
|
||||
@@ -2103,13 +2304,16 @@ export const UserMenu: React.FC = () => {
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleDoubanImageProxyTypeBackupChange(option.value);
|
||||
handleDoubanImageProxyTypeBackupChange(
|
||||
option.value
|
||||
);
|
||||
setIsDoubanImageProxyBackupDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyTypeBackup === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
doubanImageProxyTypeBackup === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{doubanImageProxyTypeBackup === option.value && (
|
||||
@@ -2211,7 +2415,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={defaultAggregateSearch}
|
||||
onChange={(e) => handleAggregateToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleAggregateToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2235,7 +2441,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={enableOptimization}
|
||||
onChange={(e) => handleOptimizationToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleOptimizationToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2293,35 +2501,57 @@ export const UserMenu: React.FC = () => {
|
||||
max='30000'
|
||||
step='1000'
|
||||
value={speedTestTimeout}
|
||||
onChange={(e) => handleSpeedTestTimeoutChange(Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleSpeedTestTimeoutChange(Number(e.target.value))
|
||||
}
|
||||
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
|
||||
style={{
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${((speedTestTimeout - 4000) / (30000 - 4000)) * 100}%, #e5e7eb ${((speedTestTimeout - 4000) / (30000 - 4000)) * 100}%, #e5e7eb 100%)`
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
|
||||
((speedTestTimeout - 4000) / (30000 - 4000)) * 100
|
||||
}%, #e5e7eb ${
|
||||
((speedTestTimeout - 4000) / (30000 - 4000)) * 100
|
||||
}%, #e5e7eb 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||
<button
|
||||
onClick={() => handleSpeedTestTimeoutChange(4000)}
|
||||
className={`px-2 py-0.5 rounded ${speedTestTimeout === 4000 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
speedTestTimeout === 4000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
4秒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSpeedTestTimeoutChange(10000)}
|
||||
className={`px-2 py-0.5 rounded ${speedTestTimeout === 10000 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
speedTestTimeout === 10000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
10秒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSpeedTestTimeoutChange(20000)}
|
||||
className={`px-2 py-0.5 rounded ${speedTestTimeout === 20000 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
speedTestTimeout === 20000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
20秒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSpeedTestTimeoutChange(30000)}
|
||||
className={`px-2 py-0.5 rounded ${speedTestTimeout === 30000 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
speedTestTimeout === 30000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
30秒
|
||||
</button>
|
||||
@@ -2348,7 +2578,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={fluidSearch}
|
||||
onChange={(e) => handleFluidSearchToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleFluidSearchToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2372,7 +2604,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={tmdbBackdropDisabled}
|
||||
onChange={(e) => handleTmdbBackdropDisabledToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleTmdbBackdropDisabledToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2396,7 +2630,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={enableTrailers}
|
||||
onChange={(e) => handleEnableTrailersToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleEnableTrailersToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2420,7 +2656,11 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={searchTraditionalToSimplified}
|
||||
onChange={(e) => handleSearchTraditionalToSimplifiedToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleSearchTraditionalToSimplifiedToggle(
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2444,7 +2684,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={exactSearch}
|
||||
onChange={(e) => handleExactSearchToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleExactSearchToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2500,23 +2742,39 @@ export const UserMenu: React.FC = () => {
|
||||
max='10'
|
||||
step='1'
|
||||
value={maxConcurrentDownloads}
|
||||
onChange={(e) => handleMaxConcurrentDownloadsChange(Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleMaxConcurrentDownloadsChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
|
||||
style={{
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${((maxConcurrentDownloads - 1) / (10 - 1)) * 100}%, #e5e7eb ${((maxConcurrentDownloads - 1) / (10 - 1)) * 100}%, #e5e7eb 100%)`
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
|
||||
((maxConcurrentDownloads - 1) / (10 - 1)) * 100
|
||||
}%, #e5e7eb ${
|
||||
((maxConcurrentDownloads - 1) / (10 - 1)) * 100
|
||||
}%, #e5e7eb 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||
<button
|
||||
onClick={() => handleMaxConcurrentDownloadsChange(1)}
|
||||
className={`px-2 py-0.5 rounded ${maxConcurrentDownloads === 1 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
maxConcurrentDownloads === 1
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
1个
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMaxConcurrentDownloadsChange(10)}
|
||||
className={`px-2 py-0.5 rounded ${maxConcurrentDownloads === 10 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
maxConcurrentDownloads === 10
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
10个
|
||||
</button>
|
||||
@@ -2548,23 +2806,39 @@ export const UserMenu: React.FC = () => {
|
||||
max='32'
|
||||
step='1'
|
||||
value={downloadThreadsPerTask}
|
||||
onChange={(e) => handleDownloadThreadsPerTaskChange(Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleDownloadThreadsPerTaskChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
|
||||
style={{
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${((downloadThreadsPerTask - 1) / (32 - 1)) * 100}%, #e5e7eb ${((downloadThreadsPerTask - 1) / (32 - 1)) * 100}%, #e5e7eb 100%)`
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
|
||||
((downloadThreadsPerTask - 1) / (32 - 1)) * 100
|
||||
}%, #e5e7eb ${
|
||||
((downloadThreadsPerTask - 1) / (32 - 1)) * 100
|
||||
}%, #e5e7eb 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||
<button
|
||||
onClick={() => handleDownloadThreadsPerTaskChange(1)}
|
||||
className={`px-2 py-0.5 rounded ${downloadThreadsPerTask === 1 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
downloadThreadsPerTask === 1
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
1个
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadThreadsPerTaskChange(32)}
|
||||
className={`px-2 py-0.5 rounded ${downloadThreadsPerTask === 32 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`px-2 py-0.5 rounded ${
|
||||
downloadThreadsPerTask === 32
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
32个
|
||||
</button>
|
||||
@@ -2598,7 +2872,9 @@ export const UserMenu: React.FC = () => {
|
||||
name='downloadMode'
|
||||
value='filesystem'
|
||||
checked={downloadMode === 'filesystem'}
|
||||
onChange={() => handleDownloadModeChange('filesystem')}
|
||||
onChange={() =>
|
||||
handleDownloadModeChange('filesystem')
|
||||
}
|
||||
className='w-4 h-4 text-green-500'
|
||||
/>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||
@@ -2696,27 +2972,58 @@ export const UserMenu: React.FC = () => {
|
||||
value={getSliderValueFromStrategy(bufferStrategy)}
|
||||
onChange={(e) => {
|
||||
const sliderValue = parseInt(e.target.value);
|
||||
const strategy = getBufferStrategyFromSlider(sliderValue);
|
||||
const strategy =
|
||||
getBufferStrategyFromSlider(sliderValue);
|
||||
handleBufferStrategyChange(strategy);
|
||||
}}
|
||||
className='w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-green-500'
|
||||
style={{
|
||||
background: `linear-gradient(to right, rgb(34 197 94) 0%, rgb(34 197 94) ${(getSliderValueFromStrategy(bufferStrategy) / 3) * 100}%, rgb(229 231 235) ${(getSliderValueFromStrategy(bufferStrategy) / 3) * 100}%, rgb(229 231 235) 100%)`
|
||||
background: `linear-gradient(to right, rgb(34 197 94) 0%, rgb(34 197 94) ${
|
||||
(getSliderValueFromStrategy(bufferStrategy) / 3) *
|
||||
100
|
||||
}%, rgb(229 231 235) ${
|
||||
(getSliderValueFromStrategy(bufferStrategy) / 3) *
|
||||
100
|
||||
}%, rgb(229 231 235) 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 标签显示 */}
|
||||
<div className='flex justify-between text-xs text-gray-500 dark:text-gray-400 px-1'>
|
||||
<span className={bufferStrategy === 'low' ? 'font-semibold text-green-600 dark:text-green-400' : ''}>
|
||||
<span
|
||||
className={
|
||||
bufferStrategy === 'low'
|
||||
? 'font-semibold text-green-600 dark:text-green-400'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
低缓冲
|
||||
</span>
|
||||
<span className={bufferStrategy === 'medium' ? 'font-semibold text-green-600 dark:text-green-400' : ''}>
|
||||
<span
|
||||
className={
|
||||
bufferStrategy === 'medium'
|
||||
? 'font-semibold text-green-600 dark:text-green-400'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
中缓冲
|
||||
</span>
|
||||
<span className={bufferStrategy === 'high' ? 'font-semibold text-green-600 dark:text-green-400' : ''}>
|
||||
<span
|
||||
className={
|
||||
bufferStrategy === 'high'
|
||||
? 'font-semibold text-green-600 dark:text-green-400'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
高缓冲
|
||||
</span>
|
||||
<span className={bufferStrategy === 'ultra' ? 'font-semibold text-green-600 dark:text-green-400' : ''}>
|
||||
<span
|
||||
className={
|
||||
bufferStrategy === 'ultra'
|
||||
? 'font-semibold text-green-600 dark:text-green-400'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
超高缓冲
|
||||
</span>
|
||||
</div>
|
||||
@@ -2748,7 +3055,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={nextEpisodePreCache}
|
||||
onChange={(e) => handleNextEpisodePreCacheToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleNextEpisodePreCacheToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2795,7 +3104,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={disableAutoLoadDanmaku}
|
||||
onChange={(e) => handleDisableAutoLoadDanmakuToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleDisableAutoLoadDanmakuToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2819,7 +3130,11 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={nextEpisodeDanmakuPreload}
|
||||
onChange={(e) => handleNextEpisodeDanmakuPreloadToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleNextEpisodeDanmakuPreloadToggle(
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2843,7 +3158,9 @@ export const UserMenu: React.FC = () => {
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={danmakuHeatmapDisabled}
|
||||
onChange={(e) => handleDanmakuHeatmapDisabledToggle(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleDanmakuHeatmapDisabledToggle(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
@@ -2858,7 +3175,9 @@ export const UserMenu: React.FC = () => {
|
||||
弹幕加载上限
|
||||
</span>
|
||||
<span className='text-xs font-medium text-gray-700 dark:text-gray-300'>
|
||||
{danmakuMaxCount === 0 ? '无上限' : `${danmakuMaxCount} 条`}
|
||||
{danmakuMaxCount === 0
|
||||
? '无上限'
|
||||
: `${danmakuMaxCount} 条`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -2868,38 +3187,63 @@ export const UserMenu: React.FC = () => {
|
||||
max='10000'
|
||||
step='100'
|
||||
value={danmakuMaxCount}
|
||||
onChange={(e) => handleDanmakuMaxCountChange(parseInt(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleDanmakuMaxCountChange(parseInt(e.target.value))
|
||||
}
|
||||
className='flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
|
||||
style={{
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${(danmakuMaxCount / 10000) * 100}%, #e5e7eb ${(danmakuMaxCount / 10000) * 100}%, #e5e7eb 100%)`
|
||||
background: `linear-gradient(to right, #10b981 0%, #10b981 ${
|
||||
(danmakuMaxCount / 10000) * 100
|
||||
}%, #e5e7eb ${
|
||||
(danmakuMaxCount / 10000) * 100
|
||||
}%, #e5e7eb 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='relative text-xs text-gray-500 dark:text-gray-400' style={{ height: '24px' }}>
|
||||
<div
|
||||
className='relative text-xs text-gray-500 dark:text-gray-400'
|
||||
style={{ height: '24px' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDanmakuMaxCountChange(0)}
|
||||
className={`absolute px-2 py-0.5 rounded ${danmakuMaxCount === 0 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`absolute px-2 py-0.5 rounded ${
|
||||
danmakuMaxCount === 0
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
style={{ left: '0%', transform: 'translateX(0%)' }}
|
||||
>
|
||||
无上限
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDanmakuMaxCountChange(3000)}
|
||||
className={`absolute px-2 py-0.5 rounded ${danmakuMaxCount === 3000 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`absolute px-2 py-0.5 rounded ${
|
||||
danmakuMaxCount === 3000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
style={{ left: '30%', transform: 'translateX(-50%)' }}
|
||||
>
|
||||
3000
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDanmakuMaxCountChange(5000)}
|
||||
className={`absolute px-2 py-0.5 rounded ${danmakuMaxCount === 5000 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`absolute px-2 py-0.5 rounded ${
|
||||
danmakuMaxCount === 5000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
style={{ left: '50%', transform: 'translateX(-50%)' }}
|
||||
>
|
||||
5000
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDanmakuMaxCountChange(10000)}
|
||||
className={`absolute px-2 py-0.5 rounded ${danmakuMaxCount === 10000 ? 'bg-green-500 text-white' : 'hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
className={`absolute px-2 py-0.5 rounded ${
|
||||
danmakuMaxCount === 10000
|
||||
? 'bg-green-500 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
style={{ left: '100%', transform: 'translateX(-100%)' }}
|
||||
>
|
||||
10000
|
||||
@@ -2935,8 +3279,18 @@ export const UserMenu: React.FC = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' />
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
|
||||
/>
|
||||
</svg>
|
||||
<span>清除弹幕缓存</span>
|
||||
</>
|
||||
@@ -2945,11 +3299,13 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
{/* 成功/失败提示 */}
|
||||
{clearCacheMessage && (
|
||||
<div className={`text-sm p-3 rounded-lg border ${
|
||||
clearCacheMessage.includes('成功')
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
<div
|
||||
className={`text-sm p-3 rounded-lg border ${
|
||||
clearCacheMessage.includes('成功')
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{clearCacheMessage}
|
||||
</div>
|
||||
)}
|
||||
@@ -2980,15 +3336,71 @@ export const UserMenu: React.FC = () => {
|
||||
<div className='p-3 md:p-4 space-y-4 md:space-y-6'>
|
||||
<div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mb-3'>
|
||||
配置首页模块的显示顺序和可见性
|
||||
配置首页轮播图显示效果,以及首页模块布局
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 首页顶部组件显示 */}
|
||||
{/* 轮播图配置 */}
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
<h4 className='text-sm font-semibold text-gray-800 dark:text-gray-200'>
|
||||
轮播图配置
|
||||
</h4>
|
||||
</div>
|
||||
<div className='p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3'>
|
||||
<div>
|
||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
轮播图高度
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
调整首页轮播图显示高度
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
{homeBannerHeightOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleHomeBannerHeightScaleChange(option.value)
|
||||
}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
homeBannerHeightScale === option.value
|
||||
? 'bg-blue-500 border-blue-500 text-white shadow-sm'
|
||||
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={`${option.label}(${option.description})`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<span
|
||||
className={`ml-1 text-xs ${
|
||||
homeBannerHeightScale === option.value
|
||||
? 'text-blue-100'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模块显示与排序 */}
|
||||
<div className='space-y-3 rounded-lg border border-gray-200 dark:border-gray-700 p-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-semibold text-gray-800 dark:text-gray-200'>
|
||||
模块显示与排序
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
控制首页组件和内容模块的显示/隐藏与顺序
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={() => handleHomeBannerToggle(!homeBannerEnabled)}
|
||||
onClick={() =>
|
||||
handleHomeBannerToggle(!homeBannerEnabled)
|
||||
}
|
||||
className='flex-shrink-0'
|
||||
title={homeBannerEnabled ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
@@ -2999,11 +3411,13 @@ export const UserMenu: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
<div className='flex-1'>
|
||||
<span className={`text-sm font-medium ${
|
||||
homeBannerEnabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
homeBannerEnabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
首页轮播图
|
||||
</span>
|
||||
</div>
|
||||
@@ -3011,9 +3425,15 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
<div className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={() => handleHomeContinueWatchingToggle(!homeContinueWatchingEnabled)}
|
||||
onClick={() =>
|
||||
handleHomeContinueWatchingToggle(
|
||||
!homeContinueWatchingEnabled
|
||||
)
|
||||
}
|
||||
className='flex-shrink-0'
|
||||
title={homeContinueWatchingEnabled ? '点击隐藏' : '点击显示'}
|
||||
title={
|
||||
homeContinueWatchingEnabled ? '点击隐藏' : '点击显示'
|
||||
}
|
||||
>
|
||||
{homeContinueWatchingEnabled ? (
|
||||
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
|
||||
@@ -3022,69 +3442,75 @@ export const UserMenu: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
<div className='flex-1'>
|
||||
<span className={`text-sm font-medium ${
|
||||
homeContinueWatchingEnabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
homeContinueWatchingEnabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
继续观看
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模块列表 */}
|
||||
<div className='space-y-2'>
|
||||
{homeModules.map((module, index) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'
|
||||
>
|
||||
{/* 左侧:显示/隐藏开关 */}
|
||||
<button
|
||||
onClick={() => handleHomeModuleToggle(module.id, !module.enabled)}
|
||||
className='flex-shrink-0'
|
||||
title={module.enabled ? '点击隐藏' : '点击显示'}
|
||||
{/* 模块列表 */}
|
||||
<div className='space-y-2'>
|
||||
{homeModules.map((module, index) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'
|
||||
>
|
||||
{module.enabled ? (
|
||||
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
|
||||
) : (
|
||||
<EyeOff className='w-5 h-5 text-gray-400 dark:text-gray-500' />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 中间:模块名称 */}
|
||||
<div className='flex-1'>
|
||||
<span className={`text-sm font-medium ${
|
||||
module.enabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{module.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧:上下移动按钮 */}
|
||||
<div className='flex gap-1'>
|
||||
{/* 左侧:显示/隐藏开关 */}
|
||||
<button
|
||||
onClick={() => handleHomeModuleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className='p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
|
||||
title='上移'
|
||||
onClick={() =>
|
||||
handleHomeModuleToggle(module.id, !module.enabled)
|
||||
}
|
||||
className='flex-shrink-0'
|
||||
title={module.enabled ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
<MoveUp className='w-4 h-4 text-gray-600 dark:text-gray-400' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleHomeModuleMoveDown(index)}
|
||||
disabled={index === homeModules.length - 1}
|
||||
className='p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
|
||||
title='下移'
|
||||
>
|
||||
<MoveDown className='w-4 h-4 text-gray-600 dark:text-gray-400' />
|
||||
{module.enabled ? (
|
||||
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
|
||||
) : (
|
||||
<EyeOff className='w-5 h-5 text-gray-400 dark:text-gray-500' />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 中间:模块名称 */}
|
||||
<div className='flex-1'>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
module.enabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{module.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧:上下移动按钮 */}
|
||||
<div className='flex gap-1'>
|
||||
<button
|
||||
onClick={() => handleHomeModuleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className='p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
|
||||
title='上移'
|
||||
>
|
||||
<MoveUp className='w-4 h-4 text-gray-600 dark:text-gray-400' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleHomeModuleMoveDown(index)}
|
||||
disabled={index === homeModules.length - 1}
|
||||
className='p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
|
||||
title='下移'
|
||||
>
|
||||
<MoveDown className='w-4 h-4 text-gray-600 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 恢复默认按钮 */}
|
||||
@@ -3092,12 +3518,22 @@ export const UserMenu: React.FC = () => {
|
||||
onClick={() => {
|
||||
setHomeModules(defaultHomeModules);
|
||||
setHomeBannerEnabled(true);
|
||||
setHomeBannerHeightScale('1');
|
||||
setHomeContinueWatchingEnabled(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||
localStorage.setItem(
|
||||
'homeModules',
|
||||
JSON.stringify(defaultHomeModules)
|
||||
);
|
||||
localStorage.setItem('homeBannerEnabled', 'true');
|
||||
localStorage.setItem('homeContinueWatchingEnabled', 'true');
|
||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||
localStorage.setItem('homeBannerHeightScale', '1');
|
||||
localStorage.setItem(
|
||||
'homeContinueWatchingEnabled',
|
||||
'true'
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('homeModulesUpdated')
|
||||
);
|
||||
}
|
||||
}}
|
||||
className='w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg transition-colors'
|
||||
@@ -3107,7 +3543,10 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
|
||||
<p>💡 提示:点击眼睛图标可显示/隐藏模块,使用箭头按钮调整模块顺序</p>
|
||||
<p>
|
||||
💡
|
||||
提示:点击眼睛图标可显示/隐藏模块,使用箭头按钮调整模块顺序
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -3144,9 +3583,7 @@ export const UserMenu: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 订阅面板 */}
|
||||
<div
|
||||
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
|
||||
>
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
|
||||
<div
|
||||
className='h-full p-6'
|
||||
data-panel-content
|
||||
@@ -3220,14 +3657,28 @@ export const UserMenu: React.FC = () => {
|
||||
开启后通过代理处理播放链接,兼容性可能略低
|
||||
</div>
|
||||
</div>
|
||||
<div className={`relative h-6 w-11 rounded-full transition-colors ${subscribeAdFilterEnabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
||||
<div className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${subscribeAdFilterEnabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
<div
|
||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||
subscribeAdFilterEnabled
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||
subscribeAdFilterEnabled
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setSubscribeYellowFilterEnabled((prev) => !prev)}
|
||||
onClick={() =>
|
||||
setSubscribeYellowFilterEnabled((prev) => !prev)
|
||||
}
|
||||
className='w-full flex items-center justify-between rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3 text-left bg-gray-50 dark:bg-gray-800/70'
|
||||
>
|
||||
<div>
|
||||
@@ -3238,8 +3689,20 @@ export const UserMenu: React.FC = () => {
|
||||
开启后同样走代理,并在代理搜索时过滤黄色内容
|
||||
</div>
|
||||
</div>
|
||||
<div className={`relative h-6 w-11 rounded-full transition-colors ${subscribeYellowFilterEnabled ? 'bg-yellow-500' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
||||
<div className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${subscribeYellowFilterEnabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
<div
|
||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||
subscribeYellowFilterEnabled
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||
subscribeYellowFilterEnabled
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3263,7 +3726,8 @@ export const UserMenu: React.FC = () => {
|
||||
{copySuccess ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
{(subscribeAdFilterEnabled || subscribeYellowFilterEnabled) && (
|
||||
{(subscribeAdFilterEnabled ||
|
||||
subscribeYellowFilterEnabled) && (
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
💡 代理模式已开启,某些源可能因为区域或兼容问题无法播放
|
||||
</p>
|
||||
@@ -3283,7 +3747,10 @@ export const UserMenu: React.FC = () => {
|
||||
⚠️ 重置后旧链接将失效
|
||||
</p>
|
||||
{/* 消息提示 */}
|
||||
<p id='tvbox-token-message' className='text-xs text-center hidden'></p>
|
||||
<p
|
||||
id='tvbox-token-message'
|
||||
className='text-xs text-center hidden'
|
||||
></p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -3321,9 +3788,7 @@ export const UserMenu: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 修改密码面板 */}
|
||||
<div
|
||||
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
|
||||
>
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
|
||||
{/* 内容容器 - 独立的滚动区域 */}
|
||||
<div
|
||||
className='h-full p-6'
|
||||
@@ -3438,9 +3903,7 @@ export const UserMenu: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 举报信息面板 */}
|
||||
<div
|
||||
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1003] overflow-hidden'
|
||||
>
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1003] overflow-hidden'>
|
||||
<div
|
||||
className='h-full max-h-[70vh] flex flex-col'
|
||||
data-panel-content
|
||||
@@ -3469,13 +3932,20 @@ export const UserMenu: React.FC = () => {
|
||||
<div className='flex-1 overflow-y-auto p-6'>
|
||||
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
|
||||
<p className='text-gray-800 dark:text-gray-200 leading-relaxed'>
|
||||
抄袭狗<span className='font-bold text-red-600 dark:text-red-400'>SzeMeng76</span>毫无廉耻,盯着本项目的commit区,疯狂抄袭。警告亦全当看不见,实为开源界耻辱。
|
||||
抄袭狗
|
||||
<span className='font-bold text-red-600 dark:text-red-400'>
|
||||
SzeMeng76
|
||||
</span>
|
||||
毫无廉耻,盯着本项目的commit区,疯狂抄袭。警告亦全当看不见,实为开源界耻辱。
|
||||
</p>
|
||||
<p className='text-gray-800 dark:text-gray-200 leading-relaxed mt-3'>
|
||||
超分,观影室,豆瓣反爬,精确搜索等等等等,直接抄袭,最不要脸的就是,刚更新一版,几小时后直接抄走。
|
||||
</p>
|
||||
<p className='text-gray-800 dark:text-gray-200 leading-relaxed mt-3'>
|
||||
<span className='font-semibold text-red-600 dark:text-red-400'>2026-02-25:</span>抄袭emby功能
|
||||
<span className='font-semibold text-red-600 dark:text-red-400'>
|
||||
2026-02-25:
|
||||
</span>
|
||||
抄袭emby功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3513,9 +3983,7 @@ export const UserMenu: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 生态应用面板 */}
|
||||
<div
|
||||
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
|
||||
>
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
|
||||
<div
|
||||
className='h-full max-h-[85vh] flex flex-col'
|
||||
data-panel-content
|
||||
@@ -3675,7 +4143,8 @@ export const UserMenu: React.FC = () => {
|
||||
私人影库转码器
|
||||
</h4>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3'>
|
||||
为私人影库中的 MKV 视频提供转码播放能力,可解析内封字幕并解决部分视频无音频问题,但通常需要较高的本机性能配置。
|
||||
为私人影库中的 MKV
|
||||
视频提供转码播放能力,可解析内封字幕并解决部分视频无音频问题,但通常需要较高的本机性能配置。
|
||||
</p>
|
||||
<a
|
||||
href='https://github.com/mtvpls/moontvplus-transcoder/tags'
|
||||
@@ -3843,14 +4312,10 @@ export const UserMenu: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 使用 Portal 将生态应用面板渲染到 document.body */}
|
||||
{isEcoAppsOpen &&
|
||||
mounted &&
|
||||
createPortal(ecoAppsPanel, document.body)}
|
||||
{isEcoAppsOpen && mounted && createPortal(ecoAppsPanel, document.body)}
|
||||
|
||||
{/* 使用 Portal 将举报信息面板渲染到 document.body */}
|
||||
{isReportOpen &&
|
||||
mounted &&
|
||||
createPortal(reportPanel, document.body)}
|
||||
{isReportOpen && mounted && createPortal(reportPanel, document.body)}
|
||||
|
||||
{/* 确认对话框 */}
|
||||
{confirmDialog.isOpen &&
|
||||
@@ -3875,7 +4340,9 @@ export const UserMenu: React.FC = () => {
|
||||
{/* 按钮 */}
|
||||
<div className='p-6 pt-0 flex gap-3 justify-end'>
|
||||
<button
|
||||
onClick={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
|
||||
onClick={() =>
|
||||
setConfirmDialog({ ...confirmDialog, isOpen: false })
|
||||
}
|
||||
className='px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors'
|
||||
>
|
||||
取消
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface VideoCardProps {
|
||||
episodes?: string[];
|
||||
episodes_titles?: string[];
|
||||
};
|
||||
onBeforeNavigate?: () => void;
|
||||
}
|
||||
|
||||
export type VideoCardHandle = {
|
||||
@@ -107,6 +108,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
playTime,
|
||||
totalTime,
|
||||
cmsData,
|
||||
onBeforeNavigate,
|
||||
}: VideoCardProps,
|
||||
ref
|
||||
) {
|
||||
@@ -326,6 +328,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
return;
|
||||
}
|
||||
|
||||
onBeforeNavigate?.();
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||
@@ -376,6 +380,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
isAggregate,
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
]);
|
||||
|
||||
// 新标签页播放处理函数
|
||||
@@ -385,6 +390,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
return;
|
||||
}
|
||||
|
||||
onBeforeNavigate?.();
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||
@@ -411,6 +418,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
isAggregate,
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
]);
|
||||
|
||||
// 检查搜索结果的收藏状态
|
||||
@@ -1031,10 +1039,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 集数显示 */}
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
>
|
||||
{/* 集数显示 */}
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
@@ -1044,9 +1052,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
共{actualEpisodes}集
|
||||
</div>
|
||||
>
|
||||
{currentEpisode !== undefined && currentEpisode !== null
|
||||
? `${currentEpisode}/${actualEpisodes}`
|
||||
: `共${actualEpisodes}集`}
|
||||
</div>
|
||||
|
||||
{/* 年份显示 */}
|
||||
{displayYear && (
|
||||
|
||||
@@ -26,7 +26,7 @@ function getStaticMeta(pathname: string) {
|
||||
if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
|
||||
if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
|
||||
if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
|
||||
return { title: '电子书馆', subtitle: 'OPDS 目录、搜索、阅读与书架' };
|
||||
return { title: '电子书馆' };
|
||||
}
|
||||
|
||||
export default function BooksLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -141,6 +141,10 @@ export interface AdminConfig {
|
||||
RootPath?: string; // 旧字段:根目录路径(向后兼容,迁移后删除)
|
||||
RootPaths?: string[]; // 新字段:多根目录路径列表
|
||||
OfflineDownloadPath: string; // 离线下载目录,默认 "/"
|
||||
OfflineDownloadUseCustomSource?: boolean; // 离线下载是否使用独立 OpenList 源
|
||||
OfflineDownloadURL?: string; // 独立离线下载 OpenList 服务器地址
|
||||
OfflineDownloadUsername?: string; // 独立离线下载 OpenList 账号
|
||||
OfflineDownloadPassword?: string; // 独立离线下载 OpenList 密码
|
||||
LastRefreshTime?: number; // 上次刷新时间戳
|
||||
ResourceCount?: number; // 资源数量
|
||||
ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟
|
||||
@@ -152,6 +156,8 @@ export interface AdminConfig {
|
||||
Enabled: boolean;
|
||||
Cookie: string;
|
||||
SavePath: string;
|
||||
PlayMode?: 'direct_first' | 'transcode_first';
|
||||
MultiThreadPlayback?: boolean;
|
||||
};
|
||||
Mobile?: {
|
||||
Enabled: boolean;
|
||||
@@ -285,6 +291,7 @@ export interface AdminConfig {
|
||||
Sources?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type?: 'opds';
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
authMode?: 'none' | 'basic' | 'header';
|
||||
@@ -296,6 +303,16 @@ export interface AdminConfig {
|
||||
preferFormat?: Array<'epub' | 'pdf'>;
|
||||
language?: string;
|
||||
}>;
|
||||
LegadoSubscriptions?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
sourceCount?: number;
|
||||
lastSyncAt?: number;
|
||||
lastSuccessAt?: number;
|
||||
lastError?: string;
|
||||
}>;
|
||||
CacheTTL?: number;
|
||||
};
|
||||
EmailConfig?: {
|
||||
@@ -330,6 +347,7 @@ export interface AdminConfig {
|
||||
};
|
||||
AnimeSubscriptionConfig?: {
|
||||
Enabled: boolean; // 是否启用追番功能
|
||||
DownloadTool?: 'aria2' | 'qBittorrent' | 'Transmission'; // 追番订阅全局下载方式
|
||||
Subscriptions: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -5,8 +5,20 @@ import { parseStringPromise } from 'xml2js';
|
||||
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||
import { db, getStorage } from '@/lib/db';
|
||||
import { EmailService } from '@/lib/email.service';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import { AnimeSubscription } from '@/types/anime-subscription';
|
||||
import {
|
||||
addOpenListOfflineDownload,
|
||||
getOfflineDownloadBasePath,
|
||||
joinOpenListPath,
|
||||
} from '@/lib/openlist-offline-download';
|
||||
import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription';
|
||||
|
||||
const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission'];
|
||||
|
||||
function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool {
|
||||
return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool)
|
||||
? tool as AnimeSubscriptionDownloadTool
|
||||
: 'aria2';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标题中提取集数
|
||||
@@ -120,47 +132,11 @@ export async function addOfflineDownload(
|
||||
downloadPath: string
|
||||
) {
|
||||
const config = await getConfig();
|
||||
const openlistConfig = config.OpenListConfig;
|
||||
|
||||
if (!openlistConfig?.Enabled) {
|
||||
throw new Error('私人影库功能未启用');
|
||||
}
|
||||
|
||||
if (
|
||||
!openlistConfig.URL ||
|
||||
!openlistConfig.Username ||
|
||||
!openlistConfig.Password
|
||||
) {
|
||||
throw new Error('OpenList 配置不完整');
|
||||
}
|
||||
|
||||
const client = new OpenListClient(
|
||||
openlistConfig.URL,
|
||||
openlistConfig.Username,
|
||||
openlistConfig.Password
|
||||
const downloadTool = getAnimeSubscriptionDownloadTool(
|
||||
config.AnimeSubscriptionConfig?.DownloadTool
|
||||
);
|
||||
|
||||
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: [torrentUrl],
|
||||
tool: 'aria2',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.code !== 200) {
|
||||
throw new Error(data.message || '添加离线下载任务失败');
|
||||
}
|
||||
await addOpenListOfflineDownload(config, downloadPath, torrentUrl, downloadTool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,9 +271,7 @@ async function sendAnimeUpdateNotifications(
|
||||
*/
|
||||
export async function checkSubscription(subscription: AnimeSubscription) {
|
||||
const config = await getConfig();
|
||||
const openlistConfig = config.OpenListConfig;
|
||||
|
||||
if (!openlistConfig?.OfflineDownloadPath) {
|
||||
if (!config.OpenListConfig?.OfflineDownloadPath) {
|
||||
throw new Error('OpenList 离线下载路径未配置');
|
||||
}
|
||||
|
||||
@@ -318,7 +292,10 @@ export async function checkSubscription(subscription: AnimeSubscription) {
|
||||
const downloaded = [];
|
||||
for (const item of newEpisodes) {
|
||||
try {
|
||||
const downloadPath = `${openlistConfig.OfflineDownloadPath.replace(/\/$/, '')}/${subscription.title}`;
|
||||
const downloadPath = joinOpenListPath(
|
||||
getOfflineDownloadBasePath(config),
|
||||
subscription.title
|
||||
);
|
||||
await addOfflineDownload(item.torrentUrl, downloadPath);
|
||||
|
||||
// 成功后更新 lastEpisode
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
BookCatalogResult,
|
||||
BookDetail,
|
||||
BookListItem,
|
||||
BookSearchFailure,
|
||||
BookSearchResult,
|
||||
BookSource,
|
||||
} from './book.types';
|
||||
import { legadoClient } from './legado.client';
|
||||
import { opdsClient } from './opds.client';
|
||||
|
||||
function sourceKind(source?: Pick<BookSource, 'type'>) {
|
||||
return source?.type === 'legado' || (source as BookSource | undefined)?.legado ? 'legado' : 'opds';
|
||||
}
|
||||
|
||||
export class BookProvider {
|
||||
async getSources(): Promise<BookSource[]> {
|
||||
const [opdsSources, legadoSources] = await Promise.all([
|
||||
opdsClient.getSources().catch(() => []),
|
||||
legadoClient.getSources().catch(() => []),
|
||||
]);
|
||||
return [...opdsSources.map((source) => ({ ...source, type: source.type || 'opds' as const })), ...legadoSources];
|
||||
}
|
||||
|
||||
async getSourceById(sourceId: string): Promise<BookSource> {
|
||||
const sources = await this.getSources();
|
||||
const source = sources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new Error('未找到对应的电子书源');
|
||||
return source;
|
||||
}
|
||||
|
||||
async getCatalog(sourceId: string, href?: string): Promise<BookCatalogResult> {
|
||||
const source = await this.getSourceById(sourceId);
|
||||
return sourceKind(source) === 'legado'
|
||||
? legadoClient.getCatalog(sourceId, href)
|
||||
: opdsClient.getCatalog(sourceId, href);
|
||||
}
|
||||
|
||||
async getSearchSources(sourceId?: string): Promise<BookSource[]> {
|
||||
if (sourceId) return [await this.getSourceById(sourceId)];
|
||||
return this.getSources();
|
||||
}
|
||||
|
||||
async searchBooksSource(q: string, source: BookSource): Promise<{ source: BookSource; results: BookListItem[] }> {
|
||||
return sourceKind(source) === 'legado'
|
||||
? legadoClient.searchBooksSource(q, source)
|
||||
: opdsClient.searchBooksSource(q, source);
|
||||
}
|
||||
|
||||
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||
const sources = await this.getSearchSources(sourceId);
|
||||
const results: BookListItem[] = [];
|
||||
const failedSources: BookSearchFailure[] = [];
|
||||
await Promise.all(sources.map(async (source) => {
|
||||
try {
|
||||
const sourceResult = await this.searchBooksSource(q, source);
|
||||
results.push(...sourceResult.results);
|
||||
} catch (error) {
|
||||
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
|
||||
}
|
||||
}));
|
||||
return { results, failedSources };
|
||||
}
|
||||
|
||||
async getBookDetail(sourceId: string, href: string, fallback?: Partial<BookDetail>): Promise<BookDetail> {
|
||||
const source = await this.getSourceById(sourceId);
|
||||
return sourceKind(source) === 'legado'
|
||||
? legadoClient.getBookDetail(sourceId, href, fallback)
|
||||
: opdsClient.getBookDetail(sourceId, href, fallback);
|
||||
}
|
||||
|
||||
async getPreferredAcquisition(sourceId: string, href: string): Promise<{ format: 'epub' | 'pdf' | 'chapters'; href: string }> {
|
||||
const source = await this.getSourceById(sourceId);
|
||||
if (sourceKind(source) === 'legado') {
|
||||
const detail = await legadoClient.getBookDetail(sourceId, href);
|
||||
const chapters = detail.acquisitionLinks.find((item) => item.type === 'application/x-legado-chapters+json') || detail.acquisitionLinks[0];
|
||||
if (!chapters?.href) throw new Error('当前书籍没有可用章节目录');
|
||||
return { format: 'chapters', href: chapters.href };
|
||||
}
|
||||
return opdsClient.getPreferredAcquisition(sourceId, href);
|
||||
}
|
||||
}
|
||||
|
||||
export const bookProvider = new BookProvider();
|
||||
@@ -16,7 +16,7 @@ export interface BookRouteCacheItem {
|
||||
detailHref?: string;
|
||||
acquisitionHref?: string;
|
||||
acquisitionLinks?: BookAcquisitionLink[];
|
||||
format?: 'epub' | 'pdf';
|
||||
format?: 'epub' | 'pdf' | 'chapters';
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,10 @@ export function cacheBookListItem(item: BookListItem) {
|
||||
}
|
||||
|
||||
export function cacheBookDetail(detail: BookDetail) {
|
||||
const readable = detail.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf'));
|
||||
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';
|
||||
});
|
||||
saveBookRouteCache({
|
||||
sourceId: detail.sourceId,
|
||||
bookId: detail.id,
|
||||
@@ -87,7 +90,7 @@ export function cacheBookDetail(detail: BookDetail) {
|
||||
summary: detail.summary,
|
||||
detailHref: detail.detailHref,
|
||||
acquisitionHref: readable?.href,
|
||||
format: readable?.type.toLowerCase().includes('pdf') ? 'pdf' : readable ? 'epub' : undefined,
|
||||
format: readable?.type.toLowerCase().includes('pdf') ? 'pdf' : readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters' ? 'chapters' : readable ? 'epub' : undefined,
|
||||
acquisitionLinks: detail.acquisitionLinks,
|
||||
});
|
||||
}
|
||||
@@ -124,6 +127,11 @@ export function buildBookDetailPath(sourceId: string, bookId: string) {
|
||||
return `/books/detail?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(bookId)}`;
|
||||
}
|
||||
|
||||
export function buildBookReadPath(sourceId: string, bookId: string) {
|
||||
return `/books/read?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(bookId)}`;
|
||||
export function buildBookReadPath(sourceId: string, bookId: string, chapterHref?: string) {
|
||||
const params = new URLSearchParams({
|
||||
sourceId,
|
||||
bookId,
|
||||
});
|
||||
if (chapterHref) params.set('chapterHref', chapterHref);
|
||||
return `/books/read?${params.toString()}`;
|
||||
}
|
||||
|
||||
+110
-7
@@ -1,16 +1,101 @@
|
||||
export interface BookSourceCapabilities {
|
||||
searchSupported: boolean;
|
||||
catalogSupported: boolean;
|
||||
searchMode: 'opds' | 'template' | 'disabled';
|
||||
catalogMode: 'navigation' | 'acquisition' | 'flat' | 'disabled';
|
||||
searchMode: 'opds' | 'template' | 'legado' | 'disabled';
|
||||
catalogMode: 'navigation' | 'acquisition' | 'flat' | 'legado' | 'disabled';
|
||||
acquisitionTypes: string[];
|
||||
lastCheckedAt?: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface LegadoRuleSearch {
|
||||
bookList?: string;
|
||||
name?: string;
|
||||
author?: string;
|
||||
intro?: string;
|
||||
coverUrl?: string;
|
||||
bookUrl?: string;
|
||||
kind?: string;
|
||||
lastChapter?: string;
|
||||
checkKeyWord?: string;
|
||||
wordCount?: string;
|
||||
}
|
||||
|
||||
|
||||
export type LegadoRuleExplore = LegadoRuleSearch;
|
||||
|
||||
export interface LegadoRuleBookInfo {
|
||||
init?: string;
|
||||
name?: string;
|
||||
author?: string;
|
||||
intro?: string;
|
||||
coverUrl?: string;
|
||||
tocUrl?: string;
|
||||
kind?: string;
|
||||
lastChapter?: string;
|
||||
wordCount?: string;
|
||||
}
|
||||
|
||||
export interface LegadoRuleToc {
|
||||
chapterList?: string;
|
||||
chapterName?: string;
|
||||
chapterUrl?: string;
|
||||
isVip?: string;
|
||||
isPay?: string;
|
||||
nextTocUrl?: string;
|
||||
updateTime?: string;
|
||||
wordCount?: string;
|
||||
chapterInfo?: string;
|
||||
}
|
||||
|
||||
export interface LegadoRuleContent {
|
||||
content?: string;
|
||||
nextContentUrl?: string;
|
||||
imageStyle?: string;
|
||||
sourceRegex?: string;
|
||||
replaceRegex?: string;
|
||||
payAction?: string;
|
||||
}
|
||||
|
||||
export interface LegadoBookSourceRule {
|
||||
bookSourceName?: string;
|
||||
bookSourceUrl?: string;
|
||||
bookSourceGroup?: string;
|
||||
bookSourceType?: number;
|
||||
customButton?: boolean | unknown;
|
||||
eventListener?: boolean | unknown;
|
||||
variable?: string | Record<string, unknown>;
|
||||
loginUi?: string | unknown;
|
||||
concurrentRate?: string | number;
|
||||
respondTime?: number;
|
||||
lastUpdateTime?: string | number;
|
||||
enabled?: boolean;
|
||||
enabledCookieJar?: boolean;
|
||||
enabledExplore?: boolean;
|
||||
exploreUrl?: string;
|
||||
header?: string | Record<string, string>;
|
||||
jsLib?: string;
|
||||
loginUrl?: string;
|
||||
searchUrl?: string;
|
||||
bookInfoUrl?: string;
|
||||
bookInfoInit?: string;
|
||||
bookUrlPattern?: string;
|
||||
tocUrl?: string;
|
||||
chapterUrl?: string;
|
||||
ruleSearch?: LegadoRuleSearch;
|
||||
ruleExplore?: LegadoRuleExplore;
|
||||
ruleBookInfo?: LegadoRuleBookInfo;
|
||||
ruleToc?: LegadoRuleToc;
|
||||
ruleContent?: LegadoRuleContent;
|
||||
ruleReview?: unknown;
|
||||
customOrder?: number;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface BookSource {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: 'opds' | 'legado';
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
authMode?: 'none' | 'basic' | 'header';
|
||||
@@ -21,6 +106,7 @@ export interface BookSource {
|
||||
searchTemplate?: string;
|
||||
preferFormat?: Array<'epub' | 'pdf'>;
|
||||
language?: string;
|
||||
legado?: LegadoBookSourceRule;
|
||||
capabilities?: BookSourceCapabilities;
|
||||
}
|
||||
|
||||
@@ -87,7 +173,7 @@ export interface BookSearchResult {
|
||||
}
|
||||
|
||||
export interface BookLocator {
|
||||
type: 'epub-cfi' | 'pdf-page' | 'href';
|
||||
type: 'epub-cfi' | 'pdf-page' | 'href' | 'chapter';
|
||||
value: string;
|
||||
href?: string;
|
||||
chapterTitle?: string;
|
||||
@@ -100,7 +186,7 @@ export interface BookShelfItem {
|
||||
title: string;
|
||||
author?: string;
|
||||
cover?: string;
|
||||
format?: 'epub' | 'pdf';
|
||||
format?: 'epub' | 'pdf' | 'chapters';
|
||||
detailHref?: string;
|
||||
acquisitionHref?: string;
|
||||
progressPercent?: number;
|
||||
@@ -118,7 +204,7 @@ export interface BookReadRecord {
|
||||
title: string;
|
||||
author?: string;
|
||||
cover?: string;
|
||||
format: 'epub' | 'pdf';
|
||||
format: 'epub' | 'pdf' | 'chapters';
|
||||
detailHref?: string;
|
||||
acquisitionHref?: string;
|
||||
locator: BookLocator;
|
||||
@@ -159,10 +245,27 @@ export interface BookTtsProgress {
|
||||
|
||||
export interface BookReadManifest {
|
||||
book: BookDetail;
|
||||
format: 'epub' | 'pdf';
|
||||
fileUrl: string;
|
||||
format: 'epub' | 'pdf' | 'chapters';
|
||||
fileUrl?: string;
|
||||
chaptersUrl?: string;
|
||||
acquisitionHref?: string;
|
||||
cacheKey?: string;
|
||||
coverUrl?: string;
|
||||
lastRecord?: BookReadRecord | null;
|
||||
}
|
||||
|
||||
export interface BookChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface BookChapterContent {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
content: string;
|
||||
nextHref?: string;
|
||||
previousHref?: string;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,37 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "221.0.0",
|
||||
date: "2026-05-24",
|
||||
added: [
|
||||
"电子书馆引入 Legado 书源支持",
|
||||
"漫画和电子书馆搜索增加流式输出功能",
|
||||
"增加 Emby 格式 SxxExx 分组功能",
|
||||
"登录增加 fail2ban 机制,防止暴力破解",
|
||||
"夸克网盘增加直链优先 / 转码优先切换",
|
||||
"夸克网盘增加多线程播放功能",
|
||||
"网盘搜索磁链类型增加存到私人影库功能",
|
||||
"存至私人影库增加下载方式选择",
|
||||
"OpenList 离线下载增加额外源选择",
|
||||
"用户管理增加用户名搜索功能",
|
||||
"增加轮播图高度配置",
|
||||
"搜索增加部分缓存快速启动机制"
|
||||
],
|
||||
changed: [
|
||||
"音乐封面改为黑胶留声机样式",
|
||||
"播放记录保存时不再保存相同秒数的记录",
|
||||
"优化切集性能",
|
||||
"优化 episode_progress 渲染时机",
|
||||
"AI 问片手动渲染表格以修复渲染表格报错"
|
||||
],
|
||||
fixed: [
|
||||
"修复当前播放的音乐记录置底问题",
|
||||
"修复无网盘权限仍可直链播放网盘链接的权限漏洞",
|
||||
"修复播放记录面板不显示当前集数",
|
||||
"修复音乐分页问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "220.0.0",
|
||||
date: "2026-05-14",
|
||||
|
||||
@@ -558,6 +558,29 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
}
|
||||
adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(adminConfig.LiveRefreshIntervalHours);
|
||||
|
||||
if (adminConfig.OpenListConfig) {
|
||||
if (!adminConfig.OpenListConfig.RootPaths) {
|
||||
adminConfig.OpenListConfig.RootPaths = adminConfig.OpenListConfig.RootPath
|
||||
? [adminConfig.OpenListConfig.RootPath]
|
||||
: ['/'];
|
||||
}
|
||||
if (!adminConfig.OpenListConfig.OfflineDownloadPath) {
|
||||
adminConfig.OpenListConfig.OfflineDownloadPath = '/';
|
||||
}
|
||||
if (adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined) {
|
||||
adminConfig.OpenListConfig.OfflineDownloadUseCustomSource = false;
|
||||
}
|
||||
if (adminConfig.OpenListConfig.OfflineDownloadURL === undefined) {
|
||||
adminConfig.OpenListConfig.OfflineDownloadURL = '';
|
||||
}
|
||||
if (adminConfig.OpenListConfig.OfflineDownloadUsername === undefined) {
|
||||
adminConfig.OpenListConfig.OfflineDownloadUsername = '';
|
||||
}
|
||||
if (adminConfig.OpenListConfig.OfflineDownloadPassword === undefined) {
|
||||
adminConfig.OpenListConfig.OfflineDownloadPassword = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 用户信息已迁移到新版数据库
|
||||
// 这里只保留站长用户用于兼容性,其他用户从数据库读取
|
||||
const ownerUser = process.env.USERNAME;
|
||||
@@ -695,6 +718,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
return [{
|
||||
id: 'default',
|
||||
name: process.env.OPDS_NAME || '默认书源',
|
||||
type: 'opds',
|
||||
url: envUrl,
|
||||
enabled: true,
|
||||
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none',
|
||||
@@ -714,6 +738,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
|
||||
adminConfig.OPDSConfig.Sources = [];
|
||||
}
|
||||
adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter((source: any) => (source?.type || 'opds') === 'opds').map((source: any) => {
|
||||
const { legado: _legado, ...rest } = source || {};
|
||||
return { ...rest, type: 'opds' };
|
||||
});
|
||||
if (!Array.isArray(adminConfig.OPDSConfig.LegadoSubscriptions)) {
|
||||
adminConfig.OPDSConfig.LegadoSubscriptions = [];
|
||||
}
|
||||
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
|
||||
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
||||
}
|
||||
@@ -724,6 +755,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
SavePath: '/',
|
||||
PlayMode: 'transcode_first',
|
||||
MultiThreadPlayback: false,
|
||||
},
|
||||
Mobile: {
|
||||
Enabled: false,
|
||||
@@ -761,8 +794,16 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
SavePath: '/',
|
||||
PlayMode: 'transcode_first',
|
||||
MultiThreadPlayback: false,
|
||||
};
|
||||
}
|
||||
if (!adminConfig.NetDiskConfig.Quark.PlayMode) {
|
||||
adminConfig.NetDiskConfig.Quark.PlayMode = 'transcode_first';
|
||||
}
|
||||
if (adminConfig.NetDiskConfig.Quark.MultiThreadPlayback === undefined) {
|
||||
adminConfig.NetDiskConfig.Quark.MultiThreadPlayback = false;
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig.Mobile) {
|
||||
adminConfig.NetDiskConfig.Mobile = {
|
||||
|
||||
+46
-18
@@ -749,7 +749,7 @@ export class D1Storage implements IStorage {
|
||||
try {
|
||||
const results = await this.db
|
||||
// 按队列顺序返回;当前播放项由最大 last_played_at 决定
|
||||
.prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, last_played_at ASC')
|
||||
.prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, id ASC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
@@ -1306,7 +1306,8 @@ export class D1Storage implements IStorage {
|
||||
async getUserListV2(
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
ownerUsername?: string
|
||||
ownerUsername?: string,
|
||||
search?: string
|
||||
): Promise<{
|
||||
users: Array<{
|
||||
username: string;
|
||||
@@ -1320,10 +1321,16 @@ export class D1Storage implements IStorage {
|
||||
total: number;
|
||||
}> {
|
||||
try {
|
||||
const trimmedSearch = search?.trim() || '';
|
||||
const searchPattern = `%${trimmedSearch}%`;
|
||||
|
||||
// 获取总数
|
||||
const countResult = await this.db
|
||||
.prepare('SELECT COUNT(*) as total FROM users')
|
||||
.first();
|
||||
const countQuery = trimmedSearch
|
||||
? this.db
|
||||
.prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE ?')
|
||||
.bind(searchPattern)
|
||||
: this.db.prepare('SELECT COUNT(*) as total FROM users');
|
||||
const countResult = await countQuery.first();
|
||||
let total = (countResult?.total as number) || 0;
|
||||
|
||||
// 检查站长是否在数据库中
|
||||
@@ -1333,8 +1340,11 @@ export class D1Storage implements IStorage {
|
||||
ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||
ownerInDatabase = !!ownerInfo && ownerInfo.created_at !== 0;
|
||||
|
||||
// 如果站长不在数据库中,总数+1
|
||||
if (!ownerInDatabase) {
|
||||
// 如果站长不在数据库中且匹配搜索条件,总数+1
|
||||
if (
|
||||
!ownerInDatabase &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
@@ -1343,7 +1353,11 @@ export class D1Storage implements IStorage {
|
||||
let actualOffset = offset;
|
||||
let actualLimit = limit;
|
||||
|
||||
if (ownerUsername && !ownerInDatabase) {
|
||||
if (
|
||||
ownerUsername &&
|
||||
!ownerInDatabase &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
if (offset === 0) {
|
||||
// 第一页:只获取 limit-1 个用户,为站长留出位置
|
||||
actualLimit = limit - 1;
|
||||
@@ -1354,20 +1368,34 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
|
||||
// 获取用户列表(按创建时间降序)
|
||||
const result = await this.db
|
||||
.prepare(`
|
||||
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`)
|
||||
.bind(actualLimit, actualOffset)
|
||||
.all();
|
||||
const listQuery = trimmedSearch
|
||||
? this.db
|
||||
.prepare(`
|
||||
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
|
||||
FROM users
|
||||
WHERE username LIKE ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`)
|
||||
.bind(searchPattern, actualLimit, actualOffset)
|
||||
: this.db
|
||||
.prepare(`
|
||||
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`)
|
||||
.bind(actualLimit, actualOffset);
|
||||
const result = await listQuery.all();
|
||||
|
||||
const users = [];
|
||||
|
||||
// 如果有站长且在第一页,确保站长始终在第一位
|
||||
if (ownerUsername && offset === 0) {
|
||||
if (
|
||||
ownerUsername &&
|
||||
offset === 0 &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
users.push({
|
||||
username: ownerUsername,
|
||||
role: 'owner' as const,
|
||||
|
||||
+3
-2
@@ -555,7 +555,8 @@ export class DbManager {
|
||||
async getUserListV2(
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
ownerUsername?: string
|
||||
ownerUsername?: string,
|
||||
search?: string
|
||||
): Promise<{
|
||||
users: Array<{
|
||||
username: string;
|
||||
@@ -569,7 +570,7 @@ export class DbManager {
|
||||
total: number;
|
||||
}> {
|
||||
if (typeof (this.storage as any).getUserListV2 === 'function') {
|
||||
return (this.storage as any).getUserListV2(offset, limit, ownerUsername);
|
||||
return (this.storage as any).getUserListV2(offset, limit, ownerUsername, search);
|
||||
}
|
||||
return { users: [], total: 0 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,1789 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as cheerio from 'cheerio/slim';
|
||||
import crypto from 'crypto';
|
||||
import he from 'he';
|
||||
import vm from 'vm';
|
||||
import { DOMParser } from '@xmldom/xmldom';
|
||||
import xpath from 'xpath';
|
||||
|
||||
import { getConfig } from './config';
|
||||
import {
|
||||
BookAcquisitionLink,
|
||||
BookCatalogResult,
|
||||
BookChapter,
|
||||
BookChapterContent,
|
||||
BookDetail,
|
||||
BookListItem,
|
||||
BookSearchFailure,
|
||||
BookSearchResult,
|
||||
BookSource,
|
||||
BookSourceCapabilities,
|
||||
LegadoBookSourceRule,
|
||||
LegadoRuleSearch,
|
||||
} from './book.types';
|
||||
import { validateProxyUrlServerSide } from './server/ssrf';
|
||||
import { legadoSubscriptionStore } from './legado/subscription-store';
|
||||
|
||||
interface ResolvedLegadoConfig {
|
||||
enabled: boolean;
|
||||
sources: BookSource[];
|
||||
cacheTTL: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = Number(process.env.LEGADO_TIMEOUT_MS || process.env.OPDS_TIMEOUT_MS || 20000);
|
||||
const MAX_TEXT_BYTES = Number(process.env.LEGADO_MAX_TEXT_BYTES || 3 * 1024 * 1024);
|
||||
const DEFAULT_LEGADO_SEARCH_PAGES = Number(process.env.LEGADO_SEARCH_PAGES || 5);
|
||||
const textCache = new Map<string, { expiresAt: number; data: string }>();
|
||||
const searchCache = new Map<string, { expiresAt: number; data: BookListItem[] }>();
|
||||
const detailCache = new Map<string, { expiresAt: number; data: BookDetail }>();
|
||||
const tocCache = new Map<string, { expiresAt: number; data: BookChapter[] }>();
|
||||
const chapterCache = new Map<string, { expiresAt: number; data: BookChapterContent }>();
|
||||
|
||||
interface RequestOptions {
|
||||
url: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
charset?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
const cookieJar = new Map<string, string>();
|
||||
const variableStore = new Map<string, any>();
|
||||
const imageMemoryCache = new Map<string, { expiresAt: number; contentType: string; data: Uint8Array }>();
|
||||
|
||||
function stableId(input: string) {
|
||||
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function isSafeHeaderName(name: string) {
|
||||
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
|
||||
}
|
||||
|
||||
function isJsRuleString(value?: string) {
|
||||
const trimmed = (value || '').trim();
|
||||
return /^(?:@js:|<js>)/i.test(trimmed) || /<js>[\s\S]*?<\/js>/i.test(trimmed);
|
||||
}
|
||||
|
||||
function resolveLegadoDynamicValue(value: any, context: Record<string, any>, timeout = 1000): any {
|
||||
if (Array.isArray(value)) return value.map((item) => resolveLegadoDynamicValue(item, context, timeout));
|
||||
if (!value || typeof value !== 'object') {
|
||||
if (typeof value !== 'string') return value;
|
||||
if (!isJsRuleString(value)) return value;
|
||||
return evaluateJsRuleString(value, context, timeout);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, resolveLegadoDynamicValue(val, context, timeout)]));
|
||||
}
|
||||
|
||||
function evaluateJsRuleString(value: string, context: Record<string, any>, timeout = 1000) {
|
||||
const trimmed = value.trim();
|
||||
if (/^(?:@js:|<js>)/i.test(trimmed)) return runJsSnippet(trimmed, context, timeout);
|
||||
return value.replace(/<js>([\s\S]*?)<\/js>/gi, (_, code) => runJsSnippet(code, context, timeout));
|
||||
}
|
||||
|
||||
function parseHeaderLines(raw: string, context: Record<string, any>) {
|
||||
return raw.split('\n').reduce<Record<string, string>>((headers, line) => {
|
||||
const index = line.indexOf(':');
|
||||
if (index <= 0) return headers;
|
||||
const name = line.slice(0, index).trim();
|
||||
if (!isSafeHeaderName(name)) return headers;
|
||||
const value = line.slice(index + 1).trim();
|
||||
headers[name] = isJsRuleString(value) ? evaluateJsRuleString(value, context) : value;
|
||||
return headers;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function asObjectHeader(value?: string | Record<string, string>, context?: Record<string, any>): Record<string, string> {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object') return resolveLegadoDynamicValue(value, context || {});
|
||||
let raw = value.trim();
|
||||
if (isJsRuleString(raw)) {
|
||||
raw = evaluateJsRuleString(raw, context || {});
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
return resolveLegadoDynamicValue(parsed, context || {});
|
||||
} catch {
|
||||
return parseHeaderLines(raw, context || {});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegadoRule(rule?: LegadoBookSourceRule, source?: BookSource): LegadoBookSourceRule | undefined {
|
||||
if (!rule) return rule;
|
||||
const base = source?.url || rule.bookSourceUrl || '';
|
||||
const context = { baseUrl: base, source: rule };
|
||||
return resolveLegadoDynamicValue(rule, context);
|
||||
}
|
||||
|
||||
function resolveLegadoSource(source: BookSource): BookSource {
|
||||
const rule = resolveLegadoRule(source.legado, source);
|
||||
const resolved: BookSource = {
|
||||
...source,
|
||||
legado: rule,
|
||||
};
|
||||
return resolveLegadoDynamicValue(resolved, { baseUrl: source.url || rule?.bookSourceUrl || '', source: rule || source.legado || source });
|
||||
}
|
||||
|
||||
function buildHeaders(source: BookSource): HeadersInit {
|
||||
const rule = source.legado;
|
||||
const baseUrl = sourceBase(source);
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
...asObjectHeader(rule?.header, { baseUrl, source: rule }),
|
||||
};
|
||||
if (source.authMode === 'header' && source.headerName && source.headerValue) headers[source.headerName] = source.headerValue;
|
||||
if (source.authMode === 'basic' && source.username) headers.Authorization = `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`;
|
||||
delete headers.Host;
|
||||
delete headers.host;
|
||||
delete headers['Content-Length'];
|
||||
delete headers['content-length'];
|
||||
return headers;
|
||||
}
|
||||
|
||||
function sourceBase(source: BookSource) {
|
||||
return source.legado?.bookSourceUrl || source.url;
|
||||
}
|
||||
|
||||
function normalizeUrl(base: string, href?: string): string {
|
||||
if (!href) return base;
|
||||
const trimmed = href.trim();
|
||||
if (!trimmed) return base;
|
||||
if (/^javascript:/i.test(trimmed)) return '';
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
try {
|
||||
return new URL(trimmed, base).toString();
|
||||
} catch {
|
||||
// 部分 Legado 源会把 bookSourceUrl 当作名称,真实服务端地址放在变量里。
|
||||
// 无法解析相对地址时保留原值,让上层给出更准确的“非 HTTP URL”错误,而不是直接 Invalid URL。
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRuleParam(value: string) {
|
||||
return encodeURIComponent(value).replace(/%20/g, '+');
|
||||
}
|
||||
|
||||
function encryptTongrenKeyword(plainText: string) {
|
||||
const passphrase = 'zc89s30ipHG2Dw';
|
||||
const key = Buffer.alloc(32);
|
||||
const iv = Buffer.alloc(16);
|
||||
Buffer.from(passphrase).copy(key);
|
||||
Buffer.from(passphrase).copy(iv);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
return encodeURIComponent(Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]).toString('base64'));
|
||||
}
|
||||
|
||||
function decryptTongrenOpenUrl(encryptedBase64: string, num: string, source?: LegadoBookSourceRule) {
|
||||
try {
|
||||
const numStr = Buffer.from(String(num || ''), 'base64').toString('utf8');
|
||||
const userAgent = (asObjectHeader(source?.header)['User-Agent'] || 'Mozilla/5.0 (Linux; Android 9) Mobile Safari/537.36').toLowerCase();
|
||||
const key = Buffer.from(crypto.createHash('md5').update(userAgent + numStr).digest('hex'), 'utf8');
|
||||
const encryptedData = Buffer.from(String(encryptedBase64 || ''), 'base64');
|
||||
const iv = encryptedData.subarray(0, 16);
|
||||
const data = encryptedData.subarray(16);
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function safeEvalTemplateExpression(expr: string, keyword: string, page: number) {
|
||||
const key = keyword;
|
||||
const searchTerms = keyword;
|
||||
const java = {
|
||||
base64Encode: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8').toString('base64'),
|
||||
base64Decode: (value: unknown) => Buffer.from(String(value ?? ''), 'base64').toString('utf8'),
|
||||
encodeURI: (value: unknown) => encodeURIComponent(String(value ?? '')),
|
||||
encodeURIComponent: (value: unknown) => encodeURIComponent(String(value ?? '')),
|
||||
};
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('key', 'keyword', 'searchTerms', 'page', 'java', `return (${expr});`);
|
||||
return jsonPrimitiveToString(fn(key, keyword, searchTerms, page, java));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTemplateExpressions(raw: string, keyword = '', page = 1) {
|
||||
return raw.replace(/<([^,<>]*),\s*\{\{(.*?)\}\}>/g, (_, prefix, expr) => {
|
||||
const value = safeEvalTemplateExpression(String(expr).trim(), keyword, page);
|
||||
return value ? `${prefix || ''}${value}` : '';
|
||||
}).replace(/\{\{(.*?)\}\}/g, (_, expr) => {
|
||||
const value = String(expr).trim();
|
||||
if (/^(key|keyword|searchTerms)$/.test(value)) return encodeRuleParam(keyword || '');
|
||||
if (/^(page|pageIndex)$/.test(value)) return String(page);
|
||||
const evaluated = safeEvalTemplateExpression(value, keyword, page);
|
||||
// 表达式里如果已经显式调用 encodeURIComponent/encodeURI/java.encodeURI,
|
||||
// 结果就是编码后的参数,不能再 encode 一次,否则会把 % 编成 %25。
|
||||
if (/\b(?:encodeURIComponent|encodeURI|java\.encodeURI|java\.encodeURIComponent)\s*\(/.test(value)) return evaluated;
|
||||
return encodeRuleParam(evaluated);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function unwrapJsRule(code: string) {
|
||||
const raw = String(code || '').trim();
|
||||
const block = raw.match(/^<js>([\s\S]*?)<\/js>$/i);
|
||||
if (block) return block[1].trim();
|
||||
return raw.replace(/^@js:/, '').trim();
|
||||
}
|
||||
|
||||
function parseArguments(value?: string) {
|
||||
const out: Record<string, string> = {};
|
||||
String(value || '').split(/[&;\n]+/).forEach((part) => {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx <= 0) return;
|
||||
const key = part.slice(0, idx).trim();
|
||||
const val = part.slice(idx + 1).trim();
|
||||
try { out[key] = decodeURIComponent(val); } catch { out[key] = val; }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeSourceRuntime(source: any, baseUrl?: string) {
|
||||
const raw = source || {};
|
||||
const variable = raw.variable || raw.loginUrl || raw.bookSourceUrl || baseUrl || '';
|
||||
return {
|
||||
...raw,
|
||||
getVariable: () => variable,
|
||||
getLoginInfoMap: () => ({}),
|
||||
getKey: () => raw.key || raw.bookSourceUrl || baseUrl || '',
|
||||
};
|
||||
}
|
||||
|
||||
function runJsSnippetRaw(code: string, context: Record<string, any>, timeout = 1000): any {
|
||||
const sourceRuntime = makeSourceRuntime(context.source, context.baseUrl);
|
||||
let lastPut: any = null;
|
||||
const java = {
|
||||
base64Encode: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8').toString('base64'),
|
||||
base64Decode: (value: unknown) => Buffer.from(String(value ?? ''), 'base64').toString('utf8'),
|
||||
md5Encode: (value: unknown) => crypto.createHash('md5').update(String(value ?? '')).digest('hex'),
|
||||
strToBytes: (value: unknown) => Buffer.from(String(value ?? ''), 'utf8'),
|
||||
base64DecodeToByteArray: (value: unknown) => Buffer.from(String(value ?? ''), 'base64'),
|
||||
hexDecodeToString: (value: unknown) => Buffer.from(String(value ?? ''), 'hex').toString('utf8'),
|
||||
ajax: () => '',
|
||||
getWebViewUA: () => 'Mozilla/5.0 (Linux; Android 10) Mobile Safari/537.36',
|
||||
getElements: (selectorRule: string) => {
|
||||
const raw = jsonPrimitiveToString(context.result ?? context.src ?? '');
|
||||
if (!raw) return [];
|
||||
const $ = cheerio.load(raw);
|
||||
let nodes = selectElements($, $.root(), selectorRule);
|
||||
if (nodes.length === 0 && /#chapter-items@a/.test(selectorRule)) nodes = $('#chapter-items').find('a');
|
||||
return nodes.toArray().map((element) => {
|
||||
const node = $(element);
|
||||
const attrs = (element as any).attribs || {};
|
||||
return { ...attrs, text: node.text().trim(), href: attrs.href, src: attrs.src, html: node.html() || '' };
|
||||
});
|
||||
},
|
||||
deviceID: () => '',
|
||||
androidId: () => '',
|
||||
longToast: () => undefined,
|
||||
toast: () => undefined,
|
||||
startBrowserAwait: () => ({ body: () => '' }),
|
||||
encodeURI: (value: unknown) => encodeURIComponent(String(value ?? '')),
|
||||
encodeURIComponent: (value: unknown) => encodeURIComponent(String(value ?? '')),
|
||||
put: (key: string, value: any) => { lastPut = { key, value }; variableStore.set(key, value); return value; },
|
||||
get: (key: string) => variableStore.get(key),
|
||||
log: () => undefined,
|
||||
htmlFormat: (value: unknown) => he.decode(String(value ?? '')).replace(/<br\s*\/?/gi, '\n').replace(/<[^>]+>/g, ''),
|
||||
};
|
||||
const cookie = {
|
||||
getCookie: () => '',
|
||||
removeCookie: () => undefined,
|
||||
mapToCookie: (input: any) => typeof input === 'string' ? input : Array.isArray(input) ? input.join('; ') : '',
|
||||
};
|
||||
const sandbox: Record<string, any> = {
|
||||
...context,
|
||||
source: sourceRuntime,
|
||||
book: context.book || {},
|
||||
java,
|
||||
cookie,
|
||||
Buffer,
|
||||
JSON,
|
||||
String,
|
||||
Number,
|
||||
Math,
|
||||
Array,
|
||||
Object,
|
||||
encodeURIComponent,
|
||||
encryptText: encryptTongrenKeyword,
|
||||
openUrl: (encryptedText: string, num: string) => decryptTongrenOpenUrl(encryptedText, num, context.source),
|
||||
getArguments: (value: string, key?: string) => key ? (parseArguments(value)[key] || '') : parseArguments(value),
|
||||
console: { log: () => undefined },
|
||||
};
|
||||
const body = unwrapJsRule(code);
|
||||
try {
|
||||
const script = new vm.Script(`(function(){ ${body}\n})()`);
|
||||
const result = script.runInNewContext(sandbox, { timeout });
|
||||
if (result !== undefined && result !== null && result !== '') return result;
|
||||
if (sandbox.result !== context.result && sandbox.result !== undefined && sandbox.result !== null && sandbox.result !== '') return sandbox.result;
|
||||
if (lastPut?.key === 'url') return lastPut.value;
|
||||
} catch {
|
||||
// 如果 @js 后面是单个表达式(例如 header: @js:JSON.stringify({...})),上面的函数体不会自动返回。
|
||||
}
|
||||
try {
|
||||
const script = new vm.Script(`(function(){ ${body}
|
||||
; if (typeof res !== 'undefined') return res; return ''; })()`);
|
||||
const result = script.runInNewContext(sandbox, { timeout });
|
||||
if (result !== undefined && result !== null && result !== '') return result;
|
||||
if (sandbox.result !== context.result && sandbox.result !== undefined && sandbox.result !== null && sandbox.result !== '') return sandbox.result;
|
||||
if (lastPut?.key === 'url') return lastPut.value;
|
||||
} catch {
|
||||
// fallback to expression mode below
|
||||
}
|
||||
try {
|
||||
const script = new vm.Script(`(function(){ return (${body}); })()`);
|
||||
const result = script.runInNewContext(sandbox, { timeout });
|
||||
if (result !== undefined && result !== null && result !== '') return result;
|
||||
if (sandbox.result !== context.result && sandbox.result !== undefined && sandbox.result !== null && sandbox.result !== '') return sandbox.result;
|
||||
if (lastPut?.key === 'url') return lastPut.value;
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function runJsSnippet(code: string, context: Record<string, any>, timeout = 1000): string {
|
||||
return jsonPrimitiveToString(runJsSnippetRaw(code, context, timeout));
|
||||
}
|
||||
|
||||
function applyPutGetRules(rule: string, value: string) {
|
||||
const putMatch = rule.match(/@put:\s*\{([\s\S]*?)\}/);
|
||||
if (putMatch) {
|
||||
try {
|
||||
const obj = JSON.parse(`{${putMatch[1]}}`);
|
||||
Object.entries(obj).forEach(([key, val]) => variableStore.set(key, val));
|
||||
} catch {
|
||||
const pair = putMatch[1].match(/([A-Za-z0-9_$-]+)\s*:\s*['"]?([^,'"]+)['"]?/);
|
||||
if (pair) variableStore.set(pair[1], value || pair[2]);
|
||||
}
|
||||
}
|
||||
const getMatch = rule.match(/@get:\s*\{\s*([A-Za-z0-9_$-]+)\s*\}/);
|
||||
if (getMatch) return jsonPrimitiveToString(variableStore.get(getMatch[1]));
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildUrlFromTemplate(template: string, source: BookSource, keyword?: string, page = 1, baseOverride?: string) {
|
||||
const base = baseOverride || sourceBase(source);
|
||||
let raw = template || base;
|
||||
if (isJsRuleString(raw)) {
|
||||
if (/\/k-\{\{encryptText\(key\)\}\}-\{\{page\}\}\.html/.test(raw)) {
|
||||
return `https://www.rrssk.com/k-${encryptTongrenKeyword(keyword || '')}-${page}.html`;
|
||||
}
|
||||
const evaluated = evaluateJsRuleString(raw, { key: keyword || '', keyword: keyword || '', page, baseUrl: base, source: { ...(source.legado || {}), key: base } });
|
||||
raw = evaluated || raw;
|
||||
if (/,(\s*)\{/.test(raw)) return raw;
|
||||
}
|
||||
raw = renderTemplateExpressions(raw, keyword || '', page);
|
||||
raw = raw
|
||||
.replace(/\{searchTerms\}/g, encodeRuleParam(keyword || ''))
|
||||
.replace(/\{key\}/g, encodeRuleParam(keyword || ''))
|
||||
.replace(/\{keyword\}/g, encodeRuleParam(keyword || ''))
|
||||
.replace(/\{page\}/g, String(page))
|
||||
.replace(/\{pageIndex\}/g, String(page));
|
||||
return normalizeUrl(base, raw);
|
||||
}
|
||||
|
||||
function stripRuleJsBlocks(rule?: string) {
|
||||
return (rule || '').replace(/<js>[\s\S]*?<\/js>/gi, '').trim();
|
||||
}
|
||||
|
||||
function applyRuleJsBlocks(raw: string, rule?: string) {
|
||||
const blocks = Array.from((rule || '').matchAll(/<js>([\s\S]*?)<\/js>/gi));
|
||||
for (const block of blocks) {
|
||||
runJsSnippet(block[1], { src: raw, result: raw });
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonMaybe(value: string): any | null {
|
||||
try {
|
||||
return JSON.parse(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonPrimitiveToString(value: any): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return value.map(jsonPrimitiveToString).filter(Boolean).join(', ');
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonPath(input: any, path?: string): any {
|
||||
if (!path) return input;
|
||||
let normalized = path.trim();
|
||||
if (normalized.startsWith('@json:')) normalized = normalized.slice(6);
|
||||
if (normalized.startsWith('-@json:')) normalized = normalized.slice(7);
|
||||
if (!normalized || normalized === '$') return input;
|
||||
|
||||
const filterMatch = normalized.match(/^(.*)\[\?\(@\.([A-Za-z0-9_$-]+)\s*(==|=|!=)\s*['"]?([^'"\]]+)['"]?\)\](.*)$/);
|
||||
if (filterMatch) {
|
||||
const base = readJsonPath(input, filterMatch[1] || '$');
|
||||
const list = Array.isArray(base) ? base : [];
|
||||
const filtered = list.filter((item) => {
|
||||
const actual = jsonPrimitiveToString(item?.[filterMatch[2]]);
|
||||
return filterMatch[3] === '!=' ? actual !== filterMatch[4] : actual === filterMatch[4];
|
||||
});
|
||||
return filterMatch[5] ? readJsonPath(filtered, `$${filterMatch[5]}`) : filtered;
|
||||
}
|
||||
|
||||
const recursive = normalized.match(/^\$\.\.([A-Za-z0-9_$-]+)(.*)$/);
|
||||
if (recursive) {
|
||||
const key = recursive[1];
|
||||
const rest = recursive[2] || '';
|
||||
const out: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) return node.forEach(walk);
|
||||
if (node[key] !== undefined) out.push(node[key]);
|
||||
Object.values(node).forEach(walk);
|
||||
};
|
||||
walk(input);
|
||||
return rest ? readJsonPath(out.flat(), `$${rest}`) : out.flat();
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/^\$\.?/, '');
|
||||
const tokens = normalized.match(/[^.[\]]+|\[\*\]|\[-?\d+\]|\[-?\d*:-?\d*(?::-?\d+)?\]|\[['"][^\]]+['"](?:,\s*['"][^\]]+['"])*\]/g) || [];
|
||||
let current = input;
|
||||
for (const token of tokens) {
|
||||
if (current === undefined || current === null) return undefined;
|
||||
if (token === '[*]') {
|
||||
current = Array.isArray(current) ? current.flat() : [];
|
||||
} else if (/^\[-?\d+\]$/.test(token)) {
|
||||
const idx = Number(token.slice(1, -1));
|
||||
current = Array.isArray(current) ? current[idx < 0 ? current.length + idx : idx] : undefined;
|
||||
} else if (/^\[-?\d*:-?\d*/.test(token)) {
|
||||
if (!Array.isArray(current)) return [];
|
||||
const parts = token.slice(1, -1).split(':').map((item) => item === '' ? undefined : Number(item));
|
||||
const start = parts[0] === undefined ? 0 : parts[0] < 0 ? current.length + parts[0] : parts[0];
|
||||
const end = parts[1] === undefined ? current.length : parts[1] < 0 ? current.length + parts[1] : parts[1];
|
||||
const step = parts[2] || 1;
|
||||
const sliced = current.slice(start, end);
|
||||
current = step === 1 ? sliced : sliced.filter((_, index) => index % Math.abs(step) === 0);
|
||||
} else if (/^\[/.test(token)) {
|
||||
const keys = Array.from(token.matchAll(/['"]([^'"]+)['"]/g)).map((m) => m[1]);
|
||||
if (Array.isArray(current)) current = current.map((item) => keys.map((key) => item?.[key])).flat().filter((item) => item !== undefined);
|
||||
else current = keys.map((key) => current?.[key]).filter((item) => item !== undefined);
|
||||
} else if (Array.isArray(current)) {
|
||||
current = current.map((item) => item?.[token]).filter((item) => item !== undefined);
|
||||
} else {
|
||||
current = current[token];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function ruleIsJson(rule?: string) {
|
||||
return !!rule && /@json:|-@json:|^\$\./.test(rule.trim());
|
||||
}
|
||||
|
||||
function selectJsonItems(json: any, rule?: string): any[] {
|
||||
const alternatives = splitAlternatives(rule);
|
||||
for (let index = 0; index < alternatives.length; index += 1) {
|
||||
const alternative = alternatives[index];
|
||||
const reverse = alternative.trim().startsWith('-');
|
||||
const value = readJsonPath(json, alternative);
|
||||
if (Array.isArray(value) && value.length > 0) return reverse ? [...value].reverse() : value;
|
||||
// 备用 JSONPath 常见写法:$.data||$.data.data[*]。前者可能是分页包装对象,优先继续尝试后续数组规则。
|
||||
if (value && typeof value === 'object' && index === alternatives.length - 1) return [value];
|
||||
if (value && typeof value !== 'object') return [value];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function evaluateJsListRule(rule: string | undefined, context: Record<string, any>): any[] {
|
||||
const trimmed = (rule || '').trim();
|
||||
if (!isJsRuleString(trimmed)) return [];
|
||||
const elementRules = Array.from(trimmed.matchAll(/java\.getElements\(\s*(['"`])([\s\S]*?)\1\s*\)/g)).map((match) => match[2]).filter(Boolean);
|
||||
if (elementRules.length > 0) {
|
||||
const raw = jsonPrimitiveToString(context.result ?? context.src ?? '');
|
||||
const $ = cheerio.load(raw);
|
||||
for (const selectorRule of elementRules) {
|
||||
let nodes = selectElements($, $.root(), selectorRule);
|
||||
if (nodes.length === 0 && /#chapter-items@a/.test(selectorRule)) nodes = $('#chapter-items').find('a');
|
||||
const items = nodes.toArray().map((element) => {
|
||||
const node = $(element);
|
||||
const attrs = (element as any).attribs || {};
|
||||
return { ...attrs, text: node.text().trim(), href: attrs.href, src: attrs.src, html: node.html() || '' };
|
||||
});
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
}
|
||||
const value = runJsSnippetRaw(trimmed, context);
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value && typeof value === 'object') return [value];
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseJsonMaybe(value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && typeof parsed === 'object') return [parsed];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderTemplateWithJson(template: string, json: any, source: BookSource, baseUrl: string) {
|
||||
const rendered = template.replace(/\{\{(.*?)\}\}/g, (_, expr) => {
|
||||
const normalizedExpr = String(expr).trim().replace(/^@json:/, '');
|
||||
const value = readJsonPath(json, normalizedExpr);
|
||||
return encodeRuleParam(jsonPrimitiveToString(value));
|
||||
});
|
||||
return normalizeUrl(baseUrl || sourceBase(source), rendered);
|
||||
}
|
||||
|
||||
function readJsonRule(json: any, rule?: string, source?: BookSource, baseUrl?: string): string {
|
||||
if (!rule) return '';
|
||||
const trimmed = rule.trim();
|
||||
if (trimmed.includes('{{')) return renderTemplateWithJson(trimmed, json, source as BookSource, baseUrl || sourceBase(source as BookSource));
|
||||
if (isJsRuleString(trimmed)) {
|
||||
if (/result\s*=\s*['"]([^'"]+)['"]\s*\+\s*result\.([A-Za-z0-9_$-]+)/.test(trimmed)) {
|
||||
const match = trimmed.match(/result\s*=\s*['"]([^'"]+)['"]\s*\+\s*result\.([A-Za-z0-9_$-]+)/);
|
||||
return normalizeUrl(baseUrl || sourceBase(source as BookSource), `${match?.[1] || ''}${jsonPrimitiveToString(json?.[match?.[2] || ''])}`);
|
||||
}
|
||||
if (/item\.img|\.reverse\(\)/.test(trimmed)) {
|
||||
const data = Array.isArray(json?.data) ? [...json.data].reverse() : Array.isArray(json) ? [...json].reverse() : [];
|
||||
return data
|
||||
.map((item) => item?.img ? `<img src="${String(item.img)}" style="max-width:100%; display:block;" referrerpolicy="no-referrer">` : '')
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
const resultText = typeof json === 'string' ? json : JSON.stringify(json);
|
||||
if (/java\.base64Decode/.test(trimmed)) return Buffer.from(resultText, 'base64').toString('utf8');
|
||||
if (/java\.base64Encode/.test(trimmed)) return Buffer.from(resultText, 'utf8').toString('base64');
|
||||
if (/java\.md5Encode/.test(trimmed)) return crypto.createHash('md5').update(resultText).digest('hex');
|
||||
const replaceMatch = trimmed.match(/result\.replace\(\s*\/([^/]+)\/[gimuy]*\s*,\s*['"]([^'"]*)['"]\s*\)/);
|
||||
if (replaceMatch) return resultText.replace(new RegExp(replaceMatch[1], 'g'), replaceMatch[2]);
|
||||
const matchMatch = trimmed.match(/result\.match\(\s*\/([^/]+)\/[gimuy]*\s*\)/);
|
||||
if (matchMatch) return resultText.match(new RegExp(matchMatch[1]))?.[1] || resultText.match(new RegExp(matchMatch[1]))?.[0] || '';
|
||||
return runJsSnippet(trimmed, { result: json, baseUrl, src: json });
|
||||
}
|
||||
const value = readJsonPath(json, trimmed);
|
||||
const text = jsonPrimitiveToString(value);
|
||||
if ((/url|href|pic|cover/i.test(trimmed) || /^https?:\/\//i.test(text)) && text && baseUrl) return normalizeUrl(baseUrl, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
function fallbackChapterHrefFromItem(item: any, rule?: string, baseUrl?: string): string {
|
||||
const id = jsonPrimitiveToString(item?.id || item?.cid || item?.chapter_id || item?.chapterId);
|
||||
if (!id) return '';
|
||||
const match = (rule || '').match(/['"]([^'"]*(?:pic|chapter)[^'"]*(?:cid|id)=)['"]/i);
|
||||
if (match?.[1]) return normalizeUrl(baseUrl || '', `${match[1]}${id}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
function splitAlternatives(rule?: string): string[] {
|
||||
return (rule || '').split('||').map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function splitRuleFilters(rule: string) {
|
||||
const parts = (rule || '').split('##');
|
||||
return { base: (parts.shift() || '').trim(), filters: parts };
|
||||
}
|
||||
|
||||
function applyRuleFilters(value: string, filters: string[]) {
|
||||
let result = value;
|
||||
for (let index = 0; index < filters.length; index += 2) {
|
||||
const pattern = filters[index];
|
||||
const replacement = filters[index + 1] ?? '';
|
||||
if (!pattern) continue;
|
||||
try {
|
||||
result = result.replace(new RegExp(pattern, 'g'), replacement.replace(/\$(\d+)/g, '$$$$1'));
|
||||
} catch {
|
||||
result = result.split(pattern).join(replacement);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readAllInOneList(raw: string, rule?: string): Array<Record<string, string>> {
|
||||
const trimmed = (rule || '').trim();
|
||||
if (!trimmed.startsWith(':')) return [];
|
||||
const pattern = trimmed.slice(1);
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'gs');
|
||||
const out: Array<Record<string, string>> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(raw)) !== null) {
|
||||
const groups = (match.groups || {}) as Record<string, string>;
|
||||
const item: Record<string, string> = { _0: match[0] };
|
||||
Array.from(match).forEach((value: string | undefined, index: number) => { item[`_${index}`] = value || ''; });
|
||||
Object.entries(groups).forEach(([key, value]) => { item[key] = String(value || ''); });
|
||||
out.push(item);
|
||||
if (match[0] === '') regex.lastIndex += 1;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readRegexItem(item: Record<string, string>, rule?: string, baseUrl?: string) {
|
||||
const key = (rule || '').trim().replace(/^\$?\{?/, '').replace(/\}?$/, '');
|
||||
const value = item[key] || item[`_${key}`] || '';
|
||||
if ((/url|href|src|cover/i.test(key) || /^https?:\/\//i.test(value)) && value && baseUrl) return normalizeUrl(baseUrl, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isLegadoAttrToken(value: string) {
|
||||
return /^(href|src|title|alt|text|textNodes|ownText|all|html|content|value|data-[\w-]+)$/i.test(value.trim());
|
||||
}
|
||||
|
||||
function normalizeLegadoSelector(selector: string) {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed) return '';
|
||||
const classMatch = trimmed.match(/^class\.([\w-]+(?:\.[\w-]+)*)$/i);
|
||||
if (classMatch) return classMatch[1].split('.').map((item) => `.${item}`).join('');
|
||||
const idMatch = trimmed.match(/^id\.([\w-]+)$/i);
|
||||
if (idMatch) return `#${idMatch[1]}`;
|
||||
const tagMatch = trimmed.match(/^tag\.([\w-]+)$/i);
|
||||
if (tagMatch) return tagMatch[1];
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseStep(step: string): { selector: string; attr: string } {
|
||||
const trimmed = step.trim();
|
||||
if (isLegadoAttrToken(trimmed)) return { selector: '', attr: trimmed };
|
||||
|
||||
const parts = trimmed.split('@').map((item) => item.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
const last = parts[parts.length - 1];
|
||||
const hasAttr = isLegadoAttrToken(last);
|
||||
const selectorParts = hasAttr ? parts.slice(0, -1) : parts;
|
||||
return {
|
||||
selector: selectorParts.map(normalizeLegadoSelector).filter(Boolean).join(' '),
|
||||
attr: hasAttr ? last : '',
|
||||
};
|
||||
}
|
||||
|
||||
const attrMatch = trimmed.match(/(?:@|::)(text|textNodes|ownText|all|html|href|src|title|alt|content|value|data-[\w-]+)$/i);
|
||||
if (attrMatch) {
|
||||
return { selector: normalizeLegadoSelector(trimmed.slice(0, attrMatch.index).trim()), attr: attrMatch[1] };
|
||||
}
|
||||
const dotAttr = trimmed.match(/\.(text|html|href|src)$/i);
|
||||
if (dotAttr) return { selector: normalizeLegadoSelector(trimmed.slice(0, dotAttr.index).trim()), attr: dotAttr[1] };
|
||||
return { selector: normalizeLegadoSelector(trimmed), attr: '' };
|
||||
}
|
||||
|
||||
function stripFilters(rule: string) {
|
||||
return splitRuleFilters(stripRuleJsBlocks(rule)).base;
|
||||
}
|
||||
|
||||
function applyLegadoIndexSelector(current: cheerio.Cheerio<any>, selector: string): { current: cheerio.Cheerio<any>; selector: string } {
|
||||
let normalized = selector.trim();
|
||||
const exclude = normalized.match(/(?:\[!(-?\d+)\]|!(-?\d+))$/);
|
||||
if (exclude) {
|
||||
normalized = normalized.slice(0, exclude.index).trim();
|
||||
current = normalized ? current.find(normalized) : current;
|
||||
const idx = Number(exclude[1] ?? exclude[2]);
|
||||
const real = idx < 0 ? current.length + idx : idx;
|
||||
return { current: current.filter((index) => index !== real), selector: '' };
|
||||
}
|
||||
const range = normalized.match(/(?:\[(-?\d*):(-?\d*)(?::(-?\d+))?\]|\.(-?\d*):(-?\d*)(?::(-?\d+))?)$/);
|
||||
if (range) {
|
||||
normalized = normalized.slice(0, range.index).trim();
|
||||
current = normalized ? current.find(normalized) : current;
|
||||
const length = current.length;
|
||||
const startRaw = range[1] ?? range[4];
|
||||
const endRaw = range[2] ?? range[5];
|
||||
const start = startRaw ? Number(startRaw) : 0;
|
||||
const end = endRaw ? Number(endRaw) : length;
|
||||
const realStart = start < 0 ? length + start : start;
|
||||
const realEnd = end < 0 ? length + end : end;
|
||||
const step = Number(range[3] ?? range[6] ?? 1) || 1;
|
||||
const sliced = current.slice(realStart, realEnd);
|
||||
return { current: step === 1 ? sliced : sliced.filter((_, index) => index % Math.abs(step) === 0), selector: '' };
|
||||
}
|
||||
const indexMatch = normalized.match(/(?:\[(-?\d+)\]|\.(-?\d+))$/);
|
||||
if (indexMatch) {
|
||||
normalized = normalized.slice(0, indexMatch.index).trim();
|
||||
current = normalized ? current.find(normalized) : current;
|
||||
const idx = Number(indexMatch[1] ?? indexMatch[2]);
|
||||
const real = idx < 0 ? current.length + idx : idx;
|
||||
return { current: current.eq(real), selector: '' };
|
||||
}
|
||||
return { current, selector: normalized };
|
||||
}
|
||||
|
||||
function applyLegadoSelector($: cheerio.CheerioAPI, current: cheerio.Cheerio<any>, selector: string): cheerio.Cheerio<any> {
|
||||
const normalized = selector.trim();
|
||||
if (!normalized) return current;
|
||||
const textMatch = normalized.match(/^text\.(.+)$/);
|
||||
if (textMatch) {
|
||||
const keyword = textMatch[1].trim();
|
||||
const links = current.find('a[href]').filter((_, el) => $(el).text().includes(keyword));
|
||||
if (links.length > 0) return links;
|
||||
return current.find('button,span,div,p,li,a').filter((_, el) => $(el).text().includes(keyword));
|
||||
}
|
||||
const tokens = normalized.split(/\s+/).filter(Boolean);
|
||||
if (
|
||||
tokens.length > 1
|
||||
&& tokens.every((token) => !/[>+~]/.test(token))
|
||||
&& tokens.some((token) => /(?:\[!?\-?\d+\]|\[-?\d*:|-?\d+\]$|\.-?\d+(?::\-?\d*){0,2})$/.test(token))
|
||||
) {
|
||||
let next = current;
|
||||
for (const token of tokens) {
|
||||
const indexed = applyLegadoIndexSelector(next, token);
|
||||
next = indexed.selector ? next.find(indexed.selector) : indexed.current;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
const indexed = applyLegadoIndexSelector(current, normalized);
|
||||
return indexed.selector ? current.find(indexed.selector) : indexed.current;
|
||||
}
|
||||
|
||||
function selectElements($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string): cheerio.Cheerio<any> {
|
||||
for (const alternative of splitAlternatives(rule)) {
|
||||
try {
|
||||
const normalized = stripFilters(alternative || '');
|
||||
const reverse = normalized.trim().startsWith('-') && !normalized.trim().startsWith('-@');
|
||||
const effective = reverse ? normalized.trim().slice(1).trim() : normalized;
|
||||
if (!effective) continue;
|
||||
const steps = effective.split(/&&|@css:/).map((item) => item.trim()).filter(Boolean);
|
||||
let current = root;
|
||||
for (const rawStep of steps) {
|
||||
const { selector, attr } = parseStep(rawStep);
|
||||
if (attr) break;
|
||||
if (!selector) continue;
|
||||
if (/^children$/i.test(selector)) {
|
||||
current = current.children();
|
||||
continue;
|
||||
}
|
||||
current = applyLegadoSelector($, current, selector);
|
||||
}
|
||||
if (reverse) current = $(current.toArray().reverse());
|
||||
if (current.length > 0) return current;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return cheerio.load('')('');
|
||||
}
|
||||
|
||||
|
||||
function selectXPath($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule: string): { nodes: cheerio.Cheerio<any>; attr: string; value?: string } {
|
||||
const expr = rule.trim().replace(/^@XPath:/i, '');
|
||||
try {
|
||||
const html = $.html(root);
|
||||
const doc = new DOMParser({ errorHandler: () => undefined }).parseFromString(html, 'text/html');
|
||||
const selected = xpath.select(expr, doc as any) as any;
|
||||
const list = Array.isArray(selected) ? selected : [selected];
|
||||
const values = list.map((node) => {
|
||||
if (node === undefined || node === null) return '';
|
||||
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') return String(node);
|
||||
if (node.nodeType === 2) return node.nodeValue || '';
|
||||
if (node.nodeType === 3 || node.nodeType === 4) return node.nodeValue || '';
|
||||
return node.textContent || '';
|
||||
}).filter(Boolean);
|
||||
if (values.length > 0) return { nodes: root, attr: '', value: values.join('\n') };
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
let fallbackExpr = expr;
|
||||
let attr = '';
|
||||
const attrMatch = fallbackExpr.match(/\/@([A-Za-z0-9_-]+)$/);
|
||||
if (attrMatch) {
|
||||
attr = attrMatch[1];
|
||||
fallbackExpr = fallbackExpr.slice(0, attrMatch.index);
|
||||
} else if (/\/text\(\)$/.test(fallbackExpr)) {
|
||||
attr = 'text';
|
||||
fallbackExpr = fallbackExpr.replace(/\/text\(\)$/, '');
|
||||
}
|
||||
const parts = fallbackExpr.split('/').filter(Boolean).map((part) => {
|
||||
const match = part.match(/^([A-Za-z0-9_*.-]+)(?:\[@([A-Za-z0-9_-]+)=['"]([^'"]+)['"]\])?(?:\[(\d+)\])?$/);
|
||||
if (!match) return '';
|
||||
const tag = match[1] === '*' ? '*' : match[1];
|
||||
const filter = match[2] ? `[${match[2]}="${match[3]}"]` : '';
|
||||
const index = match[4] ? `:nth-of-type(${match[4]})` : '';
|
||||
return `${tag}${filter}${index}`;
|
||||
}).filter(Boolean);
|
||||
return { nodes: parts.length ? root.find(parts.join(' ')) : root, attr };
|
||||
}
|
||||
|
||||
function readValue($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string, jsContext?: Record<string, any>): string {
|
||||
for (const alternative of splitAlternatives(rule)) {
|
||||
const templateKind = alternative.match(/\{\{\s*@@([\s\S]*?)\}\}/);
|
||||
if (templateKind) {
|
||||
const value = alternative.replace(/\{\{\s*@@([\s\S]*?)\}\}/g, (_, innerRule) => readValue($, root, String(innerRule).trim(), baseUrl, jsContext));
|
||||
if (value) return value;
|
||||
continue;
|
||||
}
|
||||
if (alternative.includes('{{baseUrl}}')) {
|
||||
const { base, filters } = splitRuleFilters(alternative);
|
||||
const value = applyRuleFilters(base.replace(/\{\{baseUrl\}\}/g, baseUrl || ''), filters);
|
||||
if (value) return value;
|
||||
continue;
|
||||
}
|
||||
if (isJsRuleString(alternative)) {
|
||||
const transformed = evaluateJsRuleString(alternative, { ...(jsContext || {}), result: $.html(root), src: $.html(root), baseUrl });
|
||||
if (transformed) return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed;
|
||||
continue;
|
||||
}
|
||||
const jsIndex = alternative.indexOf('@js:');
|
||||
if (jsIndex > 0) {
|
||||
const selectorRule = alternative.slice(0, jsIndex).trim();
|
||||
const jsRule = alternative.slice(jsIndex).trim();
|
||||
const selected = readValue($, root, selectorRule, baseUrl, jsContext);
|
||||
const transformed = runJsSnippet(jsRule, { ...(jsContext || {}), result: selected, src: selected, baseUrl });
|
||||
if (transformed) {
|
||||
if (/,(\s*)\{/.test(transformed)) return transformed;
|
||||
return /^(?:https?:)?\/\//i.test(transformed) || transformed.startsWith('/') ? normalizeUrl(baseUrl || '', transformed) : transformed;
|
||||
}
|
||||
if (selected) return selected;
|
||||
continue;
|
||||
}
|
||||
const normalized = stripFilters(alternative);
|
||||
const getMatch = normalized.match(/^@get:\s*\{\s*([A-Za-z0-9_$-]+)\s*\}$/);
|
||||
if (getMatch) {
|
||||
const value = jsonPrimitiveToString(variableStore.get(getMatch[1]));
|
||||
if (value) return value;
|
||||
continue;
|
||||
}
|
||||
if (/^@?XPath:/i.test(normalized) || normalized.startsWith('//')) {
|
||||
const { nodes, attr, value: xpathValue } = selectXPath($, root, normalized);
|
||||
const node = nodes.first();
|
||||
let value = xpathValue !== undefined ? xpathValue : attr === 'text' || !attr ? node.text() : node.attr(attr) || '';
|
||||
value = he.decode(value || '').replace(/\u00a0/g, ' ').trim();
|
||||
value = applyRuleFilters(value, splitRuleFilters(alternative).filters);
|
||||
if (value) return value;
|
||||
continue;
|
||||
}
|
||||
const steps = normalized.split(/&&|@css:/).map((item) => item.trim()).filter(Boolean);
|
||||
let current = root;
|
||||
let attr = '';
|
||||
for (const rawStep of steps) {
|
||||
const parsed = parseStep(rawStep);
|
||||
if (parsed.selector) {
|
||||
if (/^children$/i.test(parsed.selector)) current = current.children();
|
||||
else {
|
||||
current = applyLegadoSelector($, current, parsed.selector);
|
||||
}
|
||||
}
|
||||
if (parsed.attr) attr = parsed.attr;
|
||||
}
|
||||
if (current.length === 0 && steps.length === 1) {
|
||||
const parsed = parseStep(steps[0]);
|
||||
if (!parsed.selector && parsed.attr) current = root;
|
||||
}
|
||||
const node = current.first();
|
||||
let value = '';
|
||||
const normalizedAttr = attr.toLowerCase();
|
||||
if (!attr || normalizedAttr === 'text' || normalizedAttr === 'textnodes') value = node.text();
|
||||
else if (normalizedAttr === 'owntext') value = node.clone().children().remove().end().text();
|
||||
else if (normalizedAttr === 'all') value = current.toArray().map((el) => $(el).text()).join('\n');
|
||||
else if (normalizedAttr === 'html') value = node.html() || '';
|
||||
else value = node.attr(attr) || '';
|
||||
value = he.decode(value || '').replace(/\u00a0/g, ' ').trim();
|
||||
if ((normalizedAttr === 'href' || normalizedAttr === 'src') && value && baseUrl) value = normalizeUrl(baseUrl, value);
|
||||
value = applyRuleFilters(value, splitRuleFilters(alternative).filters);
|
||||
value = applyPutGetRules(alternative, value);
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readValues($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, rule?: string, baseUrl?: string): string[] {
|
||||
for (const alternative of splitAlternatives(rule)) {
|
||||
const normalized = stripFilters(alternative);
|
||||
const steps = normalized.split(/&&|@css:/).map((item) => item.trim()).filter(Boolean);
|
||||
let current = root;
|
||||
let attr = '';
|
||||
for (const rawStep of steps) {
|
||||
const parsed = parseStep(rawStep);
|
||||
if (parsed.selector) {
|
||||
if (/^children$/i.test(parsed.selector)) current = current.children();
|
||||
else {
|
||||
current = applyLegadoSelector($, current, parsed.selector);
|
||||
}
|
||||
}
|
||||
if (parsed.attr) attr = parsed.attr;
|
||||
}
|
||||
if (current.length === 0 && steps.length === 1) {
|
||||
const parsed = parseStep(steps[0]);
|
||||
if (!parsed.selector && parsed.attr) current = root;
|
||||
}
|
||||
const normalizedAttr = attr.toLowerCase();
|
||||
const values = current.toArray().map((element) => {
|
||||
const node = $(element);
|
||||
let value = '';
|
||||
if (!attr || normalizedAttr === 'text' || normalizedAttr === 'textnodes') value = node.text();
|
||||
else if (normalizedAttr === 'owntext') value = node.clone().children().remove().end().text();
|
||||
else if (normalizedAttr === 'all') value = node.text();
|
||||
else if (normalizedAttr === 'html') value = node.html() || '';
|
||||
else value = node.attr(attr) || '';
|
||||
value = he.decode(value || '').replace(/\u00a0/g, ' ').trim();
|
||||
if ((normalizedAttr === 'href' || normalizedAttr === 'src' || normalizedAttr === 'data-original') && value && baseUrl) value = normalizeUrl(baseUrl, value);
|
||||
return value;
|
||||
}).filter(Boolean);
|
||||
if (values.length > 0) return values;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function applyBookInfoInit($: cheerio.CheerioAPI, root: cheerio.Cheerio<any>, initRule?: string, baseUrl?: string) {
|
||||
const putMatch = (initRule || '').match(/@put:\s*\{([\s\S]*)\}\s*$/);
|
||||
if (!putMatch) return;
|
||||
const body = putMatch[1];
|
||||
const entryRegex = /([A-Za-z0-9_$-]+)\s*:\s*"((?:\\.|[^"\\])*)"/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = entryRegex.exec(body)) !== null) {
|
||||
const key = match[1];
|
||||
const selector = match[2].replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
||||
variableStore.set(key, readValue($, root, selector, baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
function applyContentJsRule(value: string, jsRule: string): string {
|
||||
const encryptedParams = value.match(/params\s*=\s*'([^']+)'/)?.[1];
|
||||
if (encryptedParams) {
|
||||
try {
|
||||
const encrypted = Buffer.from(encryptedParams, 'base64');
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from('5V&RoR%Jf@pJPydF'), encrypted.subarray(0, 16));
|
||||
const decoded = Buffer.concat([decipher.update(encrypted.subarray(16)), decipher.final()]).toString('utf8');
|
||||
const data = JSON.parse(decoded);
|
||||
const images = Array.isArray(data?.chapter_images) ? data.chapter_images : [];
|
||||
const imageBase = data?.chapter_domain || data?.images_domain || data?.cdnurl || 'https://six.mhpic.net';
|
||||
if (images.length > 0) {
|
||||
return images
|
||||
.map((src: string) => normalizeUrl(imageBase, String(src || '')))
|
||||
.filter(Boolean)
|
||||
.map((src: string) => `<img src="${src}" style="max-width:100%; display:block;" referrerpolicy="no-referrer">`)
|
||||
.join('\n');
|
||||
}
|
||||
} catch {
|
||||
// fallback to generic JS execution below
|
||||
}
|
||||
}
|
||||
if (/window\.comicInfo/.test(value)) {
|
||||
try {
|
||||
const match = value.match(/window\.comicInfo\s*=\s*(.*?)(?:,window\.hideguide|;|<\/script>)/);
|
||||
if (match?.[1]) {
|
||||
const comicInfo = new vm.Script(`(${match[1]})`).runInNewContext({}, { timeout: 1000 });
|
||||
const images = comicInfo?.current_chapter?.chapter_img_list;
|
||||
if (Array.isArray(images)) {
|
||||
return images
|
||||
.map((src) => String(src || '').replace(/^\/\//, 'https://'))
|
||||
.filter(Boolean)
|
||||
.map((src) => `<img src="${src}" style="max-width:100%; display:block;" referrerpolicy="no-referrer">`)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback to generic JS execution below
|
||||
}
|
||||
}
|
||||
if (/result\.split\(["']\\n["']\)/.test(jsRule)) {
|
||||
return value
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((src) => `<img src="${src}" style="max-width:100%; display:block;" referrerpolicy="no-referrer">`)
|
||||
.join('');
|
||||
}
|
||||
return runJsSnippet(jsRule, { result: value, src: value }) || value;
|
||||
}
|
||||
|
||||
function contentFromRule(raw: string, rule?: string, baseUrl?: string): string {
|
||||
const json = parseJsonMaybe(raw);
|
||||
if (json && (ruleIsJson(rule) || isJsRuleString(rule))) {
|
||||
return readJsonRule(json, rule, undefined, baseUrl);
|
||||
}
|
||||
const rawRule = rule || '';
|
||||
const blockJs = rawRule.trim().match(/^<js>([\s\S]*?)<\/js>$/i);
|
||||
const jsIndex = rawRule.indexOf('@js:');
|
||||
const selectorRule = blockJs ? '' : jsIndex >= 0 ? rawRule.slice(0, jsIndex).trim() : rawRule;
|
||||
const jsRule = blockJs ? blockJs[1].trim() : jsIndex >= 0 ? rawRule.slice(jsIndex + 4).trim() : '';
|
||||
const $ = cheerio.load(raw);
|
||||
if (jsRule) {
|
||||
const values = selectorRule ? readValues($, $.root(), selectorRule, baseUrl) : [];
|
||||
const value = selectorRule ? (values.length > 0 ? values.join('\n') : readValue($, $.root(), selectorRule, baseUrl)) : raw;
|
||||
return applyContentJsRule(value, jsRule);
|
||||
}
|
||||
return readValue($, $.root(), selectorRule, baseUrl);
|
||||
}
|
||||
|
||||
function cleanContent(value: string) {
|
||||
const decoded = he.decode(value || '').trim();
|
||||
if (/<img\b/i.test(decoded)) return decoded;
|
||||
return decoded
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function chapterPageStem(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
parsed.pathname = parsed.pathname.replace(/_\d+(?=\.html?$)/i, '');
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url.replace(/_\d+(?=\.html?(?:[?#]|$))/i, '');
|
||||
}
|
||||
}
|
||||
|
||||
function proxyChapterImages(content: string, source: BookSource) {
|
||||
if (!/<img\b/i.test(content)) return content;
|
||||
const rule = source.legado;
|
||||
if (rule?.bookSourceType !== 2 && source.legado?.bookSourceType !== 2) return content;
|
||||
return content.replace(/<img\b([^>]*?)\bsrc=(['"])(.*?)\2([^>]*)>/gi, (match, before, quote, rawSrc, after) => {
|
||||
if (!rawSrc || rawSrc.startsWith('/api/books/image')) return match;
|
||||
const optionIndex = rawSrc.indexOf(',{');
|
||||
const src = optionIndex > 0 ? rawSrc.slice(0, optionIndex) : rawSrc;
|
||||
const options = optionIndex > 0 ? rawSrc.slice(optionIndex + 1) : '';
|
||||
const proxied = `/api/books/image?sourceId=${encodeURIComponent(source.id)}&url=${encodeURIComponent(src)}${options ? `&options=${encodeURIComponent(options)}` : ''}`;
|
||||
return `<img${before}src=${quote}${proxied}${quote}${after}>`;
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveLegadoConfig(): Promise<ResolvedLegadoConfig> {
|
||||
let enabled = process.env.OPDS_ENABLED === 'true' || process.env.LEGADO_ENABLED === 'true';
|
||||
let sources: BookSource[] = [];
|
||||
const cacheTTL = Number(process.env.LEGADO_CACHE_TTL_MS || process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
||||
|
||||
const envJson = process.env.LEGADO_SOURCES_JSON;
|
||||
if (envJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(envJson);
|
||||
sources = normalizeImportedSources(parsed);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
if (config.OPDSConfig) {
|
||||
enabled = config.OPDSConfig.Enabled ?? enabled;
|
||||
const subscriptionSources = await legadoSubscriptionStore.getSourcesForSubscriptions(config.OPDSConfig.LegadoSubscriptions || []);
|
||||
sources = [...sources, ...subscriptionSources];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return { enabled, cacheTTL, sources: sources.filter((source) => !!source.url && source.enabled !== false) };
|
||||
}
|
||||
|
||||
export function normalizeImportedSources(input: unknown): BookSource[] {
|
||||
const list = Array.isArray(input) ? input : [input];
|
||||
return list
|
||||
.filter((item): item is LegadoBookSourceRule => !!item && typeof item === 'object')
|
||||
.map((rule, index) => {
|
||||
const name = rule.bookSourceName || `Legado 书源 ${index + 1}`;
|
||||
const url = rule.bookSourceUrl || '';
|
||||
return {
|
||||
id: `legado_${stableId(`${name}|${url}|${index}`)}`,
|
||||
name,
|
||||
type: 'legado' as const,
|
||||
url,
|
||||
enabled: rule.enabled !== false,
|
||||
authMode: 'none' as const,
|
||||
preferFormat: ['epub' as const],
|
||||
language: '',
|
||||
legado: rule,
|
||||
};
|
||||
})
|
||||
.filter((source) => !!source.url)
|
||||
.map((source) => resolveLegadoSource(source));
|
||||
}
|
||||
|
||||
function normalizeConfiguredLegadoSource(item: any, index: number): BookSource | null {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
if (item.type === 'legado' || item.legado) {
|
||||
const rule = item.legado || item;
|
||||
const name = item.name || rule.bookSourceName || `Legado 书源 ${index + 1}`;
|
||||
const url = item.url || rule.bookSourceUrl || '';
|
||||
if (!url) return null;
|
||||
return resolveLegadoSource({
|
||||
...item,
|
||||
id: item.id || `legado_${stableId(`${name}|${url}|${index}`)}`,
|
||||
name,
|
||||
type: 'legado',
|
||||
url,
|
||||
enabled: item.enabled !== false && rule.enabled !== false,
|
||||
authMode: item.authMode || 'none',
|
||||
legado: { ...rule, bookSourceName: rule.bookSourceName || name, bookSourceUrl: rule.bookSourceUrl || url },
|
||||
});
|
||||
}
|
||||
if (item.bookSourceUrl || item.searchUrl || item.ruleSearch) {
|
||||
return normalizeImportedSources([item])[0] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function splitUrlOptions(input: string): RequestOptions {
|
||||
const raw = input.trim();
|
||||
const comma = raw.indexOf(',');
|
||||
if (comma <= 0) return { url: raw };
|
||||
const candidate = raw.slice(comma + 1).trim();
|
||||
if (!candidate.startsWith('{')) return { url: raw };
|
||||
try {
|
||||
const options = JSON.parse(candidate);
|
||||
return {
|
||||
url: raw.slice(0, comma).trim(),
|
||||
method: options.method,
|
||||
body: options.body,
|
||||
headers: options.headers && typeof options.headers === 'object' ? options.headers : undefined,
|
||||
charset: options.charset,
|
||||
retry: Number.isFinite(Number(options.retry)) ? Number(options.retry) : undefined,
|
||||
};
|
||||
} catch {
|
||||
try {
|
||||
const options = JSON.parse(candidate.replace(/'/g, '"'));
|
||||
return {
|
||||
url: raw.slice(0, comma).trim(),
|
||||
method: options.method,
|
||||
body: options.body,
|
||||
headers: options.headers && typeof options.headers === 'object' ? options.headers : undefined,
|
||||
charset: options.charset,
|
||||
retry: Number.isFinite(Number(options.retry)) ? Number(options.retry) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return { url: raw.slice(0, comma).trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCookieHeader(sourceId: string) {
|
||||
return cookieJar.get(sourceId) || '';
|
||||
}
|
||||
|
||||
function mergeSetCookie(sourceId: string, setCookie: string | null) {
|
||||
if (!setCookie) return;
|
||||
const current = new Map<string, string>();
|
||||
(cookieJar.get(sourceId) || '').split(/;\s*/).filter(Boolean).forEach((item) => {
|
||||
const idx = item.indexOf('=');
|
||||
if (idx > 0) current.set(item.slice(0, idx), item.slice(idx + 1));
|
||||
});
|
||||
setCookie.split(/,(?=\s*[^;,]+=)/).forEach((cookie) => {
|
||||
const pair = cookie.split(';')[0]?.trim();
|
||||
const idx = pair?.indexOf('=') ?? -1;
|
||||
if (idx > 0) current.set(pair.slice(0, idx), pair.slice(idx + 1));
|
||||
});
|
||||
cookieJar.set(sourceId, Array.from(current.entries()).map(([key, value]) => `${key}=${value}`).join('; '));
|
||||
}
|
||||
|
||||
async function fetchText(source: BookSource, url: string): Promise<string> {
|
||||
if (!url?.trim()) throw new Error('书源请求地址为空');
|
||||
const request = splitUrlOptions(url);
|
||||
const safe = await validateProxyUrlServerSide(request.url);
|
||||
if (!safe) throw new Error(`书源地址未通过安全校验: ${request.url}`);
|
||||
const cacheKey = `text|${source.id}|${request.method || 'GET'}|${request.url}|${request.body || ''}`;
|
||||
const cached = textCache.get(cacheKey);
|
||||
const { cacheTTL } = await resolveLegadoConfig();
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||
|
||||
let lastError: unknown;
|
||||
const maxAttempts = Math.max(1, (request.retry ?? 2) + 1);
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
...(buildHeaders(source) as Record<string, string>),
|
||||
...(request.headers || {}),
|
||||
};
|
||||
const cookie = getCookieHeader(source.id);
|
||||
if (source.legado?.enabledCookieJar && cookie) headers.Cookie = cookie;
|
||||
const method = (request.method || (request.body ? 'POST' : 'GET')).toUpperCase();
|
||||
const response = await fetch(request.url, {
|
||||
method,
|
||||
headers,
|
||||
body: method === 'GET' || method === 'HEAD' ? undefined : request.body,
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (source.legado?.enabledCookieJar) mergeSetCookie(source.id, response.headers.get('set-cookie'));
|
||||
if (!response.ok) throw new Error(`请求失败: ${response.status}`);
|
||||
const contentLength = Number(response.headers.get('content-length') || '0');
|
||||
if (contentLength > MAX_TEXT_BYTES) throw new Error('响应内容过大');
|
||||
const buffer = await response.arrayBuffer();
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const charset = request.charset || contentType.match(/charset=([^;]+)/i)?.[1]?.trim().replace(/^['"]|['"]$/g, '');
|
||||
const decoderName = /gbk|gb2312|gb18030/i.test(charset || '') ? 'gb18030' : (charset || 'utf-8');
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder(decoderName).decode(buffer);
|
||||
} catch {
|
||||
text = new TextDecoder('utf-8').decode(buffer);
|
||||
}
|
||||
if (text.length > MAX_TEXT_BYTES) throw new Error('响应内容过大');
|
||||
textCache.set(cacheKey, { data: text, expiresAt: Date.now() + cacheTTL });
|
||||
return text;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxAttempts - 1) await wait(300 * (attempt + 1));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('请求失败');
|
||||
}
|
||||
|
||||
async function getSourceById(sourceId: string): Promise<BookSource> {
|
||||
const config = await resolveLegadoConfig();
|
||||
const source = config.sources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new Error('未找到对应的 Legado 书源');
|
||||
return source;
|
||||
}
|
||||
|
||||
function getRule(source: BookSource): LegadoBookSourceRule {
|
||||
if (!source.legado) throw new Error('Legado 书源缺少规则');
|
||||
return resolveLegadoRule(source.legado, source) || source.legado;
|
||||
}
|
||||
|
||||
function makeItem(source: BookSource, partial: Partial<BookListItem> & { detailHref?: string; title?: string }): BookListItem {
|
||||
const detailHref = partial.detailHref || '';
|
||||
return {
|
||||
id: partial.id || stableId(`${source.id}|${detailHref || partial.title || Date.now()}`),
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
title: partial.title || '未命名电子书',
|
||||
author: partial.author,
|
||||
cover: partial.cover,
|
||||
summary: partial.summary,
|
||||
tags: partial.tags,
|
||||
detailHref,
|
||||
acquisitionLinks: partial.acquisitionLinks || [],
|
||||
};
|
||||
}
|
||||
|
||||
interface ExploreTarget {
|
||||
title: string;
|
||||
template: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
function hasRuleBookList(rule?: LegadoRuleSearch) {
|
||||
return !!rule?.bookList?.trim();
|
||||
}
|
||||
|
||||
function getEffectiveExploreRule(rule: LegadoBookSourceRule): LegadoRuleSearch | undefined {
|
||||
if (hasRuleBookList(rule.ruleExplore)) return rule.ruleExplore;
|
||||
if (hasRuleBookList(rule.ruleSearch)) return rule.ruleSearch;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasExplore(rule: LegadoBookSourceRule) {
|
||||
return rule.enabledExplore !== false && (isJsRuleString(rule.exploreUrl) || parseExploreUrl(rule.exploreUrl).length > 0) && !!getEffectiveExploreRule(rule);
|
||||
}
|
||||
|
||||
function parseExploreUrl(exploreUrl?: string): Array<{ title: string; template: string }> {
|
||||
const raw = (exploreUrl || '').trim();
|
||||
if (!raw) return [];
|
||||
if (isJsRuleString(raw)) return [];
|
||||
const json = parseJsonMaybe(raw);
|
||||
if (Array.isArray(json)) {
|
||||
return json
|
||||
.map((item) => ({ title: jsonPrimitiveToString(item?.title), template: jsonPrimitiveToString(item?.url) || `__group__:${jsonPrimitiveToString(item?.title)}` }))
|
||||
.filter((item) => !!item.title);
|
||||
}
|
||||
return raw
|
||||
.split(/&&|\r?\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const index = item.indexOf('::');
|
||||
if (index >= 0) return { title: item.slice(0, index).trim(), template: item.slice(index + 2).trim() };
|
||||
return { title: item, template: item };
|
||||
})
|
||||
.filter((item) => !!item.template);
|
||||
}
|
||||
|
||||
function encodeExploreTarget(target: ExploreTarget) {
|
||||
return `legado-explore:${Buffer.from(JSON.stringify(target), 'utf8').toString('base64url')}`;
|
||||
}
|
||||
|
||||
function decodeExploreTarget(href?: string): ExploreTarget | null {
|
||||
if (!href?.startsWith('legado-explore:')) return null;
|
||||
try {
|
||||
const raw = Buffer.from(href.slice('legado-explore:'.length), 'base64url').toString('utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed?.template) return null;
|
||||
return { title: parsed.title || '分类', template: parsed.template, page: Number(parsed.page || 1) || 1 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildExploreTargetUrl(source: BookSource, target: ExploreTarget) {
|
||||
return buildUrlFromTemplate(target.template, source, undefined, target.page);
|
||||
}
|
||||
|
||||
async function resolveExploreCategories(source: BookSource, rule: LegadoBookSourceRule): Promise<Array<{ title: string; template: string }>> {
|
||||
const raw = (rule.exploreUrl || '').trim();
|
||||
if (!isJsRuleString(raw)) return parseExploreUrl(raw);
|
||||
|
||||
const jsValue = runJsSnippetRaw(raw, { baseUrl: sourceBase(source), source: rule });
|
||||
const parsed = Array.isArray(jsValue) ? jsValue : typeof jsValue === 'string' ? parseJsonMaybe(jsValue) : null;
|
||||
if (Array.isArray(parsed)) {
|
||||
const categories = parsed
|
||||
.map((item) => ({ title: jsonPrimitiveToString(item?.title), template: jsonPrimitiveToString(item?.url) || `__group__:${jsonPrimitiveToString(item?.title)}` }))
|
||||
.filter((item) => !!item.title);
|
||||
if (categories.length > 0) return categories;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await fetchText(source, sourceBase(source));
|
||||
const $ = cheerio.load(html);
|
||||
const categories = $('a[href^="/list"]').toArray().map((el) => {
|
||||
const title = $(el).text().trim();
|
||||
const href = ($(el).attr('href') || '').replace(/\/$/, '-{{page}}/');
|
||||
return title && href ? { title, template: href } : null;
|
||||
}).filter(Boolean) as Array<{ title: string; template: string }>;
|
||||
if (categories.length > 0) return [{ title: '全部分类', template: '__group__:全部分类' }, ...categories];
|
||||
} catch {
|
||||
// 动态分类首页被源站拦截时,至少不要把 @js 当成分类项显示。
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export class LegadoClient {
|
||||
async getSources(): Promise<BookSource[]> {
|
||||
const config = await resolveLegadoConfig();
|
||||
if (!config.enabled) return [];
|
||||
return config.sources.map((source) => {
|
||||
const resolved = resolveLegadoSource(source);
|
||||
return {
|
||||
...resolved,
|
||||
capabilities: {
|
||||
searchSupported: !!resolved.legado?.searchUrl,
|
||||
catalogSupported: hasExplore(resolved.legado || {}),
|
||||
searchMode: resolved.legado?.searchUrl ? 'legado' : 'disabled',
|
||||
catalogMode: hasExplore(resolved.legado || {}) ? 'legado' : 'disabled',
|
||||
acquisitionTypes: ['application/x-legado-chapters+json'],
|
||||
lastCheckedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getSearchSources(sourceId?: string): Promise<BookSource[]> {
|
||||
return sourceId ? [await getSourceById(sourceId)] : (await resolveLegadoConfig()).sources;
|
||||
}
|
||||
|
||||
async searchBooksSource(q: string, source: BookSource): Promise<{ source: BookSource; results: BookListItem[] }> {
|
||||
const rule = getRule(source);
|
||||
if (!rule.searchUrl || !rule.ruleSearch?.bookList) throw new Error('该 Legado 书源不支持搜索');
|
||||
const cacheKey = `search|${source.id}|${q}`;
|
||||
const { cacheTTL } = await resolveLegadoConfig();
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return { source, results: cached.data };
|
||||
|
||||
const results: BookListItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let page = 1; page <= DEFAULT_LEGADO_SEARCH_PAGES; page += 1) {
|
||||
const targetUrl = buildUrlFromTemplate(rule.searchUrl, source, q, page);
|
||||
const html = await fetchText(source, targetUrl);
|
||||
applyRuleJsBlocks(html, rule.ruleSearch.bookList);
|
||||
if (page === 1 && rule.ruleSearch.checkKeyWord && !html.includes(rule.ruleSearch.checkKeyWord) && !html.includes(q)) {
|
||||
throw new Error('搜索结果校验失败');
|
||||
}
|
||||
let pageCount = 0;
|
||||
const json = parseJsonMaybe(html);
|
||||
const searchBookListRule = stripRuleJsBlocks(rule.ruleSearch.bookList);
|
||||
if (json && ruleIsJson(searchBookListRule)) {
|
||||
const items = selectJsonItems(json, searchBookListRule);
|
||||
pageCount = items.length;
|
||||
items.forEach((item) => {
|
||||
const detailHref = readJsonRule(item, rule.ruleSearch?.bookUrl, source, targetUrl);
|
||||
const title = readJsonRule(item, rule.ruleSearch?.name, source, targetUrl);
|
||||
if (!title && !detailHref) return;
|
||||
const cover = readJsonRule(item, rule.ruleSearch?.coverUrl, source, targetUrl);
|
||||
const itemId = jsonPrimitiveToString(item?.id) || undefined;
|
||||
const dedupeKey = itemId || detailHref || `${title}|${cover}`;
|
||||
if (dedupeKey && seen.has(dedupeKey)) return;
|
||||
if (dedupeKey) seen.add(dedupeKey);
|
||||
results.push(makeItem(source, {
|
||||
id: itemId,
|
||||
title,
|
||||
author: readJsonRule(item, rule.ruleSearch?.author, source, targetUrl),
|
||||
summary: readJsonRule(item, rule.ruleSearch?.intro, source, targetUrl),
|
||||
cover: cover || undefined,
|
||||
detailHref,
|
||||
tags: readJsonRule(item, rule.ruleSearch?.kind, source, targetUrl).split(/[,,\s]+/).filter(Boolean),
|
||||
}));
|
||||
});
|
||||
} else if (searchBookListRule.trim().startsWith(':')) {
|
||||
const items = readAllInOneList(html, searchBookListRule);
|
||||
pageCount = items.length;
|
||||
items.forEach((item) => {
|
||||
const detailHref = readRegexItem(item, rule.ruleSearch?.bookUrl, targetUrl);
|
||||
const title = readRegexItem(item, rule.ruleSearch?.name, targetUrl);
|
||||
if (!title && !detailHref) return;
|
||||
const cover = readRegexItem(item, rule.ruleSearch?.coverUrl, targetUrl);
|
||||
const dedupeKey = detailHref || `${title}|${cover}`;
|
||||
if (dedupeKey && seen.has(dedupeKey)) return;
|
||||
if (dedupeKey) seen.add(dedupeKey);
|
||||
results.push(makeItem(source, {
|
||||
id: detailHref || undefined,
|
||||
title,
|
||||
author: readRegexItem(item, rule.ruleSearch?.author, targetUrl),
|
||||
summary: readRegexItem(item, rule.ruleSearch?.intro, targetUrl),
|
||||
cover: cover || undefined,
|
||||
detailHref,
|
||||
tags: readRegexItem(item, rule.ruleSearch?.kind, targetUrl).split(/[,,\s]+/).filter(Boolean),
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
const $ = cheerio.load(html);
|
||||
const items = selectElements($, $.root(), searchBookListRule);
|
||||
pageCount = items.length;
|
||||
items.each((_, element) => {
|
||||
const root = $(element);
|
||||
const detailHref = readValue($, root, rule.ruleSearch?.bookUrl, targetUrl, { source: rule });
|
||||
const title = readValue($, root, rule.ruleSearch?.name, targetUrl);
|
||||
if (!title && !detailHref) return;
|
||||
const cover = readValue($, root, rule.ruleSearch?.coverUrl, targetUrl);
|
||||
const dedupeKey = detailHref || `${title}|${cover}`;
|
||||
if (dedupeKey && seen.has(dedupeKey)) return;
|
||||
if (dedupeKey) seen.add(dedupeKey);
|
||||
results.push(makeItem(source, {
|
||||
id: detailHref || undefined,
|
||||
title,
|
||||
author: readValue($, root, rule.ruleSearch?.author, targetUrl),
|
||||
summary: readValue($, root, rule.ruleSearch?.intro, targetUrl),
|
||||
cover: cover || undefined,
|
||||
detailHref,
|
||||
tags: readValue($, root, rule.ruleSearch?.kind, targetUrl).split(/[,,\s]+/).filter(Boolean),
|
||||
}));
|
||||
});
|
||||
}
|
||||
if (pageCount === 0) break;
|
||||
}
|
||||
searchCache.set(cacheKey, { data: results, expiresAt: Date.now() + cacheTTL });
|
||||
return { source, results };
|
||||
}
|
||||
|
||||
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||
const sources = await this.getSearchSources(sourceId);
|
||||
const results: BookListItem[] = [];
|
||||
const failedSources: BookSearchFailure[] = [];
|
||||
await Promise.all(sources.map(async (source) => {
|
||||
try {
|
||||
const sourceResult = await this.searchBooksSource(q, source);
|
||||
results.push(...sourceResult.results);
|
||||
} catch (error) {
|
||||
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
|
||||
}
|
||||
}));
|
||||
return { results, failedSources };
|
||||
}
|
||||
|
||||
async getCatalog(sourceId: string, href?: string): Promise<BookCatalogResult> {
|
||||
const source = await getSourceById(sourceId);
|
||||
const rule = getRule(source);
|
||||
if (!hasExplore(rule)) {
|
||||
return { sourceId: source.id, sourceName: source.name, title: source.name, href: href || source.url, entries: [], navigation: [] };
|
||||
}
|
||||
|
||||
const categories = await resolveExploreCategories(source, rule);
|
||||
const target = decodeExploreTarget(href) || null;
|
||||
const navigation = categories.map((item) => ({
|
||||
title: item.title,
|
||||
href: item.template.startsWith('__group__:') ? '' : encodeExploreTarget({ title: item.title, template: item.template, page: 1 }),
|
||||
rel: item.template.startsWith('__group__:') ? 'legado:group' : 'legado:explore',
|
||||
type: 'application/x-legado-explore',
|
||||
}));
|
||||
|
||||
if (!target) {
|
||||
return { sourceId: source.id, sourceName: source.name, title: source.name, subtitle: '请选择分类', href: href || source.url, entries: [], navigation };
|
||||
}
|
||||
|
||||
if (target.template.startsWith('__group__:')) {
|
||||
return { sourceId: source.id, sourceName: source.name, title: target.title, href: href || '', entries: [], navigation };
|
||||
}
|
||||
const targetUrl = buildExploreTargetUrl(source, target);
|
||||
const html = await fetchText(source, targetUrl);
|
||||
const exploreRule = getEffectiveExploreRule(rule);
|
||||
const entries: BookListItem[] = [];
|
||||
let pageCount = 0;
|
||||
const json = parseJsonMaybe(html);
|
||||
if (json && ruleIsJson(exploreRule?.bookList)) {
|
||||
const items = selectJsonItems(json, exploreRule?.bookList);
|
||||
pageCount = items.length;
|
||||
items.forEach((item) => {
|
||||
const detailHref = readJsonRule(item, exploreRule?.bookUrl, source, targetUrl);
|
||||
const title = readJsonRule(item, exploreRule?.name, source, targetUrl);
|
||||
if (!title && !detailHref) return;
|
||||
const cover = readJsonRule(item, exploreRule?.coverUrl, source, targetUrl);
|
||||
entries.push(makeItem(source, {
|
||||
id: jsonPrimitiveToString(item?.id) || detailHref || undefined,
|
||||
title,
|
||||
author: readJsonRule(item, exploreRule?.author, source, targetUrl),
|
||||
summary: readJsonRule(item, exploreRule?.intro, source, targetUrl),
|
||||
cover: cover || undefined,
|
||||
detailHref,
|
||||
tags: readJsonRule(item, exploreRule?.kind, source, targetUrl).split(/[,,\s]+/).filter(Boolean),
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
const $ = cheerio.load(html);
|
||||
const items = selectElements($, $.root(), exploreRule?.bookList);
|
||||
pageCount = items.length;
|
||||
items.each((_, element) => {
|
||||
const root = $(element);
|
||||
const detailHref = readValue($, root, exploreRule?.bookUrl, targetUrl);
|
||||
const title = readValue($, root, exploreRule?.name, targetUrl);
|
||||
if (!title && !detailHref) return;
|
||||
const cover = readValue($, root, exploreRule?.coverUrl, targetUrl);
|
||||
entries.push(makeItem(source, {
|
||||
id: detailHref || undefined,
|
||||
title,
|
||||
author: readValue($, root, exploreRule?.author, targetUrl),
|
||||
summary: readValue($, root, exploreRule?.intro, targetUrl),
|
||||
cover: cover || undefined,
|
||||
detailHref,
|
||||
tags: readValue($, root, exploreRule?.kind, targetUrl).split(/[,,\s]+/).filter(Boolean),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
title: target.title || source.name,
|
||||
subtitle: `第 ${target.page} 页`,
|
||||
href: href || encodeExploreTarget(target),
|
||||
entries,
|
||||
navigation,
|
||||
nextHref: pageCount > 0 ? encodeExploreTarget({ ...target, page: target.page + 1 }) : undefined,
|
||||
previousHref: target.page > 1 ? encodeExploreTarget({ ...target, page: target.page - 1 }) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getChaptersByBookId(sourceId: string, bookId: string): Promise<BookChapter[]> {
|
||||
const source = await getSourceById(sourceId);
|
||||
const rule = getRule(source);
|
||||
const base = sourceBase(source);
|
||||
const searchBookUrlRule = rule.ruleSearch?.bookUrl || '';
|
||||
const detailHref = /^https?:\/\//i.test(bookId) || bookId.startsWith('/')
|
||||
? normalizeUrl(base, bookId)
|
||||
: /\{\{\s*(?:\$\.id|id)\s*\}\}|\{id\}/.test(searchBookUrlRule)
|
||||
? normalizeUrl(base, searchBookUrlRule
|
||||
.replace(/\{\{\s*\$\.id\s*\}\}/g, encodeURIComponent(bookId))
|
||||
.replace(/\{\{\s*id\s*\}\}/g, encodeURIComponent(bookId))
|
||||
.replace(/\{id\}/g, encodeURIComponent(bookId)))
|
||||
: '';
|
||||
if (!detailHref) throw new Error('该 Legado 书源无法通过 bookId 定位详情,请重新搜索后打开');
|
||||
const detail = await this.getBookDetail(sourceId, detailHref, { id: bookId, detailHref });
|
||||
const tocHref = detail.acquisitionLinks.find((item) => item.rel === 'legado:chapters' || item.type.toLowerCase().includes('legado-chapters'))?.href;
|
||||
if (!tocHref) return [];
|
||||
return this.getChapters(sourceId, tocHref);
|
||||
}
|
||||
|
||||
async getBookDetail(sourceId: string, href: string, fallback?: Partial<BookDetail>): Promise<BookDetail> {
|
||||
const source = await getSourceById(sourceId);
|
||||
const rule = getRule(source);
|
||||
const detailHref = href || fallback?.detailHref || '';
|
||||
const cacheKey = `detail|${source.id}|${detailHref}`;
|
||||
const { cacheTTL } = await resolveLegadoConfig();
|
||||
const cached = detailCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return { ...cached.data, ...(!href && fallback ? fallback : {}) };
|
||||
|
||||
let detail: BookDetail | null = null;
|
||||
if (detailHref && rule.ruleBookInfo) {
|
||||
const targetUrl = rule.bookInfoUrl ? buildUrlFromTemplate(rule.bookInfoUrl, source, undefined, 1, detailHref).replace(/\{bookUrl\}/g, encodeURIComponent(detailHref)) : detailHref;
|
||||
const html = await fetchText(source, targetUrl);
|
||||
const initData = rule.bookInfoInit?.trim().startsWith(':') ? readAllInOneList(html, rule.bookInfoInit)[0] : null;
|
||||
const json = initData || parseJsonMaybe(html);
|
||||
const $ = json && initData ? null : json ? null : cheerio.load(html);
|
||||
const root = $?.root();
|
||||
if ($ && root) applyBookInfoInit($, root, (rule.ruleBookInfo as any).init || rule.bookInfoInit, targetUrl);
|
||||
const read = (itemRule?: string) => initData
|
||||
? readRegexItem(initData, itemRule, targetUrl)
|
||||
: json
|
||||
? readJsonRule(json, itemRule, source, targetUrl)
|
||||
: readValue($ as cheerio.CheerioAPI, root as cheerio.Cheerio<any>, itemRule, targetUrl);
|
||||
const tocUrl = read(rule.ruleBookInfo.tocUrl) || (rule.tocUrl ? buildUrlFromTemplate(rule.tocUrl, source, undefined, 1, detailHref).replace(/\{bookUrl\}/g, encodeURIComponent(detailHref)) : targetUrl);
|
||||
const cover = read(rule.ruleBookInfo.coverUrl) || fallback?.cover;
|
||||
const title = read(rule.ruleBookInfo.name) || fallback?.title || '未命名电子书';
|
||||
const chapterCountText = json
|
||||
? jsonPrimitiveToString(readJsonPath(json, '@json:$.data.nums') ?? readJsonPath(json, '@json:$.data.chapter_nums'))
|
||||
: '';
|
||||
const chapterCount = chapterCountText ? Number(chapterCountText) : NaN;
|
||||
const hasKnownEmptyChapters = Number.isFinite(chapterCount) && chapterCount <= 0;
|
||||
const acquisitionLinks: BookAcquisitionLink[] = hasKnownEmptyChapters ? [] : [{ rel: 'legado:chapters', type: 'application/x-legado-chapters+json', href: tocUrl, title: '章节目录' }];
|
||||
detail = {
|
||||
id: fallback?.id || stableId(`${source.id}|${detailHref || title}`),
|
||||
sourceId,
|
||||
sourceName: source.name,
|
||||
title,
|
||||
author: read(rule.ruleBookInfo.author) || fallback?.author,
|
||||
cover: cover || undefined,
|
||||
summary: read(rule.ruleBookInfo.intro) || fallback?.summary,
|
||||
tags: read(rule.ruleBookInfo.kind).split(/[,,\s]+/).filter(Boolean),
|
||||
categories: read(rule.ruleBookInfo.kind).split(/[,,\s]+/).filter(Boolean),
|
||||
detailHref,
|
||||
acquisitionLinks,
|
||||
navigation: hasKnownEmptyChapters ? [] : [{ title: '目录', href: tocUrl, rel: 'legado:toc', type: 'application/x-legado-chapters+json' }],
|
||||
};
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
const tocUrl = fallback?.acquisitionLinks?.[0]?.href || detailHref;
|
||||
detail = {
|
||||
id: fallback?.id || stableId(`${source.id}|${detailHref || fallback?.title || ''}`),
|
||||
sourceId,
|
||||
sourceName: source.name,
|
||||
title: fallback?.title || '未命名电子书',
|
||||
author: fallback?.author,
|
||||
cover: fallback?.cover,
|
||||
summary: fallback?.summary,
|
||||
detailHref,
|
||||
acquisitionLinks: [{ rel: 'legado:chapters', type: 'application/x-legado-chapters+json', href: tocUrl, title: '章节目录' }],
|
||||
navigation: [{ title: '目录', href: tocUrl, rel: 'legado:toc', type: 'application/x-legado-chapters+json' }],
|
||||
};
|
||||
}
|
||||
detailCache.set(cacheKey, { data: detail, expiresAt: Date.now() + cacheTTL });
|
||||
return detail;
|
||||
}
|
||||
|
||||
async getChapters(sourceId: string, tocHref: string): Promise<BookChapter[]> {
|
||||
const source = await getSourceById(sourceId);
|
||||
const rule = getRule(source);
|
||||
if (!rule.ruleToc?.chapterList) throw new Error('该 Legado 书源缺少目录规则');
|
||||
const cacheKey = `toc|${source.id}|${tocHref}`;
|
||||
const { cacheTTL } = await resolveLegadoConfig();
|
||||
const cached = tocCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||
|
||||
const targetUrl = normalizeUrl(sourceBase(source), tocHref);
|
||||
const html = await fetchText(source, targetUrl);
|
||||
const chapters: BookChapter[] = [];
|
||||
const json = parseJsonMaybe(html);
|
||||
const jsItems = evaluateJsListRule(rule.ruleToc.chapterList, { result: html, src: html, baseUrl: targetUrl, source: rule });
|
||||
if (jsItems.length > 0) {
|
||||
jsItems.forEach((item, index) => {
|
||||
const title = readJsonRule(item, rule.ruleToc?.chapterName, source, targetUrl) || `第 ${index + 1} 章`;
|
||||
const href = readJsonRule(item, rule.ruleToc?.chapterUrl, source, targetUrl)
|
||||
|| fallbackChapterHrefFromItem(item, rule.ruleToc?.chapterUrl, targetUrl);
|
||||
if (!href) return;
|
||||
const normalizedHref = normalizeUrl(targetUrl, href);
|
||||
chapters.push({ id: stableId(`${source.id}|${normalizedHref}`), title, href: normalizedHref, order: index });
|
||||
});
|
||||
} else if (json && ruleIsJson(rule.ruleToc.chapterList)) {
|
||||
const items = selectJsonItems(json, rule.ruleToc.chapterList);
|
||||
items.forEach((item, index) => {
|
||||
const title = readJsonRule(item, rule.ruleToc?.chapterName, source, targetUrl) || `第 ${index + 1} 章`;
|
||||
const href = readJsonRule(item, rule.ruleToc?.chapterUrl, source, targetUrl)
|
||||
|| fallbackChapterHrefFromItem(item, rule.ruleToc?.chapterUrl, targetUrl);
|
||||
if (!href) return;
|
||||
const normalizedHref = normalizeUrl(targetUrl, href);
|
||||
chapters.push({ id: stableId(`${source.id}|${normalizedHref}`), title, href: normalizedHref, order: index });
|
||||
});
|
||||
} else {
|
||||
const $ = cheerio.load(html);
|
||||
const items = selectElements($, $.root(), rule.ruleToc.chapterList);
|
||||
items.each((index, element) => {
|
||||
const root = $(element);
|
||||
const title = readValue($, root, rule.ruleToc?.chapterName, targetUrl) || `第 ${index + 1} 章`;
|
||||
const href = readValue($, root, rule.ruleToc?.chapterUrl, targetUrl) || root.attr('href') || '';
|
||||
if (!href) return;
|
||||
const normalizedHref = normalizeUrl(targetUrl, href);
|
||||
chapters.push({ id: stableId(`${source.id}|${normalizedHref}`), title, href: normalizedHref, order: index });
|
||||
});
|
||||
}
|
||||
if (rule.ruleToc.nextTocUrl) {
|
||||
let nextTocUrl = contentFromRule(html, rule.ruleToc.nextTocUrl, targetUrl);
|
||||
const visited = new Set([targetUrl]);
|
||||
for (let page = 0; page < 8 && nextTocUrl; page += 1) {
|
||||
const normalizedNext = normalizeUrl(targetUrl, nextTocUrl);
|
||||
if (!normalizedNext || visited.has(normalizedNext)) break;
|
||||
visited.add(normalizedNext);
|
||||
let nextHtml = '';
|
||||
try {
|
||||
nextHtml = await fetchText(source, normalizedNext);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
const $next = cheerio.load(nextHtml);
|
||||
const nextItems = selectElements($next, $next.root(), rule.ruleToc.chapterList);
|
||||
nextItems.each((index, element) => {
|
||||
const root = $next(element);
|
||||
const title = readValue($next, root, rule.ruleToc?.chapterName, normalizedNext) || `第 ${chapters.length + index + 1} 章`;
|
||||
const href = readValue($next, root, rule.ruleToc?.chapterUrl, normalizedNext) || root.attr('href') || '';
|
||||
if (!href) return;
|
||||
const normalizedHref = normalizeUrl(normalizedNext, href);
|
||||
chapters.push({ id: stableId(`${source.id}|${normalizedHref}`), title, href: normalizedHref, order: chapters.length });
|
||||
});
|
||||
nextTocUrl = contentFromRule(nextHtml, rule.ruleToc.nextTocUrl, normalizedNext);
|
||||
}
|
||||
}
|
||||
tocCache.set(cacheKey, { data: chapters, expiresAt: Date.now() + cacheTTL });
|
||||
return chapters;
|
||||
}
|
||||
|
||||
async getChapterContent(sourceId: string, chapterHref: string, tocHref?: string): Promise<BookChapterContent> {
|
||||
const source = await getSourceById(sourceId);
|
||||
const rule = getRule(source);
|
||||
if (!rule.ruleContent?.content) throw new Error('该 Legado 书源缺少正文规则');
|
||||
const targetUrl = normalizeUrl(sourceBase(source), chapterHref);
|
||||
const cacheKey = `chapter|${source.id}|${targetUrl}`;
|
||||
const cached = chapterCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||
|
||||
let pageUrl = targetUrl;
|
||||
const parts: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
for (let page = 0; page < 8 && pageUrl && !visited.has(pageUrl); page += 1) {
|
||||
visited.add(pageUrl);
|
||||
const html = await fetchText(source, pageUrl);
|
||||
const part = contentFromRule(html, rule.ruleContent.content, pageUrl);
|
||||
if (part) parts.push(part);
|
||||
const next = rule.ruleContent.nextContentUrl ? contentFromRule(html, rule.ruleContent.nextContentUrl, pageUrl) : '';
|
||||
const normalizedNext = next ? normalizeUrl(pageUrl, next) : '';
|
||||
if (!normalizedNext || normalizedNext === pageUrl || visited.has(normalizedNext)) break;
|
||||
if (chapterPageStem(normalizedNext) !== chapterPageStem(targetUrl)) break;
|
||||
pageUrl = normalizedNext;
|
||||
}
|
||||
const rawContent = parts.join('\\n\\n');
|
||||
const chapters = tocHref ? await this.getChapters(sourceId, tocHref).catch(() => []) : [];
|
||||
const index = chapters.findIndex((item) => item.href === targetUrl || item.href === chapterHref);
|
||||
const content: BookChapterContent = {
|
||||
id: stableId(`${source.id}|${targetUrl}`),
|
||||
title: index >= 0 ? chapters[index].title : '',
|
||||
href: targetUrl,
|
||||
content: proxyChapterImages(cleanContent(applyRuleFilters(rawContent, [
|
||||
...(rule.ruleContent.sourceRegex ? [rule.ruleContent.sourceRegex, ''] : []),
|
||||
...((rule.ruleContent as any).replaceRegex ? splitRuleFilters((rule.ruleContent as any).replaceRegex).filters : []),
|
||||
])), source),
|
||||
previousHref: index > 0 ? chapters[index - 1].href : undefined,
|
||||
nextHref: index >= 0 && index + 1 < chapters.length ? chapters[index + 1].href : undefined,
|
||||
};
|
||||
chapterCache.set(cacheKey, { data: content, expiresAt: Date.now() + 24 * 60 * 60 * 1000 });
|
||||
return content;
|
||||
}
|
||||
|
||||
async getSourceById(sourceId: string): Promise<BookSource> {
|
||||
return getSourceById(sourceId);
|
||||
}
|
||||
|
||||
async detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
|
||||
return {
|
||||
searchSupported: !!source.legado?.searchUrl,
|
||||
catalogSupported: hasExplore(source.legado || {}),
|
||||
searchMode: source.legado?.searchUrl ? 'legado' : 'disabled',
|
||||
catalogMode: hasExplore(source.legado || {}) ? 'legado' : 'disabled',
|
||||
acquisitionTypes: ['application/x-legado-chapters+json'],
|
||||
lastCheckedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const legadoClient = new LegadoClient();
|
||||
@@ -0,0 +1,248 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import crypto from 'crypto';
|
||||
|
||||
import type { AdminConfig } from '@/lib/admin.types';
|
||||
import type { BookSource, LegadoBookSourceRule } from '@/lib/book.types';
|
||||
import { db } from '@/lib/db';
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
|
||||
export interface LegadoSubscriptionMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
sourceCount?: number;
|
||||
lastSyncAt?: number;
|
||||
lastSuccessAt?: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
interface StoredManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
hash: string;
|
||||
sourceCount: number;
|
||||
chunkCount: number;
|
||||
updatedAt: number;
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = Number(process.env.LEGADO_SUBSCRIPTION_CHUNK_SIZE || 100);
|
||||
const TIMEOUT_MS = Number(process.env.LEGADO_SUBSCRIPTION_TIMEOUT_MS || process.env.LEGADO_TIMEOUT_MS || 30000);
|
||||
const MAX_BYTES = Number(process.env.LEGADO_SUBSCRIPTION_MAX_BYTES || 20 * 1024 * 1024);
|
||||
const sourcesCache = new Map<string, BookSource[]>();
|
||||
const sourcesLoadPromises = new Map<string, Promise<BookSource[]>>();
|
||||
|
||||
function stableId(input: string) {
|
||||
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function subscriptionId(url: string, name?: string) {
|
||||
return `legado_sub_${stableId(`${name || ''}|${url}`)}`;
|
||||
}
|
||||
|
||||
function manifestKey(id: string) {
|
||||
return `legado:subscription:${id}:manifest`;
|
||||
}
|
||||
|
||||
function chunkKey(id: string, index: number) {
|
||||
return `legado:subscription:${id}:chunk:${index}`;
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchTextWithRetry(url: string, retries = 2): Promise<{ text: string; etag?: string; lastModified?: string }> {
|
||||
const safe = await validateProxyUrlServerSide(url);
|
||||
if (!safe) throw new Error('订阅地址未通过安全校验');
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||
Accept: 'application/json,text/plain,*/*',
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`订阅请求失败: ${response.status}`);
|
||||
const contentLength = Number(response.headers.get('content-length') || '0');
|
||||
if (contentLength > MAX_BYTES) throw new Error('订阅内容过大');
|
||||
const text = await response.text();
|
||||
if (text.length > MAX_BYTES) throw new Error('订阅内容过大');
|
||||
return {
|
||||
text,
|
||||
etag: response.headers.get('etag') || undefined,
|
||||
lastModified: response.headers.get('last-modified') || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < retries) await wait(300 * (attempt + 1));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('订阅请求失败');
|
||||
}
|
||||
|
||||
function extractRuleList(input: any): LegadoBookSourceRule[] {
|
||||
if (Array.isArray(input)) return input.filter((item) => item && typeof item === 'object');
|
||||
if (!input || typeof input !== 'object') return [];
|
||||
for (const key of ['data', 'sources', 'bookSources', 'items', 'list']) {
|
||||
if (Array.isArray(input[key])) return input[key].filter((item: any) => item && typeof item === 'object');
|
||||
}
|
||||
return [input];
|
||||
}
|
||||
|
||||
function normalizeRule(rule: LegadoBookSourceRule, subId: string, index: number): BookSource | null {
|
||||
const name = rule.bookSourceName || `Legado 书源 ${index + 1}`;
|
||||
const url = rule.bookSourceUrl || '';
|
||||
if (!url) return null;
|
||||
return {
|
||||
id: `legado_${stableId(`${subId}|${name}|${url}|${index}`)}`,
|
||||
name,
|
||||
type: 'legado',
|
||||
url,
|
||||
enabled: rule.enabled !== false,
|
||||
authMode: 'none',
|
||||
username: '',
|
||||
password: '',
|
||||
headerName: '',
|
||||
headerValue: '',
|
||||
searchTemplate: '',
|
||||
preferFormat: ['epub'],
|
||||
language: '',
|
||||
legado: rule,
|
||||
};
|
||||
}
|
||||
|
||||
async function readManifest(id: string): Promise<StoredManifest | null> {
|
||||
const raw = await db.getGlobalValue(manifestKey(id));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as StoredManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSourcesFromDb(id: string): Promise<BookSource[]> {
|
||||
const manifest = await readManifest(id);
|
||||
if (!manifest) return [];
|
||||
const chunks = await Promise.all(
|
||||
Array.from({ length: manifest.chunkCount }, async (_, index) => {
|
||||
const raw = await db.getGlobalValue(chunkKey(id, index));
|
||||
if (!raw) return [] as BookSource[];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as BookSource[]) : [];
|
||||
} catch {
|
||||
return [] as BookSource[];
|
||||
}
|
||||
})
|
||||
);
|
||||
return chunks.flat();
|
||||
}
|
||||
|
||||
export const legadoSubscriptionStore = {
|
||||
makeId: subscriptionId,
|
||||
|
||||
async sync(input: { id?: string; name?: string; url: string }): Promise<LegadoSubscriptionMeta> {
|
||||
const url = input.url.trim();
|
||||
if (!url) throw new Error('订阅 URL 不能为空');
|
||||
const id = input.id || subscriptionId(url, input.name);
|
||||
const name = input.name?.trim() || 'Legado 订阅';
|
||||
const previous = await readManifest(id);
|
||||
const { text, etag, lastModified } = await fetchTextWithRetry(url);
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('订阅内容不是合法 JSON');
|
||||
}
|
||||
const rules = extractRuleList(parsed);
|
||||
const sources = rules.map((rule, index) => normalizeRule(rule, id, index)).filter((item): item is BookSource => !!item);
|
||||
if (sources.length === 0) throw new Error('订阅内没有识别到有效 Legado 书源');
|
||||
|
||||
const chunkCount = Math.ceil(sources.length / CHUNK_SIZE);
|
||||
const hash = crypto.createHash('sha1').update(JSON.stringify(sources)).digest('hex');
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
await db.setGlobalValue(chunkKey(id, index), JSON.stringify(sources.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)));
|
||||
}
|
||||
if (previous && previous.chunkCount > chunkCount) {
|
||||
for (let index = chunkCount; index < previous.chunkCount; index += 1) {
|
||||
await db.deleteGlobalValue(chunkKey(id, index));
|
||||
}
|
||||
}
|
||||
const manifest: StoredManifest = { id, name, url, hash, sourceCount: sources.length, chunkCount, updatedAt: Date.now(), etag, lastModified };
|
||||
await db.setGlobalValue(manifestKey(id), JSON.stringify(manifest));
|
||||
sourcesLoadPromises.delete(id);
|
||||
sourcesCache.set(id, sources);
|
||||
return { id, name, url, enabled: true, sourceCount: sources.length, lastSyncAt: manifest.updatedAt, lastSuccessAt: manifest.updatedAt, lastError: '' };
|
||||
},
|
||||
|
||||
async getSources(id: string): Promise<BookSource[]> {
|
||||
const cached = sourcesCache.get(id);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = sourcesLoadPromises.get(id);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = readSourcesFromDb(id)
|
||||
.then((sources) => {
|
||||
sourcesCache.set(id, sources);
|
||||
sourcesLoadPromises.delete(id);
|
||||
return sources;
|
||||
})
|
||||
.catch((error) => {
|
||||
sourcesLoadPromises.delete(id);
|
||||
throw error;
|
||||
});
|
||||
sourcesLoadPromises.set(id, promise);
|
||||
return promise;
|
||||
},
|
||||
|
||||
async getSourcesForSubscriptions(subscriptions: LegadoSubscriptionMeta[] = []): Promise<BookSource[]> {
|
||||
const enabled = subscriptions.filter((item) => item.enabled !== false);
|
||||
const groups = await Promise.all(enabled.map((item) => this.getSources(item.id)));
|
||||
return groups.flat().filter((source) => source.enabled !== false);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
sourcesLoadPromises.delete(id);
|
||||
sourcesCache.delete(id);
|
||||
const manifest = await readManifest(id);
|
||||
if (manifest) {
|
||||
for (let index = 0; index < manifest.chunkCount; index += 1) {
|
||||
await db.deleteGlobalValue(chunkKey(id, index));
|
||||
}
|
||||
}
|
||||
await db.deleteGlobalValue(manifestKey(id));
|
||||
},
|
||||
|
||||
clearCache(id?: string): void {
|
||||
if (id) {
|
||||
sourcesLoadPromises.delete(id);
|
||||
sourcesCache.delete(id);
|
||||
return;
|
||||
}
|
||||
sourcesLoadPromises.clear();
|
||||
sourcesCache.clear();
|
||||
},
|
||||
|
||||
mergeMeta(config: AdminConfig, meta: LegadoSubscriptionMeta): AdminConfig {
|
||||
const opds = config.OPDSConfig || { Enabled: false, Sources: [], CacheTTL: 10 * 60 * 1000 };
|
||||
const list = opds.LegadoSubscriptions || [];
|
||||
const next = list.some((item) => item.id === meta.id)
|
||||
? list.map((item) => item.id === meta.id ? { ...item, ...meta } : item)
|
||||
: [...list, meta];
|
||||
return { ...config, OPDSConfig: { ...opds, LegadoSubscriptions: next } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
const MAX_FAILURES = 5;
|
||||
const BAN_DURATIONS_MS = [
|
||||
60 * 60 * 1000,
|
||||
6 * 60 * 60 * 1000,
|
||||
24 * 60 * 60 * 1000,
|
||||
];
|
||||
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const RECORD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface LoginFail2BanRecord {
|
||||
failures: number[];
|
||||
bannedUntil: number;
|
||||
banLevel: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
interface LoginFail2BanStore {
|
||||
records: Map<string, LoginFail2BanRecord>;
|
||||
lastCleanup: number;
|
||||
}
|
||||
|
||||
interface LoginFail2BanGlobal {
|
||||
__loginFail2BanStore?: LoginFail2BanStore;
|
||||
}
|
||||
|
||||
export interface LoginBanStatus {
|
||||
banned: boolean;
|
||||
bannedUntil?: number;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export interface LoginFailureResult extends LoginBanStatus {
|
||||
failureCount: number;
|
||||
banLevel: number;
|
||||
}
|
||||
|
||||
function getStore(): LoginFail2BanStore {
|
||||
const globalStore = globalThis as typeof globalThis & LoginFail2BanGlobal;
|
||||
if (!globalStore.__loginFail2BanStore) {
|
||||
globalStore.__loginFail2BanStore = {
|
||||
records: new Map<string, LoginFail2BanRecord>(),
|
||||
lastCleanup: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return globalStore.__loginFail2BanStore;
|
||||
}
|
||||
|
||||
function cleanupExpiredRecords(now: number) {
|
||||
const store = getStore();
|
||||
if (now - store.lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
|
||||
for (const [ip, record] of Array.from(store.records.entries())) {
|
||||
const hasActiveBan = record.bannedUntil > now;
|
||||
const recentlySeen = now - record.lastSeen < RECORD_TTL_MS;
|
||||
|
||||
if (!hasActiveBan && !recentlySeen) {
|
||||
store.records.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
store.lastCleanup = now;
|
||||
}
|
||||
|
||||
function pruneFailures(record: LoginFail2BanRecord, now: number) {
|
||||
record.failures = record.failures.filter((time) => now - time <= WINDOW_MS);
|
||||
}
|
||||
|
||||
function normalizeIp(ip: string): string | null {
|
||||
const normalized = ip.trim();
|
||||
if (!normalized || normalized.toLowerCase() === 'unknown') return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getLoginClientIp(req: NextRequest): string | null {
|
||||
const cfConnectingIp = normalizeIp(req.headers.get('cf-connecting-ip') || '');
|
||||
if (cfConnectingIp) return cfConnectingIp;
|
||||
|
||||
const xRealIp = normalizeIp(req.headers.get('x-real-ip') || '');
|
||||
if (xRealIp) return xRealIp;
|
||||
|
||||
const xForwardedFor = req.headers.get('x-forwarded-for');
|
||||
if (xForwardedFor) {
|
||||
const firstIp = normalizeIp(xForwardedFor.split(',')[0] || '');
|
||||
if (firstIp) return firstIp;
|
||||
}
|
||||
|
||||
const forwarded = req.headers.get('forwarded');
|
||||
if (forwarded) {
|
||||
const match = forwarded.match(/for=(?:"?)([^;,\"]+)/i);
|
||||
const forwardedIp = normalizeIp(match?.[1]?.replace(/^\[|\]$/g, '') || '');
|
||||
if (forwardedIp) return forwardedIp;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function checkLoginBan(ip: string | null, now = Date.now()): LoginBanStatus {
|
||||
if (!ip) return { banned: false };
|
||||
|
||||
cleanupExpiredRecords(now);
|
||||
|
||||
const record = getStore().records.get(ip);
|
||||
if (!record) return { banned: false };
|
||||
|
||||
record.lastSeen = now;
|
||||
if (record.bannedUntil > now) {
|
||||
return {
|
||||
banned: true,
|
||||
bannedUntil: record.bannedUntil,
|
||||
retryAfterSeconds: Math.ceil((record.bannedUntil - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
pruneFailures(record, now);
|
||||
return { banned: false };
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string | null, now = Date.now()): LoginFailureResult {
|
||||
if (!ip) {
|
||||
return { banned: false, failureCount: 0, banLevel: 0 };
|
||||
}
|
||||
|
||||
cleanupExpiredRecords(now);
|
||||
|
||||
const store = getStore();
|
||||
const record = store.records.get(ip) || {
|
||||
failures: [],
|
||||
bannedUntil: 0,
|
||||
banLevel: 0,
|
||||
lastSeen: now,
|
||||
};
|
||||
|
||||
record.lastSeen = now;
|
||||
pruneFailures(record, now);
|
||||
record.failures.push(now);
|
||||
|
||||
if (record.failures.length >= MAX_FAILURES) {
|
||||
record.banLevel += 1;
|
||||
const durationIndex = Math.min(record.banLevel - 1, BAN_DURATIONS_MS.length - 1);
|
||||
record.bannedUntil = now + BAN_DURATIONS_MS[durationIndex];
|
||||
record.failures = [];
|
||||
}
|
||||
|
||||
store.records.set(ip, record);
|
||||
|
||||
if (record.bannedUntil > now) {
|
||||
return {
|
||||
banned: true,
|
||||
bannedUntil: record.bannedUntil,
|
||||
retryAfterSeconds: Math.ceil((record.bannedUntil - now) / 1000),
|
||||
failureCount: record.failures.length,
|
||||
banLevel: record.banLevel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
banned: false,
|
||||
failureCount: record.failures.length,
|
||||
banLevel: record.banLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordLoginSuccess(ip: string | null, now = Date.now()) {
|
||||
if (!ip) return;
|
||||
|
||||
const record = getStore().records.get(ip);
|
||||
if (!record) return;
|
||||
|
||||
record.failures = [];
|
||||
record.lastSeen = now;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { QuarkRangeWindow } from './quark.client';
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 1024 * 1024;
|
||||
const DEFAULT_CONCURRENCY = 8;
|
||||
const DEFAULT_CHUNK_RETRIES = 2;
|
||||
const RETRY_BASE_DELAY_MS = 300;
|
||||
|
||||
function buildRangeHeader(start: number, end: number) {
|
||||
return `bytes=${start}-${end}`;
|
||||
}
|
||||
|
||||
function splitRange(start: number, end: number, chunkSize = DEFAULT_CHUNK_SIZE) {
|
||||
const chunks: Array<{ index: number; start: number; end: number }> = [];
|
||||
let index = 0;
|
||||
for (let cursor = start; cursor <= end; cursor += chunkSize) {
|
||||
chunks.push({
|
||||
index,
|
||||
start: cursor,
|
||||
end: Math.min(cursor + chunkSize - 1, end),
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' || error.message.includes('aborted'))
|
||||
);
|
||||
}
|
||||
|
||||
function waitForRetry(ms: number, signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchChunkOnce(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
chunk: { start: number; end: number },
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...headers,
|
||||
Range: buildRangeHeader(chunk.start, chunk.end),
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
throw new Error(`chunk request failed (${response.status})`);
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function fetchChunk(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
chunk: { start: number; end: number },
|
||||
signal: AbortSignal,
|
||||
retries = DEFAULT_CHUNK_RETRIES
|
||||
) {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
return await fetchChunkOnce(url, headers, chunk, signal);
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || attempt >= retries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
await waitForRetry(RETRY_BASE_DELAY_MS * 2 ** attempt, signal);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('chunk request failed');
|
||||
}
|
||||
|
||||
export function createQuarkMultiThreadStream(input: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
window: QuarkRangeWindow;
|
||||
contentHeaders: Headers;
|
||||
status: number;
|
||||
}) {
|
||||
const { url, headers, window, contentHeaders, status } = input;
|
||||
const chunks = splitRange(window.start, window.end);
|
||||
const abortController = new AbortController();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const results = new Map<number, Uint8Array>();
|
||||
let nextFetch = 0;
|
||||
let active = 0;
|
||||
let failed = false;
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const launch = () => {
|
||||
if (failed) return;
|
||||
if (results.size >= chunks.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
while (active < DEFAULT_CONCURRENCY && nextFetch < chunks.length) {
|
||||
const chunk = chunks[nextFetch];
|
||||
nextFetch += 1;
|
||||
active += 1;
|
||||
|
||||
fetchChunk(url, headers, chunk, abortController.signal)
|
||||
.then((data) => {
|
||||
results.set(chunk.index, data);
|
||||
if (results.size >= chunks.length) {
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
failed = true;
|
||||
reject(error);
|
||||
})
|
||||
.finally(() => {
|
||||
active -= 1;
|
||||
launch();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
launch();
|
||||
});
|
||||
|
||||
await done;
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const data = results.get(index);
|
||||
if (!data) {
|
||||
throw new Error(`chunk missing (${index})`);
|
||||
}
|
||||
controller.enqueue(data);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
abortController.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status,
|
||||
headers: contentHeaders,
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export interface QuarkNetdiskSessionFile {
|
||||
pdirFid?: string;
|
||||
}
|
||||
|
||||
export interface QuarkPlayUrlCacheItem {
|
||||
name: string;
|
||||
url: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface QuarkNetdiskSession {
|
||||
id: string;
|
||||
provider: 'quark';
|
||||
@@ -18,6 +24,7 @@ export interface QuarkNetdiskSession {
|
||||
shareToken: string;
|
||||
files: QuarkNetdiskSessionFile[];
|
||||
savedFileIds: Record<string, string>;
|
||||
playUrlCaches: Record<string, { urls: QuarkPlayUrlCacheItem[]; expiresAt: number }>;
|
||||
playFolderFid?: string;
|
||||
playFolderPath?: string;
|
||||
createdAt: number;
|
||||
@@ -82,6 +89,7 @@ export function createQuarkNetdiskSession(input: {
|
||||
shareToken: input.shareToken,
|
||||
files: input.files,
|
||||
savedFileIds: {},
|
||||
playUrlCaches: {},
|
||||
createdAt: now,
|
||||
expiresAt: now + TTL_MS,
|
||||
};
|
||||
@@ -107,4 +115,3 @@ export function refreshQuarkNetdiskSession(id: string): QuarkNetdiskSession | nu
|
||||
sessionStore.set(id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
import { listQuarkShareVideos } from './quark.client';
|
||||
import type { QuarkPlayMode } from './quark.client';
|
||||
import {
|
||||
createQuarkNetdiskSession,
|
||||
getQuarkNetdiskSession,
|
||||
@@ -33,5 +34,13 @@ export async function resolveQuarkSession(id: string) {
|
||||
throw new Error('夸克网盘播放信息恢复失败');
|
||||
}
|
||||
|
||||
return { session, cookie: quarkConfig.Cookie, savePath: quarkConfig.SavePath || '/' };
|
||||
const playMode: QuarkPlayMode = quarkConfig.PlayMode === 'direct_first' ? 'direct_first' : 'transcode_first';
|
||||
|
||||
return {
|
||||
session,
|
||||
cookie: quarkConfig.Cookie,
|
||||
savePath: quarkConfig.SavePath || '/',
|
||||
playMode,
|
||||
multiThreadPlayback: Boolean(quarkConfig.MultiThreadPlayback),
|
||||
};
|
||||
}
|
||||
|
||||
+101
-13
@@ -3,6 +3,8 @@
|
||||
const QUARK_SHARE_API_BASE = 'https://drive-h.quark.cn/1/clouddrive';
|
||||
const QUARK_DRIVE_API_BASE = 'https://drive-pc.quark.cn/1/clouddrive';
|
||||
const QUARK_QUERY = 'pr=ucpro&fr=pc';
|
||||
const QUARK_API_USER_AGENT =
|
||||
'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';
|
||||
|
||||
export interface QuarkShareLinkInfo {
|
||||
pwdId: string;
|
||||
@@ -40,6 +42,9 @@ export interface QuarkShareVideoListResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type QuarkPlayMode = 'direct_first' | 'transcode_first';
|
||||
export type QuarkRangeWindow = { start: number; end: number; total: number };
|
||||
|
||||
const VIDEO_EXTENSIONS = [
|
||||
'.mp4',
|
||||
'.mkv',
|
||||
@@ -69,20 +74,16 @@ function getHeaders(cookie: string): HeadersInit {
|
||||
return {
|
||||
'content-type': 'application/json',
|
||||
cookie,
|
||||
origin: 'https://pan.quark.cn',
|
||||
referer: 'https://pan.quark.cn/',
|
||||
'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',
|
||||
'user-agent': QUARK_API_USER_AGENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getQuarkPlayHeaders(cookie: string): Record<string, string> {
|
||||
return {
|
||||
cookie,
|
||||
origin: 'https://pan.quark.cn',
|
||||
referer: 'https://pan.quark.cn/',
|
||||
'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',
|
||||
'user-agent': QUARK_API_USER_AGENT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,6 +299,27 @@ async function findDirectoryByName(
|
||||
) || null;
|
||||
}
|
||||
|
||||
async function findDriveFileInFolder(
|
||||
cookie: string,
|
||||
parentFid: string,
|
||||
input: { fileName?: string; size?: number }
|
||||
): Promise<any | null> {
|
||||
const fileName = input.fileName?.trim();
|
||||
if (!fileName) return null;
|
||||
|
||||
const items = await fetchAllDriveFolderItems(cookie, parentFid);
|
||||
return items.find((item: any) => {
|
||||
const isDir = Boolean(item.dir || item.is_dir || item.file_type === 0);
|
||||
if (isDir) return false;
|
||||
const itemName = String(item.file_name || item.name || '');
|
||||
if (itemName !== fileName) return false;
|
||||
if (input.size && Number(item.size || 0) > 0) {
|
||||
return Number(item.size || 0) === input.size;
|
||||
}
|
||||
return true;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
export async function validateQuarkCookieReadable(cookie: string): Promise<void> {
|
||||
const safeCookie = assertQuarkCookieHeaderSafe(cookie);
|
||||
await fetchDriveFolderItems(safeCookie, '0');
|
||||
@@ -626,11 +648,22 @@ export async function saveQuarkShareFile(
|
||||
shareId: string;
|
||||
shareToken: string;
|
||||
fileId: string;
|
||||
fileName?: string;
|
||||
size?: number;
|
||||
shareFileToken?: string;
|
||||
playFolderFid: string;
|
||||
}
|
||||
): Promise<string> {
|
||||
const safeCookie = assertQuarkCookieHeaderSafe(cookie);
|
||||
|
||||
const existedFile = await findDriveFileInFolder(safeCookie, input.playFolderFid, {
|
||||
fileName: input.fileName,
|
||||
size: input.size,
|
||||
});
|
||||
if (existedFile) {
|
||||
return String(existedFile.fid || existedFile.file_id);
|
||||
}
|
||||
|
||||
const taskId = await submitSaveTask(
|
||||
safeCookie,
|
||||
{ pwdId: input.shareId, passcode: '' },
|
||||
@@ -639,8 +672,9 @@ export async function saveQuarkShareFile(
|
||||
[
|
||||
{
|
||||
fid: input.fileId,
|
||||
fileName: '',
|
||||
fileName: input.fileName || '',
|
||||
dir: false,
|
||||
size: input.size,
|
||||
shareFidToken: input.shareFileToken,
|
||||
},
|
||||
]
|
||||
@@ -668,6 +702,14 @@ export async function saveQuarkShareFile(
|
||||
return String(saveAsTopFids[0]);
|
||||
}
|
||||
|
||||
const savedFile = await findDriveFileInFolder(safeCookie, input.playFolderFid, {
|
||||
fileName: input.fileName,
|
||||
size: input.size,
|
||||
});
|
||||
if (savedFile) {
|
||||
return String(savedFile.fid || savedFile.file_id);
|
||||
}
|
||||
|
||||
const status = data?.data?.status;
|
||||
if (status === -1 || status === 'failed' || data?.data?.err_code) {
|
||||
throw new Error(data?.data?.message || data?.data?.err_msg || '夸克任务执行失败');
|
||||
@@ -685,7 +727,8 @@ export async function saveQuarkShareFile(
|
||||
|
||||
export async function getQuarkPlayUrls(
|
||||
cookie: string,
|
||||
savedFileId: string
|
||||
savedFileId: string,
|
||||
playMode: QuarkPlayMode = 'transcode_first'
|
||||
): Promise<Array<{ name: string; url: string; priority: number }>> {
|
||||
const safeCookie = assertQuarkCookieHeaderSafe(cookie);
|
||||
const headers = getHeaders(safeCookie);
|
||||
@@ -710,8 +753,8 @@ export async function getQuarkPlayUrls(
|
||||
priority: 9999,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore download failure, continue transcoding fallback
|
||||
} catch (error) {
|
||||
console.warn('[quark] get original download url failed:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -748,12 +791,18 @@ export async function getQuarkPlayUrls(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore transcoding failure
|
||||
} catch (error) {
|
||||
console.warn('[quark] get transcoding play url failed:', error);
|
||||
}
|
||||
|
||||
const deduped = urls.filter((item, index, array) => array.findIndex((v) => v.url === item.url) === index);
|
||||
deduped.sort((a, b) => b.priority - a.priority);
|
||||
deduped.sort((a, b) => {
|
||||
if (playMode === 'transcode_first') {
|
||||
if (a.name === '原画' && b.name !== '原画') return 1;
|
||||
if (a.name !== '原画' && b.name === '原画') return -1;
|
||||
}
|
||||
return b.priority - a.priority;
|
||||
});
|
||||
|
||||
if (deduped.length === 0) {
|
||||
throw new Error('未获取到夸克播放地址');
|
||||
@@ -761,3 +810,42 @@ export async function getQuarkPlayUrls(
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function parseContentRangeHeader(contentRange: string | null): QuarkRangeWindow | null {
|
||||
if (!contentRange) return null;
|
||||
const match = contentRange.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i);
|
||||
if (!match) return null;
|
||||
const start = Number(match[1]);
|
||||
const end = Number(match[2]);
|
||||
const total = Number(match[3]);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(total)) return null;
|
||||
return { start, end, total };
|
||||
}
|
||||
|
||||
export async function probeQuarkPlayRange(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
range?: string
|
||||
): Promise<{ response: Response; window: QuarkRangeWindow | null } | null> {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 300000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...headers,
|
||||
...(range ? { Range: range } : {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const window = parseContentRangeHeader(response.headers.get('content-range'));
|
||||
return { response, window };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-6
@@ -234,7 +234,7 @@ async function resolveOPDSConfig(): Promise<ResolvedOPDSConfig> {
|
||||
return {
|
||||
enabled,
|
||||
cacheTTL,
|
||||
sources: (sources || []).filter((source) => !!source?.url && source.enabled !== false),
|
||||
sources: (sources || []).filter((source) => !!source?.url && source.enabled !== false && (source.type || 'opds') === 'opds' && !source.legado),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -484,17 +484,26 @@ export class OPDSClient {
|
||||
};
|
||||
}
|
||||
|
||||
async getSearchSources(sourceId?: string): Promise<BookSource[]> {
|
||||
return sourceId ? [await getSourceById(sourceId)] : (await resolveOPDSConfig()).sources;
|
||||
}
|
||||
|
||||
async searchBooksSource(q: string, source: BookSource): Promise<{ source: BookSource; results: BookListItem[] }> {
|
||||
const targetUrl = await resolveSearchTargetUrl(source, q);
|
||||
if (!targetUrl) throw new Error('未配置可用的搜索地址');
|
||||
const feed = await getFeed(source, targetUrl);
|
||||
return { source, results: feed.entries.map((entry) => mapEntryToItem(source, entry)) };
|
||||
}
|
||||
|
||||
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||
const sources = sourceId ? [await getSourceById(sourceId)] : (await resolveOPDSConfig()).sources;
|
||||
const sources = await this.getSearchSources(sourceId);
|
||||
const results: BookListItem[] = [];
|
||||
const failedSources: BookSearchFailure[] = [];
|
||||
|
||||
await Promise.all(sources.map(async (source) => {
|
||||
try {
|
||||
const targetUrl = await resolveSearchTargetUrl(source, q);
|
||||
if (!targetUrl) throw new Error('未配置可用的搜索地址');
|
||||
const feed = await getFeed(source, targetUrl);
|
||||
results.push(...feed.entries.map((entry) => mapEntryToItem(source, entry)));
|
||||
const sourceResult = await this.searchBooksSource(q, source);
|
||||
results.push(...sourceResult.results);
|
||||
} catch (error) {
|
||||
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
|
||||
type OpenListOfflineDownloadSource = {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function getOfflineDownloadSource(config: AdminConfig): OpenListOfflineDownloadSource {
|
||||
const openlistConfig = config.OpenListConfig;
|
||||
|
||||
if (!openlistConfig?.Enabled) {
|
||||
throw new Error('私人影库功能未启用');
|
||||
}
|
||||
|
||||
const useCustomSource = openlistConfig.OfflineDownloadUseCustomSource === true;
|
||||
const source = useCustomSource
|
||||
? {
|
||||
url: openlistConfig.OfflineDownloadURL || '',
|
||||
username: openlistConfig.OfflineDownloadUsername || '',
|
||||
password: openlistConfig.OfflineDownloadPassword || '',
|
||||
}
|
||||
: {
|
||||
url: openlistConfig.URL,
|
||||
username: openlistConfig.Username,
|
||||
password: openlistConfig.Password,
|
||||
};
|
||||
|
||||
if (!source.url || !source.username || !source.password) {
|
||||
throw new Error(
|
||||
useCustomSource
|
||||
? '离线下载 OpenList 配置不完整'
|
||||
: 'OpenList 配置不完整'
|
||||
);
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
export function getOfflineDownloadBasePath(config: AdminConfig): string {
|
||||
const path = config.OpenListConfig?.OfflineDownloadPath || '/';
|
||||
const normalizedPath = path.replace(/\/$/, '');
|
||||
return normalizedPath || '/';
|
||||
}
|
||||
|
||||
export function joinOpenListPath(basePath: string, name: string): string {
|
||||
return basePath === '/' ? `/${name}` : `${basePath}/${name}`;
|
||||
}
|
||||
|
||||
export async function addOpenListOfflineDownload(
|
||||
config: AdminConfig,
|
||||
downloadPath: string,
|
||||
url: string,
|
||||
tool: string
|
||||
) {
|
||||
const source = getOfflineDownloadSource(config);
|
||||
const client = new OpenListClient(source.url, source.username, source.password);
|
||||
const token = await (client as any).getToken();
|
||||
const openlistUrl = `${source.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,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.code !== 200) {
|
||||
throw new Error(data.message || '添加离线下载任务失败');
|
||||
}
|
||||
}
|
||||
+46
-18
@@ -563,7 +563,8 @@ export class PostgresStorage implements IStorage {
|
||||
async getUserListV2(
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
ownerUsername?: string
|
||||
ownerUsername?: string,
|
||||
search?: string
|
||||
): Promise<{
|
||||
users: Array<{
|
||||
username: string;
|
||||
@@ -577,10 +578,16 @@ export class PostgresStorage implements IStorage {
|
||||
total: number;
|
||||
}> {
|
||||
try {
|
||||
const trimmedSearch = search?.trim() || '';
|
||||
const searchPattern = `%${trimmedSearch}%`;
|
||||
|
||||
// 获取总数
|
||||
const countResult = await this.db
|
||||
.prepare('SELECT COUNT(*) as total FROM users')
|
||||
.first();
|
||||
const countQuery = trimmedSearch
|
||||
? this.db
|
||||
.prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE $1')
|
||||
.bind(searchPattern)
|
||||
: this.db.prepare('SELECT COUNT(*) as total FROM users');
|
||||
const countResult = await countQuery.first();
|
||||
let total = (countResult?.total as number) || 0;
|
||||
|
||||
// 检查站长是否在数据库中
|
||||
@@ -590,8 +597,11 @@ export class PostgresStorage implements IStorage {
|
||||
ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||
ownerInDatabase = !!ownerInfo && ownerInfo.created_at !== 0;
|
||||
|
||||
// 如果站长不在数据库中,总数+1
|
||||
if (!ownerInDatabase) {
|
||||
// 如果站长不在数据库中且匹配搜索条件,总数+1
|
||||
if (
|
||||
!ownerInDatabase &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
@@ -600,7 +610,11 @@ export class PostgresStorage implements IStorage {
|
||||
let actualOffset = offset;
|
||||
let actualLimit = limit;
|
||||
|
||||
if (ownerUsername && !ownerInDatabase) {
|
||||
if (
|
||||
ownerUsername &&
|
||||
!ownerInDatabase &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
if (offset === 0) {
|
||||
// 第一页:只获取 limit-1 个用户,为站长留出位置
|
||||
actualLimit = limit - 1;
|
||||
@@ -611,20 +625,34 @@ export class PostgresStorage implements IStorage {
|
||||
}
|
||||
|
||||
// 获取用户列表(按创建时间降序)
|
||||
const result = await this.db
|
||||
.prepare(`
|
||||
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`)
|
||||
.bind(actualLimit, actualOffset)
|
||||
.all();
|
||||
const listQuery = trimmedSearch
|
||||
? this.db
|
||||
.prepare(`
|
||||
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
|
||||
FROM users
|
||||
WHERE username LIKE $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`)
|
||||
.bind(searchPattern, actualLimit, actualOffset)
|
||||
: this.db
|
||||
.prepare(`
|
||||
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`)
|
||||
.bind(actualLimit, actualOffset);
|
||||
const result = await listQuery.all();
|
||||
|
||||
const users = [];
|
||||
|
||||
// 如果有站长且在第一页,确保站长始终在第一位
|
||||
if (ownerUsername && offset === 0) {
|
||||
if (
|
||||
ownerUsername &&
|
||||
offset === 0 &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
users.push({
|
||||
username: ownerUsername,
|
||||
role: 'owner' as const,
|
||||
@@ -1404,7 +1432,7 @@ export class PostgresStorage implements IStorage {
|
||||
try {
|
||||
const results = await this.db
|
||||
// 按队列顺序返回;当前播放项由最大 last_played_at 决定
|
||||
.prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, last_played_at ASC')
|
||||
.prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, id ASC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
|
||||
@@ -807,11 +807,12 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
return Object.values(rows || {})
|
||||
.filter(Boolean)
|
||||
.map(value => JSON.parse(value as string) as MusicV2HistoryRecord)
|
||||
// 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定
|
||||
// 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定。
|
||||
// createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾。
|
||||
.sort((a, b) => {
|
||||
const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0);
|
||||
if (createdAtDiff !== 0) return createdAtDiff;
|
||||
return (a.lastPlayedAt || 0) - (b.lastPlayedAt || 0);
|
||||
return `${a.source}:${a.songId}`.localeCompare(`${b.source}:${b.songId}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1346,7 +1347,8 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
async getUserListV2(
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
ownerUsername?: string
|
||||
ownerUsername?: string,
|
||||
search?: string
|
||||
): Promise<{
|
||||
users: Array<{
|
||||
username: string;
|
||||
@@ -1359,6 +1361,8 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
const trimmedSearch = search?.trim() || '';
|
||||
|
||||
// 获取总数
|
||||
let total = await this.withRetry(() => this.adapter.zCard(this.userListKey()));
|
||||
|
||||
@@ -1385,8 +1389,11 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
ownerExistenceCache.set(ownerUsername, ownerInDatabase);
|
||||
}
|
||||
|
||||
// 如果站长不在数据库中,总数+1(无论在哪一页都要加)
|
||||
if (!ownerInDatabase) {
|
||||
// 如果站长不在数据库中且匹配搜索条件,总数+1(无论在哪一页都要加)
|
||||
if (
|
||||
!ownerInDatabase &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
@@ -1395,7 +1402,11 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
let actualOffset = offset;
|
||||
let actualLimit = limit;
|
||||
|
||||
if (ownerUsername && !ownerInDatabase) {
|
||||
if (
|
||||
ownerUsername &&
|
||||
!ownerInDatabase &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
if (offset === 0) {
|
||||
// 第一页:只获取 limit-1 个用户,为站长留出位置
|
||||
actualLimit = limit - 1;
|
||||
@@ -1406,14 +1417,38 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
// 获取用户列表(按注册时间升序)
|
||||
const usernames = await this.withRetry(() =>
|
||||
this.adapter.zRange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||
let usernames = await this.withRetry(() =>
|
||||
trimmedSearch
|
||||
? this.adapter.zRange(this.userListKey(), 0, -1)
|
||||
: this.adapter.zRange(
|
||||
this.userListKey(),
|
||||
actualOffset,
|
||||
actualOffset + actualLimit - 1
|
||||
)
|
||||
);
|
||||
if (trimmedSearch) {
|
||||
usernames = usernames.filter((username) =>
|
||||
ensureString(username).includes(trimmedSearch)
|
||||
);
|
||||
total = usernames.length;
|
||||
if (
|
||||
ownerUsername &&
|
||||
!ownerInDatabase &&
|
||||
ownerUsername.includes(trimmedSearch)
|
||||
) {
|
||||
total += 1;
|
||||
}
|
||||
usernames = usernames.slice(actualOffset, actualOffset + actualLimit);
|
||||
}
|
||||
|
||||
const users = [];
|
||||
|
||||
// 如果有站长且在第一页,确保站长始终在第一位
|
||||
if (ownerUsername && offset === 0) {
|
||||
if (
|
||||
ownerUsername &&
|
||||
offset === 0 &&
|
||||
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
|
||||
) {
|
||||
// 即使站长不在数据库中,也要添加站长(站长使用环境变量认证)
|
||||
users.push({
|
||||
username: ownerUsername,
|
||||
|
||||
+89
-63
@@ -338,26 +338,39 @@ export class SuwayomiClient {
|
||||
}));
|
||||
}
|
||||
|
||||
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
|
||||
async getSearchSources(sourceId?: string): Promise<Array<{ id: string; displayName?: string; name?: string }>> {
|
||||
const resolved = await resolveSuwayomiConfig(this.options);
|
||||
let sources: Array<{ id: string; displayName?: string; name?: string }>;
|
||||
|
||||
if (sourceId) {
|
||||
sources = [{ id: sourceId, displayName: sourceId, name: sourceId }];
|
||||
} else {
|
||||
try {
|
||||
sources = (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources);
|
||||
} catch (error) {
|
||||
if (resolved.sourceIds.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
sources = resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({
|
||||
id,
|
||||
displayName: id,
|
||||
name: id,
|
||||
}));
|
||||
}
|
||||
const matched = (await this.getSources()).find((item) => item.id === sourceId);
|
||||
return [
|
||||
{
|
||||
id: sourceId,
|
||||
displayName: matched?.displayName || matched?.name || sourceId,
|
||||
name: matched?.name || matched?.displayName || sourceId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
return (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources);
|
||||
} catch (error) {
|
||||
if (resolved.sourceIds.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
return resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({
|
||||
id,
|
||||
displayName: id,
|
||||
name: id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async searchMangaSource(
|
||||
keyword: string,
|
||||
source: { id: string; displayName?: string; name?: string },
|
||||
page = 1
|
||||
): Promise<{ source: { id: string; displayName?: string; name?: string }; results: MangaSearchItem[] }> {
|
||||
const query = `
|
||||
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
||||
fetchSourceManga(input: $input) {
|
||||
@@ -376,6 +389,60 @@ export class SuwayomiClient {
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchSourceManga?: {
|
||||
mangas?: Array<{
|
||||
id: string | number;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceId?: string | number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
query,
|
||||
{
|
||||
input: {
|
||||
type: 'SEARCH',
|
||||
source: source.id,
|
||||
query: keyword,
|
||||
page,
|
||||
},
|
||||
},
|
||||
'GET_SOURCE_MANGAS_FETCH'
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const sourceName = source.displayName || source.name || String(source.id);
|
||||
const results = (data.fetchSourceManga?.mangas || [])
|
||||
.filter((manga) => {
|
||||
const key = `${source.id}:${manga.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.map((manga) => ({
|
||||
id: String(manga.id),
|
||||
sourceId: String(manga.sourceId || source.id),
|
||||
sourceName,
|
||||
title: manga.title || '未命名漫画',
|
||||
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
||||
description: manga.description,
|
||||
author: manga.author,
|
||||
artist: manga.artist,
|
||||
genre: manga.genre,
|
||||
status: normalizeMangaStatus(manga.status),
|
||||
}));
|
||||
|
||||
return { source, results };
|
||||
}
|
||||
|
||||
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
|
||||
const sources = await this.getSearchSources(sourceId);
|
||||
const results: MangaSearchItem[] = [];
|
||||
const failedSources: MangaSearchFailure[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -383,37 +450,7 @@ export class SuwayomiClient {
|
||||
const perSourceResults = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
try {
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchSourceManga?: {
|
||||
mangas?: Array<{
|
||||
id: string | number;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceId?: string | number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
query,
|
||||
{
|
||||
input: {
|
||||
type: 'SEARCH',
|
||||
source: source.id,
|
||||
query: keyword,
|
||||
page,
|
||||
},
|
||||
},
|
||||
'GET_SOURCE_MANGAS_FETCH'
|
||||
);
|
||||
|
||||
return {
|
||||
source,
|
||||
mangas: data.fetchSourceManga?.mangas || [],
|
||||
};
|
||||
return await this.searchMangaSource(keyword, source, page);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知错误';
|
||||
console.warn(`[Suwayomi] manga search source failed: ${source.id} - ${message}`);
|
||||
@@ -424,29 +461,18 @@ export class SuwayomiClient {
|
||||
});
|
||||
return {
|
||||
source,
|
||||
mangas: [],
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const { source, mangas } of perSourceResults) {
|
||||
for (const manga of mangas) {
|
||||
const key = `${source.id}:${manga.id}`;
|
||||
for (const { results: sourceResults } of perSourceResults) {
|
||||
for (const manga of sourceResults) {
|
||||
const key = `${manga.sourceId}:${manga.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
results.push({
|
||||
id: String(manga.id),
|
||||
sourceId: String(manga.sourceId || source.id),
|
||||
sourceName: source.displayName || source.name || String(source.id),
|
||||
title: manga.title || '未命名漫画',
|
||||
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
||||
description: manga.description,
|
||||
author: manga.author,
|
||||
artist: manga.artist,
|
||||
genre: manga.genre,
|
||||
status: normalizeMangaStatus(manga.status),
|
||||
});
|
||||
results.push(manga);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const CURRENT_VERSION = '220.0.0';
|
||||
const CURRENT_VERSION = '221.0.0';
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { CURRENT_VERSION };
|
||||
|
||||
@@ -11,7 +11,10 @@ export interface AnimeSubscription {
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type AnimeSubscriptionDownloadTool = 'aria2' | 'qBittorrent' | 'Transmission';
|
||||
|
||||
export interface AnimeSubscriptionConfig {
|
||||
Enabled: boolean;
|
||||
DownloadTool?: AnimeSubscriptionDownloadTool;
|
||||
Subscriptions: AnimeSubscription[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user