Merge branch 'dev'

This commit is contained in:
mtvpls
2026-05-24 20:18:10 +08:00
72 changed files with 8176 additions and 2540 deletions
+28
View File
@@ -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
View File
@@ -1 +1 @@
220.0.0
221.0.0
+2 -1
View File
@@ -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": {
+21 -517
View File
@@ -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
View File
@@ -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>
+24 -49
View File
@@ -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,
+12 -2
View File
@@ -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 });
}
}
+2
View File
@@ -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),
+7 -1
View File
@@ -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) {
+52 -1
View File
@@ -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,
+2 -1
View File
@@ -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) {
// 使用新版本数据
+2 -2
View File
@@ -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 -2
View File
@@ -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,
+8 -6
View File
@@ -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) {
+90
View File
@@ -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 });
}
}
+24
View File
@@ -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 });
}
}
+31
View File
@@ -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 });
}
}
+8 -7
View File
@@ -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,
+2 -2
View File
@@ -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 });
+109
View File
@@ -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',
},
});
}
+2 -2
View File
@@ -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 });
+29
View File
@@ -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(
+114
View File
@@ -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',
},
});
}
+1 -1
View File
@@ -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,
};
}
+216 -72
View File
@@ -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
View File
@@ -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 源不使用代理模式
};
+84 -2
View File
@@ -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>
))}
+83 -7
View File
@@ -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>
);
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'>
UC123115 线
</div>
{netdiskTempPlayEnabled && (
<div className='text-xs text-gray-500 dark:text-gray-400'>
UC123115 线
</div>
)}
<input
value={directPlayUrl}
onChange={(event) => setDirectPlayUrl(event.target.value)}
+146 -117
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+25
View File
@@ -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'
>
+64 -8
View File
@@ -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}
+136 -70
View File
@@ -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}
+115 -77
View File
@@ -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+)?)(?:集|话)/);
+141
View File
@@ -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
View File
@@ -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'
>
+17 -7
View File
@@ -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 && (
+1 -1
View File
@@ -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 }) {
+18
View File
@@ -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;
+22 -45
View File
@@ -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
+84
View File
@@ -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();
+13 -5
View File
@@ -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
View File
@@ -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;
}
+31
View File
@@ -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",
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+1789
View File
@@ -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();
+248
View File
@@ -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 } };
},
};
+175
View File
@@ -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;
}
+179
View File
@@ -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 -1
View File
@@ -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;
}
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
}
+82
View File
@@ -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
View File
@@ -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();
+44 -9
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
const CURRENT_VERSION = '220.0.0';
const CURRENT_VERSION = '221.0.0';
// 导出当前版本号供其他地方使用
export { CURRENT_VERSION };
+3
View File
@@ -11,7 +11,10 @@ export interface AnimeSubscription {
createdBy: string;
}
export type AnimeSubscriptionDownloadTool = 'aria2' | 'qBittorrent' | 'Transmission';
export interface AnimeSubscriptionConfig {
Enabled: boolean;
DownloadTool?: AnimeSubscriptionDownloadTool;
Subscriptions: AnimeSubscription[];
}