Compare commits
733 Commits
12076f4cbb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecab93de84 | ||
| 7701ec5a7e | |||
| 9141fe4f09 | |||
| 9026f96b04 | |||
| 0c413782e6 | |||
| 4ecd9599c7 | |||
| dbc675d79b | |||
| 6040e3254e | |||
| b16b3edf4d | |||
| 2bfeb6325c | |||
| 76cee8ce21 | |||
| 2565cdfcc7 | |||
| 2c6e03eee6 | |||
| 5da2769288 | |||
| 6ad6d8b197 | |||
| 775ca09389 | |||
| 5c8f1d6359 | |||
| ce91822a25 | |||
| d55a9087eb | |||
| 496aea9d0d | |||
| f940c678d6 | |||
| 63a404bf48 | |||
| 201d3479cd | |||
| 3c50fc5d53 | |||
| 3dfbcdb815 | |||
| 6a039dc9ac | |||
| 3515476374 | |||
| 4c7abea589 | |||
| 3b53bde103 | |||
| 648eb568ff | |||
| 2724f0f7ed | |||
| e40e657d60 | |||
| 7e26f2ee77 | |||
| 9a0a6d74bb | |||
| 454b5a0cb3 | |||
| e48c719a68 | |||
| fe2f5be812 | |||
| 18861593f8 | |||
| a014ad40a9 | |||
| 0d53d5efdc | |||
| 546ab66b1a | |||
| c1e8cf63b4 | |||
| e333ae36e8 | |||
| 01564ffd52 | |||
| 1250c591b7 | |||
| 3d76046762 | |||
| 66cd131756 | |||
| a1aa5d2540 | |||
| b5281df6a5 | |||
| e87af49dff | |||
| a9a00dbf3b | |||
| 86d0c2b5c0 | |||
| 963f6e925b | |||
| 216febc02d | |||
| 680d2a1a33 | |||
| de2488216c | |||
| 7e850ec740 | |||
| dacd1b300a | |||
| cdf322602f | |||
| fb8433a435 | |||
| 4bb9caa972 | |||
| 34c1cfa084 | |||
| 45e69dd38d | |||
| b0bebec656 | |||
| cf641ce79b | |||
| 81e1b861b5 | |||
| ce64efaf5f | |||
| 8ebaac758c | |||
| b793566221 | |||
| 23fa885053 | |||
| 4ca27faf08 | |||
| c0e2e7f8fb | |||
| ebb5e59134 | |||
| 379ade5fd4 | |||
| f59aca5e92 | |||
| 7ad0b74730 | |||
| c3bd8840b7 | |||
| 0afb4e4c6d | |||
| b7e06810c6 | |||
| 2cc6eb3224 | |||
| cc071ce9a7 | |||
| 65d5d1774c | |||
| a602fa085b | |||
| 3b39b1abce | |||
| bb83966491 | |||
| cffaa143a9 | |||
| ce3571c88b | |||
| 14d08e93b3 | |||
| 7050ef3cb7 | |||
| 4d4ee7bd58 | |||
| ff710a354c | |||
| 292f779ee8 | |||
| 69d7cad5c1 | |||
| 4815080ec9 | |||
| 445e37c2d8 | |||
| b1cbc5d3fe | |||
| d1d6ea9f24 | |||
| d3e294b7c9 | |||
| 0d343dfff9 | |||
| 967c897300 | |||
| 9054f43a11 | |||
| ffc08ccb9c | |||
| e66432ac0a | |||
| 4b739ac149 | |||
| 239dd501aa | |||
| 9c8c9c9d3c | |||
| 9d82a6dce8 | |||
| 9ca3eb0a27 | |||
| 8cd9ac94e9 | |||
| a1582226ef | |||
| 3228b5cfa6 | |||
| 327af5f75a | |||
| 6832625260 | |||
| 3404dfe511 | |||
| 8f1fae8141 | |||
| a91d0cd87b | |||
| fc56734a74 | |||
| 7ab263ae2d | |||
| 8219e83135 | |||
| 4c5d52dfee | |||
| 5234d567c3 | |||
| 6df9c1b9f6 | |||
| c5bd09623e | |||
| 193fede0ab | |||
| 55eeb052e5 | |||
| 7d68095d87 | |||
| 41b5246111 | |||
| 95db00f389 | |||
| 85d986039b | |||
| 3aa25aeef3 | |||
| f91d9733a1 | |||
| 64eb94f128 | |||
| 32bcb1a188 | |||
| 72facaad68 | |||
| 9b251d5191 | |||
| 077499cf9e | |||
| 4835cf9835 | |||
| 77b9802751 | |||
| e64ce1dc47 | |||
| c732d86018 | |||
| 82cee146de | |||
| 171a45c015 | |||
| 3fe8059e77 | |||
| 1de75db825 | |||
| c0808fe5f3 | |||
| 30a23dae5e | |||
| c29f6fad92 | |||
| 511bf8338d | |||
| d319be4492 | |||
| 64de95cdee | |||
| 8e9d2586e1 | |||
| 9b3f972766 | |||
| d2b8649af2 | |||
| 76373faf8f | |||
| d3241fc146 | |||
| 363a125e31 | |||
| 9d8b09a9a7 | |||
| 9a961d9815 | |||
| c20a22b2a8 | |||
| 81cc3e2d0b | |||
| e91120dd63 | |||
| f0f9337c31 | |||
| c045e00b40 | |||
| 20ee50c2b9 | |||
| 8af1808d4a | |||
| ee90a78adf | |||
| e7aca4afb8 | |||
| c88833feb1 | |||
| 4cb1bc1179 | |||
| 2593a45cc3 | |||
| 2dca69c9f4 | |||
| 6e29cb59ef | |||
| b39add4362 | |||
| 6f6d09e24b | |||
| 584754c0ca | |||
| 87eb4c6403 | |||
| 9ae57ad2b1 | |||
| 734b59f760 | |||
| 353adb3eed | |||
| 39e96ec073 | |||
| 5397759192 | |||
| 921c476b5b | |||
| d696981821 | |||
| 60fd2fe90c | |||
| 74d3a6d7e7 | |||
| f1573ce802 | |||
| 31308b20ab | |||
| 31010ed51c | |||
| f31dc1dc9e | |||
| 51f1c60c81 | |||
| 57604d5be6 | |||
| e38760d62d | |||
| 5ddfd78240 | |||
| dcf506f94d | |||
| b634c950d0 | |||
| c1125ee44c | |||
| 54439bccd1 | |||
| 8380f32228 | |||
| 9549fda1b1 | |||
| 41ee7a1d72 | |||
| 890ab5e3f3 | |||
| 3c7c22310d | |||
| e784d7d2a8 | |||
| 3430541aef | |||
| a00d854062 | |||
| 226bb69709 | |||
| ec78f11b2e | |||
| 8d1c1640ce | |||
| e11a15383c | |||
| 2f035ebdd9 | |||
| fed837f868 | |||
| 98f6b1c6cf | |||
| 1828306c27 | |||
| 04521675ed | |||
| 1917b22e77 | |||
| 7930ece337 | |||
| 7eb51e853f | |||
| 0957329c41 | |||
| b99acf719b | |||
| 9571310cfc | |||
| 30ba627016 | |||
| 2705244dcb | |||
| b73f96fa0b | |||
| c85977c728 | |||
| c6d11d83b9 | |||
| bcf9d48d8e | |||
| 8909fb9229 | |||
| 1c24dc221f | |||
| 0acefe636e | |||
| 0c685e6c09 | |||
| 10fafcc848 | |||
| 690bd6a82e | |||
| d994647e62 | |||
| 1c21474ff6 | |||
| c668914edd | |||
| 087ff429ab | |||
| 6bf91f293b | |||
| f137e6be58 | |||
| 2ccb23abf1 | |||
| 69a1fe3707 | |||
| ce41785ffa | |||
| 9e8e49691c | |||
| 86206127d6 | |||
| 6248cd75e9 | |||
| 3dcfc6157e | |||
| bb37b8e18a | |||
| e1ab6e714e | |||
| bda3c58a98 | |||
| 9e0f2231b5 | |||
| aed61b8b61 | |||
| dcefb08cdb | |||
| 16ba3d25ba | |||
| ff1ce6588a | |||
| 99d5d89fe1 | |||
| ac91bd945e | |||
| 59e25d414c | |||
| 8b4963e1c2 | |||
| ab268ab698 | |||
| 7c636455c1 | |||
| 1c286e0194 | |||
| 2f41e95864 | |||
| d8f51a74f8 | |||
| 1f159edf07 | |||
| ff24e85cd8 | |||
| f478de537e | |||
| 7fb6309a25 | |||
| cdcc21c6c6 | |||
| 2eae804dad | |||
| eaabb28b23 | |||
| bb8aac06eb | |||
| d4e6de9e98 | |||
| ed3c50f452 | |||
| 5788216bb6 | |||
| 4557d8552c | |||
| 795bbe825f | |||
| 43a1fff446 | |||
| 2ec1cdec38 | |||
| 2a8294c405 | |||
| 3a1a2129d9 | |||
| 0cd47ab0fe | |||
| 262dc6e406 | |||
| 34d26c7ecb | |||
| bb5ec87654 | |||
| 8c146fa06e | |||
| bc7a3f58cf | |||
| 8f0549b290 | |||
| a83377671e | |||
| ac33f1c0dd | |||
| 656ddbd005 | |||
| dc2366cbcc | |||
| 1d531ab181 | |||
| 06b50509e8 | |||
| 0d1ae305b5 | |||
| e545ef1a06 | |||
| a5a8df096a | |||
| 5f531aa771 | |||
| 5be8bce461 | |||
| 966eced0f8 | |||
| e170d81652 | |||
| ca08af2dbb | |||
| 290dc36298 | |||
| ff54e9c5db | |||
| 7aaead6c67 | |||
| b569b06591 | |||
| 4d8486e6ea | |||
| 1770492b00 | |||
| a37e609880 | |||
| 510549c6ec | |||
| 99fa808d30 | |||
| 8e43731d1f | |||
| 51d26943df | |||
| f0ad92a8f9 | |||
| f39fcacadc | |||
| f4486655d1 | |||
| 9d8a497c48 | |||
| c3b3c606db | |||
| c70ec383c5 | |||
| 50e74326c5 | |||
| 71ab6a3abd | |||
| c9bdc4a75e | |||
| 7c25907c92 | |||
| c1e313d684 | |||
| d2a3b0ccda | |||
| e7fb4264f7 | |||
| a2d16caea0 | |||
| e836d464cb | |||
| 22f05580df | |||
| 641f97fb8e | |||
| 12b72b227d | |||
| eaabdc5475 | |||
| 941a282d3f | |||
| 622418f96c | |||
| ec10fa56b4 | |||
| 31a8da10b4 | |||
| 3c30688058 | |||
| 2a04876754 | |||
| 9e25745804 | |||
| 4f73b0ca97 | |||
| 1e4a5612e8 | |||
| 2146876f24 | |||
| b88a859b66 | |||
| aa9375eff2 | |||
| 0a483ad2a2 | |||
| 8224934046 | |||
| 57a2ff874a | |||
| 5a0c8b7476 | |||
| 82e850070c | |||
| a1c5726eee | |||
| fda2346d9a | |||
| 0bde5ac778 | |||
|
|
84e4ddefa2 | ||
| 8fd7c1104c | |||
| 6841f5c55a | |||
| 3e100c1a97 | |||
| 4a74fdcf31 | |||
| f9f3322797 | |||
| c891382efb | |||
| ef36578c4b | |||
| 20aadd36f8 | |||
| 5dcf39c401 | |||
| 7b56f587e5 | |||
| 43d31865ed | |||
| 3668ccb541 | |||
| 7bf0ffbd06 | |||
| 08a16f3302 | |||
| dcebe90620 | |||
| d28b187ac0 | |||
| c57ecf3d4b | |||
| d2528ba4f1 | |||
| c8112e5062 | |||
| 0d7c572f2c | |||
| 5dbb04dbdd | |||
| ff1cd7ce4a | |||
| 4ac155c8cc | |||
| e3d82389e4 | |||
| f99b30bf43 | |||
| 21a1965fdd | |||
| fdb79633df | |||
| 4876995652 | |||
| 40be6d3132 | |||
| 6a256a20c5 | |||
| 9e8fb5c033 | |||
| 84a967856b | |||
| 639f8f424f | |||
| 9fcdd36c5e | |||
| 04c0b8d601 | |||
| b578bd661e | |||
| e2d9ecfb03 | |||
| d6f1c37ac3 | |||
| 837b99bc58 | |||
| e1ddd59417 | |||
| ec5a17c392 | |||
| 19c5f7ef1f | |||
| 5a703bc323 | |||
| 1a65ef2a9c | |||
| 263bfafd04 | |||
| 7523215a71 | |||
| ea411e5feb | |||
| aced7bb5d9 | |||
| 195d8c0e60 | |||
| bcc75467f0 | |||
| a922953776 | |||
| bcd4106dce | |||
| b519706429 | |||
| 6ac9e38423 | |||
| e13022c7d4 | |||
| c82d25d9c8 | |||
| 55bd2a2582 | |||
| 443dbeda77 | |||
| b53a58905d | |||
| e393d2759b | |||
| df4867c60d | |||
| b281acdf88 | |||
| ef1cd20f0b | |||
| 66faa1a13f | |||
| a0bfe9f889 | |||
| e44d64a651 | |||
| 4256480e0c | |||
| a976769cdd | |||
| 8304c0a338 | |||
| 6918f0bf48 | |||
| 997957a232 | |||
| bd268ead10 | |||
| 18f9ec2a95 | |||
| 168fea8ab5 | |||
| b4e2930112 | |||
| 370dec5f3b | |||
| b11af766f2 | |||
| 7d55aa7837 | |||
| fb03d73e66 | |||
| 012bfee03d | |||
| c47ffcb5be | |||
| b1afd2ef82 | |||
| f2213bd4aa | |||
| 70a6e9a6b5 | |||
| 9b7a2cac8f | |||
| bf85c3b018 | |||
| 26a8878fc2 | |||
| a09ff85ff8 | |||
| c4a7151d99 | |||
| 4ba1944f70 | |||
| 45b4a01801 | |||
| ffe42a352b | |||
| 1f2fd4f53d | |||
| 35a367d569 | |||
| 36c0e87ae8 | |||
| 18ed806fc0 | |||
| 164232cf0d | |||
| ea587665f2 | |||
| fa88badc69 | |||
| 4c4c10b154 | |||
| 97814b7223 | |||
| c509144b30 | |||
| ab9d585a1f | |||
| de9bcb5e40 | |||
| b607b091d5 | |||
| 15ad54a847 | |||
| 3ae09d4014 | |||
| 90ae58b99e | |||
| c252739610 | |||
| 3c2e6a6984 | |||
| 25471e0bd5 | |||
| ed90b5c7aa | |||
| 1485800c32 | |||
| faae7bc719 | |||
| acabd50970 | |||
| 6ba387bb6a | |||
| 3d13cf9be8 | |||
| 7f05f026e9 | |||
| 2ed03a667b | |||
| 2e79c32afe | |||
| 7968fb57f6 | |||
| ba578d969a | |||
| 5998b59e81 | |||
| c5acc63370 | |||
| b0769ddce7 | |||
| 1c86f802b4 | |||
| 97dcb19b7d | |||
| 8e4ce81232 | |||
| f360e22beb | |||
| 27c84a9603 | |||
| a925cc069e | |||
| 76af597f4d | |||
| 0227c8688b | |||
| 188eec58a2 | |||
| 233472b14d | |||
| 7265dec446 | |||
| 1ad3be5160 | |||
| 01ee9b1022 | |||
| e77debb085 | |||
| c575bfae47 | |||
| 0262f22876 | |||
| e04b11f97f | |||
| 55095791c7 | |||
| 983d805240 | |||
| 0beec5fd56 | |||
| 650fac1c90 | |||
| 870f8086e2 | |||
| 7af597d8fc | |||
| b72bace16a | |||
| de939cc5f3 | |||
| 4d6736a439 | |||
| 02bbc6c4d4 | |||
| 5ada1f72e4 | |||
| 4b95f85d4d | |||
| c36f02862d | |||
| 704058a512 | |||
| 9b19661fa3 | |||
| ca957b5cdc | |||
| 03ccd54c85 | |||
| c70adbd0ec | |||
| 5f346d8dec | |||
| 3ade952653 | |||
| 37d7e0f6f0 | |||
| f32bcf1288 | |||
| 7f98fbfa7a | |||
| 827b77cb20 | |||
| b67727c21c | |||
| 470039d9e9 | |||
| ea518a7d0a | |||
| bd89715ea0 | |||
| 49512a6708 | |||
| 070375eaa5 | |||
| 1d4364d63e | |||
| 15876a4f86 | |||
| 1a35bd81bd | |||
| 21fd1110d4 | |||
| f8cf4579af | |||
| 1a1189d035 | |||
| db4dc20603 | |||
| a4fa0beff5 | |||
| 39df0ff99a | |||
| 80a3481ebe | |||
| 6efea21632 | |||
| 4c90f759c9 | |||
| 470f9e3532 | |||
| e355933ba8 | |||
| 102317c9b0 | |||
| cd7fab7fbd | |||
| 7f6d2c82cb | |||
| bc9820c536 | |||
| f90ff2e4c7 | |||
| 79be865989 | |||
| 18a335fd74 | |||
| 082219d2d4 | |||
| b661b577dd | |||
| fb6e48cf92 | |||
| a6cb71c65b | |||
| e70574ac08 | |||
| f9064b3b6c | |||
| 4b1b4266d9 | |||
| f7e7dfd161 | |||
| 651db05cd0 | |||
| 470e5b092b | |||
| e06a20b5d0 | |||
| fe46dd9c48 | |||
| cb8ef29cde | |||
| 03e741c561 | |||
| 9cb3e8fe27 | |||
| b9ca82dbd9 | |||
| 5441b14737 | |||
| 5cc03579b2 | |||
| b5fc2dfe4e | |||
| 78b36452ae | |||
| 392bc10b99 | |||
| 5019e9fcb7 | |||
| 4bcfc8fdb7 | |||
| b85b29aa13 | |||
| ede17ce8aa | |||
| 9d964824dc | |||
| 620434f61b | |||
| 6aeb887830 | |||
| 24bc63e8e2 | |||
| 4791eebf48 | |||
| 6b43fa7ce5 | |||
| 60ba1a4fb5 | |||
| 3ea5ea68ff | |||
| 97623aad4d | |||
| 9587dd5a71 | |||
| 8b26e5f036 | |||
| b4061bc9b1 | |||
| e326f89d62 | |||
| 55ee13d4eb | |||
| 356ac99c64 | |||
| 9d58adea9c | |||
| a8a53d2677 | |||
| 51ee38bb57 | |||
| 8ae79c301a | |||
| c725d96035 | |||
| ede479c3e1 | |||
| 390f6386af | |||
| 3fe1135203 | |||
| 342bd096da | |||
| 404fa3c406 | |||
| 8b3bd30b6c | |||
| 0c4b35cc4b | |||
| b639e933ff | |||
| 59d903d400 | |||
| 4316ce3f1d | |||
| 5604432187 | |||
| 0483bc5cc1 | |||
| 983981a186 | |||
| 55bf11d8be | |||
| 455490f07d | |||
| 36435b6eb5 | |||
| 340daeadc6 | |||
| 625c3bbe25 | |||
| d5406a6857 | |||
| 9f754012eb | |||
| 58036bea5a | |||
| 25a8167461 | |||
| 70be78fd7b | |||
| fbd2c5b602 | |||
| bfe23276ba | |||
| 208281aee7 | |||
| 7943822194 | |||
| d9ed4287a5 | |||
| e907c7ae07 | |||
| 957905299e | |||
| a865da79d4 | |||
| 156cb92fbe | |||
| 1861e20e2a | |||
| e146b0320a | |||
| fdd09bc004 | |||
| 10c3923352 | |||
| b862b6e08b | |||
| 5e553ceecc | |||
| 475625de35 | |||
| f4b3d1bccb | |||
| 7f45e62dce | |||
| 2b761127a0 | |||
| 1da19d500e | |||
| 2e3650b77b | |||
| 9321f36a0f | |||
| 77acc627dc | |||
| 6929124ee3 | |||
| 0b27974258 | |||
| dd38b1f7ba | |||
| c94e0699f3 | |||
| cfb0ea724d | |||
| 32586d6b08 | |||
| aebdd75942 | |||
| f89012f23c | |||
| 1242297742 | |||
| e8dcf1466b | |||
| 54b03f85a2 | |||
| 5dd49e585a | |||
| 04b241392c | |||
| fd36b97908 | |||
| f9a2649bec | |||
| dc09dcc547 | |||
| 271a24dbbe | |||
| 363121465b | |||
| cf9c60ba70 | |||
| cdebd407e4 | |||
| 82543d39fb | |||
| c000e7c778 | |||
| 65a1d15383 | |||
| 7122a5d34d | |||
| 0b115e583d | |||
| 589bf53597 | |||
| 6e2ba51c28 | |||
| 8b405845a1 | |||
| ceec637a43 | |||
| 21b84d7440 | |||
| 7cdbcd7c04 | |||
| 68462d5591 | |||
| 9a0506913c | |||
| 221155bed3 | |||
| f9543d0d79 | |||
| d0d115cc93 | |||
| f392610b4e | |||
| 3bbcc71460 | |||
| cb51800ae3 | |||
| b29fb5a3d6 | |||
| 9695d7772d | |||
| 866d293419 | |||
| 91f6ba9db8 | |||
| 3c1e4d34a9 | |||
| 186ea65545 | |||
| 3b930c5b79 | |||
| 0f96ec0c18 | |||
| 0dbe4e75bc | |||
| 05b9dfd216 | |||
| f7e5f46234 | |||
| fe0de5a214 | |||
| dd4c7f80f3 | |||
| 4329bce4a7 | |||
| 6cc25af18a | |||
| 3e67602e92 | |||
| 794eb8da27 | |||
| b52cd311a5 | |||
| a48d48f5ad | |||
| 8a21dadf21 | |||
| a0c5005937 | |||
| 606df97eae | |||
| fab242736d | |||
| 15d311ace6 | |||
| 47b96107a5 | |||
| 2f88c14620 | |||
| 4a3e2e19d8 | |||
| a4cf0375b7 | |||
| 9f88e48786 | |||
| 8578bdb9e3 | |||
| 4b883c6572 | |||
| 9375cf68b4 | |||
| c2931f941a | |||
| 41128bd632 | |||
| 2823c6f026 | |||
| fa7fe2f178 | |||
| 40204f04a1 | |||
| 6868722061 | |||
| 049c78ac06 | |||
| eaeb2d09ee | |||
| 650a415c2d | |||
| a9b20dff4c | |||
| 28dc915a8d | |||
| 7b23d3f4c1 | |||
| 843b98db5b | |||
| 3233894e6a | |||
| dd482da9aa | |||
| ef52daf3fa | |||
| 98e6ca64d1 | |||
| 4aa12e9fe5 | |||
| bb1eb8cb10 | |||
| 1076fa58b7 | |||
| 69cfac8c9f | |||
| 0ebe6e5963 | |||
| 7e77f57a6f | |||
| ab37268e8b | |||
| 5dd6eedc3f | |||
| c044ebdda0 | |||
| c8e0c673ca |
34
.air.toml
Normal file
34
.air.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
cmd = "just build-dev"
|
||||||
|
entrypoint = "./tmp/server"
|
||||||
|
full_bin = "./tmp/server"
|
||||||
|
delay = 300
|
||||||
|
exclude_dir = [".git", ".mise", "dist", "node_modules", "tmp"]
|
||||||
|
exclude_file = ["mal.db", "mal.db-shm", "mal.db-wal"]
|
||||||
|
exclude_regex = ["_test\\.go"]
|
||||||
|
exclude_unchanged = true
|
||||||
|
follow_symlink = false
|
||||||
|
include_ext = ["css", "go", "gohtml", "html", "sql", "toml", "ts"]
|
||||||
|
kill_delay = "500ms"
|
||||||
|
log = "air-build.log"
|
||||||
|
send_interrupt = true
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = "white"
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
silent = false
|
||||||
|
time = true
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
|
startup_banner = ""
|
||||||
@@ -2,7 +2,13 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
*.db
|
*.db
|
||||||
|
*.db-shm
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
server
|
||||||
|
main_server
|
||||||
|
create_user
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.git
|
.git
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,11 +5,13 @@ node_modules
|
|||||||
out
|
out
|
||||||
dist
|
dist
|
||||||
*.tgz
|
*.tgz
|
||||||
dist/
|
|
||||||
|
|
||||||
# code coverage
|
# code coverage
|
||||||
coverage
|
coverage
|
||||||
*.lcov
|
*.lcov
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
@@ -3,15 +3,58 @@ version: "2"
|
|||||||
linters:
|
linters:
|
||||||
default: none
|
default: none
|
||||||
enable:
|
enable:
|
||||||
|
- bodyclose
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
|
- cyclop
|
||||||
|
- dogsled
|
||||||
|
- dupl
|
||||||
- errcheck
|
- errcheck
|
||||||
|
- funlen
|
||||||
|
- gocognit
|
||||||
|
- gocritic
|
||||||
|
- gocyclo
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
|
- maintidx
|
||||||
|
- makezero
|
||||||
|
- nakedret
|
||||||
|
- nilerr
|
||||||
|
- noctx
|
||||||
|
- prealloc
|
||||||
|
- predeclared
|
||||||
- revive
|
- revive
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unconvert
|
- unconvert
|
||||||
|
- unparam
|
||||||
- unused
|
- unused
|
||||||
|
- usestdlibvars
|
||||||
|
- wastedassign
|
||||||
|
- whitespace
|
||||||
settings:
|
settings:
|
||||||
|
gocritic:
|
||||||
|
disable-all: true
|
||||||
|
enabled-checks:
|
||||||
|
- appendCombine
|
||||||
|
- boolExprSimplify
|
||||||
|
- commentedOutCode
|
||||||
|
- commentedOutImport
|
||||||
|
- deferUnlambda
|
||||||
|
- dupBranchBody
|
||||||
|
- dupImport
|
||||||
|
- dupSubExpr
|
||||||
|
- emptyDecl
|
||||||
|
- emptyFallthrough
|
||||||
|
- emptyStringTest
|
||||||
|
- equalFold
|
||||||
|
- redundantSprint
|
||||||
|
- regexpPattern
|
||||||
|
- stringConcatSimplify
|
||||||
|
- typeUnparen
|
||||||
|
- underef
|
||||||
|
- unlambda
|
||||||
|
- unnecessaryBlock
|
||||||
|
- unnecessaryDefer
|
||||||
|
- unslice
|
||||||
revive:
|
revive:
|
||||||
enable-all-rules: false
|
enable-all-rules: false
|
||||||
rules:
|
rules:
|
||||||
@@ -39,7 +82,7 @@ linters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
- node_modules$
|
- node_modules/
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
6
.mise.toml
Normal file
6
.mise.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[tools]
|
||||||
|
go = "1.25.7"
|
||||||
|
bun = "1.3.14"
|
||||||
|
just = "1.53.0"
|
||||||
|
golangci-lint = "2.12.2"
|
||||||
|
"go:github.com/air-verse/air" = "latest"
|
||||||
@@ -1,4 +1,56 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
"ignorePatterns": []
|
"arrowParens": "always",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"ignorePatterns": ["dist/**", "node_modules/**", "README.md", "static/assets/manifest.json"],
|
||||||
|
"insertFinalNewline": true,
|
||||||
|
"jsdoc": true,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"objectWrap": "collapse",
|
||||||
|
"printWidth": 100,
|
||||||
|
"proseWrap": "always",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"semi": true,
|
||||||
|
"singleAttributePerLine": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"sortImports": {
|
||||||
|
"groups": [
|
||||||
|
"side_effect_style",
|
||||||
|
"side_effect",
|
||||||
|
{ "newlinesBetween": true },
|
||||||
|
"type",
|
||||||
|
"builtin",
|
||||||
|
"external",
|
||||||
|
["internal", "subpath"],
|
||||||
|
["parent", "sibling", "index"],
|
||||||
|
"style",
|
||||||
|
"unknown"
|
||||||
|
],
|
||||||
|
"ignoreCase": false,
|
||||||
|
"internalPattern": ["~/**", "@/**", "#/**"],
|
||||||
|
"newlinesBetween": true,
|
||||||
|
"order": "asc",
|
||||||
|
"partitionByComment": false,
|
||||||
|
"partitionByNewline": false,
|
||||||
|
"sortSideEffects": false
|
||||||
|
},
|
||||||
|
"sortPackageJson": { "sortScripts": true },
|
||||||
|
"sortTailwindcss": {
|
||||||
|
"attributes": ["class"],
|
||||||
|
"functions": ["clsx", "cn", "cva", "tw"],
|
||||||
|
"preserveDuplicates": false,
|
||||||
|
"preserveWhitespace": false,
|
||||||
|
"stylesheet": "./static/assets/style.css"
|
||||||
|
},
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false,
|
||||||
|
"overrides": [
|
||||||
|
{ "files": ["*.md", "**/*.md"], "options": { "proseWrap": "always" } },
|
||||||
|
{ "files": ["*.json", "**/*.json"], "options": { "printWidth": 120 } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
209
.oxlintrc.json
209
.oxlintrc.json
@@ -1,15 +1,208 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
"plugins": ["typescript", "unicorn", "oxc"],
|
"plugins": ["eslint", "import", "typescript", "unicorn", "oxc", "promise", "node"],
|
||||||
"categories": {
|
"categories": {
|
||||||
"correctness": "error"
|
"correctness": "error",
|
||||||
|
"nursery": "error",
|
||||||
|
"pedantic": "error",
|
||||||
|
"perf": "error",
|
||||||
|
"restriction": "off",
|
||||||
|
"style": "error",
|
||||||
|
"suspicious": "error"
|
||||||
},
|
},
|
||||||
|
"options": {
|
||||||
|
"denyWarnings": true,
|
||||||
|
"maxWarnings": 0,
|
||||||
|
"reportUnusedDisableDirectives": "error",
|
||||||
|
"respectEslintDisableDirectives": true,
|
||||||
|
"typeAware": true,
|
||||||
|
"typeCheck": true
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist/**", "node_modules/**", "static/assets/**"],
|
||||||
|
"env": { "browser": true, "builtin": true, "es2026": true, "node": true },
|
||||||
"rules": {
|
"rules": {
|
||||||
"typescript/unbound-method": "off",
|
"import/exports-last": "off",
|
||||||
"typescript/no-base-to-string": "off",
|
"import/group-exports": "off",
|
||||||
"typescript/no-floating-promises": "off"
|
"import/no-default-export": "off",
|
||||||
|
"import/no-mutable-exports": "error",
|
||||||
|
"import/no-named-export": "off",
|
||||||
|
"import/no-named-default": "error",
|
||||||
|
"import/no-self-import": "error",
|
||||||
|
"import/no-unassigned-import": "off",
|
||||||
|
"import/no-relative-parent-imports": "off",
|
||||||
|
"capitalized-comments": "off",
|
||||||
|
"curly": "error",
|
||||||
|
"id-length": "off",
|
||||||
|
"max-lines": "off",
|
||||||
|
"max-lines-per-function": "off",
|
||||||
|
"max-statements": "off",
|
||||||
|
"no-console": "error",
|
||||||
|
"no-debugger": "error",
|
||||||
|
"no-empty-function": "error",
|
||||||
|
"no-eval": "error",
|
||||||
|
"no-implicit-coercion": "error",
|
||||||
|
"no-magic-numbers": "off",
|
||||||
|
"no-negated-condition": "off",
|
||||||
|
"no-param-reassign": "error",
|
||||||
|
"no-plusplus": "off",
|
||||||
|
"no-process-exit": "error",
|
||||||
|
"no-restricted-globals": [
|
||||||
|
"error",
|
||||||
|
{ "name": "event", "message": "Use the event parameter instead of the legacy global." },
|
||||||
|
{ "name": "name", "message": "Avoid the ambiguous window.name global." }
|
||||||
|
],
|
||||||
|
"no-ternary": "off",
|
||||||
|
"no-undefined": "off",
|
||||||
|
"no-use-before-define": "off",
|
||||||
|
"no-warning-comments": "warn",
|
||||||
|
"oxc/no-async-await": "off",
|
||||||
|
"oxc/no-barrel-file": "off",
|
||||||
|
"oxc/no-optional-chaining": "off",
|
||||||
|
"oxc/no-rest-spread-properties": "off",
|
||||||
|
"sort-imports": "off",
|
||||||
|
"sort-keys": "off",
|
||||||
|
"typescript/array-type": ["error", { "default": "array-simple" }],
|
||||||
|
"typescript/consistent-type-definitions": ["error", "type"],
|
||||||
|
"typescript/consistent-type-exports": "error",
|
||||||
|
"typescript/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{ "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports", "prefer": "type-imports" }
|
||||||
|
],
|
||||||
|
"typescript/explicit-function-return-type": "off",
|
||||||
|
"typescript/explicit-member-accessibility": "error",
|
||||||
|
"typescript/explicit-module-boundary-types": "off",
|
||||||
|
"typescript/no-base-to-string": "error",
|
||||||
|
"typescript/no-confusing-non-null-assertion": "error",
|
||||||
|
"typescript/no-explicit-any": "error",
|
||||||
|
"typescript/no-floating-promises": "error",
|
||||||
|
"typescript/no-inferrable-types": "error",
|
||||||
|
"typescript/no-invalid-void-type": "error",
|
||||||
|
"typescript/no-misused-promises": "error",
|
||||||
|
"typescript/no-non-null-assertion": "error",
|
||||||
|
"typescript/no-unsafe-type-assertion": "off",
|
||||||
|
"typescript/no-unnecessary-condition": "error",
|
||||||
|
"typescript/no-unsafe-argument": "error",
|
||||||
|
"typescript/no-unsafe-assignment": "error",
|
||||||
|
"typescript/no-unsafe-call": "error",
|
||||||
|
"typescript/no-unsafe-member-access": "error",
|
||||||
|
"typescript/no-unsafe-return": "error",
|
||||||
|
"typescript/no-var-requires": "error",
|
||||||
|
"typescript/prefer-readonly": "error",
|
||||||
|
"typescript/prefer-readonly-parameter-types": "off",
|
||||||
|
"typescript/require-await": "error",
|
||||||
|
"typescript/restrict-plus-operands": "error",
|
||||||
|
"typescript/restrict-template-expressions": "error",
|
||||||
|
"typescript/strict-boolean-expressions": "error",
|
||||||
|
"typescript/strict-void-return": "off",
|
||||||
|
"typescript/switch-exhaustiveness-check": "error",
|
||||||
|
"typescript/unbound-method": "error",
|
||||||
|
"unicorn/filename-case": "off",
|
||||||
|
"unicorn/no-array-for-each": "off",
|
||||||
|
"unicorn/no-array-reduce": "off",
|
||||||
|
"unicorn/no-null": "off",
|
||||||
|
"unicorn/no-useless-undefined": "off",
|
||||||
|
"unicorn/prefer-global-this": "off",
|
||||||
|
"unicorn/prefer-module": "error",
|
||||||
|
"unicorn/prefer-query-selector": "error",
|
||||||
|
"unicorn/prefer-string-replace-all": "error"
|
||||||
},
|
},
|
||||||
"env": {
|
"overrides": [
|
||||||
"builtin": true
|
{
|
||||||
}
|
"files": ["static/**/*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"curly": "off",
|
||||||
|
"eqeqeq": "off",
|
||||||
|
"import/first": "off",
|
||||||
|
"import/max-dependencies": "off",
|
||||||
|
"import/no-duplicates": "off",
|
||||||
|
"import/no-named-as-default-member": "off",
|
||||||
|
"import/prefer-default-export": "off",
|
||||||
|
"init-declarations": "off",
|
||||||
|
"max-params": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-continue": "off",
|
||||||
|
"no-duplicate-imports": "off",
|
||||||
|
"no-useless-assignment": "off",
|
||||||
|
"no-inline-comments": "off",
|
||||||
|
"no-negated-condition": "off",
|
||||||
|
"no-underscore-dangle": "off",
|
||||||
|
"no-useless-return": "off",
|
||||||
|
"prefer-const": "off",
|
||||||
|
"prefer-destructuring": "off",
|
||||||
|
"require-await": "off",
|
||||||
|
"require-unicode-regexp": "off",
|
||||||
|
"promise/always-return": "off",
|
||||||
|
"promise/avoid-new": "off",
|
||||||
|
"promise/param-names": "off",
|
||||||
|
"promise/prefer-await-to-callbacks": "off",
|
||||||
|
"promise/prefer-await-to-then": "off",
|
||||||
|
"oxc/no-map-spread": "off",
|
||||||
|
"typescript/consistent-type-definitions": "off",
|
||||||
|
"typescript/explicit-member-accessibility": "off",
|
||||||
|
"typescript/no-base-to-string": "off",
|
||||||
|
"typescript/no-floating-promises": "off",
|
||||||
|
"typescript/no-inferrable-types": "off",
|
||||||
|
"typescript/no-misused-promises": "off",
|
||||||
|
"typescript/no-unnecessary-condition": "off",
|
||||||
|
"typescript/no-unnecessary-type-assertion": "off",
|
||||||
|
"typescript/no-unnecessary-type-conversion": "off",
|
||||||
|
"typescript/no-unnecessary-type-parameters": "off",
|
||||||
|
"typescript/no-unsafe-argument": "off",
|
||||||
|
"typescript/no-unsafe-assignment": "off",
|
||||||
|
"typescript/no-unsafe-call": "off",
|
||||||
|
"typescript/no-unsafe-member-access": "off",
|
||||||
|
"typescript/no-unsafe-return": "off",
|
||||||
|
"typescript/prefer-nullish-coalescing": "off",
|
||||||
|
"typescript/prefer-optional-chain": "off",
|
||||||
|
"typescript/strict-boolean-expressions": "off",
|
||||||
|
"typescript/unbound-method": "off",
|
||||||
|
"unicorn/consistent-function-scoping": "off",
|
||||||
|
"unicorn/no-array-callback-reference": "off",
|
||||||
|
"unicorn/no-lonely-if": "off",
|
||||||
|
"unicorn/no-negated-condition": "off",
|
||||||
|
"unicorn/prefer-at": "off",
|
||||||
|
"unicorn/prefer-dom-node-append": "off",
|
||||||
|
"unicorn/prefer-query-selector": "off",
|
||||||
|
"unicorn/prefer-spread": "off",
|
||||||
|
"unicorn/prefer-string-replace-all": "off",
|
||||||
|
"unicorn/require-module-specifiers": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["static/**/*.test.ts", "static/**/*.spec.ts"],
|
||||||
|
"env": { "node": true },
|
||||||
|
"rules": { "import/no-nodejs-modules": "off" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["**/*.test.ts", "**/*.spec.ts"],
|
||||||
|
"env": { "vitest": true },
|
||||||
|
"rules": { "typescript/no-explicit-any": "off" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["tests/e2e/**/*.ts"],
|
||||||
|
"env": { "browser": false, "node": true },
|
||||||
|
"rules": {
|
||||||
|
"import/no-nodejs-modules": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-duplicate-imports": "off",
|
||||||
|
"no-process-exit": "off",
|
||||||
|
"promise/prefer-await-to-then": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["scripts/**/*.ts"],
|
||||||
|
"env": { "browser": false, "node": true },
|
||||||
|
"rules": {
|
||||||
|
"import/no-nodejs-modules": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-process-exit": "off",
|
||||||
|
"promise/prefer-await-to-callbacks": "off",
|
||||||
|
"promise/prefer-await-to-then": "off",
|
||||||
|
"typescript/no-unnecessary-condition": "off",
|
||||||
|
"unicorn/no-array-sort": "off",
|
||||||
|
"unicorn/prefer-string-replace-all": "off",
|
||||||
|
"unicorn/prefer-top-level-await": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
11
.prettierrc
11
.prettierrc
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"printWidth": 100,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
55
CODE_OF_CONDUCT.md
Normal file
55
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Our Standard
|
||||||
|
|
||||||
|
This project should be a respectful, constructive place to discuss code, design decisions, issues,
|
||||||
|
and improvements. Contributions and conversations are expected to be professional, specific, and
|
||||||
|
generous in intent.
|
||||||
|
|
||||||
|
Examples of positive behavior include:
|
||||||
|
|
||||||
|
- giving feedback that is clear, actionable, and focused on the work;
|
||||||
|
- assuming good intent while still naming problems directly;
|
||||||
|
- welcoming questions from people with different experience levels;
|
||||||
|
- crediting ideas, reports, and contributions accurately;
|
||||||
|
- disagreeing without making the conversation personal.
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
- harassment, insults, threats, or discriminatory language;
|
||||||
|
- sexualized language or imagery in project spaces;
|
||||||
|
- personal attacks, trolling, or repeated disruptive comments;
|
||||||
|
- publishing private information without explicit permission;
|
||||||
|
- pressuring maintainers or contributors outside the scope of the project.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This code of conduct applies to project spaces such as issues, pull requests, discussions, commits,
|
||||||
|
reviews, and any other forum used to coordinate work on this repository. It also applies when
|
||||||
|
someone is representing the project in public.
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
If you notice behavior that violates this code of conduct, please contact the maintainer privately.
|
||||||
|
Include the relevant context, links, screenshots, or timestamps when possible so the report can be
|
||||||
|
reviewed fairly.
|
||||||
|
|
||||||
|
Reports will be handled with care and discretion. The goal is to protect contributors, keep the
|
||||||
|
project healthy, and respond proportionally to the situation.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
The maintainer may take any action needed to keep the project environment constructive, including:
|
||||||
|
|
||||||
|
- clarifying expectations in a thread;
|
||||||
|
- editing or removing inappropriate comments;
|
||||||
|
- closing or locking conversations;
|
||||||
|
- declining contributions;
|
||||||
|
- limiting or blocking future participation.
|
||||||
|
|
||||||
|
Enforcement decisions are based on the behavior, its impact, and the needs of the project community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This code of conduct is adapted from common open source community standards and tailored for this
|
||||||
|
repository.
|
||||||
18
Dockerfile
18
Dockerfile
@@ -18,9 +18,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
RUN curl -fsSL https://bun.sh/install | bash
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
ENV PATH="/root/.bun/bin:${PATH}"
|
ENV PATH="/root/.bun/bin:${PATH}"
|
||||||
|
|
||||||
# Install sqlc for code generation
|
|
||||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
|
|
||||||
|
|
||||||
ENV GOPROXY=direct
|
ENV GOPROXY=direct
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -32,14 +29,11 @@ RUN bun install --frozen-lockfile
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Ensure dist is clean at build time (belt + suspenders)
|
# Ensure dist is clean at build time (belt + suspenders)
|
||||||
RUN rm -rf dist/ && bun run build:assets
|
RUN rm -rf dist/ && bun run build:assets && bun run build:ts
|
||||||
|
|
||||||
# Generate sqlc code
|
|
||||||
RUN sqlc generate
|
|
||||||
|
|
||||||
# Build the server and CLI tools
|
# Build the server and CLI tools
|
||||||
RUN go build -ldflags="-s -w" -o main_server ./cmd/server
|
RUN go build -ldflags="-s -w" -o main_server ./cmd/server
|
||||||
RUN go build -ldflags="-s -w" -o create-user ./cmd/user
|
RUN go build -ldflags="-s -w" -o user_admin ./cmd/user
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -55,13 +49,15 @@ RUN mkdir -p /app/data
|
|||||||
ENV DATABASE_FILE=/app/data/mal.db
|
ENV DATABASE_FILE=/app/data/mal.db
|
||||||
|
|
||||||
COPY --from=builder /app/main_server .
|
COPY --from=builder /app/main_server .
|
||||||
COPY --from=builder /app/create-user .
|
COPY --from=builder /app/user_admin .
|
||||||
COPY --from=builder /app/templates ./templates
|
COPY --from=builder /app/templates ./templates
|
||||||
COPY --from=builder /app/static ./static
|
COPY --from=builder /app/static ./static
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/internal/database/migrations ./migrations
|
COPY --from=builder /app/internal/database/migrations ./migrations
|
||||||
COPY entrypoint.sh ./entrypoint.sh
|
|
||||||
|
RUN printf '%s\n' '#!/bin/sh' 'set -e' 'exec /app/user_admin "$@"' > /app/create-user \
|
||||||
|
&& chmod +x /app/create-user
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["/app/main_server"]
|
||||||
|
|||||||
196
README.md
196
README.md
@@ -1,71 +1,189 @@
|
|||||||
# MyAnimeList
|
# MyAnimeList
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<img src="/static/assets/logo.png" alt="MyAnimeList logo" width="120" />
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
|
</p>
|
||||||
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="120" />
|
|
||||||
</picture>
|
<p align="center">
|
||||||
|
<strong>A local-first anime catalog, watchlist, recommendation, and playback app.</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
|
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
|
||||||
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
|
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
|
||||||
|
<img alt="Bun" src="https://img.shields.io/badge/runtime-bun-000000?style=flat-square&logo=bun" />
|
||||||
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
|
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
|
||||||
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
|
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
|
||||||
|
<img alt="License" src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
MyAnimeList is a self-hosted media app for browsing anime, managing a watchlist, resuming episodes,
|
||||||
|
and playing streams through a browser-based player. It collects the parts of an anime workflow that
|
||||||
|
usually live across several products and keeps them in one small Go application backed by SQLite.
|
||||||
|
|
||||||
I built this because nothing else felt right. Every tracker I tried had decent pieces but the whole never clicked — awkward UI, missing features, or it just got in the way of actually watching anime. So I built one that fits how I work.
|
I built it as a portfolio project, but the goal was never to make a disposable demo. The interesting
|
||||||
|
part of the project is the product shape: server-rendered pages, a local database, provider
|
||||||
|
integrations, playback proxying, recommendations, migrations, tests, and a TypeScript player that
|
||||||
|
only appears where browser state actually earns its place.
|
||||||
|
|
||||||
It is a self-hosted Go server that streams anime through a proxy layer, catalogs metadata, and tracks your progress.
|
> [!NOTE]
|
||||||
|
> This is a personal, local-first project. It is written to demonstrate product engineering choices,
|
||||||
|
> not to present itself as an official MyAnimeList client or a hosted streaming platform.
|
||||||
|
|
||||||
The frontend is Tailwind CSS v4 with HTMX handling pagination, infinite scroll, search, and watchlist interactions. TypeScript only steps in where HTMX cannot — the video player, command palette bound to Cmd+K, skip segment editor, theme toggling with system preference detection, and custom UI components. Everything lives in one process, one SQLite database, one deployment.
|
### Contents
|
||||||
|
|
||||||
## Repository structure
|
- [What This Project Is](#what-this-project-is)
|
||||||
|
- [What It Includes](#what-it-includes)
|
||||||
|
- [How It Is Built](#how-it-is-built)
|
||||||
|
- [Working Locally](#working-locally)
|
||||||
|
- [Repository Map](#repository-map)
|
||||||
|
|
||||||
| Path | Purpose |
|
### What This Project Is
|
||||||
| ----------------- | ------------------------------------------------ |
|
|
||||||
| `api/*` | Feature routes: anime, auth, playback, watchlist |
|
|
||||||
| `cmd/server` | Application entrypoint and CLI commands |
|
|
||||||
| `cmd/user` | User management CLI (create, update, delete) |
|
|
||||||
| `integrations/*` | External API clients and scraping |
|
|
||||||
| `internal/*` | Core services: db, middleware, server, worker |
|
|
||||||
| `pkg/middleware` | Generic HTTP middleware |
|
|
||||||
| `templates/*` | Server-rendered HTML templates |
|
|
||||||
| `migrations` | Schema evolution (20 migrations) |
|
|
||||||
| `static` / `dist` | Frontend assets |
|
|
||||||
|
|
||||||
## Running locally
|
This project started from a simple idea: anime tracking becomes more interesting when catalog data,
|
||||||
|
personal progress, and playback live in the same interface. A user should be able to discover a
|
||||||
|
title, inspect its metadata, add it to a watchlist, watch an episode, come back later, and continue
|
||||||
|
from the right place without stitching that flow together manually.
|
||||||
|
|
||||||
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just). Migrations run on startup. Configuration lives in environment variables — see `cmd/server/main.go` for the full list.
|
That makes the app a useful playground for real application concerns. It has authentication,
|
||||||
|
long-lived user state, external APIs, background refresh behavior, migrations, data fixes, cache
|
||||||
|
boundaries, provider-specific code, and enough frontend complexity to justify TypeScript without
|
||||||
|
turning the whole product into a single-page app.
|
||||||
|
|
||||||
An optional API key from [animeschedule.net](https://animeschedule.net) can be used for the schedule board to enable English titles and improve performance. Create an account, generate a token under your profile, and set it as `ANIMESCHEDULE_API_TOKEN`.
|
The project is also intentionally modest. It uses a single Go server and a SQLite database because
|
||||||
|
those choices make the system easy to run, inspect, and reason about. The architecture is more about
|
||||||
|
clear ownership than novelty: feature packages own their handlers and services, integrations stay at
|
||||||
|
the edges, and the UI is mostly rendered by the server.
|
||||||
|
|
||||||
|
### What It Includes
|
||||||
|
|
||||||
|
| Area | What it does |
|
||||||
|
| --------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Catalog | Browse, search, and inspect anime metadata from external catalog sources. |
|
||||||
|
| Details | Render synopsis, reviews, characters, statistics, relations, themes, and watch-order data. |
|
||||||
|
| Watchlist | Store local user state for saved titles, statuses, and progress-driven flows. |
|
||||||
|
| Playback | Serve watch pages, proxy streams/subtitles, rewrite playlists, and track progress. |
|
||||||
|
| Player | Handle HLS playback, quality selection, subtitles, keyboard controls, episode navigation, and skip segments. |
|
||||||
|
| Recommendations | Generate personal top picks from watchlist signals and recommendation data. |
|
||||||
|
| Maintenance | Run migrations, startup fixes, local user commands, and data repair scripts. |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Implementation notes</strong></summary>
|
||||||
|
|
||||||
|
The backend is written in Go with Gin for HTTP routing and Fx for module wiring. SQLite is used for
|
||||||
|
local persistence, with migrations and data fixes committed alongside the application. Templates are
|
||||||
|
rendered on the server, HTMX handles small partial updates, and TypeScript powers the interactive
|
||||||
|
parts of the browser experience.
|
||||||
|
|
||||||
|
The most stateful frontend code lives under `static/player`, where the app handles playback mode,
|
||||||
|
source loading, progress storage, subtitles, timelines, quality changes, keyboard shortcuts, skip
|
||||||
|
segments, episode completion, and thumbnail navigation.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### How It Is Built
|
||||||
|
|
||||||
|
The application is organized around product boundaries rather than framework layers.
|
||||||
|
`internal/anime` owns catalog-facing behavior, `internal/watchlist` owns saved user state,
|
||||||
|
`internal/playback` owns watch data and proxy behavior, and `integrations` contains provider
|
||||||
|
clients. This keeps the core app from depending directly on the details of a specific metadata or
|
||||||
|
playback source.
|
||||||
|
|
||||||
|
Server-rendered templates are the default because most pages are content-heavy and benefit from
|
||||||
|
simple request-response rendering. TypeScript is used where the browser has real ongoing state:
|
||||||
|
search interactions, theme handling, carousels, watchlist actions, toast messages, and especially
|
||||||
|
the video player.
|
||||||
|
|
||||||
|
The result is a codebase that behaves like a small product rather than a tutorial project: it has a
|
||||||
|
repeatable toolchain, database evolution, local maintenance commands, focused tests, and a clear
|
||||||
|
split between app code and external integrations.
|
||||||
|
|
||||||
|
### Working Locally
|
||||||
|
|
||||||
|
The local workflow assumes [`mise`](https://mise.jdx.dev/) for tool versions and `just` for common
|
||||||
|
commands.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
mise install
|
||||||
|
bun install
|
||||||
just dev
|
just dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quality checks
|
The development server runs on `http://localhost:3000` by default. `just dev` uses Air to rebuild
|
||||||
|
the Go server and frontend assets when relevant files change.
|
||||||
|
|
||||||
|
Playback proxying requires a local `PLAYBACK_PROXY_SECRET` so the server can mint stream and
|
||||||
|
subtitle proxy tokens. Generate a strong value and add it to `.env` before using playback:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gofmt -l .
|
echo "PLAYBACK_PROXY_SECRET=$(openssl rand -base64 32)" >> .env
|
||||||
go test ./...
|
|
||||||
go build -o server ./cmd/server
|
|
||||||
golangci-lint run ./...
|
|
||||||
go mod tidy
|
|
||||||
go test -race ./...
|
|
||||||
bunx oxfmt --check
|
|
||||||
bun run lint:ts
|
|
||||||
bun run typecheck
|
|
||||||
bun run build:assets
|
|
||||||
docker build -t mal:ci .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
Create a local user with:
|
||||||
|
|
||||||
Bug reports and pull requests are welcome. This is a personal project, so there is no strict roadmap or issue triage cycle. If something is broken or missing, open an issue or send a PR.
|
```bash
|
||||||
|
go run ./cmd/user <username> <password>
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
#### Commands
|
||||||
|
|
||||||
MIT. See `LICENSE`.
|
| Command | Use it for |
|
||||||
|
| ------------------------------- | --------------------------------------------------- |
|
||||||
|
| `just setup` | Install pinned tools and Bun dependencies. |
|
||||||
|
| `just dev` | Run the app locally with live rebuilds. |
|
||||||
|
| `just build` | Build the Go binary, CSS, and TypeScript assets. |
|
||||||
|
| `just test` | Run the Go test suite. |
|
||||||
|
| `just check` | Run linting, tests, typechecking, and a full build. |
|
||||||
|
| `just lint-go` / `just lint-ts` | Run backend or frontend linting separately. |
|
||||||
|
| `just typecheck` | Run TypeScript without emitting files. |
|
||||||
|
| `just run` | Build and run the compiled server. |
|
||||||
|
| `just clean` | Remove generated build output. |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Configuration</strong></summary>
|
||||||
|
|
||||||
|
Configuration is loaded from environment variables, and a local `.env` file is read automatically.
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --------------------------- | --------------- | -------------------------------------------------------------------------- |
|
||||||
|
| `PORT` | `3000` | HTTP port for the server. |
|
||||||
|
| `DATABASE_FILE` | `mal.db` | SQLite database path. |
|
||||||
|
| `GIN_MODE` | release default | Gin runtime mode. |
|
||||||
|
| `MAL_CORS_ALLOW_ALL` | disabled | Allows any origin when set to `1`; intended for local/proxy setups. |
|
||||||
|
| `PLAYBACK_PROXY_SECRET` | empty | Secret used to mint playback proxy tokens; required for playback proxying. |
|
||||||
|
| `EPISODE_AVAILABILITY_MODE` | `auto` | Episode availability strategy: `auto`, `legacy`, or `jikan`. |
|
||||||
|
| `MAL_JIKAN_TRACE` | disabled | Enables optional Jikan client tracing when truthy. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Maintenance commands</strong></summary>
|
||||||
|
|
||||||
|
| Command | Use it for |
|
||||||
|
| ------------------------ | ---------------------------------------------------------- |
|
||||||
|
| `just new-data-fix name` | Scaffold a new data-fix file. |
|
||||||
|
| `just run-fixes` | Run registered data fixes through `cmd/user`. |
|
||||||
|
| `just fix-all` | Run the Bun maintenance script for data fixes. |
|
||||||
|
| `bun run format` | Format TypeScript and related frontend files with `oxfmt`. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Repository Map
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
| -------------------------------- | --------------------------------------------------------------- |
|
||||||
|
| `cmd/server` | Web server entry point. |
|
||||||
|
| `cmd/user` | Local user and maintenance commands. |
|
||||||
|
| `internal/anime` | Catalog, details, browse, search, reviews, and recommendations. |
|
||||||
|
| `internal/auth` | Authentication, middleware, and local user handling. |
|
||||||
|
| `internal/watchlist` | Watchlist handlers, service logic, and persistence. |
|
||||||
|
| `internal/playback` | Watch data, progress, proxy tokens, and skip segments. |
|
||||||
|
| `internal/episodes` | Episode refresh and provider mapping. |
|
||||||
|
| `internal/database` | SQLite setup, migrations, and startup data fixes. |
|
||||||
|
| `integrations/jikan` | Jikan API client and catalog types. |
|
||||||
|
| `integrations/playback/allanime` | Playback provider client and extraction logic. |
|
||||||
|
| `templates` | Server-rendered pages and reusable components. |
|
||||||
|
| `static` | TypeScript source for client-side behavior. |
|
||||||
|
| `scripts` | Bun-powered development and maintenance scripts. |
|
||||||
|
|
||||||
|
Released under the [MIT License](LICENSE).
|
||||||
|
|||||||
67
SECURITY.md
Normal file
67
SECURITY.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
This is a personal portfolio project, so there is no formal long-term support schedule. Security
|
||||||
|
fixes are applied to the current main branch when issues are confirmed and within the practical
|
||||||
|
maintenance capacity of the project.
|
||||||
|
|
||||||
|
## Reporting A Vulnerability
|
||||||
|
|
||||||
|
Please do not open a public issue for a security vulnerability.
|
||||||
|
|
||||||
|
Report security concerns privately to the repository maintainer. Include as much detail as you can:
|
||||||
|
|
||||||
|
- a description of the vulnerability;
|
||||||
|
- steps to reproduce the issue;
|
||||||
|
- affected routes, commands, files, or configuration;
|
||||||
|
- the potential impact;
|
||||||
|
- any suggested fix or mitigation, if you have one.
|
||||||
|
|
||||||
|
You can expect a best-effort response acknowledging the report, followed by validation and a fix
|
||||||
|
when the issue is reproducible and in scope.
|
||||||
|
|
||||||
|
## Security Scope
|
||||||
|
|
||||||
|
The most important security areas for this project are:
|
||||||
|
|
||||||
|
- local authentication and session handling;
|
||||||
|
- watchlist and playback progress data;
|
||||||
|
- playback proxy tokens and signed stream access;
|
||||||
|
- subtitle and playlist proxying;
|
||||||
|
- external provider integration boundaries;
|
||||||
|
- SQLite database access and migrations;
|
||||||
|
- configuration loaded from environment variables or `.env` files.
|
||||||
|
|
||||||
|
Reports involving these areas are especially useful.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
The following are generally out of scope unless they expose a direct application vulnerability:
|
||||||
|
|
||||||
|
- issues that require full local machine access;
|
||||||
|
- denial-of-service reports against a local development server;
|
||||||
|
- vulnerabilities in third-party services outside this repository;
|
||||||
|
- missing production hardening for deployments that are not documented or supported by the project;
|
||||||
|
- social engineering or physical attacks.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
This application is designed to be self-hosted and local-first. If you deploy it beyond a private
|
||||||
|
local environment, you are responsible for the surrounding production controls, including TLS,
|
||||||
|
network access, backups, secrets management, reverse proxy configuration, logging retention, and
|
||||||
|
dependency monitoring.
|
||||||
|
|
||||||
|
Use a strong `PLAYBACK_PROXY_SECRET` if playback proxy token signing is enabled. Do not commit real
|
||||||
|
secrets, provider tokens, session data, or production databases to the repository.
|
||||||
|
|
||||||
|
## Dependency Security
|
||||||
|
|
||||||
|
Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal
|
||||||
|
local checks before merging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just check
|
||||||
|
```
|
||||||
|
|
||||||
|
Security-related dependency updates should be kept small and reviewed separately when possible.
|
||||||
15
bun.lock
15
bun.lock
@@ -3,14 +3,15 @@
|
|||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "myanimelist-ui",
|
"name": "mal",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"htmx.org": "1.9.12",
|
"htmx.org": "1.9.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/cli": "^4.3.0",
|
"@tailwindcss/cli": "^4.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"jiti": "^2.7.0",
|
|
||||||
"lefthook": "^2.1.6",
|
"lefthook": "^2.1.6",
|
||||||
"oxfmt": "^0.52.0",
|
"oxfmt": "^0.52.0",
|
||||||
"oxlint": "^1.67.0",
|
"oxlint": "^1.67.0",
|
||||||
@@ -147,6 +148,8 @@
|
|||||||
|
|
||||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||||
|
|
||||||
|
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
|
||||||
|
|
||||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="],
|
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||||
@@ -183,8 +186,12 @@
|
|||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="],
|
||||||
|
|
||||||
"htmx.org": ["htmx.org@1.9.12", "", {}, "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw=="],
|
"htmx.org": ["htmx.org@1.9.12", "", {}, "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
@@ -255,6 +262,10 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# cmd
|
|
||||||
|
|
||||||
Application entrypoints.
|
|
||||||
|
|
||||||
| binary | purpose |
|
|
||||||
| ------------ | -------------------------------- |
|
|
||||||
| `cmd/server` | HTTP server and worker processes |
|
|
||||||
| `cmd/user` | User management CLI |
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Each subdirectory is a `package main` that compiles to a standalone binary.
|
|
||||||
- Shared logic lives in `internal/` or `pkg/`, not in `cmd/`.
|
|
||||||
- Configuration is read from environment variables — see each binary's `main.go` for the full list.
|
|
||||||
@@ -2,14 +2,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/internal/app"
|
"mal/internal"
|
||||||
|
"mal/internal/observability"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
if err := godotenv.Load(); err != nil {
|
||||||
|
observability.Warn("env_file_load_failed", "server", "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
application := app.NewApp()
|
application := internal.NewApp()
|
||||||
application.Run()
|
application.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
325
cmd/user/main.go
325
cmd/user/main.go
@@ -1,250 +1,195 @@
|
|||||||
// Package main provides small CLI utilities for local admin tasks.
|
// Package main provides local user administration commands.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"io"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"mal/internal"
|
"mal/internal"
|
||||||
"mal/internal/config"
|
"mal/internal/config"
|
||||||
"mal/internal/database"
|
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
if err := godotenv.Load(); err != nil {
|
||||||
if err != nil {
|
observability.Warn("env_file_load_failed", "user", "", nil, err)
|
||||||
observability.Error("cli_config_load_failed", "cmd/user", "", nil, err)
|
}
|
||||||
|
|
||||||
|
if err := run(os.Args[1:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbConn, err := db.Open(cfg.DatabaseFile)
|
|
||||||
if err != nil {
|
|
||||||
observability.Error("cli_db_open_failed", "cmd/user", "", map[string]any{"db_file": cfg.DatabaseFile}, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer func() { _ = dbConn.Close() }()
|
|
||||||
|
|
||||||
os.Exit(run(dbConn, os.Args))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(dbConn *sql.DB, args []string) int {
|
func run(args []string) error {
|
||||||
cmd, err := parseArgs(args)
|
if len(args) == 1 && args[0] == "run-fixes" {
|
||||||
if err != nil {
|
return runFixes()
|
||||||
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(args)}, err)
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, usage())
|
|
||||||
return 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch cmd.kind {
|
if len(args) != 1 && len(args) != 2 {
|
||||||
case commandUpdateAvatar:
|
return errors.New("usage: create-user <username> [password]")
|
||||||
updateAvatars(dbConn)
|
|
||||||
return 0
|
|
||||||
case commandRunFixes:
|
|
||||||
runFixes(dbConn)
|
|
||||||
return 0
|
|
||||||
case commandCreateOrUpdateUser:
|
|
||||||
if err := createOrUpdateUser(dbConn, cmd.username, cmd.password); err != nil {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
default:
|
|
||||||
observability.Error("cli_command_unreachable", "cmd/user", "", map[string]any{"kind": cmd.kind}, errors.New("unhandled command"))
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type commandKind string
|
username := strings.TrimSpace(args[0])
|
||||||
|
password := ""
|
||||||
const (
|
|
||||||
commandUpdateAvatar commandKind = "update-avatar"
|
|
||||||
commandRunFixes commandKind = "run-fixes"
|
|
||||||
commandCreateOrUpdateUser commandKind = "create-or-update-user"
|
|
||||||
)
|
|
||||||
|
|
||||||
type command struct {
|
|
||||||
kind commandKind
|
|
||||||
username string
|
|
||||||
password string
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseArgs(args []string) (command, error) {
|
|
||||||
if len(args) == 2 {
|
if len(args) == 2 {
|
||||||
switch args[1] {
|
password = args[1]
|
||||||
case string(commandUpdateAvatar):
|
}
|
||||||
return command{kind: commandUpdateAvatar}, nil
|
if username == "" {
|
||||||
case string(commandRunFixes):
|
return errors.New("username must not be empty")
|
||||||
return command{kind: commandRunFixes}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(args) == 3 {
|
sqlDB, err := openDatabase()
|
||||||
return command{
|
|
||||||
kind: commandCreateOrUpdateUser,
|
|
||||||
username: args[1],
|
|
||||||
password: args[2],
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return command{}, errors.New("invalid arguments")
|
|
||||||
}
|
|
||||||
|
|
||||||
func usage() string {
|
|
||||||
return "Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes"
|
|
||||||
}
|
|
||||||
|
|
||||||
func createOrUpdateUser(dbConn *sql.DB, username string, password string) error {
|
|
||||||
existingID, err := lookupUserID(dbConn, username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
if existingID != "" {
|
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
|
||||||
if !promptConfirmOverwrite(username) {
|
return fmt.Errorf("prepare database: %w", err)
|
||||||
fmt.Println("Operation cancelled.")
|
}
|
||||||
return nil
|
|
||||||
}
|
return createOrUpdateUser(sqlDB, username, password)
|
||||||
if err := updateUserPassword(dbConn, existingID, username, password); err != nil {
|
}
|
||||||
return err
|
|
||||||
}
|
func runFixes() error {
|
||||||
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
sqlDB, err := openDatabase()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
|
||||||
|
return fmt.Errorf("run migrations and fixes: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Database migrations and fixes complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDatabase() (*sql.DB, error) {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.Open(cfg.DatabaseFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
return sqlDB, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOrUpdateUser(sqlDB *sql.DB, username, password string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
err := sqlDB.QueryRowContext(ctx, `SELECT id FROM user WHERE username = ? LIMIT 1`, username).Scan(&userID)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("check user: %w", err)
|
||||||
|
}
|
||||||
|
userExists := err == nil
|
||||||
|
|
||||||
|
if !userExists {
|
||||||
|
return createUser(ctx, sqlDB, username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
update, err := confirmPasswordUpdate(username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !update {
|
||||||
|
fmt.Println("No changes made")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := createUser(dbConn, username, password); err != nil {
|
return updateUserPassword(ctx, sqlDB, userID, username, password)
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func lookupUserID(dbConn *sql.DB, username string) (string, error) {
|
func createUser(ctx context.Context, sqlDB *sql.DB, username, password string) error {
|
||||||
var id string
|
password, err := resolvePassword(password)
|
||||||
err := dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&id)
|
|
||||||
if err == nil {
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
func promptConfirmOverwrite(username string) bool {
|
|
||||||
fmt.Printf("User '%s' already exists. Do you want to overwrite their password? [y/N]: ", username)
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
response, _ := reader.ReadString('\n')
|
|
||||||
response = strings.TrimSpace(strings.ToLower(response))
|
|
||||||
return response == "y" || response == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUserPassword(dbConn *sql.DB, userID string, username string, password string) error {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), userID)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
return fmt.Errorf("hash password: %w", err)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createUser(dbConn *sql.DB, username string, password string) error {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
|
||||||
if err != nil {
|
|
||||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
id := uuid.New().String()
|
_, err = sqlDB.ExecContext(
|
||||||
avatarURL := internal.DefaultAvatarURL(username)
|
ctx,
|
||||||
_, err = dbConn.Exec(
|
`INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)`,
|
||||||
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
|
uuid.NewString(), username, string(passwordHash), internal.DefaultAvatarURL(username),
|
||||||
id,
|
|
||||||
username,
|
|
||||||
string(hash),
|
|
||||||
avatarURL,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
return fmt.Errorf("create user: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("Created user %q\n", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAvatars(dbConn *sql.DB) {
|
func updateUserPassword(ctx context.Context, sqlDB *sql.DB, userID, username, password string) error {
|
||||||
rows, err := dbConn.Query("SELECT id, username FROM user")
|
password, err := resolvePassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.Error("cli_users_list_failed", "cmd/user", "", nil, err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
for rows.Next() {
|
|
||||||
var id, username string
|
|
||||||
if err := rows.Scan(&id, &username); err != nil {
|
|
||||||
observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
avatarURL := internal.DefaultAvatarURL(username)
|
|
||||||
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
|
|
||||||
if err != nil {
|
|
||||||
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
|
if err != nil {
|
||||||
os.Exit(1)
|
return fmt.Errorf("hash password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Updated avatars for %d user(s)\n", count)
|
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET password_hash = ? WHERE id = ?`, string(passwordHash), userID); err != nil {
|
||||||
|
return fmt.Errorf("update password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Updated password for user %q\n", username)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runFixes(dbConn *sql.DB) {
|
func resolvePassword(password string) (string, error) {
|
||||||
if err := database.RunMigrationsAndFixes(dbConn); err != nil {
|
if password != "" {
|
||||||
observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err)
|
return password, nil
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := dbConn.Query("SELECT id, applied_at FROM data_fixes ORDER BY id ASC")
|
fmt.Print("Password: ")
|
||||||
|
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
|
return "", fmt.Errorf("read password: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
if len(passwordBytes) == 0 {
|
||||||
|
return "", errors.New("password must not be empty")
|
||||||
count := 0
|
}
|
||||||
for rows.Next() {
|
return string(passwordBytes), nil
|
||||||
var id string
|
}
|
||||||
var appliedAt string
|
|
||||||
if err := rows.Scan(&id, &appliedAt); err != nil {
|
func confirmPasswordUpdate(username string) (bool, error) {
|
||||||
observability.Error("cli_data_fix_scan_failed", "cmd/user", "", nil, err)
|
fmt.Printf("User %q already exists. Change password? [Y/n] ", username)
|
||||||
os.Exit(1)
|
answer, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
}
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
fmt.Printf("%s applied_at=%s\n", id, appliedAt)
|
return false, fmt.Errorf("read confirmation: %w", err)
|
||||||
count++
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.TrimSpace(answer)) {
|
||||||
|
case "", "y", "yes":
|
||||||
|
return true, nil
|
||||||
|
case "n", "no":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New("invalid response; enter y or n")
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
observability.Error("cli_data_fixes_iter_failed", "cmd/user", "", nil, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Applied fixes: %d\n", count)
|
|
||||||
}
|
}
|
||||||
|
|||||||
4
create-user
Executable file
4
create-user
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
exec go run ./cmd/user "$@"
|
||||||
@@ -17,4 +17,4 @@ namespace: mal
|
|||||||
images:
|
images:
|
||||||
- name: main
|
- name: main
|
||||||
newName: reg.milasholsting.dk/apps/mal
|
newName: reg.milasholsting.dk/apps/mal
|
||||||
newTag: sha-30a00eb
|
newTag: sha-7701ec5
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
: "${DATABASE_FILE:=/app/data/mal.db}"
|
|
||||||
|
|
||||||
if [ ! -x /app/main_server ]; then
|
|
||||||
echo "ERROR: /app/main_server not found or not executable" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec /app/main_server
|
|
||||||
|
|
||||||
3
go.mod
3
go.mod
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
|
golang.org/x/term v0.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -56,6 +57,6 @@ require (
|
|||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
golang.org/x/sync v0.20.0 // direct
|
golang.org/x/sync v0.20.0 // direct
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -158,8 +158,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -169,6 +169,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
|
|||||||
return nil, url, err
|
return nil, url, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return document, response.Request.URL.String(), nil
|
return document, response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type timetableAnimeAPI struct {
|
type timetableAnimeAPI struct {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package jikan
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,10 +41,48 @@ func (c *Client) WarmAnimeRecommendations(id int) {
|
|||||||
|
|
||||||
c.runAsyncRefresh(func(ctx context.Context) {
|
c.runAsyncRefresh(func(ctx context.Context) {
|
||||||
var resp RecommendationsResponse
|
var resp RecommendationsResponse
|
||||||
_ = c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp)
|
if err := c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp); err != nil {
|
||||||
|
c.EnqueueAnimeFetchRetry(ctx, id, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTopAnime returns the top-rated anime list for a given page.
|
||||||
|
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
cacheKey := fmt.Sprintf("top:%d", page)
|
||||||
|
|
||||||
|
var result TopAnimeResponse
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
reqURL := buildRequestURL(c.baseURL, "/top/anime", params)
|
||||||
|
|
||||||
|
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||||
|
return TopAnimeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return TopAnimeResult{
|
||||||
|
Animes: result.Data,
|
||||||
|
HasNextPage: result.Pagination.HasNextPage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAnimeGenres returns list of all anime genres, cached long-term.
|
||||||
|
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
|
||||||
|
const cacheKey = "anime_genres"
|
||||||
|
|
||||||
|
var result GenresResponse
|
||||||
|
reqURL := fmt.Sprintf("%s/genres/anime", c.baseURL)
|
||||||
|
|
||||||
|
if err := c.getWithCache(ctx, cacheKey, longCacheTTL, reqURL, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day.
|
// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day.
|
||||||
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||||
cacheKey := fmt.Sprintf("anime:%d", id)
|
cacheKey := fmt.Sprintf("anime:%d", id)
|
||||||
@@ -71,7 +112,7 @@ func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
|||||||
func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||||
cacheKey := fmt.Sprintf("anime:%d", id)
|
cacheKey := fmt.Sprintf("anime:%d", id)
|
||||||
|
|
||||||
value, err, _ := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
value, err, shared := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
||||||
var cached Anime
|
var cached Anime
|
||||||
if c.getCache(ctx, cacheKey, &cached) && cached.MalID != 0 {
|
if c.getCache(ctx, cacheKey, &cached) && cached.MalID != 0 {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
@@ -95,6 +136,14 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Anime{}, err
|
return Anime{}, err
|
||||||
}
|
}
|
||||||
|
if shared {
|
||||||
|
observability.Info(
|
||||||
|
"jikan_anime_refresh_shared",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{"anime_id": id, "cache_key": cacheKey},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if anime, ok := value.(Anime); ok && anime.MalID != 0 {
|
if anime, ok := value.(Anime); ok && anime.MalID != 0 {
|
||||||
return anime, nil
|
return anime, nil
|
||||||
@@ -105,6 +154,8 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
|||||||
|
|
||||||
func (c *Client) refreshAnimeByIDAsync(id int) {
|
func (c *Client) refreshAnimeByIDAsync(id int) {
|
||||||
c.runAsyncRefresh(func(ctx context.Context) {
|
c.runAsyncRefresh(func(ctx context.Context) {
|
||||||
_, _ = c.refreshAnimeByID(ctx, id)
|
if _, err := c.refreshAnimeByID(ctx, id); err != nil {
|
||||||
|
c.EnqueueAnimeFetchRetry(ctx, id, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package jikan
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *Client) GetAnimeStaff(ctx context.Context, id int) ([]StaffEntry, error) {
|
func (c *Client) GetAnimeStaff(ctx context.Context, id int) ([]StaffEntry, error) {
|
||||||
@@ -46,7 +48,9 @@ func (c *Client) GetAnimeReviews(ctx context.Context, id int, page int) ([]Revie
|
|||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/anime/%d/reviews?page=%d", c.baseURL, id, page)
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
url := buildRequestURL(c.baseURL, fmt.Sprintf("/anime/%d/reviews", id), params)
|
||||||
cacheKey := fmt.Sprintf("anime:reviews:%d:%d", id, page)
|
cacheKey := fmt.Sprintf("anime:reviews:%d:%d", id, page)
|
||||||
|
|
||||||
var resp ReviewsResponse
|
var resp ReviewsResponse
|
||||||
79
integrations/jikan/cache/store.go
vendored
Normal file
79
integrations/jikan/cache/store.go
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db db.Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(queries db.Querier) *Store {
|
||||||
|
return &Store{db: queries}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a fresh cached value by key.
|
||||||
|
func (s *Store) Get(parentCtx context.Context, key string, out any) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
data, err := s.db.GetJikanCache(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(data), out); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStale retrieves an expired-but-available cached value by key.
|
||||||
|
func (s *Store) GetStale(parentCtx context.Context, key string, out any) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
data, err := s.db.GetJikanCacheStale(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(data), out); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores data in cache with the specified TTL.
|
||||||
|
func (s *Store) Set(parentCtx context.Context, key string, data any, ttl time.Duration) {
|
||||||
|
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
bytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.db.SetJikanCache(ctx, db.SetJikanCacheParams{
|
||||||
|
Key: key,
|
||||||
|
Data: string(bytes),
|
||||||
|
ExpiresAt: time.Now().Add(ttl),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
observability.LogJSON(
|
||||||
|
observability.LogLevelError,
|
||||||
|
"jikan_cache_set",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{"cache_key": key},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Package jikan provides a client for the Jikan v4 API.
|
|
||||||
package jikan
|
package jikan
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// Cache TTLs used by the Jikan client for endpoint responses.
|
||||||
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
|
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
|
||||||
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
|
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
|
||||||
const producerCacheTTL = time.Hour * 24 * 30
|
const producerCacheTTL = time.Hour * 24 * 30
|
||||||
@@ -5,34 +5,29 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
jcache "mal/integrations/jikan/cache"
|
||||||
|
"mal/integrations/jikan/rate"
|
||||||
|
jtransport "mal/integrations/jikan/transport"
|
||||||
"mal/internal/config"
|
"mal/internal/config"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
netutil "mal/pkg/net"
|
|
||||||
|
|
||||||
"golang.org/x/sync/singleflight"
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
var traceEnabled bool
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
httpClient *http.Client
|
baseURL string
|
||||||
baseURL string
|
db db.Querier
|
||||||
db db.Querier
|
retrySignal chan struct{} // signals retry worker to process queued retries
|
||||||
retrySignal chan struct{} // signals retry worker to process queued retries
|
sf singleflight.Group
|
||||||
mu sync.Mutex
|
refreshSem chan struct{}
|
||||||
lastReqTime time.Time // rate limiting: last request timestamp
|
cache *jcache.Store
|
||||||
sf singleflight.Group
|
fetcher *jtransport.Client
|
||||||
refreshSem chan struct{}
|
traceEnabled bool
|
||||||
metrics *observability.Metrics
|
|
||||||
|
|
||||||
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
||||||
randomPool []Anime
|
randomPool []Anime
|
||||||
@@ -42,132 +37,78 @@ type Client struct {
|
|||||||
|
|
||||||
const jikanSlowLogThreshold = 750 * time.Millisecond
|
const jikanSlowLogThreshold = 750 * time.Millisecond
|
||||||
|
|
||||||
func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client {
|
type APIError = jtransport.APIError
|
||||||
traceEnabled = cfg.JikanTrace
|
|
||||||
return &Client{
|
func NewClient(cfg config.Config, queries *db.Queries) *Client {
|
||||||
httpClient: &http.Client{
|
limiter := rate.NewLimiter(400 * time.Millisecond)
|
||||||
Timeout: 10 * time.Second,
|
client := &Client{
|
||||||
Transport: &http.Transport{
|
baseURL: "https://api.jikan.moe/v4",
|
||||||
MaxIdleConns: 10,
|
db: queries,
|
||||||
IdleConnTimeout: 30 * time.Second,
|
retrySignal: make(chan struct{}, 1),
|
||||||
DisableKeepAlives: false,
|
refreshSem: make(chan struct{}, 4),
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
cache: jcache.NewStore(queries),
|
||||||
},
|
traceEnabled: cfg.JikanTrace,
|
||||||
},
|
randomPool: make([]Anime, 0),
|
||||||
baseURL: "https://api.jikan.moe/v4",
|
|
||||||
db: queries,
|
|
||||||
metrics: metrics,
|
|
||||||
retrySignal: make(chan struct{}, 1),
|
|
||||||
refreshSem: make(chan struct{}, 4),
|
|
||||||
randomPool: make([]Anime, 0),
|
|
||||||
}
|
}
|
||||||
}
|
client.fetcher = jtransport.NewClient(jtransport.Config{
|
||||||
|
HTTPClient: jtransport.NewHTTPClient(),
|
||||||
|
Limiter: limiter,
|
||||||
|
TraceEnabled: client.jikanTraceEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
type APIError struct {
|
return client
|
||||||
StatusCode int
|
|
||||||
URL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
|
||||||
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRetryableError returns true if the error should trigger a retry.
|
// IsRetryableError returns true if the error should trigger a retry.
|
||||||
func IsRetryableError(err error) bool {
|
func IsRetryableError(err error) bool {
|
||||||
if err == nil {
|
return jtransport.IsRetryableError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) jikanTraceEnabled() bool {
|
||||||
|
return c.traceEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
|
||||||
|
if c.jikanTraceEnabled() || err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiErr *APIError
|
if source == "fresh" {
|
||||||
if errors.As(err, &apiErr) {
|
return duration < 50*time.Millisecond
|
||||||
return isRetryableStatus(apiErr.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var netErr net.Error
|
if source == "refresh" {
|
||||||
if errors.As(err, &netErr) {
|
return duration < jikanSlowLogThreshold
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isRetryableStatus(statusCode int) bool {
|
func jikanCacheLogLevel(source string, err error) observability.LogLevel {
|
||||||
if statusCode == http.StatusTooManyRequests {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusCode >= 500 && statusCode <= 504
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped).
|
|
||||||
func retryDelay(attempt int) time.Duration {
|
|
||||||
base := 500 * time.Millisecond
|
|
||||||
delay := base * time.Duration(1<<attempt)
|
|
||||||
if delay > 8*time.Second {
|
|
||||||
return 8 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
return delay
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRetryAfter parses Retry-After header value (seconds) into duration.
|
|
||||||
func parseRetryAfter(value string) (time.Duration, bool) {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
seconds, err := strconv.Atoi(trimmed)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false
|
return observability.LogLevelError
|
||||||
}
|
}
|
||||||
|
|
||||||
if seconds <= 0 {
|
if source != "fresh" && source != "refresh" {
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Duration(seconds) * time.Second, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
|
||||||
timer := time.NewTimer(delay)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-timer.C:
|
|
||||||
return nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
return fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func jikanTraceEnabled() bool {
|
|
||||||
return traceEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
|
|
||||||
duration := time.Since(startedAt)
|
|
||||||
if !jikanTraceEnabled() && err == nil && source == "fresh" && duration < 50*time.Millisecond {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !jikanTraceEnabled() && err == nil && source == "refresh" && duration < jikanSlowLogThreshold {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
level := observability.LogLevelInfo
|
|
||||||
if err != nil {
|
|
||||||
level = observability.LogLevelError
|
|
||||||
} else if source != "fresh" && source != "refresh" {
|
|
||||||
// Stale reads are expected sometimes, but worth tracking in logs.
|
// Stale reads are expected sometimes, but worth tracking in logs.
|
||||||
level = observability.LogLevelWarn
|
return observability.LogLevelWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
return observability.LogLevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Since(startedAt)
|
||||||
|
if c.shouldSkipJikanCacheLog(source, duration, err) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
observability.LogJSON(
|
observability.LogJSON(
|
||||||
level,
|
jikanCacheLogLevel(source, err),
|
||||||
"jikan_cache",
|
"jikan_cache",
|
||||||
"jikan",
|
"jikan",
|
||||||
"",
|
"",
|
||||||
@@ -180,43 +121,6 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt time.Time, err error) {
|
|
||||||
duration := time.Since(startedAt)
|
|
||||||
if !jikanTraceEnabled() && err == nil && statusCode < http.StatusBadRequest && duration < jikanSlowLogThreshold {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
level := observability.LogLevelInfo
|
|
||||||
if err != nil || statusCode >= http.StatusInternalServerError {
|
|
||||||
level = observability.LogLevelError
|
|
||||||
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
|
|
||||||
level = observability.LogLevelWarn
|
|
||||||
}
|
|
||||||
|
|
||||||
observability.LogJSON(
|
|
||||||
level,
|
|
||||||
"jikan_upstream",
|
|
||||||
"jikan",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"url": urlStr,
|
|
||||||
"endpoint": metricsEndpoint(urlStr),
|
|
||||||
"status": statusCode,
|
|
||||||
"attempts": attempts,
|
|
||||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateErrorMessage(message string) string {
|
|
||||||
if len(message) <= 400 {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
|
|
||||||
return message[:400]
|
|
||||||
}
|
|
||||||
|
|
||||||
// notifyRetryWorker signals the retry worker, non-blocking.
|
// notifyRetryWorker signals the retry worker, non-blocking.
|
||||||
func (c *Client) notifyRetryWorker() {
|
func (c *Client) notifyRetryWorker() {
|
||||||
select {
|
select {
|
||||||
@@ -239,121 +143,76 @@ func (c *Client) EnqueueAnimeFetchRetry(parentCtx context.Context, animeID int,
|
|||||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
message := cause.Error()
|
||||||
|
if len(message) > 400 {
|
||||||
|
message = message[:400]
|
||||||
|
}
|
||||||
|
|
||||||
err := c.db.EnqueueAnimeFetchRetry(ctx, db.EnqueueAnimeFetchRetryParams{
|
err := c.db.EnqueueAnimeFetchRetry(ctx, db.EnqueueAnimeFetchRetryParams{
|
||||||
AnimeID: int64(animeID),
|
AnimeID: int64(animeID),
|
||||||
LastError: truncateErrorMessage(cause.Error()),
|
LastError: message,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"jikan_retry_enqueue_failed",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{"anime_id": animeID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.notifyRetryWorker()
|
c.notifyRetryWorker()
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitRateLimit enforces Jikan's 3 req/sec rate limit with 400ms spacing.
|
|
||||||
func (c *Client) waitRateLimit(ctx context.Context) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
// Jikan has a 3 req/sec limit AND a 60 req/min limit.
|
|
||||||
// 400ms base delay keeps us safely under the 3/sec limit.
|
|
||||||
nextAllowed := c.lastReqTime.Add(400 * time.Millisecond)
|
|
||||||
if now.Before(nextAllowed) {
|
|
||||||
timer := time.NewTimer(nextAllowed.Sub(now))
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-timer.C:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return fmt.Errorf("request canceled while waiting for rate limit: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
c.lastReqTime = time.Now()
|
|
||||||
} else {
|
|
||||||
c.lastReqTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCache retrieves cached data by key, returns true on cache hit.
|
|
||||||
func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
|
func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
return c.cache.Get(parentCtx, key, out)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
data, err := c.db.GetJikanCache(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
c.metrics.ObserveCache("jikan", "miss")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal([]byte(data), out)
|
|
||||||
if err != nil {
|
|
||||||
c.metrics.ObserveCache("jikan", "miss")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
c.metrics.ObserveCache("jikan", "hit")
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStaleCache retrieves expired-but-available cache by key.
|
|
||||||
func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) bool {
|
func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) bool {
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
return c.cache.GetStale(parentCtx, key, out)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
data, err := c.db.GetJikanCacheStale(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal([]byte(data), out)
|
|
||||||
if err != nil {
|
|
||||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
c.metrics.ObserveCache("jikan_stale", "hit")
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setCache stores data in cache with specified TTL.
|
|
||||||
func (c *Client) setCache(parentCtx context.Context, key string, data any, ttl time.Duration) {
|
func (c *Client) setCache(parentCtx context.Context, key string, data any, ttl time.Duration) {
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
c.cache.Set(parentCtx, key, data, ttl)
|
||||||
defer cancel()
|
}
|
||||||
|
|
||||||
bytes, err := json.Marshal(data)
|
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
||||||
if err != nil {
|
return c.fetcher.FetchWithRetry(ctx, urlStr, out)
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_ = c.db.SetJikanCache(ctx, db.SetJikanCacheParams{
|
var emptyResultChecks = map[reflect.Type]func(any) bool{
|
||||||
Key: key,
|
reflect.TypeFor[*TopAnimeResponse](): func(out any) bool {
|
||||||
Data: string(bytes),
|
return len(out.(*TopAnimeResponse).Data) == 0
|
||||||
ExpiresAt: time.Now().Add(ttl),
|
},
|
||||||
})
|
reflect.TypeFor[*AnimeResponse](): func(out any) bool {
|
||||||
|
return out.(*AnimeResponse).Data.MalID == 0
|
||||||
|
},
|
||||||
|
reflect.TypeFor[*EpisodesResponse](): func(out any) bool {
|
||||||
|
return len(out.(*EpisodesResponse).Data) == 0
|
||||||
|
},
|
||||||
|
reflect.TypeFor[*StaffResponse](): func(out any) bool {
|
||||||
|
return len(out.(*StaffResponse).Data) == 0
|
||||||
|
},
|
||||||
|
reflect.TypeFor[*StatisticsResponse](): func(out any) bool {
|
||||||
|
return out.(*StatisticsResponse).Data.Total == 0
|
||||||
|
},
|
||||||
|
reflect.TypeFor[*ThemesResponse](): func(out any) bool {
|
||||||
|
themes := out.(*ThemesResponse).Data
|
||||||
|
return len(themes.Openings) == 0 && len(themes.Endings) == 0
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// isEmptyResult detects if response contains no meaningful data.
|
// isEmptyResult detects if response contains no meaningful data.
|
||||||
func isEmptyResult(out any) bool {
|
func isEmptyResult(out any) bool {
|
||||||
switch v := out.(type) {
|
if out == nil {
|
||||||
case *TopAnimeResponse:
|
return true
|
||||||
return len(v.Data) == 0
|
}
|
||||||
case *SearchResponse:
|
|
||||||
return len(v.Data) == 0
|
outType := reflect.TypeOf(out)
|
||||||
case *AnimeResponse:
|
if check, ok := emptyResultChecks[outType]; ok {
|
||||||
return v.Data.MalID == 0
|
return check(out)
|
||||||
case *EpisodesResponse:
|
|
||||||
return len(v.Data) == 0
|
|
||||||
case *StaffResponse:
|
|
||||||
return len(v.Data) == 0
|
|
||||||
case *StatisticsResponse:
|
|
||||||
return v.Data.Total == 0
|
|
||||||
case *ThemesResponse:
|
|
||||||
return len(v.Data.Openings) == 0 && len(v.Data.Endings) == 0
|
|
||||||
case *ReviewsResponse:
|
|
||||||
return false // empty reviews is a valid state
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -372,7 +231,7 @@ func cloneResponseTarget(out any) (any, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time.Duration, url string, out any) error {
|
func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time.Duration, url string, out any) error {
|
||||||
value, err, _ := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
value, err, shared := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
||||||
if c.getCache(ctx, cacheKey, out) {
|
if c.getCache(ctx, cacheKey, out) {
|
||||||
if !isEmptyResult(out) {
|
if !isEmptyResult(out) {
|
||||||
return json.Marshal(out)
|
return json.Marshal(out)
|
||||||
@@ -383,7 +242,7 @@ func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't cache empty results to avoid caching failures
|
// Don't cache empty results to avoid caching failures.
|
||||||
if isEmptyResult(out) {
|
if isEmptyResult(out) {
|
||||||
return nil, fmt.Errorf("jikan: empty response for %s", cacheKey)
|
return nil, fmt.Errorf("jikan: empty response for %s", cacheKey)
|
||||||
}
|
}
|
||||||
@@ -394,6 +253,14 @@ func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if shared {
|
||||||
|
observability.Info(
|
||||||
|
"jikan_cache_refresh_shared",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{"cache_key": cacheKey, "url": url},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if bytes, ok := value.([]byte); ok {
|
if bytes, ok := value.([]byte); ok {
|
||||||
if err := json.Unmarshal(bytes, out); err == nil && !isEmptyResult(out) {
|
if err := json.Unmarshal(bytes, out); err == nil && !isEmptyResult(out) {
|
||||||
@@ -411,7 +278,15 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.runAsyncRefresh(func(ctx context.Context) {
|
c.runAsyncRefresh(func(ctx context.Context) {
|
||||||
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
|
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, target); err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"jikan_async_cache_refresh_failed",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{"cache_key": cacheKey, "url": url},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,149 +312,26 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
|
|||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
if c.getCache(ctx, cacheKey, out) {
|
if c.getCache(ctx, cacheKey, out) {
|
||||||
if !isEmptyResult(out) {
|
if !isEmptyResult(out) {
|
||||||
logJikanCache(cacheKey, "fresh", startedAt, nil)
|
c.logJikanCache(cacheKey, "fresh", startedAt, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
|
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
|
||||||
logJikanCache(cacheKey, "stale", startedAt, nil)
|
c.logJikanCache(cacheKey, "stale", startedAt, nil)
|
||||||
c.refreshWithCacheAsync(cacheKey, ttl, url, out)
|
c.refreshWithCacheAsync(cacheKey, ttl, url, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, out); err != nil {
|
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, out); err != nil {
|
||||||
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
|
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
|
||||||
logJikanCache(cacheKey, "stale_after_error", startedAt, err)
|
c.logJikanCache(cacheKey, "stale_after_error", startedAt, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
logJikanCache(cacheKey, "miss", startedAt, err)
|
c.logJikanCache(cacheKey, "miss", startedAt, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logJikanCache(cacheKey, "refresh", startedAt, nil)
|
c.logJikanCache(cacheKey, "refresh", startedAt, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchWithRetry makes HTTP request with exponential backoff retry on transient failures.
|
|
||||||
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
|
||||||
maxRetries := 5
|
|
||||||
startedAt := time.Now()
|
|
||||||
attempts := 0
|
|
||||||
endpoint := metricsEndpoint(urlStr)
|
|
||||||
logAndReturn := func(statusCode int, err error) error {
|
|
||||||
c.metrics.ObserveJikanRequest(endpoint, statusCode, time.Since(startedAt), err)
|
|
||||||
logJikanUpstream(urlStr, statusCode, attempts, startedAt, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for attempt := range maxRetries {
|
|
||||||
attempts = attempt + 1
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return logAndReturn(0, fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err()))
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.waitRateLimit(ctx); err != nil {
|
|
||||||
return logAndReturn(0, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
|
||||||
if err != nil {
|
|
||||||
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", netutil.Generic)
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, context.Canceled) {
|
|
||||||
return logAndReturn(0, fmt.Errorf("request canceled while retrying jikan request: %w", err))
|
|
||||||
}
|
|
||||||
if attempt < maxRetries-1 && IsRetryableError(err) {
|
|
||||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
|
||||||
return logAndReturn(0, retryErr)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return logAndReturn(0, fmt.Errorf("jikan api error: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
apiErr := &APIError{StatusCode: resp.StatusCode, URL: urlStr}
|
|
||||||
retryable := isRetryableStatus(resp.StatusCode)
|
|
||||||
|
|
||||||
retryAfter := time.Duration(0)
|
|
||||||
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
|
|
||||||
retryAfter = parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
if retryable && attempt < maxRetries-1 {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
delay := max(retryAfter, retryDelay(attempt))
|
|
||||||
|
|
||||||
if retryErr := waitForRetry(ctx, delay); retryErr != nil {
|
|
||||||
return logAndReturn(resp.StatusCode, retryErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort decode (often useful for debugging), but still treat non-200 as error.
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(out)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
return logAndReturn(resp.StatusCode, apiErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(out)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
if err == nil {
|
|
||||||
return logAndReturn(resp.StatusCode, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt < maxRetries-1 {
|
|
||||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
|
||||||
return logAndReturn(resp.StatusCode, retryErr)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return logAndReturn(resp.StatusCode, fmt.Errorf("failed to decode jikan response: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func metricsEndpoint(urlStr string) string {
|
|
||||||
trimmed := strings.TrimSpace(urlStr)
|
|
||||||
if trimmed == "" {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := "https://api.jikan.moe/v4"
|
|
||||||
trimmed = strings.TrimPrefix(trimmed, prefix)
|
|
||||||
|
|
||||||
if idx := strings.Index(trimmed, "?"); idx >= 0 {
|
|
||||||
trimmed = trimmed[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(trimmed, "/")
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := strconv.Atoi(part); err == nil {
|
|
||||||
out = append(out, "{id}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, part)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(out) == 0 {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/" + strings.Join(out, "/")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mal/internal/config"
|
"mal/internal/config"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/observability"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -23,44 +22,19 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
||||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
sqlDB := newTestCacheDB(t)
|
||||||
if err != nil {
|
defer func() {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
if err := sqlDB.Close(); err != nil {
|
||||||
}
|
t.Errorf("close sqlite: %v", err)
|
||||||
defer sqlDB.Close()
|
}
|
||||||
sqlDB.SetMaxOpenConns(1)
|
}()
|
||||||
|
|
||||||
_, err = sqlDB.Exec(`
|
|
||||||
CREATE TABLE jikan_cache (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
expires_at DATETIME NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create cache table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
queries := db.New(sqlDB)
|
queries := db.New(sqlDB)
|
||||||
client := NewClient(config.Config{}, queries, observability.NewMetrics())
|
client := NewClient(config.Config{}, queries)
|
||||||
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
||||||
staleBytes, err := json.Marshal(stale)
|
insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour))
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal stale response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = sqlDB.Exec(
|
client.fetcher.HTTPClient = &http.Client{
|
||||||
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
|
|
||||||
"top:1",
|
|
||||||
string(staleBytes),
|
|
||||||
time.Now().Add(-time.Hour),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert stale cache: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client.httpClient = &http.Client{
|
|
||||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
body := `{"data":[{"mal_id":2,"title":"fresh"}]}`
|
body := `{"data":[{"mal_id":2,"title":"fresh"}]}`
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
@@ -78,11 +52,142 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
|||||||
if len(got.Data) != 1 || got.Data[0].Title != "stale" {
|
if len(got.Data) != 1 || got.Data[0].Title != "stale" {
|
||||||
t.Fatalf("got %+v, want stale cache response", got.Data)
|
t.Fatalf("got %+v, want stale cache response", got.Data)
|
||||||
}
|
}
|
||||||
|
waitForFreshCache(t, sqlDB, client, "top:1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWithCacheAllowsEmptySearchResults(t *testing.T) {
|
||||||
|
sqlDB := newTestCacheDB(t)
|
||||||
|
defer func() {
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
queries := db.New(sqlDB)
|
||||||
|
client := NewClient(config.Config{}, queries)
|
||||||
|
client.fetcher.HTTPClient = &http.Client{
|
||||||
|
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
body := `{"pagination":{"has_next_page":false},"data":[]}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var got SearchResponse
|
||||||
|
if err := client.getWithCache(context.Background(), "search::::::12:0:true:1:24", time.Hour, "https://example.test/anime?genres=12", &got); err != nil {
|
||||||
|
t.Fatalf("getWithCache() returned error for empty search response: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Data) != 0 {
|
||||||
|
t.Fatalf("getWithCache() data length = %d, want 0", len(got.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCachedRandomPoolIgnoresExpiredAnimeCache(t *testing.T) {
|
||||||
|
sqlDB := newTestCacheDB(t)
|
||||||
|
defer func() {
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
queries := db.New(sqlDB)
|
||||||
|
client := NewClient(config.Config{}, queries)
|
||||||
|
insertCachedAnime(t, sqlDB, "anime:1", Anime{MalID: 1, Title: "fresh"}, time.Now().Add(time.Hour))
|
||||||
|
insertCachedAnime(t, sqlDB, "anime:2", Anime{MalID: 2, Title: "expired"}, time.Now().Add(-time.Hour))
|
||||||
|
|
||||||
|
client.loadCachedRandomPool(context.Background())
|
||||||
|
|
||||||
|
client.poolMu.RLock()
|
||||||
|
defer client.poolMu.RUnlock()
|
||||||
|
|
||||||
|
if len(client.randomPool) != 1 {
|
||||||
|
t.Fatalf("randomPool length = %d, want 1", len(client.randomPool))
|
||||||
|
}
|
||||||
|
if client.randomPool[0].MalID != 1 || client.randomPool[0].Title != "fresh" {
|
||||||
|
t.Fatalf("randomPool[0] = %+v, want fresh anime", client.randomPool[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestCacheDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
_, err = sqlDB.ExecContext(ctx, `
|
||||||
|
CREATE TABLE jikan_cache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
if closeErr := sqlDB.Close(); closeErr != nil {
|
||||||
|
t.Fatalf("create cache table: %v; close sqlite: %v", err, closeErr)
|
||||||
|
}
|
||||||
|
t.Fatalf("create cache table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertCachedResponse(t *testing.T, sqlDB *sql.DB, key string, value TopAnimeResponse, expiresAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal cached response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sqlDB.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
key,
|
||||||
|
string(encoded),
|
||||||
|
expiresAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert cached response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertCachedAnime(t *testing.T, sqlDB *sql.DB, key string, value Anime, expiresAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal cached anime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sqlDB.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
key,
|
||||||
|
string(encoded),
|
||||||
|
expiresAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert cached anime: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForFreshCache(t *testing.T, sqlDB *sql.DB, client *Client, key string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
var refreshed TopAnimeResponse
|
var refreshed TopAnimeResponse
|
||||||
if client.getCache(context.Background(), "top:1", &refreshed) && len(refreshed.Data) == 1 && refreshed.Data[0].Title == "fresh" {
|
if client.getCache(context.Background(), key, &refreshed) && len(refreshed.Data) == 1 && refreshed.Data[0].Title == "fresh" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -90,6 +195,8 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
|||||||
|
|
||||||
var rawData string
|
var rawData string
|
||||||
var rawExpires string
|
var rawExpires string
|
||||||
_ = sqlDB.QueryRow(`SELECT data, expires_at FROM jikan_cache WHERE key = ?`, "top:1").Scan(&rawData, &rawExpires)
|
if err := sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).Scan(&rawData, &rawExpires); err != nil {
|
||||||
|
t.Fatalf("query cached refresh result: %v", err)
|
||||||
|
}
|
||||||
t.Fatalf("cache was not refreshed asynchronously; data=%s expires_at=%s", rawData, rawExpires)
|
t.Fatalf("cache was not refreshed asynchronously; data=%s expires_at=%s", rawData, rawExpires)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package jikan
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -15,7 +17,9 @@ func (c *Client) GetEpisodes(ctx context.Context, animeID int, page int) (Episod
|
|||||||
|
|
||||||
cacheKey := fmt.Sprintf("anime:%d:episodes:%d", animeID, page)
|
cacheKey := fmt.Sprintf("anime:%d:episodes:%d", animeID, page)
|
||||||
var result EpisodesResponse
|
var result EpisodesResponse
|
||||||
reqURL := fmt.Sprintf("%s/anime/%d/episodes?page=%d", c.baseURL, animeID, page)
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
reqURL := buildRequestURL(c.baseURL, fmt.Sprintf("/anime/%d/episodes", animeID), params)
|
||||||
|
|
||||||
err := c.getWithCache(ctx, cacheKey, 12*time.Hour, reqURL, &result)
|
err := c.getWithCache(ctx, cacheKey, 12*time.Hour, reqURL, &result)
|
||||||
return result, err
|
return result, err
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ type ProducerListResult struct {
|
|||||||
HasNextPage bool
|
HasNextPage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return ProducerResponse{}, fmt.Errorf("invalid producer id")
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("producer:%d", id)
|
||||||
|
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
|
||||||
|
|
||||||
|
var result ProducerResponse
|
||||||
|
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
||||||
|
return ProducerResponse{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -56,10 +71,11 @@ func (c *Client) GetProducers(ctx context.Context, query string, page int, limit
|
|||||||
func (c *Client) fetchProducersPage(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
func (c *Client) fetchProducersPage(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||||
q := strings.TrimSpace(query)
|
q := strings.TrimSpace(query)
|
||||||
cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit)
|
cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit)
|
||||||
reqURL := fmt.Sprintf("%s/producers?page=%d&limit=%d", c.baseURL, page, limit)
|
params := url.Values{}
|
||||||
if q != "" {
|
params.Set("page", strconv.Itoa(page))
|
||||||
reqURL += "&q=" + url.QueryEscape(q)
|
params.Set("limit", strconv.Itoa(limit))
|
||||||
}
|
setQueryValue(params, "q", q)
|
||||||
|
reqURL := buildRequestURL(c.baseURL, "/producers", params)
|
||||||
|
|
||||||
var result ProducersResponse
|
var result ProducersResponse
|
||||||
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
||||||
|
|||||||
43
integrations/jikan/query_params.go
Normal file
43
integrations/jikan/query_params.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package jikan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildRequestURL(baseURL, path string, params url.Values) string {
|
||||||
|
encoded := params.Encode()
|
||||||
|
if encoded == "" {
|
||||||
|
return fmt.Sprintf("%s%s", baseURL, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s%s?%s", baseURL, path, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setQueryValue(values url.Values, key, value string) {
|
||||||
|
if value == "" {
|
||||||
|
values.Del(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPositiveInt(values url.Values, key string, value int) {
|
||||||
|
if value <= 0 {
|
||||||
|
values.Del(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values.Set(key, strconv.Itoa(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTrueQueryValue(values url.Values, key string, enabled bool) {
|
||||||
|
if !enabled {
|
||||||
|
values.Del(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values.Set(key, "true")
|
||||||
|
}
|
||||||
66
integrations/jikan/rate/limiter.go
Normal file
66
integrations/jikan/rate/limiter.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package rate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
nextReqTime time.Time
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLimiter(interval time.Duration) *Limiter {
|
||||||
|
return &Limiter{interval: interval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait enforces minimum spacing between upstream Jikan requests.
|
||||||
|
func (l *Limiter) Wait(ctx context.Context) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
waitUntil := l.reserve(time.Now())
|
||||||
|
if waitUntil.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(time.Until(waitUntil))
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
l.release(waitUntil)
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) reserve(now time.Time) time.Time {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
if l.nextReqTime.IsZero() || now.After(l.nextReqTime) {
|
||||||
|
l.nextReqTime = now.Add(l.interval)
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
waitUntil := l.nextReqTime
|
||||||
|
l.nextReqTime = l.nextReqTime.Add(l.interval)
|
||||||
|
return waitUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) release(waitUntil time.Time) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
reservationEnd := waitUntil.Add(l.interval)
|
||||||
|
if l.nextReqTime.Equal(reservationEnd) {
|
||||||
|
l.nextReqTime = waitUntil
|
||||||
|
}
|
||||||
|
}
|
||||||
40
integrations/jikan/rate/limiter_test.go
Normal file
40
integrations/jikan/rate/limiter_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package rate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLimiterDoesNotHoldLockWhileWaiting(t *testing.T) {
|
||||||
|
limiter := NewLimiter(250 * time.Millisecond)
|
||||||
|
if err := limiter.Wait(context.Background()); err != nil {
|
||||||
|
t.Fatalf("initial wait: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstCtx, cancelFirst := context.WithCancel(context.Background())
|
||||||
|
defer cancelFirst()
|
||||||
|
|
||||||
|
firstDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
firstDone <- limiter.Wait(firstCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
secondCtx, cancelSecond := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
|
defer cancelSecond()
|
||||||
|
|
||||||
|
startedAt := time.Now()
|
||||||
|
err := limiter.Wait(secondCtx)
|
||||||
|
elapsed := time.Since(startedAt)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("second wait succeeded, want context timeout")
|
||||||
|
}
|
||||||
|
if elapsed > 150*time.Millisecond {
|
||||||
|
t.Fatalf("second wait took %s, want it to observe context timeout without waiting behind first caller", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelFirst()
|
||||||
|
<-firstDone
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,6 +21,22 @@ const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
|
|||||||
const watchOrderCacheTTL = time.Hour * 24
|
const watchOrderCacheTTL = time.Hour * 24
|
||||||
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
|
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
|
||||||
|
|
||||||
|
type WatchOrderMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WatchOrderModeMain WatchOrderMode = "main"
|
||||||
|
WatchOrderModeComplete WatchOrderMode = "complete"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NormalizeWatchOrderMode(value string) WatchOrderMode {
|
||||||
|
switch WatchOrderMode(strings.ToLower(strings.TrimSpace(value))) {
|
||||||
|
case WatchOrderModeComplete:
|
||||||
|
return WatchOrderModeComplete
|
||||||
|
default:
|
||||||
|
return WatchOrderModeMain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// watchOrderTypeLabel normalizes watch order type to display-friendly labels.
|
// watchOrderTypeLabel normalizes watch order type to display-friendly labels.
|
||||||
func watchOrderTypeLabel(value string) string {
|
func watchOrderTypeLabel(value string) string {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
@@ -28,31 +45,25 @@ func watchOrderTypeLabel(value string) string {
|
|||||||
return "TV"
|
return "TV"
|
||||||
case "movie":
|
case "movie":
|
||||||
return "Movie"
|
return "Movie"
|
||||||
|
case "ona":
|
||||||
|
return "ONA"
|
||||||
|
case "ova":
|
||||||
|
return "OVA"
|
||||||
default:
|
default:
|
||||||
return strings.TrimSpace(value)
|
return strings.TrimSpace(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAllowedWatchOrderType returns true only for TV and Movie types (filters out specials, etc).
|
|
||||||
func isAllowedWatchOrderType(value string) bool {
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
return normalized == "tv" || normalized == "movie"
|
|
||||||
}
|
|
||||||
|
|
||||||
func relationCacheKey(id int) string {
|
|
||||||
return fmt.Sprintf("relations:watch-order:%d", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||||
cacheKey := relationCacheKey(id)
|
cacheKey := fmt.Sprintf("relations:watch-order:%d", id)
|
||||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
|
result, err := watchorder.FetchWatchOrder(requestCtx, c.fetcher.HTTPClient, watchOrderURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var statusError *watchorder.HTTPStatusError
|
var statusError *watchorder.HTTPStatusError
|
||||||
if errors.As(err, &statusError) && statusError.StatusCode == 404 {
|
if errors.As(err, &statusError) && statusError.StatusCode == http.StatusNotFound {
|
||||||
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
|
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
|
||||||
}
|
}
|
||||||
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
|
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
|
||||||
@@ -104,13 +115,21 @@ func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.Watc
|
|||||||
|
|
||||||
func (c *Client) refreshWatchOrderAsync(id int) {
|
func (c *Client) refreshWatchOrderAsync(id int) {
|
||||||
c.runAsyncRefresh(func(ctx context.Context) {
|
c.runAsyncRefresh(func(ctx context.Context) {
|
||||||
_, _ = c.refreshWatchOrder(ctx, id)
|
if _, err := c.refreshWatchOrder(ctx, id); err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"relations_watch_order_async_refresh_failed",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{"anime_id": id},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
|
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
|
||||||
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||||
cacheKey := relationCacheKey(id)
|
cacheKey := fmt.Sprintf("relations:watch-order:%d", id)
|
||||||
|
|
||||||
var cached watchorder.WatchOrderResult
|
var cached watchorder.WatchOrderResult
|
||||||
if c.getCache(ctx, cacheKey, &cached) {
|
if c.getCache(ctx, cacheKey, &cached) {
|
||||||
@@ -148,84 +167,134 @@ func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEnt
|
|||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
|
func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) ([]RelationEntry, error) {
|
||||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
|
||||||
result, err := c.getWatchOrder(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
|
|
||||||
return c.currentOnlyRelation(ctx, id)
|
|
||||||
}
|
|
||||||
observability.Warn(
|
|
||||||
"relations_watch_order_fallback_current_only",
|
|
||||||
"jikan",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"anime_id": id,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return c.currentOnlyRelation(ctx, id)
|
return c.currentOnlyRelation(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
type fetchResult struct {
|
observability.Warn(
|
||||||
index int
|
"relations_watch_order_fallback_current_only",
|
||||||
anime Anime
|
"jikan",
|
||||||
entry watchorder.WatchOrderEntry
|
"",
|
||||||
}
|
map[string]any{
|
||||||
|
"anime_id": id,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
var allowedEntries []watchorder.WatchOrderEntry
|
return c.currentOnlyRelation(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relation filter
|
||||||
|
func allowedWatchOrder(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
|
||||||
|
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
|
||||||
seen := make(map[int]bool)
|
seen := make(map[int]bool)
|
||||||
|
hasTVEntry := false
|
||||||
|
for _, entry := range result.WatchOrder {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(entry.Type), "tv") {
|
||||||
|
hasTVEntry = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allTypes := mode == WatchOrderModeComplete || !hasTVEntry
|
||||||
|
|
||||||
for _, entry := range result.WatchOrder {
|
for _, entry := range result.WatchOrder {
|
||||||
if len(allowedEntries) >= maxWatchOrderEntries {
|
if len(allowedEntries) >= maxWatchOrderEntries {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] {
|
if seen[entry.ID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
typ := strings.ToLower(strings.TrimSpace(entry.Type))
|
||||||
|
if !allTypes && typ != "tv" && typ != "movie" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
seen[entry.ID] = true
|
seen[entry.ID] = true
|
||||||
allowedEntries = append(allowedEntries, entry)
|
allowedEntries = append(allowedEntries, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return allowedEntries, seen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchEntries(ctx context.Context, entries []watchorder.WatchOrderEntry) chan fetchResult {
|
||||||
g, gCtx := errgroup.WithContext(ctx)
|
g, gCtx := errgroup.WithContext(ctx)
|
||||||
g.SetLimit(3)
|
g.SetLimit(3)
|
||||||
|
|
||||||
results := make(chan fetchResult, len(allowedEntries))
|
results := make(chan fetchResult, len(entries))
|
||||||
|
|
||||||
for i, entry := range allowedEntries {
|
for i, entry := range entries {
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
anime, err := c.GetAnimeByID(gCtx, entry.ID)
|
anime, err := c.GetAnimeByID(gCtx, entry.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
observability.Warn(
|
||||||
|
"relations_fetch_entry_failed",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": entry.ID,
|
||||||
|
"index": i,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
|
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case results <- fetchResult{index: i, anime: anime, entry: entry}:
|
case results <- fetchResult{index: i, anime: anime, entry: entry}:
|
||||||
case <-gCtx.Done():
|
case <-gCtx.Done():
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_ = g.Wait()
|
if err := g.Wait(); err != nil {
|
||||||
|
observability.Warn("relations_fetch_group_failed", "jikan", "", nil, err)
|
||||||
|
}
|
||||||
close(results)
|
close(results)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
fetched := make([]fetchResult, 0, len(allowedEntries))
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []fetchResult {
|
||||||
|
results := c.fetchEntries(ctx, entries)
|
||||||
|
|
||||||
|
fetched := make([]fetchResult, 0, len(entries))
|
||||||
for res := range results {
|
for res := range results {
|
||||||
fetched = append(fetched, res)
|
fetched = append(fetched, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-sort because they might have finished out of order
|
if len(fetched) < len(entries) {
|
||||||
|
observability.Warn(
|
||||||
|
"relations_fetch_incomplete",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"expected": len(entries),
|
||||||
|
"fetched": len(fetched),
|
||||||
|
"missing": len(entries) - len(fetched),
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
sort.Slice(fetched, func(i, j int) bool {
|
sort.Slice(fetched, func(i, j int) bool {
|
||||||
return fetched[i].index < fetched[j].index
|
return fetched[i].index < fetched[j].index
|
||||||
})
|
})
|
||||||
|
|
||||||
relations := make([]RelationEntry, 0, len(fetched)+1)
|
return fetched
|
||||||
for _, res := range fetched {
|
}
|
||||||
|
|
||||||
|
func buildRelations(results []fetchResult, id int) []RelationEntry {
|
||||||
|
relations := make([]RelationEntry, 0, len(results)+1)
|
||||||
|
for _, res := range results {
|
||||||
relations = append(relations, RelationEntry{
|
relations = append(relations, RelationEntry{
|
||||||
Anime: res.anime,
|
Anime: res.anime,
|
||||||
Relation: watchOrderTypeLabel(res.entry.Type),
|
Relation: watchOrderTypeLabel(res.entry.Type),
|
||||||
@@ -234,18 +303,46 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if !seen[id] {
|
return relations
|
||||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
relations = append([]RelationEntry{{
|
func (c *Client) ensureCurrent(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
|
||||||
Anime: currentAnime,
|
if seen[id] {
|
||||||
Relation: "Current",
|
return relations, nil
|
||||||
IsCurrent: true,
|
}
|
||||||
IsExtra: false,
|
|
||||||
}}, relations...)
|
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return append([]RelationEntry{{
|
||||||
|
Anime: currentAnime,
|
||||||
|
Relation: "Current",
|
||||||
|
IsCurrent: true,
|
||||||
|
IsExtra: false,
|
||||||
|
}}, relations...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fetchResult struct {
|
||||||
|
index int
|
||||||
|
anime Anime
|
||||||
|
entry watchorder.WatchOrderEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
|
||||||
|
func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMode) ([]RelationEntry, error) {
|
||||||
|
result, err := c.getWatchOrder(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return c.handleWatchOrderError(ctx, id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedEntries, seen := allowedWatchOrder(result, mode)
|
||||||
|
fetched := c.fetchResults(ctx, allowedEntries)
|
||||||
|
relations := buildRelations(fetched, id)
|
||||||
|
relations, err = c.ensureCurrent(ctx, id, seen, relations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(relations) == 0 {
|
if len(relations) == 0 {
|
||||||
@@ -257,6 +354,14 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
|||||||
|
|
||||||
func (c *Client) WarmFullRelations(id int) {
|
func (c *Client) WarmFullRelations(id int) {
|
||||||
c.runAsyncRefresh(func(ctx context.Context) {
|
c.runAsyncRefresh(func(ctx context.Context) {
|
||||||
_, _ = c.GetFullRelations(ctx, id)
|
if _, err := c.GetFullRelations(ctx, id, WatchOrderModeMain); err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"relations_warm_full_failed",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{"anime_id": id},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,106 @@
|
|||||||
package jikan
|
package jikan
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"mal/integrations/watchorder"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func runBoolCases(t *testing.T, tests []struct {
|
func TestNormalizeWatchOrderMode(t *testing.T) {
|
||||||
name string
|
tests := []struct {
|
||||||
input string
|
name string
|
||||||
want bool
|
input string
|
||||||
}, fn func(string) bool) {
|
want WatchOrderMode
|
||||||
t.Helper()
|
}{
|
||||||
|
{name: "empty defaults main", input: "", want: WatchOrderModeMain},
|
||||||
|
{name: "main", input: "main", want: WatchOrderModeMain},
|
||||||
|
{name: "complete", input: "complete", want: WatchOrderModeComplete},
|
||||||
|
{name: "case and whitespace", input: " COMPLETE ", want: WatchOrderModeComplete},
|
||||||
|
{name: "unknown defaults main", input: "everything", want: WatchOrderModeMain},
|
||||||
|
}
|
||||||
|
|
||||||
for _, testCase := range tests {
|
for _, testCase := range tests {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
got := fn(testCase.input)
|
got := NormalizeWatchOrderMode(testCase.input)
|
||||||
if got != testCase.want {
|
if got != testCase.want {
|
||||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
t.Fatalf("expected %q, got %q", testCase.want, got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsAllowedWatchOrderType(t *testing.T) {
|
func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) {
|
||||||
tests := []struct {
|
result := watchorder.WatchOrderResult{
|
||||||
name string
|
WatchOrder: []watchorder.WatchOrderEntry{
|
||||||
input string
|
{ID: 1, Type: "TV"},
|
||||||
want bool
|
{ID: 2, Type: "Special"},
|
||||||
}{
|
{ID: 3, Type: " Movie "},
|
||||||
{name: "tv", input: "tv", want: true},
|
{ID: 4, Type: "ONA"},
|
||||||
{name: "movie", input: "movie", want: true},
|
},
|
||||||
{name: "case and whitespace", input: " TV ", want: true},
|
|
||||||
{name: "tv special", input: "tv special", want: false},
|
|
||||||
{name: "ova", input: "ova", want: false},
|
|
||||||
{name: "empty", input: "", want: false},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[0].ID != 1 || entries[1].ID != 3 {
|
||||||
|
t.Fatalf("unexpected entries: %+v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seen[1] || !seen[3] || seen[2] || seen[4] {
|
||||||
|
t.Fatalf("unexpected seen map: %+v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesWhenNoTVExists(t *testing.T) {
|
||||||
|
result := watchorder.WatchOrderResult{
|
||||||
|
WatchOrder: []watchorder.WatchOrderEntry{
|
||||||
|
{ID: 1, Type: "ONA"},
|
||||||
|
{ID: 2, Type: "Special"},
|
||||||
|
{ID: 3, Type: "Movie"},
|
||||||
|
{ID: 1, Type: "ONA"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[0].ID != 1 || entries[1].ID != 2 || entries[2].ID != 3 {
|
||||||
|
t.Fatalf("unexpected entries: %+v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seen[1] || !seen[2] || !seen[3] {
|
||||||
|
t.Fatalf("unexpected seen map: %+v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesInCompleteMode(t *testing.T) {
|
||||||
|
result := watchorder.WatchOrderResult{
|
||||||
|
WatchOrder: []watchorder.WatchOrderEntry{
|
||||||
|
{ID: 1, Type: "TV"},
|
||||||
|
{ID: 2, Type: "Special"},
|
||||||
|
{ID: 3, Type: "ONA"},
|
||||||
|
{ID: 4, Type: "Movie"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, seen := allowedWatchOrder(result, WatchOrderModeComplete)
|
||||||
|
if len(entries) != 4 {
|
||||||
|
t.Fatalf("expected 4 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, entry := range entries {
|
||||||
|
wantID := index + 1
|
||||||
|
if entry.ID != wantID {
|
||||||
|
t.Fatalf("expected entry %d to have id %d, got %+v", index, wantID, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seen[1] || !seen[2] || !seen[3] || !seen[4] {
|
||||||
|
t.Fatalf("unexpected seen map: %+v", seen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWatchOrderTypeLabel(t *testing.T) {
|
func TestWatchOrderTypeLabel(t *testing.T) {
|
||||||
@@ -44,6 +111,8 @@ func TestWatchOrderTypeLabel(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "tv", input: "tv", want: "TV"},
|
{name: "tv", input: "tv", want: "TV"},
|
||||||
{name: "movie", input: "movie", want: "Movie"},
|
{name: "movie", input: "movie", want: "Movie"},
|
||||||
|
{name: "ona", input: "ona", want: "ONA"},
|
||||||
|
{name: "ova", input: "ova", want: "OVA"},
|
||||||
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
|
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,17 +125,3 @@ func TestWatchOrderTypeLabel(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAllowedWatchOrderTypeFromDataset(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "label tv", input: "TV", want: true},
|
|
||||||
{name: "label movie", input: "Movie", want: true},
|
|
||||||
{name: "label special", input: "Special", want: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
|
func normalizePage(page, limit int) (int, int) {
|
||||||
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -17,46 +16,47 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
|||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
genresParam := ""
|
return page, limit
|
||||||
if len(genres) > 0 {
|
}
|
||||||
ids := make([]string, len(genres))
|
|
||||||
for i, g := range genres {
|
func joinGenreIDs(genres []int) string {
|
||||||
ids[i] = strconv.Itoa(g)
|
if len(genres) == 0 {
|
||||||
}
|
return ""
|
||||||
genresParam = strings.Join(ids, ",")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ids := make([]string, len(genres))
|
||||||
|
for i, g := range genres {
|
||||||
|
ids[i] = strconv.Itoa(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(ids, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func advancedURL(baseURL, query, animeType, status, orderBy, sort, genres string, studioID int, sfw bool, page, limit int) string {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
setTrueQueryValue(params, "sfw", sfw)
|
||||||
|
setQueryValue(params, "q", query)
|
||||||
|
setQueryValue(params, "type", animeType)
|
||||||
|
setQueryValue(params, "status", status)
|
||||||
|
setPositiveInt(params, "producers", studioID)
|
||||||
|
setQueryValue(params, "order_by", orderBy)
|
||||||
|
setQueryValue(params, "sort", sort)
|
||||||
|
setQueryValue(params, "genres", genres)
|
||||||
|
setPositiveInt(params, "limit", limit)
|
||||||
|
|
||||||
|
return buildRequestURL(baseURL, "/anime", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
|
||||||
|
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
|
||||||
|
page, limit = normalizePage(page, limit)
|
||||||
|
genresParam := joinGenreIDs(genres)
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
||||||
|
|
||||||
var result SearchResponse
|
var result SearchResponse
|
||||||
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
|
reqURL := advancedURL(c.baseURL, query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
||||||
if sfw {
|
|
||||||
reqURL += "&sfw=true"
|
|
||||||
}
|
|
||||||
if query != "" {
|
|
||||||
reqURL += "&q=" + url.QueryEscape(query)
|
|
||||||
}
|
|
||||||
if animeType != "" {
|
|
||||||
reqURL += "&type=" + url.QueryEscape(animeType)
|
|
||||||
}
|
|
||||||
if status != "" {
|
|
||||||
reqURL += "&status=" + url.QueryEscape(status)
|
|
||||||
}
|
|
||||||
if studioID > 0 {
|
|
||||||
reqURL += "&producers=" + strconv.Itoa(studioID)
|
|
||||||
}
|
|
||||||
if orderBy != "" {
|
|
||||||
reqURL += "&order_by=" + url.QueryEscape(orderBy)
|
|
||||||
}
|
|
||||||
if sort != "" {
|
|
||||||
reqURL += "&sort=" + url.QueryEscape(sort)
|
|
||||||
}
|
|
||||||
if genresParam != "" {
|
|
||||||
reqURL += "&genres=" + genresParam
|
|
||||||
}
|
|
||||||
if limit > 0 {
|
|
||||||
reqURL += fmt.Sprintf("&limit=%d", limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||||
return SearchResult{}, err
|
return SearchResult{}, err
|
||||||
@@ -67,37 +67,3 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
|||||||
HasNextPage: result.Pagination.HasNextPage,
|
HasNextPage: result.Pagination.HasNextPage,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTopAnime returns the top-rated anime list for a given page.
|
|
||||||
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
cacheKey := fmt.Sprintf("top:%d", page)
|
|
||||||
|
|
||||||
var result TopAnimeResponse
|
|
||||||
reqURL := fmt.Sprintf("%s/top/anime?page=%d", c.baseURL, page)
|
|
||||||
|
|
||||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
|
||||||
return TopAnimeResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return TopAnimeResult{
|
|
||||||
Animes: result.Data,
|
|
||||||
HasNextPage: result.Pagination.HasNextPage,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAnimeGenres returns list of all anime genres, cached long-term.
|
|
||||||
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
|
|
||||||
const cacheKey = "anime_genres"
|
|
||||||
|
|
||||||
var result GenresResponse
|
|
||||||
reqURL := fmt.Sprintf("%s/genres/anime", c.baseURL)
|
|
||||||
|
|
||||||
if err := c.getWithCache(ctx, cacheKey, longCacheTTL, reqURL, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Data, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +32,9 @@ func (c *Client) getSeasonList(ctx context.Context, page int, season string) (To
|
|||||||
cacheKey := fmt.Sprintf("seasons_%s:%d", season, page)
|
cacheKey := fmt.Sprintf("seasons_%s:%d", season, page)
|
||||||
|
|
||||||
var result TopAnimeResponse
|
var result TopAnimeResponse
|
||||||
reqURL := fmt.Sprintf("%s/seasons/%s?page=%d", c.baseURL, season, page)
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
reqURL := buildRequestURL(c.baseURL, fmt.Sprintf("/seasons/%s", season), params)
|
||||||
|
|
||||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,76 +48,119 @@ func (c *Client) getSeasonList(ctx context.Context, page int, season string) (To
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedRandomPool seeds the in-memory pool of random anime
|
// seedRandomPool seeds the in-memory pool of random anime
|
||||||
func (c *Client) seedRandomPool(ctx context.Context) error {
|
func (c *Client) seedRandomPool(ctx context.Context) {
|
||||||
|
if !c.markRandomPoolInitialized() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.loadCachedRandomPool(ctx)
|
||||||
|
|
||||||
|
// Fetch a solid baseline in the background, then start refreshing.
|
||||||
|
go c.seedRandomPoolBaseline()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) markRandomPoolInitialized() bool {
|
||||||
c.poolMu.Lock()
|
c.poolMu.Lock()
|
||||||
|
defer c.poolMu.Unlock()
|
||||||
|
|
||||||
if c.poolInitialized {
|
if c.poolInitialized {
|
||||||
c.poolMu.Unlock()
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
c.poolInitialized = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) loadCachedRandomPool(ctx context.Context) {
|
||||||
|
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
|
||||||
|
if err != nil || len(cachedJSONs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedAnimes := decodeCachedAnime(cachedJSONs)
|
||||||
|
if len(loadedAnimes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.poolMu.Lock()
|
||||||
|
c.randomPool = append(c.randomPool, loadedAnimes...)
|
||||||
|
c.poolMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCachedAnime(cachedJSONs []string) []Anime {
|
||||||
|
loadedAnimes := make([]Anime, 0, len(cachedJSONs))
|
||||||
|
for _, dataStr := range cachedJSONs {
|
||||||
|
var anime Anime
|
||||||
|
if err := json.Unmarshal([]byte(dataStr), &anime); err != nil || anime.MalID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedAnimes = append(loadedAnimes, anime)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedAnimes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) seedRandomPoolBaseline() {
|
||||||
|
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fetchedAnimes := c.fetchBaselineAnime(bgCtx)
|
||||||
|
if len(fetchedAnimes) > 0 {
|
||||||
|
c.appendUniqueRandomPool(fetchedAnimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background refresher once seeding completes
|
||||||
|
c.startPoolRefresher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchBaselineAnime(ctx context.Context) []Anime {
|
||||||
|
topPageOne := c.fetchTopAnimePage(ctx, 1)
|
||||||
|
topPageTwo := c.fetchTopAnimePage(ctx, 2)
|
||||||
|
currentSeason := c.fetchCurrentSeasonAnime(ctx)
|
||||||
|
|
||||||
|
fetchedAnimes := make([]Anime, 0, len(topPageOne)+len(topPageTwo)+len(currentSeason))
|
||||||
|
fetchedAnimes = append(fetchedAnimes, topPageOne...)
|
||||||
|
fetchedAnimes = append(fetchedAnimes, topPageTwo...)
|
||||||
|
fetchedAnimes = append(fetchedAnimes, currentSeason...)
|
||||||
|
return fetchedAnimes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchTopAnimePage(ctx context.Context, page int) []Anime {
|
||||||
|
top, err := c.GetTopAnime(ctx, page)
|
||||||
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c.poolInitialized = true
|
|
||||||
c.poolMu.Unlock()
|
|
||||||
|
|
||||||
// 1. Try to load all cached anime from the database
|
return top.Animes
|
||||||
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
|
}
|
||||||
if err == nil && len(cachedJSONs) > 0 {
|
|
||||||
var loadedAnimes []Anime
|
|
||||||
for _, dataStr := range cachedJSONs {
|
|
||||||
var anime Anime
|
|
||||||
if err := json.Unmarshal([]byte(dataStr), &anime); err == nil && anime.MalID > 0 {
|
|
||||||
loadedAnimes = append(loadedAnimes, anime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(loadedAnimes) > 0 {
|
func (c *Client) fetchCurrentSeasonAnime(ctx context.Context) []Anime {
|
||||||
c.poolMu.Lock()
|
now, err := c.GetSeasonsNow(ctx, 1)
|
||||||
c.randomPool = append(c.randomPool, loadedAnimes...)
|
if err != nil {
|
||||||
c.poolMu.Unlock()
|
return nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch Top Anime page 1 & 2 to ensure we have a robust baseline of high-quality popular anime
|
return now.Animes
|
||||||
go func() {
|
}
|
||||||
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var fetchedAnimes []Anime
|
func (c *Client) appendUniqueRandomPool(animes []Anime) {
|
||||||
|
c.poolMu.Lock()
|
||||||
|
defer c.poolMu.Unlock()
|
||||||
|
|
||||||
top, err := c.GetTopAnime(bgCtx, 1)
|
seen := make(map[int]bool, len(c.randomPool)+len(animes))
|
||||||
if err == nil && len(top.Animes) > 0 {
|
for _, anime := range c.randomPool {
|
||||||
fetchedAnimes = append(fetchedAnimes, top.Animes...)
|
seen[anime.MalID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, anime := range animes {
|
||||||
|
if seen[anime.MalID] {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
top2, err := c.GetTopAnime(bgCtx, 2)
|
c.randomPool = append(c.randomPool, anime)
|
||||||
if err == nil && len(top2.Animes) > 0 {
|
seen[anime.MalID] = true
|
||||||
fetchedAnimes = append(fetchedAnimes, top2.Animes...)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
now, err := c.GetSeasonsNow(bgCtx, 1)
|
|
||||||
if err == nil && len(now.Animes) > 0 {
|
|
||||||
fetchedAnimes = append(fetchedAnimes, now.Animes...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fetchedAnimes) > 0 {
|
|
||||||
c.poolMu.Lock()
|
|
||||||
// Use map to de-duplicate any anime
|
|
||||||
seen := make(map[int]bool)
|
|
||||||
for _, a := range c.randomPool {
|
|
||||||
seen[a.MalID] = true
|
|
||||||
}
|
|
||||||
for _, a := range fetchedAnimes {
|
|
||||||
if !seen[a.MalID] {
|
|
||||||
c.randomPool = append(c.randomPool, a)
|
|
||||||
seen[a.MalID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.poolMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start background refresher once seeding completes
|
|
||||||
c.startPoolRefresher()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// startPoolRefresher runs in the background to slowly mix in true random anime
|
// startPoolRefresher runs in the background to slowly mix in true random anime
|
||||||
@@ -162,7 +209,7 @@ func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
|
|||||||
c.poolMu.Unlock()
|
c.poolMu.Unlock()
|
||||||
|
|
||||||
if !initialized {
|
if !initialized {
|
||||||
_ = c.seedRandomPool(ctx)
|
c.seedRandomPool(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.poolMu.RLock()
|
c.poolMu.RLock()
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package jikan
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProducerResponse struct {
|
|
||||||
Data struct {
|
|
||||||
MalID int `json:"mal_id"`
|
|
||||||
Titles []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
} `json:"titles"`
|
|
||||||
Images struct {
|
|
||||||
Jpg struct {
|
|
||||||
ImageURL string `json:"image_url"`
|
|
||||||
} `json:"jpg"`
|
|
||||||
} `json:"images"`
|
|
||||||
Favorites int `json:"favorites"`
|
|
||||||
Established string `json:"established"`
|
|
||||||
About string `json:"about"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
External []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"external"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
|
|
||||||
if id <= 0 {
|
|
||||||
return ProducerResponse{}, fmt.Errorf("invalid producer id")
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("producer:%d", id)
|
|
||||||
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
|
|
||||||
|
|
||||||
var result ProducerResponse
|
|
||||||
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
|
||||||
return ProducerResponse{}, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
352
integrations/jikan/transport/client.go
Normal file
352
integrations/jikan/transport/client.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/integrations/jikan/rate"
|
||||||
|
"mal/internal/observability"
|
||||||
|
errlog "mal/pkg"
|
||||||
|
netutil "mal/pkg/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
const slowLogThreshold = 750 * time.Millisecond
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
Limiter *rate.Limiter
|
||||||
|
TraceEnabled func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
Limiter *rate.Limiter
|
||||||
|
TraceEnabled func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIError struct {
|
||||||
|
StatusCode int
|
||||||
|
URL string
|
||||||
|
Body json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
HTTPClient: cfg.HTTPClient,
|
||||||
|
Limiter: cfg.Limiter,
|
||||||
|
TraceEnabled: cfg.TraceEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryableError returns true if the error should trigger a retry.
|
||||||
|
func IsRetryableError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiErr *APIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
return isRetryableStatus(apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchWithRetry makes an HTTP request with exponential backoff on transient failures.
|
||||||
|
func (c *Client) FetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
||||||
|
maxRetries := 5
|
||||||
|
startedAt := time.Now()
|
||||||
|
attempts := 0
|
||||||
|
logAndReturn := func(statusCode int, err error) error {
|
||||||
|
if isDoneContextError(ctx, err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.logUpstream(urlStr, statusCode, attempts, startedAt, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt := range maxRetries {
|
||||||
|
attempts = attempt + 1
|
||||||
|
if err := c.prepareRetryAttempt(ctx); err != nil {
|
||||||
|
return logAndReturn(0, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.doRequest(ctx, urlStr)
|
||||||
|
if err != nil {
|
||||||
|
retry, requestErr := handleRequestRetry(ctx, err, attempt, maxRetries)
|
||||||
|
if retry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return logAndReturn(0, requestErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode, retry, err := func() (int, bool, error) {
|
||||||
|
defer func() {
|
||||||
|
errlog.Log("failed to close jikan response body", resp.Body.Close())
|
||||||
|
}()
|
||||||
|
return handleResponseRetry(ctx, resp, urlStr, out, attempt, maxRetries)
|
||||||
|
}()
|
||||||
|
if retry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return logAndReturn(statusCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) prepareRetryAttempt(ctx context.Context) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Limiter.Wait(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequest(ctx context.Context, urlStr string) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create jikan request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", netutil.Generic)
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRequestRetry(ctx context.Context, err error, attempt int, maxRetries int) (bool, error) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return false, ctx.Err()
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt >= maxRetries-1 || !IsRetryableError(err) {
|
||||||
|
return false, fmt.Errorf("jikan api error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||||
|
return false, retryErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleResponseRetry(ctx context.Context, resp *http.Response, urlStr string, out any, attempt int, maxRetries int) (int, bool, error) {
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return handleStatusRetry(ctx, resp, urlStr, attempt, maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
if err == nil {
|
||||||
|
return resp.StatusCode, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < maxRetries-1 {
|
||||||
|
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||||
|
return resp.StatusCode, false, retryErr
|
||||||
|
}
|
||||||
|
return resp.StatusCode, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode, false, fmt.Errorf("failed to decode jikan response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatusRetry(ctx context.Context, resp *http.Response, urlStr string, attempt int, maxRetries int) (int, bool, error) {
|
||||||
|
statusCode := resp.StatusCode
|
||||||
|
apiErr := &APIError{StatusCode: statusCode, URL: urlStr}
|
||||||
|
|
||||||
|
retryAfter := time.Duration(0)
|
||||||
|
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
|
||||||
|
retryAfter = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRetryableStatus(statusCode) && attempt < maxRetries-1 {
|
||||||
|
if retryErr := waitForRetry(ctx, max(retryAfter, retryDelay(attempt))); retryErr != nil {
|
||||||
|
return statusCode, false, retryErr
|
||||||
|
}
|
||||||
|
return statusCode, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiErr.Body = readErrorBody(resp)
|
||||||
|
return statusCode, false, apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func readErrorBody(resp *http.Response) json.RawMessage {
|
||||||
|
if resp.Body == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body = []byte(strings.TrimSpace(string(body)))
|
||||||
|
if len(body) == 0 || !json.Valid(body) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.RawMessage(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRetryableStatus(statusCode int) bool {
|
||||||
|
if statusCode == http.StatusTooManyRequests {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusCode >= 500 && statusCode <= 504
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped).
|
||||||
|
func retryDelay(attempt int) time.Duration {
|
||||||
|
base := 500 * time.Millisecond
|
||||||
|
delay := base * time.Duration(1<<attempt)
|
||||||
|
if delay > 8*time.Second {
|
||||||
|
return 8 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRetryAfter parses Retry-After header value (seconds) into duration.
|
||||||
|
func parseRetryAfter(value string) (time.Duration, bool) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds, err := strconv.Atoi(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if seconds <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(seconds) * time.Second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDoneContextError(ctx context.Context, err error) bool {
|
||||||
|
return err != nil && ctx.Err() != nil && errors.Is(err, ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) logUpstream(urlStr string, statusCode int, attempts int, startedAt time.Time, err error) {
|
||||||
|
duration := time.Since(startedAt)
|
||||||
|
traceEnabled := c.TraceEnabled != nil && c.TraceEnabled()
|
||||||
|
if !traceEnabled && err == nil && statusCode < http.StatusBadRequest && duration < slowLogThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
level := observability.LogLevelInfo
|
||||||
|
if err != nil || statusCode >= http.StatusInternalServerError {
|
||||||
|
level = observability.LogLevelError
|
||||||
|
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
|
||||||
|
level = observability.LogLevelWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
observability.LogJSON(
|
||||||
|
level,
|
||||||
|
"jikan_upstream",
|
||||||
|
"jikan",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"url": urlStr,
|
||||||
|
"endpoint": endpointLabel(urlStr),
|
||||||
|
"status": statusCode,
|
||||||
|
"attempts": attempts,
|
||||||
|
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointLabel(urlStr string) string {
|
||||||
|
trimmed := strings.TrimSpace(urlStr)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "https://api.jikan.moe/v4"
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, prefix)
|
||||||
|
|
||||||
|
if idx := strings.Index(trimmed, "?"); idx >= 0 {
|
||||||
|
trimmed = trimmed[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(trimmed, "/")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(part); err == nil {
|
||||||
|
out = append(out, "{id}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/" + strings.Join(out, "/")
|
||||||
|
}
|
||||||
55
integrations/jikan/transport/client_test.go
Normal file
55
integrations/jikan/transport/client_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleStatusRetryLeavesOutputUntouched(t *testing.T) {
|
||||||
|
out := struct {
|
||||||
|
Data []struct {
|
||||||
|
MalID int `json:"mal_id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}{
|
||||||
|
Data: []struct {
|
||||||
|
MalID int `json:"mal_id"`
|
||||||
|
}{{MalID: 123}},
|
||||||
|
}
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusNotFound,
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"data":[{"mal_id":999}]}`)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode, retry, err := handleResponseRetry(context.Background(), resp, "https://example.test/anime/1", &out, 0, 1)
|
||||||
|
if statusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("statusCode = %d, want %d", statusCode, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
if retry {
|
||||||
|
t.Fatal("retry = true, want false")
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("err = %v, want APIError", err)
|
||||||
|
}
|
||||||
|
if len(out.Data) != 1 || out.Data[0].MalID != 123 {
|
||||||
|
t.Fatalf("out = %+v, want original value", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Data []struct {
|
||||||
|
MalID int `json:"mal_id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(apiErr.Body, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal APIError body: %v", err)
|
||||||
|
}
|
||||||
|
if len(body.Data) != 1 || body.Data[0].MalID != 999 {
|
||||||
|
t.Fatalf("APIError body = %+v, want decoded error body", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,29 @@ type StudioAnimeResult struct {
|
|||||||
StudioName string
|
StudioName string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProducerResponse struct {
|
||||||
|
Data struct {
|
||||||
|
MalID int `json:"mal_id"`
|
||||||
|
Titles []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"titles"`
|
||||||
|
Images struct {
|
||||||
|
Jpg struct {
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
} `json:"jpg"`
|
||||||
|
} `json:"images"`
|
||||||
|
Favorites int `json:"favorites"`
|
||||||
|
Established string `json:"established"`
|
||||||
|
About string `json:"about"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
External []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"external"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type NamedEntity struct {
|
type NamedEntity struct {
|
||||||
MalID int `json:"mal_id"`
|
MalID int `json:"mal_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -33,12 +56,18 @@ type Aired struct {
|
|||||||
String string `json:"string"`
|
String string `json:"string"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TitleEntry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
type Anime struct {
|
type Anime struct {
|
||||||
MalID int `json:"mal_id"`
|
MalID int `json:"mal_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
TitleEnglish string `json:"title_english"`
|
TitleEnglish string `json:"title_english"`
|
||||||
TitleJapanese string `json:"title_japanese"`
|
TitleJapanese string `json:"title_japanese"`
|
||||||
TitleSynonyms []string `json:"title_synonyms"`
|
TitleSynonyms []string `json:"title_synonyms"`
|
||||||
|
Titles []TitleEntry `json:"titles"`
|
||||||
Images struct {
|
Images struct {
|
||||||
Jpg struct {
|
Jpg struct {
|
||||||
LargeImageURL string `json:"large_image_url"`
|
LargeImageURL string `json:"large_image_url"`
|
||||||
@@ -156,40 +185,6 @@ type RecommendationsResponse struct {
|
|||||||
Data []RecommendationEntry `json:"data"`
|
Data []RecommendationEntry `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoredByFormatted returns formatted count (e.g. "1 234 567").
|
|
||||||
func (a Anime) ScoredByFormatted() string {
|
|
||||||
return formatNumber(a.ScoredBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MembersFormatted returns formatted count (e.g. "1 234 567").
|
|
||||||
func (a Anime) MembersFormatted() string {
|
|
||||||
return formatNumber(a.Members)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FavoritesFormatted returns formatted count (e.g. "1 234 567").
|
|
||||||
func (a Anime) FavoritesFormatted() string {
|
|
||||||
return formatNumber(a.Favorites)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatNumber adds space separators to a number (1234567 -> "1 234 567").
|
|
||||||
func formatNumber(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s := fmt.Sprintf("%d", n)
|
|
||||||
var res []string
|
|
||||||
for i := len(s); i > 0; i -= 3 {
|
|
||||||
start := max(i-3, 0)
|
|
||||||
res = append([]string{s[start:i]}, res...)
|
|
||||||
}
|
|
||||||
return strings.Join(res, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageURL returns the webp large image URL for the anime.
|
|
||||||
func (a Anime) ImageURL() string {
|
|
||||||
return a.Images.Webp.LargeImageURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShortRating extracts just the rating code (e.g. "PG-13") from full rating string.
|
// ShortRating extracts just the rating code (e.g. "PG-13") from full rating string.
|
||||||
func (a Anime) ShortRating() string {
|
func (a Anime) ShortRating() string {
|
||||||
if a.Rating == "" {
|
if a.Rating == "" {
|
||||||
@@ -230,35 +225,34 @@ func (a Anime) DurationSeconds() float64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
var hours, minutes int
|
var hours, minutes int
|
||||||
var isHours bool
|
var currentValue int
|
||||||
var currentNum string
|
hasValue := false
|
||||||
|
|
||||||
for _, c := range a.Duration {
|
for token := range strings.FieldsSeq(strings.ToLower(a.Duration)) {
|
||||||
if c >= '0' && c <= '9' {
|
value, err := strconv.Atoi(token)
|
||||||
currentNum += string(c)
|
if err == nil {
|
||||||
} else if c == ' ' && currentNum != "" {
|
currentValue = value
|
||||||
val, _ := strconv.Atoi(currentNum)
|
hasValue = true
|
||||||
if isHours {
|
continue
|
||||||
hours = val
|
}
|
||||||
} else {
|
if !hasValue {
|
||||||
minutes = val
|
continue
|
||||||
}
|
}
|
||||||
currentNum = ""
|
|
||||||
} else if len(currentNum) > 0 && (c == 'h' || c == 'H') {
|
switch {
|
||||||
isHours = true
|
case strings.HasPrefix(token, "h"):
|
||||||
val, _ := strconv.Atoi(currentNum)
|
hours = currentValue
|
||||||
hours = val
|
hasValue = false
|
||||||
currentNum = ""
|
case strings.HasPrefix(token, "m"):
|
||||||
|
minutes = currentValue
|
||||||
|
hasValue = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if currentNum != "" {
|
|
||||||
val, _ := strconv.Atoi(currentNum)
|
if hasValue {
|
||||||
if isHours {
|
minutes = currentValue
|
||||||
hours = val
|
|
||||||
} else {
|
|
||||||
minutes = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return float64(hours*60+minutes) * 60
|
return float64(hours*60+minutes) * 60
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,13 +449,16 @@ type ReviewsResponse struct {
|
|||||||
Pagination Pagination `json:"pagination"`
|
Pagination Pagination `json:"pagination"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisplayTitle returns English title if available, otherwise Japanese, then default.
|
// DisplayTitle returns English title if available, otherwise default title, titles[0], then Japanese.
|
||||||
func (a Anime) DisplayTitle() string {
|
func (a Anime) DisplayTitle() string {
|
||||||
if a.TitleEnglish != "" {
|
if a.TitleEnglish != "" {
|
||||||
return a.TitleEnglish
|
return a.TitleEnglish
|
||||||
}
|
}
|
||||||
if a.TitleJapanese != "" {
|
if a.Title != "" {
|
||||||
return a.TitleJapanese
|
return a.Title
|
||||||
}
|
}
|
||||||
return a.Title
|
if len(a.Titles) > 0 && a.Titles[0].Title != "" {
|
||||||
|
return a.Titles[0].Title
|
||||||
|
}
|
||||||
|
return a.TitleJapanese
|
||||||
}
|
}
|
||||||
|
|||||||
27
integrations/jikan/types_test.go
Normal file
27
integrations/jikan/types_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package jikan
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAnimeDisplayTitlePrefersTitleBeforeJapanese(t *testing.T) {
|
||||||
|
anime := Anime{
|
||||||
|
Title: "Cyberpunk: Edgerunners",
|
||||||
|
TitleJapanese: "サイバーパンク エッジランナーズ",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := anime.DisplayTitle(); got != "Cyberpunk: Edgerunners" {
|
||||||
|
t.Fatalf("DisplayTitle() = %q, want default title", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeDisplayTitleFallsBackToFirstTitleEntryBeforeJapanese(t *testing.T) {
|
||||||
|
anime := Anime{
|
||||||
|
TitleJapanese: "サイバーパンク エッジランナーズ",
|
||||||
|
Titles: []TitleEntry{
|
||||||
|
{Type: "Default", Title: "Cyberpunk: Edgerunners"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := anime.DisplayTitle(); got != "Cyberpunk: Edgerunners" {
|
||||||
|
t.Fatalf("DisplayTitle() = %q, want first title entry", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
integrations/playback/allanime/availability.go
Normal file
104
integrations/playback/allanime/availability.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AvailableEpisodes struct {
|
||||||
|
Sub []string
|
||||||
|
Dub []string
|
||||||
|
Raw []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
|
||||||
|
showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EpisodeAvailability{}, err
|
||||||
|
}
|
||||||
|
return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
|
||||||
|
available, err := c.GetAvailableEpisodes(ctx, showID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EpisodeAvailability{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := episodeNums(append(available.Sub, available.Raw...))
|
||||||
|
dub := episodeNums(available.Dub)
|
||||||
|
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
|
||||||
|
graphqlQuery := `query($showId: String!) {
|
||||||
|
show(_id: $showId) {
|
||||||
|
availableEpisodesDetail
|
||||||
|
lastEpisodeInfo
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
|
||||||
|
if err != nil {
|
||||||
|
return AvailableEpisodes{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := result["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return AvailableEpisodes{}, errors.New("invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
show, ok := data["show"].(map[string]any)
|
||||||
|
if !ok || show == nil {
|
||||||
|
return AvailableEpisodes{}, errors.New("show not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, ok := show["availableEpisodesDetail"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return AvailableEpisodes{}, errors.New("invalid detail")
|
||||||
|
}
|
||||||
|
|
||||||
|
return AvailableEpisodes{
|
||||||
|
Sub: stringsFrom(detail["sub"]),
|
||||||
|
Dub: stringsFrom(detail["dub"]),
|
||||||
|
Raw: stringsFrom(detail["raw"]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// episode ids
|
||||||
|
func episodeNums(raw []string) []int {
|
||||||
|
seen := make(map[int]bool, len(raw))
|
||||||
|
out := make([]int, 0, len(raw))
|
||||||
|
for _, value := range raw {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil || n <= 0 || seen[n] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[n] = true
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// graphql list
|
||||||
|
func stringsFrom(value any) []string {
|
||||||
|
items, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
str, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestParseEpisodeNumbersKeepsOnlyPositiveIntegers(t *testing.T) {
|
func TestParseEpisodeNumbersKeepsOnlyPositiveIntegers(t *testing.T) {
|
||||||
got := parseEpisodeNumbers([]string{"1", " 2 ", "2", "0", "-1", "12.5", "SP1", "6"})
|
got := episodeNums([]string{"1", " 2 ", "2", "0", "-1", "12.5", "SP1", "6"})
|
||||||
want := []int{1, 2, 6}
|
want := []int{1, 2, 6}
|
||||||
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("parseEpisodeNumbers() = %v, want %v", got, want)
|
t.Fatalf("episodeNums() = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,27 @@
|
|||||||
// Package allanime provides an integration with the AllAnime API for episode playback.
|
|
||||||
package allanime
|
package allanime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/pkg"
|
errlog "mal/pkg"
|
||||||
netutil "mal/pkg/net"
|
netutil "mal/pkg/net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allAnimeBaseURL = "https://api.allanime.day"
|
allAnimeBaseURL = "https://api.allanime.day"
|
||||||
allAnimeReferer = "https://allmanga.to/"
|
allAnimeSiteURL = "https://allanime.day"
|
||||||
|
allAnimeReferer = "https://youtu-chan.com"
|
||||||
allAnimeOrigin = "https://youtu-chan.com"
|
allAnimeOrigin = "https://youtu-chan.com"
|
||||||
defaultUserAgent = netutil.Firefox121
|
defaultUserAgent = netutil.Firefox121
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
|
||||||
)
|
|
||||||
|
|
||||||
type searchResult struct {
|
|
||||||
ID string
|
|
||||||
MalID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AvailableEpisodes struct {
|
|
||||||
Sub []string
|
|
||||||
Dub []string
|
|
||||||
Raw []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AllAnimeProvider struct {
|
type AllAnimeProvider struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
utlsClient *http.Client
|
utlsClient *http.Client
|
||||||
@@ -67,139 +45,23 @@ func (c *AllAnimeProvider) Name() string {
|
|||||||
return "AllAnime"
|
return "AllAnime"
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchQuery = `query(
|
|
||||||
$search: SearchInput
|
|
||||||
$translationType: VaildTranslationTypeEnumType
|
|
||||||
$limit: Int = 40
|
|
||||||
$page: Int = 1
|
|
||||||
$countryOrigin: VaildCountryOriginEnumType = ALL
|
|
||||||
) {
|
|
||||||
shows(
|
|
||||||
search: $search
|
|
||||||
limit: $limit
|
|
||||||
page: $page
|
|
||||||
translationType: $translationType
|
|
||||||
countryOrigin: $countryOrigin
|
|
||||||
) {
|
|
||||||
edges {
|
|
||||||
_id
|
|
||||||
malId
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
|
|
||||||
type searchData struct {
|
|
||||||
Shows struct {
|
|
||||||
Edges []struct {
|
|
||||||
ID string `json:"_id"`
|
|
||||||
MalID string `json:"malId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"edges"`
|
|
||||||
} `json:"shows"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type searchInput struct {
|
|
||||||
AllowAdult bool `json:"allowAdult"`
|
|
||||||
AllowUnknown bool `json:"allowUnknown"`
|
|
||||||
Query string `json:"query"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type searchVariables struct {
|
|
||||||
Search searchInput `json:"search"`
|
|
||||||
TranslationType string `json:"translationType"`
|
|
||||||
}
|
|
||||||
|
|
||||||
vars := searchVariables{
|
|
||||||
Search: searchInput{
|
|
||||||
AllowAdult: false,
|
|
||||||
AllowUnknown: false,
|
|
||||||
Query: query,
|
|
||||||
},
|
|
||||||
TranslationType: mode,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Referer": allAnimeReferer,
|
|
||||||
"User-Agent": defaultUserAgent,
|
|
||||||
},
|
|
||||||
BodyMax: netutil.MiB2,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]searchResult, 0, len(data.Shows.Edges))
|
|
||||||
for _, edge := range data.Shows.Edges {
|
|
||||||
id := edge.ID
|
|
||||||
malID := edge.MalID
|
|
||||||
name := edge.Name
|
|
||||||
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
|
|
||||||
name = unquoted
|
|
||||||
}
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
|
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
|
||||||
// 1. Search for the show to get its AllAnime ID
|
showID := c.showID(ctx, animeID, titleCandidates, mode)
|
||||||
// Try each title candidate, preferring results with matching malId
|
|
||||||
targetMalIDStr := strconv.Itoa(animeID)
|
|
||||||
var showID string
|
|
||||||
var firstAvailableShowID string
|
|
||||||
|
|
||||||
for _, title := range titleCandidates {
|
|
||||||
searchResults, err := c.Search(ctx, title, mode)
|
|
||||||
if err != nil || len(searchResults) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, res := range searchResults {
|
|
||||||
if res.MalID == targetMalIDStr {
|
|
||||||
showID = res.ID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if showID != "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if firstAvailableShowID == "" {
|
|
||||||
firstAvailableShowID = searchResults[0].ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if showID == "" {
|
|
||||||
showID = firstAvailableShowID
|
|
||||||
}
|
|
||||||
|
|
||||||
if showID == "" {
|
if showID == "" {
|
||||||
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get sources
|
|
||||||
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
|
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
|
||||||
if err != nil || len(sources) == 0 {
|
if err != nil || len(sources) == 0 {
|
||||||
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
|
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Return the first usable source
|
|
||||||
primary := sources[0]
|
primary := sources[0]
|
||||||
|
|
||||||
result := &domain.StreamResult{
|
result := &domain.StreamResult{
|
||||||
URL: primary.URL,
|
URL: primary.URL,
|
||||||
Referer: primary.Referer,
|
Referer: primary.Referer,
|
||||||
|
Type: primary.Type,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sub := range primary.Subtitles {
|
for _, sub := range primary.Subtitles {
|
||||||
@@ -212,65 +74,6 @@ func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCan
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
|
|
||||||
showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EpisodeAvailability{}, err
|
|
||||||
}
|
|
||||||
return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
|
|
||||||
for _, mode := range []string{"sub", "dub"} {
|
|
||||||
showID, err := c.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
|
|
||||||
if err == nil {
|
|
||||||
return showID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
|
|
||||||
available, err := c.GetAvailableEpisodes(ctx, showID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.EpisodeAvailability{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
|
|
||||||
dub := parseEpisodeNumbers(available.Dub)
|
|
||||||
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
|
|
||||||
targetMalIDStr := strconv.Itoa(animeID)
|
|
||||||
for _, title := range titleCandidates {
|
|
||||||
searchResults, err := c.Search(ctx, title, mode)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, res := range searchResults {
|
|
||||||
if res.MalID == targetMalIDStr {
|
|
||||||
return res.ID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseEpisodeNumbers(raw []string) []int {
|
|
||||||
seen := make(map[int]bool, len(raw))
|
|
||||||
out := make([]int, 0, len(raw))
|
|
||||||
for _, value := range raw {
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(value))
|
|
||||||
if err != nil || n <= 0 || seen[n] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[n] = true
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
|
func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
|
||||||
if mode, ok := variables["translationType"].(string); ok {
|
if mode, ok := variables["translationType"].(string); ok {
|
||||||
variables["translationType"] = strings.ToLower(mode)
|
variables["translationType"] = strings.ToLower(mode)
|
||||||
@@ -295,13 +98,13 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
|||||||
req.Header.Set("Referer", allAnimeReferer)
|
req.Header.Set("Referer", allAnimeReferer)
|
||||||
req.Header.Set("User-Agent", defaultUserAgent)
|
req.Header.Set("User-Agent", defaultUserAgent)
|
||||||
|
|
||||||
resp, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
|
statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if statusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("graphql status %d", resp.StatusCode)
|
return nil, fmt.Errorf("graphql status %d", statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed map[string]any
|
var parsed map[string]any
|
||||||
@@ -316,487 +119,19 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
|||||||
return parsed, nil
|
return parsed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
|
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (int, []byte, error) {
|
||||||
|
|
||||||
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
|
|
||||||
mode = strings.ToLower(mode)
|
|
||||||
|
|
||||||
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode)
|
|
||||||
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("%s/api?variables=%s&extensions=%s",
|
|
||||||
allAnimeBaseURL,
|
|
||||||
url.QueryEscape(varsJSON),
|
|
||||||
url.QueryEscape(extJSON))
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create GET request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", defaultUserAgent)
|
|
||||||
req.Header.Set("Accept", "*/*")
|
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
|
||||||
req.Header.Set("Accept-Encoding", "identity")
|
|
||||||
req.Header.Set("Referer", allAnimeReferer)
|
|
||||||
req.Header.Set("Origin", allAnimeOrigin)
|
|
||||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
|
||||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
|
||||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
||||||
|
|
||||||
resp, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("GET status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed map[string]any
|
|
||||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
|
|
||||||
return nil, fmt.Errorf("graphql error: %v", errs[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := parsed["data"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("no data in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var toBeParsed string
|
|
||||||
if s, ok := data["tobeparsed"].(string); ok && s != "" {
|
|
||||||
toBeParsed = s
|
|
||||||
} else if episodeData, ok := data["episode"].(map[string]any); ok {
|
|
||||||
if s, ok := episodeData["tobeparsed"].(string); ok {
|
|
||||||
toBeParsed = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if toBeParsed != "" {
|
|
||||||
decrypted, err := decryptTobeparsed(toBeParsed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decrypt tobeparsed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ep map[string]any
|
|
||||||
if jerr := json.Unmarshal(decrypted, &ep); jerr != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal decrypted: %w", jerr)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceURLs []any
|
|
||||||
if srcs, ok := ep["sourceUrls"].([]any); ok {
|
|
||||||
sourceURLs = srcs
|
|
||||||
} else if epInner, ok := ep["episode"].(map[string]any); ok {
|
|
||||||
if srcs, ok := epInner["sourceUrls"].([]any); ok {
|
|
||||||
sourceURLs = srcs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sourceURLs) > 0 {
|
|
||||||
return map[string]any{
|
|
||||||
"episode": map[string]any{
|
|
||||||
"sourceUrls": sourceURLs,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if episodeData, ok := data["episode"].(map[string]any); ok {
|
|
||||||
if srcs, ok := episodeData["sourceUrls"].([]any); ok && len(srcs) > 0 {
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("no usable data in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub).
|
|
||||||
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
|
|
||||||
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
|
|
||||||
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
|
|
||||||
sourceUrls
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
|
|
||||||
if err == nil {
|
|
||||||
sources := c.extractSourceURLsFromData(ctx, result)
|
|
||||||
if len(sources) > 0 {
|
|
||||||
return sources, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
|
|
||||||
"showId": showID,
|
|
||||||
"translationType": mode,
|
|
||||||
"episodeString": episode,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := result["data"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid source response")
|
|
||||||
}
|
|
||||||
|
|
||||||
rawSourceURLs, ok := data["episode"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid episode response")
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
|
|
||||||
if !ok || len(sourceURLs) == 0 {
|
|
||||||
return nil, fmt.Errorf("no source urls")
|
|
||||||
}
|
|
||||||
|
|
||||||
references := buildSourceReferences(sourceURLs)
|
|
||||||
if len(references) == 0 {
|
|
||||||
return nil, fmt.Errorf("no source references")
|
|
||||||
}
|
|
||||||
|
|
||||||
out := c.resolveSourceReferences(ctx, references)
|
|
||||||
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, fmt.Errorf("no playable sources extracted")
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
|
|
||||||
episodeData, ok := data["episode"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceURLs, ok := episodeData["sourceUrls"].([]any)
|
|
||||||
if !ok || len(sourceURLs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
references := buildSourceReferences(sourceURLs)
|
|
||||||
if len(references) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.resolveSourceReferences(ctx, references)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
|
|
||||||
out := make([]StreamSource, 0, len(references))
|
|
||||||
for _, ref := range references {
|
|
||||||
target := strings.TrimSpace(ref.URL)
|
|
||||||
if target == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
|
||||||
sourceType := detectStreamType(target)
|
|
||||||
if sourceType == "unknown" {
|
|
||||||
sourceType = detectEmbedType(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, buildStreamSource(target, sourceType, ref.Name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeSourceURL(target)
|
|
||||||
if decoded == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
|
|
||||||
sourceType := detectStreamType(decoded)
|
|
||||||
if sourceType == "unknown" {
|
|
||||||
sourceType = detectEmbedType(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, buildStreamSource(decoded, sourceType, ref.Name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(decoded, "/") {
|
|
||||||
decoded = "/" + decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, extracted...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (*http.Response, []byte, error) {
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
|
return 0, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() {
|
||||||
|
errlog.Log("failed to close allanime response body", resp.Body.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
|
return 0, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, body, nil
|
return resp.StatusCode, body, nil
|
||||||
}
|
|
||||||
|
|
||||||
func buildStreamSource(url, sourceType, provider string) StreamSource {
|
|
||||||
return StreamSource{
|
|
||||||
URL: url,
|
|
||||||
Provider: provider,
|
|
||||||
Type: sourceType,
|
|
||||||
Referer: allAnimeReferer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type sourceReference struct {
|
|
||||||
URL string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSourceReferences orders source URLs by provider priority, deduplicating entries.
|
|
||||||
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
|
||||||
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
|
||||||
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
|
|
||||||
|
|
||||||
prioritized := make(map[string]sourceReference)
|
|
||||||
fallback := make([]sourceReference, 0, len(rawSourceURLs))
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
|
|
||||||
for _, source := range rawSourceURLs {
|
|
||||||
item, ok := source.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceURL, _ := item["sourceUrl"].(string)
|
|
||||||
sourceName, _ := item["sourceName"].(string)
|
|
||||||
sourceURL = strings.TrimSpace(sourceURL)
|
|
||||||
sourceName = strings.TrimSpace(sourceName)
|
|
||||||
if sourceURL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := seen[sourceURL]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[sourceURL] = struct{}{}
|
|
||||||
|
|
||||||
ref := sourceReference{URL: sourceURL, Name: sourceName}
|
|
||||||
normalized := strings.ToLower(sourceName)
|
|
||||||
// separate prioritized providers from fallback
|
|
||||||
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
|
|
||||||
if _, exists := prioritized[normalized]; !exists {
|
|
||||||
prioritized[normalized] = ref
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fallback = append(fallback, ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
// output: prioritized in order, then fallback
|
|
||||||
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
|
|
||||||
for _, provider := range priorityOrder {
|
|
||||||
if ref, ok := prioritized[provider]; ok {
|
|
||||||
ordered = append(ordered, ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ordered = append(ordered, fallback...)
|
|
||||||
return ordered
|
|
||||||
}
|
|
||||||
|
|
||||||
func decryptTobeparsed(encoded string) ([]byte, error) {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("base64 decode failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(raw) < 29 {
|
|
||||||
return nil, fmt.Errorf("encrypted payload too short")
|
|
||||||
}
|
|
||||||
|
|
||||||
version := raw[0]
|
|
||||||
iv := raw[1:13]
|
|
||||||
cipherText := raw[13 : len(raw)-16]
|
|
||||||
|
|
||||||
for _, keyStr := range aesKeys {
|
|
||||||
key := sha256.Sum256([]byte(keyStr))
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(key[:])
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if version == 1 {
|
|
||||||
plainText := tryDecryptCTR(block, iv, cipherText)
|
|
||||||
if json.Valid(plainText) {
|
|
||||||
return plainText, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err == nil {
|
|
||||||
tag := raw[len(raw)-16:]
|
|
||||||
combined := append(append([]byte{}, cipherText...), tag...)
|
|
||||||
plainText, openErr := gcm.Open(nil, iv, combined, nil)
|
|
||||||
if openErr == nil && json.Valid(plainText) {
|
|
||||||
return plainText, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("decryption failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
|
|
||||||
ctrIV := append([]byte{}, iv...)
|
|
||||||
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
|
|
||||||
ctr := cipher.NewCTR(block, ctrIV)
|
|
||||||
plainText := make([]byte, len(cipherText))
|
|
||||||
ctr.XORKeyStream(plainText, cipherText)
|
|
||||||
return plainText
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show.
|
|
||||||
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
|
|
||||||
graphqlQuery := `query($showId: String!) {
|
|
||||||
show(_id: $showId) {
|
|
||||||
availableEpisodesDetail
|
|
||||||
lastEpisodeInfo
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
|
|
||||||
if err != nil {
|
|
||||||
return AvailableEpisodes{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := result["data"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return AvailableEpisodes{}, fmt.Errorf("invalid response")
|
|
||||||
}
|
|
||||||
|
|
||||||
show, ok := data["show"].(map[string]any)
|
|
||||||
if !ok || show == nil {
|
|
||||||
return AvailableEpisodes{}, fmt.Errorf("show not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
detail, ok := show["availableEpisodesDetail"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return AvailableEpisodes{}, fmt.Errorf("invalid detail")
|
|
||||||
}
|
|
||||||
|
|
||||||
var count AvailableEpisodes
|
|
||||||
if sub, ok := detail["sub"].([]any); ok {
|
|
||||||
for _, s := range sub {
|
|
||||||
if str, ok := s.(string); ok {
|
|
||||||
count.Sub = append(count.Sub, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dub, ok := detail["dub"].([]any); ok {
|
|
||||||
for _, s := range dub {
|
|
||||||
if str, ok := s.(string); ok {
|
|
||||||
count.Dub = append(count.Dub, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if raw, ok := detail["raw"].([]any); ok {
|
|
||||||
for _, s := range raw {
|
|
||||||
if str, ok := s.(string); ok {
|
|
||||||
count.Raw = append(count.Raw, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeSourceURL(encoded string) string {
|
|
||||||
if encoded == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded = strings.TrimPrefix(encoded, "--")
|
|
||||||
|
|
||||||
substitutions := map[string]string{
|
|
||||||
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
|
|
||||||
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
|
|
||||||
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
|
|
||||||
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
|
|
||||||
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
|
|
||||||
"62": "Z",
|
|
||||||
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
|
|
||||||
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
|
|
||||||
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
|
|
||||||
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
|
|
||||||
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
|
|
||||||
"42": "z",
|
|
||||||
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
|
|
||||||
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
|
|
||||||
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
|
|
||||||
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
|
|
||||||
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
|
|
||||||
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
|
|
||||||
"05": "=", "1d": "%",
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
for idx := 0; idx < len(encoded); {
|
|
||||||
if idx+2 <= len(encoded) {
|
|
||||||
pair := encoded[idx : idx+2]
|
|
||||||
if sub, ok := substitutions[pair]; ok {
|
|
||||||
result.WriteString(sub)
|
|
||||||
idx += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.WriteByte(encoded[idx])
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := result.String()
|
|
||||||
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
|
|
||||||
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectStreamType(sourceURL string) string {
|
|
||||||
lower := strings.ToLower(sourceURL)
|
|
||||||
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
|
|
||||||
return "m3u8"
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(lower, ".mp4") {
|
|
||||||
return "mp4"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectEmbedType(rawURL string) string {
|
|
||||||
lower := strings.ToLower(rawURL)
|
|
||||||
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
|
|
||||||
for _, host := range embedHosts {
|
|
||||||
if strings.Contains(lower, host) {
|
|
||||||
return "embed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"encoding/json"
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -20,167 +22,209 @@ func isLikelyMP4(data []byte) bool {
|
|||||||
return string(data[4:8]) == "ftyp"
|
return string(data[4:8]) == "ftyp"
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecodeSourceURL(t *testing.T) {
|
type stringTransformTestCase struct {
|
||||||
t.Parallel()
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
type sourceReferencesTestCase struct {
|
||||||
name string
|
name string
|
||||||
encoded string
|
rawURLs []any
|
||||||
want string
|
wantRefs []sourceReference
|
||||||
}{
|
}
|
||||||
{
|
|
||||||
name: "empty returns empty",
|
var _ interface {
|
||||||
encoded: "",
|
GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
|
||||||
want: "",
|
} = (*AllAnimeProvider)(nil)
|
||||||
},
|
|
||||||
{
|
func runStringTransformTests(t *testing.T, tests []stringTransformTestCase, fn func(string) string) {
|
||||||
name: "with double prefix stripped",
|
t.Helper()
|
||||||
encoded: "--example.com/video.mp4",
|
|
||||||
want: "example.com/video.mp4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "hex substitution",
|
|
||||||
encoded: "7aexample",
|
|
||||||
want: "Bexample",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed substitution",
|
|
||||||
encoded: "79url7a01",
|
|
||||||
want: "AurlB9",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "clock replacement",
|
|
||||||
encoded: "/clock",
|
|
||||||
want: "/clock.json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no clock replacement if already json",
|
|
||||||
encoded: "/clock.json",
|
|
||||||
want: "/clock.json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "complex url",
|
|
||||||
encoded: "--79stream7acom",
|
|
||||||
want: "AstreamBcom",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got := decodeSourceURL(tt.encoded)
|
got := fn(tt.input)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("decodeSourceURL(%q) = %q, want %q", tt.encoded, got, tt.want)
|
t.Errorf("got %q for input %q, want %q", got, tt.input, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runSourceReferenceTests(t *testing.T, tests []sourceReferencesTestCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := sourceRefs(tt.rawURLs)
|
||||||
|
if len(got) != len(tt.wantRefs) {
|
||||||
|
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range tt.wantRefs {
|
||||||
|
if got[i].URL != want.URL {
|
||||||
|
t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL)
|
||||||
|
}
|
||||||
|
if got[i].Name != want.Name {
|
||||||
|
t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEncryptedTobeparsedPayload(t *testing.T, plaintext []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key := sha256.Sum256([]byte(aesKeys[0]))
|
||||||
|
block, err := aes.NewCipher(key[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create cipher: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iv := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}
|
||||||
|
ctrIV := append([]byte{}, iv...)
|
||||||
|
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
|
||||||
|
|
||||||
|
cipherText := make([]byte, len(plaintext))
|
||||||
|
cipher.NewCTR(block, ctrIV).XORKeyStream(cipherText, plaintext)
|
||||||
|
|
||||||
|
raw := append([]byte{1}, iv...)
|
||||||
|
raw = append(raw, cipherText...)
|
||||||
|
raw = append(raw, make([]byte, 16)...)
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSourceURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []stringTransformTestCase{
|
||||||
|
{
|
||||||
|
name: "empty returns empty",
|
||||||
|
input: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with double prefix stripped",
|
||||||
|
input: "--example.com/video.mp4",
|
||||||
|
want: "example.com/video.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hex substitution",
|
||||||
|
input: "7aexample",
|
||||||
|
want: "Bexample",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed substitution",
|
||||||
|
input: "79url7a01",
|
||||||
|
want: "AurlB9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clock replacement",
|
||||||
|
input: "/clock",
|
||||||
|
want: "/clock.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no clock replacement if already json",
|
||||||
|
input: "/clock.json",
|
||||||
|
want: "/clock.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex url",
|
||||||
|
input: "--79stream7acom",
|
||||||
|
want: "AstreamBcom",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runStringTransformTests(t, tests, decodeSourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectStreamType(t *testing.T) {
|
func TestDetectStreamType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []stringTransformTestCase{
|
||||||
name string
|
|
||||||
url string
|
|
||||||
wantType string
|
|
||||||
}{
|
|
||||||
{
|
{
|
||||||
name: "m3u8 extension",
|
name: "m3u8 extension",
|
||||||
url: "https://example.com/video.m3u8",
|
input: "https://example.com/video.m3u8",
|
||||||
wantType: "m3u8",
|
want: "m3u8",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "master m3u8",
|
name: "master m3u8",
|
||||||
url: "https://example.com/master.m3u8",
|
input: "https://example.com/master.m3u8",
|
||||||
wantType: "m3u8",
|
want: "m3u8",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mp4 extension",
|
name: "mp4 extension",
|
||||||
url: "https://example.com/video.mp4",
|
input: "https://example.com/video.mp4",
|
||||||
wantType: "mp4",
|
want: "mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown",
|
name: "unknown",
|
||||||
url: "https://example.com/video.avi",
|
input: "https://example.com/video.avi",
|
||||||
wantType: "unknown",
|
want: "unknown",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty returns unknown",
|
name: "empty returns unknown",
|
||||||
url: "",
|
input: "",
|
||||||
wantType: "unknown",
|
want: "unknown",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "case insensitive - M3U8",
|
name: "case insensitive - M3U8",
|
||||||
url: "https://example.com/MASTER.M3U8",
|
input: "https://example.com/MASTER.M3U8",
|
||||||
wantType: "m3u8",
|
want: "m3u8",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
runStringTransformTests(t, tests, detectStreamType)
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
got := detectStreamType(tt.url)
|
|
||||||
if got != tt.wantType {
|
|
||||||
t.Errorf("detectStreamType(%q) = %q, want %q", tt.url, got, tt.wantType)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectEmbedType(t *testing.T) {
|
func TestDetectEmbedType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []stringTransformTestCase{
|
||||||
name string
|
|
||||||
url string
|
|
||||||
wantType string
|
|
||||||
}{
|
|
||||||
{
|
{
|
||||||
name: "streamwish",
|
name: "streamwish",
|
||||||
url: "https://streamwish.com/e/abc123",
|
input: "https://streamwish.com/e/abc123",
|
||||||
wantType: "embed",
|
want: "embed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "streamsb",
|
name: "streamsb",
|
||||||
url: "https://streamsb.com/e/abc123",
|
input: "https://streamsb.com/e/abc123",
|
||||||
wantType: "embed",
|
want: "embed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mp4upload",
|
name: "mp4upload",
|
||||||
url: "https://mp4upload.com/e/abc123",
|
input: "https://mp4upload.com/e/abc123",
|
||||||
wantType: "embed",
|
want: "embed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ok.ru",
|
name: "ok.ru",
|
||||||
url: "https://ok.ru/video/123",
|
input: "https://ok.ru/video/123",
|
||||||
wantType: "embed",
|
want: "embed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gogoplay",
|
name: "gogoplay",
|
||||||
url: "https://gogoplay.io/embed/123",
|
input: "https://gogoplay.io/embed/123",
|
||||||
wantType: "embed",
|
want: "embed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "streamlare",
|
name: "streamlare",
|
||||||
url: "https://streamlare.com/e/abc",
|
input: "https://streamlare.com/e/abc",
|
||||||
wantType: "embed",
|
want: "embed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown host",
|
name: "unknown host",
|
||||||
url: "https://unknown.com/video",
|
input: "https://unknown.com/video",
|
||||||
wantType: "unknown",
|
want: "unknown",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
runStringTransformTests(t, tests, detectEmbedType)
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
got := detectEmbedType(tt.url)
|
|
||||||
if got != tt.wantType {
|
|
||||||
t.Errorf("detectEmbedType(%q) = %q, want %q", tt.url, got, tt.wantType)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildStreamSource(t *testing.T) {
|
func TestBuildStreamSource(t *testing.T) {
|
||||||
@@ -204,14 +248,21 @@ func TestBuildStreamSource(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if _, ok := directSource(sourceReference{
|
||||||
|
URL: "https://ok.ru/videoembed/123",
|
||||||
|
Name: "ok",
|
||||||
|
}); ok {
|
||||||
|
t.Fatal("expected embed URL to require extraction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildSourceReferences(t *testing.T) {
|
func TestBuildSourceReferences(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []sourceReferencesTestCase{
|
||||||
name string
|
|
||||||
rawURLs []any
|
|
||||||
wantRefs []sourceReference
|
|
||||||
}{
|
|
||||||
{
|
{
|
||||||
name: "empty returns empty",
|
name: "empty returns empty",
|
||||||
rawURLs: nil,
|
rawURLs: nil,
|
||||||
@@ -263,26 +314,7 @@ func TestBuildSourceReferences(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
runSourceReferenceTests(t, tests)
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
got := buildSourceReferences(tt.rawURLs)
|
|
||||||
|
|
||||||
if len(got) != len(tt.wantRefs) {
|
|
||||||
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, want := range tt.wantRefs {
|
|
||||||
if got[i].URL != want.URL {
|
|
||||||
t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL)
|
|
||||||
}
|
|
||||||
if got[i].Name != want.Name {
|
|
||||||
t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildSourceReferencesOrder(t *testing.T) {
|
func TestBuildSourceReferencesOrder(t *testing.T) {
|
||||||
@@ -295,7 +327,7 @@ func TestBuildSourceReferencesOrder(t *testing.T) {
|
|||||||
map[string]any{"sourceUrl": "https://yt.com/v.mp4", "sourceName": "yt-mp4"},
|
map[string]any{"sourceUrl": "https://yt.com/v.mp4", "sourceName": "yt-mp4"},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := buildSourceReferences(rawURLs)
|
got := sourceRefs(rawURLs)
|
||||||
|
|
||||||
wantOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
wantOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
||||||
if len(got) != len(wantOrder) {
|
if len(got) != len(wantOrder) {
|
||||||
@@ -391,22 +423,41 @@ func TestIsLikelyMP4(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseOKRUSources(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://vd.example.test/video.m3u8?cmd=videoPlayerCdn\\u0026id=123\"}"}}`
|
||||||
|
|
||||||
|
got := parseOKRUSources(body, allAnimeReferer)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len(got) = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got[0].URL != "https://vd.example.test/video.m3u8?cmd=videoPlayerCdn&id=123" {
|
||||||
|
t.Fatalf("URL = %q", got[0].URL)
|
||||||
|
}
|
||||||
|
if got[0].Type != "m3u8" {
|
||||||
|
t.Fatalf("Type = %q, want m3u8", got[0].Type)
|
||||||
|
}
|
||||||
|
if got[0].Provider != "ok" {
|
||||||
|
t.Fatalf("Provider = %q, want ok", got[0].Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDecryptTobeparsed(t *testing.T) {
|
func TestDecryptTobeparsed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
t.Run("valid encrypted payload with first key", func(t *testing.T) {
|
t.Run("valid encrypted payload with first key", func(t *testing.T) {
|
||||||
payload := "AQAAAAABc2S7yj94zW6j4A8d9D6C3qFvYjR1hI4L6z1J3qKj5pXhKj"
|
plaintext := []byte(`{"ok":true,"items":[1,2,3]}`)
|
||||||
|
payload := buildEncryptedTobeparsedPayload(t, plaintext)
|
||||||
|
|
||||||
decrypted, err := decryptTobeparsed(payload)
|
decrypted, err := decryptTobeparsed(payload)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
var result map[string]any
|
t.Fatalf("decryptTobeparsed: %v", err)
|
||||||
if err := json.Unmarshal(decrypted, &result); err != nil {
|
}
|
||||||
t.Logf("decrypted (not valid json): %s", string(decrypted))
|
|
||||||
} else {
|
if string(decrypted) != string(plaintext) {
|
||||||
t.Logf("decrypted: %+v", result)
|
t.Fatalf("decrypted = %q, want %q", decrypted, plaintext)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Logf("expected decryption to succeed or fail gracefully: %v", err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -441,21 +492,16 @@ func TestTryDecryptCTR(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iv := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}
|
iv := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}
|
||||||
cipherText := []byte("test plaintext ")
|
plaintext := []byte("test plaintext ")
|
||||||
|
|
||||||
plainText := tryDecryptCTR(block, iv, cipherText)
|
ctrIV := append([]byte{}, iv...)
|
||||||
_ = plainText
|
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
|
||||||
|
cipherText := make([]byte, len(plaintext))
|
||||||
|
cipher.NewCTR(block, ctrIV).XORKeyStream(cipherText, plaintext)
|
||||||
|
|
||||||
|
got := tryDecryptCTR(block, iv, cipherText)
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Fatalf("tryDecryptCTR() = %q, want %q", got, plaintext)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAllAnimeClientImplementsInterfaces(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ interface {
|
|
||||||
GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
|
|
||||||
} = &AllAnimeProvider{}
|
|
||||||
)
|
|
||||||
|
|
||||||
t.Log("allAnimeClient implements required interfaces")
|
|
||||||
}
|
|
||||||
|
|||||||
234
integrations/playback/allanime/crypto.go
Normal file
234
integrations/playback/allanime/crypto.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func decryptTobeparsed(encoded string) ([]byte, error) {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("base64 decode failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(raw) < 29 {
|
||||||
|
return nil, errors.New("encrypted payload too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := raw[0]
|
||||||
|
iv := raw[1:13]
|
||||||
|
cipherText := raw[13 : len(raw)-16]
|
||||||
|
|
||||||
|
for _, keyStr := range aesKeys {
|
||||||
|
key := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key[:])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if version == 1 {
|
||||||
|
plainText := tryDecryptCTR(block, iv, cipherText)
|
||||||
|
if json.Valid(plainText) {
|
||||||
|
return plainText, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err == nil {
|
||||||
|
tag := raw[len(raw)-16:]
|
||||||
|
combined := append(append([]byte{}, cipherText...), tag...)
|
||||||
|
plainText, openErr := gcm.Open(nil, iv, combined, nil)
|
||||||
|
if openErr == nil && json.Valid(plainText) {
|
||||||
|
return plainText, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("decryption failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
|
||||||
|
ctrIV := append([]byte{}, iv...)
|
||||||
|
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
|
||||||
|
ctr := cipher.NewCTR(block, ctrIV)
|
||||||
|
plainText := make([]byte, len(cipherText))
|
||||||
|
ctr.XORKeyStream(plainText, cipherText)
|
||||||
|
return plainText
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSourceURL(encoded string) string {
|
||||||
|
if encoded == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded = strings.TrimPrefix(encoded, "--")
|
||||||
|
|
||||||
|
substitutions := map[string]string{
|
||||||
|
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
|
||||||
|
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
|
||||||
|
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
|
||||||
|
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
|
||||||
|
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
|
||||||
|
"62": "Z",
|
||||||
|
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
|
||||||
|
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
|
||||||
|
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
|
||||||
|
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
|
||||||
|
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
|
||||||
|
"42": "z",
|
||||||
|
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
|
||||||
|
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
|
||||||
|
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
|
||||||
|
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
|
||||||
|
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
|
||||||
|
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
|
||||||
|
"05": "=", "1d": "%",
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
for idx := 0; idx < len(encoded); {
|
||||||
|
if idx+2 <= len(encoded) {
|
||||||
|
pair := encoded[idx : idx+2]
|
||||||
|
if sub, ok := substitutions[pair]; ok {
|
||||||
|
result.WriteString(sub)
|
||||||
|
idx += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.WriteByte(encoded[idx])
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := result.String()
|
||||||
|
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
|
||||||
|
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
|
||||||
|
toBeParsed := firstString(
|
||||||
|
nestedString(data, "tobeparsed"),
|
||||||
|
nestedString(data, "episode", "tobeparsed"),
|
||||||
|
)
|
||||||
|
if toBeParsed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := decryptTobeparsed(toBeParsed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt tobeparsed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := parseGraphQLResponse(decrypted, "unmarshal decrypted")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURLs := firstSlice(
|
||||||
|
nestedSlice(parsed, "sourceUrls"),
|
||||||
|
nestedSlice(parsed, "episode", "sourceUrls"),
|
||||||
|
)
|
||||||
|
if len(sourceURLs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"episode": map[string]any{
|
||||||
|
"sourceUrls": sourceURLs,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]any, error) {
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
|
||||||
|
return nil, fmt.Errorf("graphql error: %v", errs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// first non-empty
|
||||||
|
func firstString(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// first non-empty
|
||||||
|
func firstSlice(values ...[]any) []any {
|
||||||
|
for _, value := range values {
|
||||||
|
if len(value) > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedString(data map[string]any, path ...string) string {
|
||||||
|
value, ok := nestedValue(data, path...)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
str, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedSlice(data map[string]any, path ...string) []any {
|
||||||
|
value, ok := nestedValue(data, path...)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slice, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedValue(data map[string]any, path ...string) (any, bool) {
|
||||||
|
var current any = data
|
||||||
|
for _, key := range path {
|
||||||
|
currentMap, ok := current.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
current, ok = currentMap[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
|
errlog "mal/pkg"
|
||||||
netutil "mal/pkg/net"
|
netutil "mal/pkg/net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -19,10 +21,27 @@ type providerExtractor struct {
|
|||||||
referer string
|
referer string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type providerLinkItem struct {
|
||||||
|
link string
|
||||||
|
resolutionStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerHLSItem struct {
|
||||||
|
url string
|
||||||
|
hardsubLang string
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerResponseData struct {
|
||||||
|
referer string
|
||||||
|
links []providerLinkItem
|
||||||
|
hls []providerHLSItem
|
||||||
|
subtitles []Subtitle
|
||||||
|
}
|
||||||
|
|
||||||
func newProviderExtractor() *providerExtractor {
|
func newProviderExtractor() *providerExtractor {
|
||||||
return &providerExtractor{
|
return &providerExtractor{
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
baseURL: allAnimeBaseURL,
|
baseURL: allAnimeSiteURL,
|
||||||
referer: allAnimeReferer,
|
referer: allAnimeReferer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,75 +72,60 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() {
|
||||||
|
errlog.Log("failed to close provider response body", resp.Body.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) // 2MB limit
|
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) // 2MB limit
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read provider response: %w", err)
|
return nil, fmt.Errorf("read provider response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.parseProviderResponse(ctx, string(body)), nil
|
return e.parseResponse(ctx, string(body)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseProviderResponse extracts stream sources from provider JSON response.
|
func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL string) ([]StreamSource, error) {
|
||||||
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource {
|
resp, err := doProxiedRequest(ctx, e.httpClient, rawURL, e.referer)
|
||||||
sources := make([]StreamSource, 0)
|
if err != nil {
|
||||||
providerReferer := e.referer
|
return nil, fmt.Errorf("fetch embed response: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
errlog.Log("failed to close embed response body", resp.Body.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read embed response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseEmbed(rawURL, string(body), e.referer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// provider response
|
||||||
|
func (e *providerExtractor) parseResponse(ctx context.Context, response string) []StreamSource {
|
||||||
var root any
|
var root any
|
||||||
if err := json.Unmarshal([]byte(response), &root); err != nil {
|
if err := json.Unmarshal([]byte(response), &root); err != nil {
|
||||||
return sources
|
return []StreamSource{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type linkItem struct {
|
data := collectData(root, e.referer)
|
||||||
link string
|
sources := linkSources(data.links, data.referer)
|
||||||
resolutionStr string
|
sources = append(sources, e.hlsSources(ctx, data.hls, data.referer)...)
|
||||||
}
|
|
||||||
type hlsItem struct {
|
|
||||||
url string
|
|
||||||
hardsubLang string
|
|
||||||
}
|
|
||||||
|
|
||||||
linkItems := make([]linkItem, 0)
|
attachSubtitles(sources, data.subtitles)
|
||||||
hlsItems := make([]hlsItem, 0)
|
|
||||||
subtitles := make([]Subtitle, 0)
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
// provider payload
|
||||||
|
func collectData(root any, fallbackReferer string) providerResponseData {
|
||||||
|
data := providerResponseData{referer: fallbackReferer}
|
||||||
|
|
||||||
var walk func(v any)
|
var walk func(v any)
|
||||||
walk = func(v any) {
|
walk = func(v any) {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
if ref, ok := x["Referer"].(string); ok && strings.TrimSpace(ref) != "" {
|
collectMapData(x, &data)
|
||||||
providerReferer = strings.TrimSpace(ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
if link, ok := x["link"].(string); ok {
|
|
||||||
if res, ok := x["resolutionStr"].(string); ok {
|
|
||||||
linkItems = append(linkItems, linkItem{link: link, resolutionStr: res})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if u, ok := x["url"].(string); ok {
|
|
||||||
if lang, ok := x["hardsub_lang"].(string); ok {
|
|
||||||
hlsItems = append(hlsItems, hlsItem{url: u, hardsubLang: lang})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if subs, ok := x["subtitles"].([]any); ok {
|
|
||||||
for _, sub := range subs {
|
|
||||||
obj, ok := sub.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lang, _ := obj["lang"].(string)
|
|
||||||
src, _ := obj["src"].(string)
|
|
||||||
lang = strings.TrimSpace(lang)
|
|
||||||
src = strings.TrimSpace(src)
|
|
||||||
if lang == "" || src == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, child := range x {
|
for _, child := range x {
|
||||||
walk(child)
|
walk(child)
|
||||||
}
|
}
|
||||||
@@ -133,42 +137,104 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
|||||||
}
|
}
|
||||||
|
|
||||||
walk(root)
|
walk(root)
|
||||||
|
if data.referer == "" {
|
||||||
if providerReferer == "" {
|
data.referer = fallbackReferer
|
||||||
providerReferer = e.referer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range linkItems {
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMapData(node map[string]any, data *providerResponseData) {
|
||||||
|
if ref, ok := node["Referer"].(string); ok {
|
||||||
|
if trimmedRef := strings.TrimSpace(ref); trimmedRef != "" {
|
||||||
|
data.referer = trimmedRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if link, ok := node["link"].(string); ok {
|
||||||
|
if res, ok := node["resolutionStr"].(string); ok {
|
||||||
|
data.links = append(data.links, providerLinkItem{link: link, resolutionStr: res})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if url, ok := node["url"].(string); ok {
|
||||||
|
if lang, ok := node["hardsub_lang"].(string); ok {
|
||||||
|
data.hls = append(data.hls, providerHLSItem{url: url, hardsubLang: lang})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subs, ok := node["subtitles"].([]any); ok {
|
||||||
|
data.subtitles = append(data.subtitles, parseSubtitles(subs)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSubtitles(items []any) []Subtitle {
|
||||||
|
subtitles := make([]Subtitle, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
node, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lang, ok := node["lang"].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, ok := node["src"].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lang = strings.TrimSpace(lang)
|
||||||
|
src = strings.TrimSpace(src)
|
||||||
|
if lang == "" || src == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitles
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkSources(items []providerLinkItem, referer string) []StreamSource {
|
||||||
|
sources := make([]StreamSource, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
link := strings.TrimSpace(item.link)
|
link := strings.TrimSpace(item.link)
|
||||||
if link == "" {
|
if link == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
quality := strings.TrimSpace(item.resolutionStr)
|
|
||||||
sourceType := detectStreamType(link)
|
|
||||||
if sourceType == "unknown" {
|
|
||||||
sourceType = detectEmbedType(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
sources = append(sources, StreamSource{
|
sources = append(sources, StreamSource{
|
||||||
URL: link,
|
URL: link,
|
||||||
Quality: quality,
|
Quality: strings.TrimSpace(item.resolutionStr),
|
||||||
Provider: "wixmp",
|
Provider: "wixmp",
|
||||||
Type: sourceType,
|
Type: sourceType(link),
|
||||||
Referer: providerReferer,
|
Referer: referer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range hlsItems {
|
return sources
|
||||||
if strings.TrimSpace(item.url) == "" {
|
}
|
||||||
continue
|
|
||||||
}
|
func sourceType(link string) string {
|
||||||
if item.hardsubLang != "en-US" {
|
typ := detectStreamType(link)
|
||||||
|
if typ != "unknown" {
|
||||||
|
return typ
|
||||||
|
}
|
||||||
|
|
||||||
|
return detectEmbedType(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *providerExtractor) hlsSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
|
||||||
|
sources := make([]StreamSource, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
playlistURL, ok := playlistURL(item)
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistURL := strings.TrimSpace(item.url)
|
|
||||||
if strings.Contains(playlistURL, "master.m3u8") {
|
if strings.Contains(playlistURL, "master.m3u8") {
|
||||||
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
|
parsed, err := e.parseM3U8(ctx, playlistURL, referer)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
sources = append(sources, parsed...)
|
sources = append(sources, parsed...)
|
||||||
}
|
}
|
||||||
@@ -180,17 +246,30 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
|||||||
Quality: "auto",
|
Quality: "auto",
|
||||||
Provider: "hls",
|
Provider: "hls",
|
||||||
Type: "m3u8",
|
Type: "m3u8",
|
||||||
Referer: providerReferer,
|
Referer: referer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(subtitles) > 0 && len(sources) > 0 {
|
return sources
|
||||||
for idx := range sources {
|
}
|
||||||
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
|
|
||||||
}
|
func playlistURL(item providerHLSItem) (string, bool) {
|
||||||
|
playlistURL := strings.TrimSpace(item.url)
|
||||||
|
if playlistURL == "" || item.hardsubLang != "en-US" {
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
return sources
|
return playlistURL, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachSubtitles(sources []StreamSource, subtitles []Subtitle) {
|
||||||
|
if len(subtitles) == 0 || len(sources) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := range sources {
|
||||||
|
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality.
|
// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality.
|
||||||
@@ -199,37 +278,31 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() {
|
||||||
|
errlog.Log("failed to close m3u8 response body", resp.Body.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) // 512KB limit
|
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) // 512KB limit
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(string(body), "\n")
|
return parseM3U8Sources(string(body), masterURL, referer), nil
|
||||||
baseURL := masterURL
|
}
|
||||||
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
|
|
||||||
baseURL = masterURL[:idx+1]
|
|
||||||
}
|
|
||||||
|
|
||||||
currentBandwidth := 0
|
func parseM3U8Sources(body string, masterURL string, referer string) []StreamSource {
|
||||||
sources := make([]StreamSource, 0)
|
lines := strings.Split(body, "\n")
|
||||||
|
baseURL := playlistBaseURL(masterURL)
|
||||||
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
|
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
|
||||||
|
bw := 0
|
||||||
|
sources := make([]StreamSource, 0)
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") {
|
if bandwidth, ok := streamBandwidth(trimmed, bwPattern); ok {
|
||||||
match := bwPattern.FindStringSubmatch(trimmed)
|
bw = bandwidth
|
||||||
if len(match) >= 2 {
|
|
||||||
value, convErr := strconv.Atoi(match[1])
|
|
||||||
if convErr == nil {
|
|
||||||
currentBandwidth = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// skip empty lines and non-stream lines
|
|
||||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -239,27 +312,128 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
|||||||
streamURL = baseURL + streamURL
|
streamURL = baseURL + streamURL
|
||||||
}
|
}
|
||||||
|
|
||||||
quality := "auto"
|
|
||||||
kbps := currentBandwidth / 1000
|
|
||||||
switch {
|
|
||||||
case kbps >= 8000:
|
|
||||||
quality = "1080p"
|
|
||||||
case kbps >= 5000:
|
|
||||||
quality = "720p"
|
|
||||||
case kbps >= 2500:
|
|
||||||
quality = "480p"
|
|
||||||
case kbps > 0:
|
|
||||||
quality = "360p"
|
|
||||||
}
|
|
||||||
|
|
||||||
sources = append(sources, StreamSource{
|
sources = append(sources, StreamSource{
|
||||||
URL: streamURL,
|
URL: streamURL,
|
||||||
Quality: quality,
|
Quality: quality(bw),
|
||||||
Provider: "hls",
|
Provider: "hls",
|
||||||
Type: "m3u8",
|
Type: "m3u8",
|
||||||
Referer: referer,
|
Referer: referer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return sources, nil
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
func playlistBaseURL(masterURL string) string {
|
||||||
|
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
|
||||||
|
return masterURL[:idx+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return masterURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
|
||||||
|
if !strings.HasPrefix(line, "#EXT-X-STREAM-INF") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
match := bwPattern.FindStringSubmatch(line)
|
||||||
|
if len(match) < 2 {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func quality(bandwidth int) string {
|
||||||
|
kbps := bandwidth / 1000
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case kbps >= 8000:
|
||||||
|
return "1080p"
|
||||||
|
case kbps >= 5000:
|
||||||
|
return "720p"
|
||||||
|
case kbps >= 2500:
|
||||||
|
return "480p"
|
||||||
|
case kbps > 0:
|
||||||
|
return "360p"
|
||||||
|
default:
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embed page
|
||||||
|
func parseEmbed(rawURL string, body string, fallbackReferer string) []StreamSource {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(strings.ToLower(rawURL), "ok.ru/"):
|
||||||
|
return parseOKRUSources(body, fallbackReferer)
|
||||||
|
case strings.Contains(strings.ToLower(rawURL), "mp4upload.com/"):
|
||||||
|
return parseMP4Upload(body, fallbackReferer)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOKRUSources(body string, referer string) []StreamSource {
|
||||||
|
unescapedBody := html.UnescapeString(body)
|
||||||
|
manifestPattern := regexp.MustCompile(`\\"hlsManifestUrl\\":\\"([^"]+)\\"|"hlsManifestUrl":"([^"]+)"`)
|
||||||
|
match := manifestPattern.FindStringSubmatch(unescapedBody)
|
||||||
|
if len(match) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistURL := mediaURL(firstString(match[1], match[2]))
|
||||||
|
if playlistURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []StreamSource{{
|
||||||
|
URL: playlistURL,
|
||||||
|
Quality: "auto",
|
||||||
|
Provider: "ok",
|
||||||
|
Type: "m3u8",
|
||||||
|
Referer: referer,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMP4Upload(body string, referer string) []StreamSource {
|
||||||
|
srcPattern := regexp.MustCompile(`(?m)src:\s*"([^"]+)"`)
|
||||||
|
match := srcPattern.FindStringSubmatch(body)
|
||||||
|
if len(match) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url := mediaURL(match[1])
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []StreamSource{{
|
||||||
|
URL: url,
|
||||||
|
Provider: "mp4upload",
|
||||||
|
Type: sourceType(url),
|
||||||
|
Referer: referer,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaURL(raw string) string {
|
||||||
|
if unquoted, err := strconv.Unquote(`"` + raw + `"`); err == nil {
|
||||||
|
raw = unquoted
|
||||||
|
}
|
||||||
|
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
`\\u002F`, `/`,
|
||||||
|
`\\u0026`, "&",
|
||||||
|
`\/`, `/`,
|
||||||
|
`\u002F`, `/`,
|
||||||
|
`\u0026`, "&",
|
||||||
|
`&`, "&",
|
||||||
|
)
|
||||||
|
|
||||||
|
return strings.TrimSpace(replacer.Replace(raw))
|
||||||
}
|
}
|
||||||
|
|||||||
799
integrations/playback/allanime/http_test.go
Normal file
799
integrations/playback/allanime/http_test.go
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGraphqlRequest_SuccessAndHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var method, url, ct, referer, ua string
|
||||||
|
var bodyBuf bytes.Buffer
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
method = req.Method
|
||||||
|
url = req.URL.String()
|
||||||
|
ct = req.Header.Get("Content-Type")
|
||||||
|
referer = req.Header.Get("Referer")
|
||||||
|
ua = req.Header.Get("User-Agent")
|
||||||
|
if _, err := io.Copy(&bodyBuf, req.Body); err != nil {
|
||||||
|
t.Fatalf("copy request body: %v", err)
|
||||||
|
}
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"key":"val"}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.graphqlRequest(
|
||||||
|
context.Background(),
|
||||||
|
"query($id:String!){show(_id:$id){name}}",
|
||||||
|
map[string]any{"id": "abc"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("graphqlRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyGraphqlRequest(t, method, url, ct, referer, ua, bodyBuf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphqlRequest_Errors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "graphql error in response",
|
||||||
|
status: http.StatusOK,
|
||||||
|
body: `{"errors":[{"message":"not found"}]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-200 status",
|
||||||
|
status: http.StatusInternalServerError,
|
||||||
|
body: `{"data":{}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid json body",
|
||||||
|
status: http.StatusOK,
|
||||||
|
body: `not json`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(tt.status, tt.body), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.graphqlRequest(
|
||||||
|
context.Background(),
|
||||||
|
"query($id:String!){show(_id:$id){name}}",
|
||||||
|
map[string]any{"id": "abc"},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyGraphqlRequest(t *testing.T, method, url, ct, referer, ua string, body []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", method)
|
||||||
|
}
|
||||||
|
if url != allAnimeBaseURL+"/api" {
|
||||||
|
t.Errorf("url = %q, want %q", url, allAnimeBaseURL+"/api")
|
||||||
|
}
|
||||||
|
if ct != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
if referer != allAnimeReferer {
|
||||||
|
t.Errorf("Referer = %q", referer)
|
||||||
|
}
|
||||||
|
if ua != defaultUserAgent {
|
||||||
|
t.Errorf("User-Agent = %q", ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sent map[string]any
|
||||||
|
if err := json.Unmarshal(body, &sent); err != nil {
|
||||||
|
t.Fatalf("unmarshal sent body: %v", err)
|
||||||
|
}
|
||||||
|
if sent["query"] != "query($id:String!){show(_id:$id){name}}" {
|
||||||
|
t.Errorf("unexpected query in body")
|
||||||
|
}
|
||||||
|
vars, ok := sent["variables"].(map[string]any)
|
||||||
|
if !ok || vars["id"] != "abc" {
|
||||||
|
t.Errorf("unexpected variables in body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphqlRequest_SetsTranslationTypeLower(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.graphqlRequest(
|
||||||
|
context.Background(),
|
||||||
|
"query($t:VaildTranslationTypeEnumType!){x(translationType:$t){id}}",
|
||||||
|
map[string]any{"translationType": "SUB"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("graphqlRequest: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphqlRequestWithHash_Plain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", req.Method)
|
||||||
|
}
|
||||||
|
if !strings.Contains(req.URL.String(), episodeQueryHash) {
|
||||||
|
t.Errorf("url should contain hash, got %q", req.URL.String())
|
||||||
|
}
|
||||||
|
if req.Header.Get("Referer") != allAnimeReferer {
|
||||||
|
t.Errorf("Referer = %q", req.Header.Get("Referer"))
|
||||||
|
}
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.graphqlRequestWithHash(
|
||||||
|
context.Background(),
|
||||||
|
"show123", "1", "sub",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("graphqlRequestWithHash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := result["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("result missing data key")
|
||||||
|
}
|
||||||
|
sources := nestedSlice(data, "episode", "sourceUrls")
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("got %d sources, want 1", len(sources))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`))
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.graphqlRequestWithHash(
|
||||||
|
context.Background(),
|
||||||
|
"show456", "2", "dub",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("graphqlRequestWithHash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := nestedSlice(result, "episode", "sourceUrls")
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("got %d sources, want 1", len(sources))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphqlRequestWithHash_Non200(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.graphqlRequestWithHash(
|
||||||
|
context.Background(),
|
||||||
|
"x", "1", "sub",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphqlRequestWithHash_EmptyData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.graphqlRequestWithHash(
|
||||||
|
context.Background(),
|
||||||
|
"x", "1", "sub",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEpisodeSources_EncryptedHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}`))
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
t.Error("fallback POST should not be called")
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
extractor: newProviderExtractor(),
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := provider.GetEpisodeSources(context.Background(), "show1", "1", "sub")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEpisodeSources: %v", err)
|
||||||
|
}
|
||||||
|
if len(sources) == 0 {
|
||||||
|
t.Fatal("expected at least one source")
|
||||||
|
}
|
||||||
|
if sources[0].URL != "https://direct.test/v.mp4" {
|
||||||
|
t.Errorf("URL = %q", sources[0].URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEpisodeSources_FallbackPost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sourceResponse := `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}}}`
|
||||||
|
fallbackCalled := false
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
fallbackCalled = true
|
||||||
|
return mockStringResponse(http.StatusOK, sourceResponse), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
extractor: newProviderExtractor(),
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := provider.GetEpisodeSources(context.Background(), "show3", "3", "sub")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEpisodeSources: %v", err)
|
||||||
|
}
|
||||||
|
if !fallbackCalled {
|
||||||
|
t.Fatal("fallback POST was not called")
|
||||||
|
}
|
||||||
|
if len(sources) == 0 {
|
||||||
|
t.Fatal("expected at least one source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEpisodeSources_BothFail(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
extractor: newProviderExtractor(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.GetEpisodeSources(context.Background(), "show4", "4", "sub")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when both requests fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAvailableEpisodes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantSub int
|
||||||
|
wantDub int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sub and dub available",
|
||||||
|
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]},"lastEpisodeInfo":{}}}}`,
|
||||||
|
wantSub: 3,
|
||||||
|
wantDub: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub only",
|
||||||
|
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2"],"dub":null},"lastEpisodeInfo":{}}}}`,
|
||||||
|
wantSub: 2,
|
||||||
|
wantDub: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "show not found",
|
||||||
|
body: `{"data":{"show":null}}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, tt.body), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err := provider.GetAvailableEpisodes(context.Background(), "showX")
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("GetAvailableEpisodes() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(available.Sub) != tt.wantSub {
|
||||||
|
t.Errorf("Sub count = %d, want %d", len(available.Sub), tt.wantSub)
|
||||||
|
}
|
||||||
|
if len(available.Dub) != tt.wantDub {
|
||||||
|
t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("returns results", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"id1","malId":"1","name":"Title One"},{"_id":"id2","malId":"2","name":"Title Two"}]}}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := provider.Search(context.Background(), "test", "sub")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(results))
|
||||||
|
}
|
||||||
|
if results[0].ID != "id1" || results[0].MalID != "1" || results[0].Name != "Title One" {
|
||||||
|
t.Errorf("result[0] = %+v", results[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty results", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := provider.Search(context.Background(), "nonexistent", "sub")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0", len(results))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStreams_FullSuccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
searchBody := `{"data":{"shows":{"edges":[{"_id":"show123","malId":"1","name":"Test Anime"}]}}}`
|
||||||
|
encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://stream.test/video.mp4","sourceName":"default"}]}`))
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, searchBody), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
extractor: newProviderExtractor(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.GetStreams(context.Background(), 1, []string{"Test Anime"}, "1", "sub")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetStreams: %v", err)
|
||||||
|
}
|
||||||
|
if result.URL != "https://stream.test/video.mp4" {
|
||||||
|
t.Errorf("URL = %q", result.URL)
|
||||||
|
}
|
||||||
|
if result.Referer != allAnimeReferer {
|
||||||
|
t.Errorf("Referer = %q", result.Referer)
|
||||||
|
}
|
||||||
|
if result.Type != "mp4" {
|
||||||
|
t.Errorf("Type = %q", result.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStreams_ShowNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
t.Error("should not call episode sources when show not found")
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
extractor: newProviderExtractor(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.GetStreams(context.Background(), 999, []string{"Nothing"}, "1", "sub")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for show not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStreams_NoSources(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := &AllAnimeProvider{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
utlsClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
extractor: newProviderExtractor(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.GetStreams(context.Background(), 1, []string{"Anime"}, "1", "sub")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no sources")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProviderResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("extracts links and subtitles", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"1080p"}],"subtitles":[{"lang":"en","src":"https://sub.test/en.vtt"}]}`
|
||||||
|
extractor := &providerExtractor{
|
||||||
|
baseURL: allAnimeSiteURL,
|
||||||
|
referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := extractor.parseResponse(context.Background(), body)
|
||||||
|
if len(sources) == 0 {
|
||||||
|
t.Fatal("expected at least one source")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sources[0].URL != "https://cdn.test/video.mp4" {
|
||||||
|
t.Errorf("URL = %q", sources[0].URL)
|
||||||
|
}
|
||||||
|
if sources[0].Quality != "1080p" {
|
||||||
|
t.Errorf("Quality = %q", sources[0].Quality)
|
||||||
|
}
|
||||||
|
if len(sources[0].Subtitles) != 1 {
|
||||||
|
t.Fatalf("subtitles count = %d, want 1", len(sources[0].Subtitles))
|
||||||
|
}
|
||||||
|
if sources[0].Subtitles[0].Lang != "en" {
|
||||||
|
t.Errorf("sub lang = %q", sources[0].Subtitles[0].Lang)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid json returns empty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
extractor := &providerExtractor{
|
||||||
|
baseURL: allAnimeSiteURL,
|
||||||
|
referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := extractor.parseResponse(context.Background(), "not json")
|
||||||
|
if len(sources) != 0 {
|
||||||
|
t.Errorf("expected empty, got %d sources", len(sources))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty response returns empty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
extractor := &providerExtractor{
|
||||||
|
baseURL: allAnimeSiteURL,
|
||||||
|
referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := extractor.parseResponse(context.Background(), "{}")
|
||||||
|
if len(sources) != 0 {
|
||||||
|
t.Errorf("expected empty, got %d sources", len(sources))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExternalEmbedResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("ok.ru extracts hls manifest", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.example.test/playlist.m3u8\"}"}}`
|
||||||
|
sources := parseEmbed("https://ok.ru/video/123", body, allAnimeReferer)
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("got %d sources, want 1", len(sources))
|
||||||
|
}
|
||||||
|
if sources[0].URL != "https://ok.example.test/playlist.m3u8" {
|
||||||
|
t.Errorf("URL = %q", sources[0].URL)
|
||||||
|
}
|
||||||
|
if sources[0].Provider != "ok" {
|
||||||
|
t.Errorf("Provider = %q", sources[0].Provider)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("mp4upload extracts src", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := `src: "https://mp4upload.example.test/video.mp4"`
|
||||||
|
sources := parseEmbed("https://mp4upload.com/e/abc", body, allAnimeReferer)
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("got %d sources, want 1", len(sources))
|
||||||
|
}
|
||||||
|
if sources[0].URL != "https://mp4upload.example.test/video.mp4" {
|
||||||
|
t.Errorf("URL = %q", sources[0].URL)
|
||||||
|
}
|
||||||
|
if sources[0].Provider != "mp4upload" {
|
||||||
|
t.Errorf("Provider = %q", sources[0].Provider)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown embed returns empty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sources := parseEmbed("https://unknown.example.com/video", "<html></html>", allAnimeReferer)
|
||||||
|
if len(sources) != 0 {
|
||||||
|
t.Errorf("expected empty, got %d sources", len(sources))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseM3U8Sources(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("parses bandwidth entries", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080\n1080p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=5000000\n720p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2500000\n480p.m3u8"
|
||||||
|
masterURL := "https://cdn.test/master.m3u8"
|
||||||
|
|
||||||
|
sources := parseM3U8Sources(body, masterURL, allAnimeReferer)
|
||||||
|
if len(sources) != 3 {
|
||||||
|
t.Fatalf("got %d sources, want 3", len(sources))
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []struct {
|
||||||
|
url string
|
||||||
|
quality string
|
||||||
|
}{
|
||||||
|
{"https://cdn.test/1080p.m3u8", "1080p"},
|
||||||
|
{"https://cdn.test/720p.m3u8", "720p"},
|
||||||
|
{"https://cdn.test/480p.m3u8", "480p"},
|
||||||
|
}
|
||||||
|
for i, exp := range expected {
|
||||||
|
if sources[i].URL != exp.url {
|
||||||
|
t.Errorf("sources[%d].URL = %q, want %q", i, sources[i].URL, exp.url)
|
||||||
|
}
|
||||||
|
if sources[i].Quality != exp.quality {
|
||||||
|
t.Errorf("sources[%d].Quality = %q, want %q", i, sources[i].Quality, exp.quality)
|
||||||
|
}
|
||||||
|
if sources[i].Type != "m3u8" {
|
||||||
|
t.Errorf("sources[%d].Type = %q", i, sources[i].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty body returns nothing", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sources := parseM3U8Sources("", "https://cdn.test/master.m3u8", allAnimeReferer)
|
||||||
|
if len(sources) != 0 {
|
||||||
|
t.Errorf("expected empty, got %d", len(sources))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("absolute URLs not rebased", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := "#EXT-X-STREAM-INF:BANDWIDTH=8000000\nhttps://cdn2.test/video.m3u8"
|
||||||
|
sources := parseM3U8Sources(body, "https://cdn.test/master.m3u8", allAnimeReferer)
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("got %d sources", len(sources))
|
||||||
|
}
|
||||||
|
if sources[0].URL != "https://cdn2.test/video.m3u8" {
|
||||||
|
t.Errorf("URL = %q", sources[0].URL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractVideoLinks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("fetches and parses provider response", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
extractor := &providerExtractor{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", req.Method)
|
||||||
|
}
|
||||||
|
if req.Header.Get("Referer") != allAnimeReferer {
|
||||||
|
t.Errorf("Referer = %q", req.Header.Get("Referer"))
|
||||||
|
}
|
||||||
|
body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"720p"}]}`
|
||||||
|
return mockStringResponse(http.StatusOK, body), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
baseURL: allAnimeSiteURL,
|
||||||
|
referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := extractor.ExtractVideoLinks(context.Background(), "/some-path")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractVideoLinks: %v", err)
|
||||||
|
}
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("got %d sources, want 1", len(sources))
|
||||||
|
}
|
||||||
|
if sources[0].Provider != "wixmp" {
|
||||||
|
t.Errorf("Provider = %q", sources[0].Provider)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("server error returns empty sources", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
extractor := &providerExtractor{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusInternalServerError, ""), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
baseURL: allAnimeSiteURL,
|
||||||
|
referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := extractor.ExtractVideoLinks(context.Background(), "/error-path")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(sources) != 0 {
|
||||||
|
t.Errorf("expected empty sources, got %d", len(sources))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEmbedVideoLinks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("ok.ru embed extracted", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
extractor := &providerExtractor{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.test/play.m3u8\"}"}}`
|
||||||
|
return mockStringResponse(http.StatusOK, body), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://ok.ru/video/123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractEmbedVideoLinks: %v", err)
|
||||||
|
}
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("got %d sources, want 1", len(sources))
|
||||||
|
}
|
||||||
|
if sources[0].URL != "https://ok.test/play.m3u8" {
|
||||||
|
t.Errorf("URL = %q", sources[0].URL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown embed returns empty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
extractor := &providerExtractor{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mockStringResponse(http.StatusOK, "<html></html>"), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://unknown.com/video")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractEmbedVideoLinks: %v", err)
|
||||||
|
}
|
||||||
|
if len(sources) != 0 {
|
||||||
|
t.Errorf("expected empty, got %d sources", len(sources))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
23
integrations/playback/allanime/mock_test.go
Normal file
23
integrations/playback/allanime/mock_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockStringResponse(status int, body string) *http.Response {
|
||||||
|
hdr := make(http.Header)
|
||||||
|
hdr.Set("Content-Type", "application/json")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: hdr,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
156
integrations/playback/allanime/search.go
Normal file
156
integrations/playback/allanime/search.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"mal/pkg"
|
||||||
|
netutil "mal/pkg/net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchQuery = `query(
|
||||||
|
$search: SearchInput
|
||||||
|
$translationType: VaildTranslationTypeEnumType
|
||||||
|
$limit: Int = 40
|
||||||
|
$page: Int = 1
|
||||||
|
$countryOrigin: VaildCountryOriginEnumType = ALL
|
||||||
|
) {
|
||||||
|
shows(
|
||||||
|
search: $search
|
||||||
|
limit: $limit
|
||||||
|
page: $page
|
||||||
|
translationType: $translationType
|
||||||
|
countryOrigin: $countryOrigin
|
||||||
|
) {
|
||||||
|
edges {
|
||||||
|
_id
|
||||||
|
malId
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
type searchResult struct {
|
||||||
|
ID string
|
||||||
|
MalID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
|
||||||
|
type searchData struct {
|
||||||
|
Shows struct {
|
||||||
|
Edges []struct {
|
||||||
|
ID string `json:"_id"`
|
||||||
|
MalID string `json:"malId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"edges"`
|
||||||
|
} `json:"shows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchInput struct {
|
||||||
|
AllowAdult bool `json:"allowAdult"`
|
||||||
|
AllowUnknown bool `json:"allowUnknown"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchVariables struct {
|
||||||
|
Search searchInput `json:"search"`
|
||||||
|
TranslationType string `json:"translationType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := searchVariables{
|
||||||
|
Search: searchInput{
|
||||||
|
AllowAdult: false,
|
||||||
|
AllowUnknown: false,
|
||||||
|
Query: query,
|
||||||
|
},
|
||||||
|
TranslationType: mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Referer": allAnimeReferer,
|
||||||
|
"User-Agent": defaultUserAgent,
|
||||||
|
},
|
||||||
|
BodyMax: netutil.MiB2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]searchResult, 0, len(data.Shows.Edges))
|
||||||
|
for _, edge := range data.Shows.Edges {
|
||||||
|
id := edge.ID
|
||||||
|
malID := edge.MalID
|
||||||
|
name := edge.Name
|
||||||
|
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
|
||||||
|
name = unquoted
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) showID(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
|
||||||
|
targetMalIDStr := strconv.Itoa(animeID)
|
||||||
|
fallbackID := ""
|
||||||
|
|
||||||
|
for _, title := range titleCandidates {
|
||||||
|
searchResults, err := c.Search(ctx, title, mode)
|
||||||
|
if err != nil || len(searchResults) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if showID := exactMatchShowID(searchResults, targetMalIDStr); showID != "" {
|
||||||
|
return showID
|
||||||
|
}
|
||||||
|
if fallbackID == "" {
|
||||||
|
fallbackID = searchResults[0].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackID
|
||||||
|
}
|
||||||
|
|
||||||
|
func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
|
||||||
|
for _, res := range searchResults {
|
||||||
|
if res.MalID == targetMalID {
|
||||||
|
return res.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
|
||||||
|
for _, mode := range []string{"sub", "dub"} {
|
||||||
|
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
|
||||||
|
if err == nil {
|
||||||
|
return showID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) strictShowID(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
|
||||||
|
targetMalIDStr := strconv.Itoa(animeID)
|
||||||
|
for _, title := range titleCandidates {
|
||||||
|
searchResults, err := c.Search(ctx, title, mode)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, res := range searchResults {
|
||||||
|
if res.MalID == targetMalIDStr {
|
||||||
|
return res.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
|
||||||
|
}
|
||||||
326
integrations/playback/allanime/sources.go
Normal file
326
integrations/playback/allanime/sources.go
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
package allanime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
|
||||||
|
|
||||||
|
type sourceReference struct {
|
||||||
|
URL string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
|
||||||
|
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
|
||||||
|
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
|
||||||
|
sourceUrls
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
|
||||||
|
if err == nil {
|
||||||
|
sources := c.sourcesFrom(ctx, result)
|
||||||
|
if len(sources) > 0 {
|
||||||
|
return sources, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
|
||||||
|
"showId": showID,
|
||||||
|
"translationType": mode,
|
||||||
|
"episodeString": episode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := result["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid source response")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawSourceURLs, ok := data["episode"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid episode response")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
|
||||||
|
if !ok || len(sourceURLs) == 0 {
|
||||||
|
return nil, errors.New("no source urls")
|
||||||
|
}
|
||||||
|
|
||||||
|
references := sourceRefs(sourceURLs)
|
||||||
|
if len(references) == 0 {
|
||||||
|
return nil, errors.New("no source references")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := c.resolveRefs(ctx, references)
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, errors.New("no playable sources extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) sourcesFrom(ctx context.Context, data map[string]any) []StreamSource {
|
||||||
|
episodeData, ok := data["episode"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURLs, ok := episodeData["sourceUrls"].([]any)
|
||||||
|
if !ok || len(sourceURLs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
references := sourceRefs(sourceURLs)
|
||||||
|
if len(references) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.resolveRefs(ctx, references)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) resolveRefs(ctx context.Context, references []sourceReference) []StreamSource {
|
||||||
|
out := make([]StreamSource, 0, len(references))
|
||||||
|
for _, ref := range references {
|
||||||
|
if source, ok := directSource(ref); ok {
|
||||||
|
out = append(out, source)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted := c.resolveExtracted(ctx, ref)
|
||||||
|
if len(extracted) > 0 {
|
||||||
|
out = append(out, extracted...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func directSource(ref sourceReference) (StreamSource, bool) {
|
||||||
|
target := strings.TrimSpace(ref.URL)
|
||||||
|
if target == "" {
|
||||||
|
return StreamSource{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHTTPURL(target) {
|
||||||
|
if detectEmbedType(target) == "embed" {
|
||||||
|
return StreamSource{}, false
|
||||||
|
}
|
||||||
|
return buildStreamSource(target, detectSourceType(target), ref.Name), true
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeSourceURL(target)
|
||||||
|
if !isHTTPURL(decoded) {
|
||||||
|
return StreamSource{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if detectEmbedType(decoded) == "embed" {
|
||||||
|
return StreamSource{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStreamSource(decoded, detectSourceType(decoded), ref.Name), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) resolveExtracted(ctx context.Context, ref sourceReference) []StreamSource {
|
||||||
|
rawURL := strings.TrimSpace(ref.URL)
|
||||||
|
decoded := decodeSourceURL(rawURL)
|
||||||
|
if decoded == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHTTPURL(decoded) {
|
||||||
|
extracted, err := c.extractor.ExtractEmbedVideoLinks(ctx, decoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(decoded, "/") {
|
||||||
|
decoded = "/" + decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectSourceType(sourceURL string) string {
|
||||||
|
sourceType := detectStreamType(sourceURL)
|
||||||
|
if sourceType != "unknown" {
|
||||||
|
return sourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
return detectEmbedType(sourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTTPURL(value string) bool {
|
||||||
|
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStreamSource(url, sourceType, provider string) StreamSource {
|
||||||
|
return StreamSource{
|
||||||
|
URL: url,
|
||||||
|
Provider: provider,
|
||||||
|
Type: sourceType,
|
||||||
|
Referer: allAnimeReferer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// source priority
|
||||||
|
func sourceRefs(rawSourceURLs []any) []sourceReference {
|
||||||
|
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
||||||
|
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
|
||||||
|
|
||||||
|
prioritized := make(map[string]sourceReference)
|
||||||
|
fallback := make([]sourceReference, 0, len(rawSourceURLs))
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, source := range rawSourceURLs {
|
||||||
|
item, ok := source.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURL, ok := stringMapValue(item, "sourceUrl")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sourceName, _ := stringMapValue(item, "sourceName")
|
||||||
|
sourceURL = strings.TrimSpace(sourceURL)
|
||||||
|
sourceName = strings.TrimSpace(sourceName)
|
||||||
|
if sourceURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := seen[sourceURL]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sourceURL] = struct{}{}
|
||||||
|
|
||||||
|
ref := sourceReference{URL: sourceURL, Name: sourceName}
|
||||||
|
normalized := strings.ToLower(sourceName)
|
||||||
|
if _, priority := prioritySet[normalized]; priority {
|
||||||
|
if _, exists := prioritized[normalized]; !exists {
|
||||||
|
prioritized[normalized] = ref
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback = append(fallback, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
|
||||||
|
for _, provider := range priorityOrder {
|
||||||
|
if ref, ok := prioritized[provider]; ok {
|
||||||
|
ordered = append(ordered, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered = append(ordered, fallback...)
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringMapValue(item map[string]any, key string) (string, bool) {
|
||||||
|
value, ok := item[key].(string)
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
|
||||||
|
req, err := newHashRequest(ctx, showID, episode, mode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create GET request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", defaultUserAgent)
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||||
|
req.Header.Set("Accept-Encoding", "identity")
|
||||||
|
req.Header.Set("Referer", allAnimeReferer)
|
||||||
|
req.Header.Set("Origin", allAnimeOrigin)
|
||||||
|
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||||
|
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||||
|
|
||||||
|
statusCode, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("GET status %d: %s", statusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := parseGraphQLResponse(respBody, "decode response")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := parsed["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("no data in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := responseFromTobeparsed(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if decrypted != nil {
|
||||||
|
return decrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nestedSlice(data, "episode", "sourceUrls")) > 0 {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("no usable data in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
|
||||||
|
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode)
|
||||||
|
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("variables", varsJSON)
|
||||||
|
params.Set("extensions", extJSON)
|
||||||
|
|
||||||
|
return http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api?%s", allAnimeBaseURL, params.Encode()), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectStreamType(sourceURL string) string {
|
||||||
|
lower := strings.ToLower(sourceURL)
|
||||||
|
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
|
||||||
|
return "m3u8"
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(lower, ".mp4") {
|
||||||
|
return "mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectEmbedType(rawURL string) string {
|
||||||
|
lower := strings.ToLower(rawURL)
|
||||||
|
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
|
||||||
|
for _, host := range embedHosts {
|
||||||
|
if strings.Contains(lower, host) {
|
||||||
|
return "embed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
errlog "mal/pkg"
|
||||||
netutil "mal/pkg/net"
|
netutil "mal/pkg/net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -155,23 +156,19 @@ func extractRows(doc *goquery.Document) []watchOrderRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
title := strings.TrimSpace(selection.Find(".wo_title").First().Text())
|
title := strings.TrimSpace(selection.Find(".wo_title").First().Text())
|
||||||
alternativeTitle := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
|
alt := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
|
||||||
|
|
||||||
rows = append(rows, watchOrderRow{
|
rows = append(rows, watchOrderRow{
|
||||||
id: id,
|
id: id,
|
||||||
typeID: typeID,
|
typeID: typeID,
|
||||||
title: title,
|
title: title,
|
||||||
alternativeTitle: alternativeTitle,
|
alternativeTitle: alt,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasWatchOrderTable(doc *goquery.Document) bool {
|
|
||||||
return doc.Find("#wo_list").Length() > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// shouldTryProxy returns true for transient errors where the Jina proxy may help
|
// shouldTryProxy returns true for transient errors where the Jina proxy may help
|
||||||
// (e.g. Cloudflare blocking, rate limits)
|
// (e.g. Cloudflare blocking, rate limits)
|
||||||
func shouldTryProxy(err error) bool {
|
func shouldTryProxy(err error) bool {
|
||||||
@@ -205,7 +202,9 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("proxy request failed: %w", err)
|
return "", fmt.Errorf("proxy request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = response.Body.Close() }()
|
defer func() {
|
||||||
|
errlog.Log("failed to close watch order proxy response body", response.Body.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("proxy status %d", response.StatusCode)
|
return "", fmt.Errorf("proxy status %d", response.StatusCode)
|
||||||
@@ -355,7 +354,7 @@ func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// empty table indicates JS-rendered content; need proxy
|
// empty table indicates JS-rendered content; need proxy
|
||||||
if !hasWatchOrderTable(doc) {
|
if doc.Find("#wo_list").Length() == 0 {
|
||||||
return fetchViaProxy(ctx, httpClient, url, rootID)
|
return fetchViaProxy(ctx, httpClient, url, rootID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
431
internal/anime/browse_handler.go
Normal file
431
internal/anime/browse_handler.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"mal/internal/server"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type producerItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type browseQuery struct {
|
||||||
|
q string
|
||||||
|
animeType string
|
||||||
|
status string
|
||||||
|
orderBy string
|
||||||
|
sort string
|
||||||
|
sfw bool
|
||||||
|
studioID int
|
||||||
|
genres []int
|
||||||
|
page int
|
||||||
|
}
|
||||||
|
|
||||||
|
func producerQueryParams(c *gin.Context) (string, int, int, error) {
|
||||||
|
q := strings.TrimSpace(c.Query("q"))
|
||||||
|
|
||||||
|
rawPage := c.DefaultQuery("page", "1")
|
||||||
|
page, err := strconv.Atoi(rawPage)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, fmt.Errorf("invalid page %q: %w", rawPage, err)
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLimit := c.DefaultQuery("limit", "50")
|
||||||
|
limit, err := strconv.Atoi(rawLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, fmt.Errorf("invalid limit %q: %w", rawLimit, err)
|
||||||
|
}
|
||||||
|
if limit < 1 || limit > 12 {
|
||||||
|
limit = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
return q, page, limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func producerItems(entries []jikan.ProducerListEntry) []producerItem {
|
||||||
|
items := make([]producerItem, 0, len(entries))
|
||||||
|
for _, producer := range entries {
|
||||||
|
name := jikan.ProducerListEntryName(producer)
|
||||||
|
if producer.MalID <= 0 || name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, producerItem{ID: producer.MalID, Name: name})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func producerHTMLPayload(items []producerItem, hasNextPage bool, page int, q string, limit int) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"_fragment": "studio_dropdown_items",
|
||||||
|
"StudioItems": items,
|
||||||
|
"HasNextPage": hasNextPage,
|
||||||
|
"Page": page,
|
||||||
|
"NextPage": page + 1,
|
||||||
|
"Query": q,
|
||||||
|
"Limit": limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestWantsHTML(c *gin.Context) bool {
|
||||||
|
return strings.Contains(c.GetHeader("Accept"), "text/html")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
|
||||||
|
q, page, limit, err := producerQueryParams(c)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
observability.WarnContext(c.Request.Context(),
|
||||||
|
"producers_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"q": q,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
if requestWantsHTML(c) {
|
||||||
|
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload([]producerItem{}, false, page, q, limit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"producers_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"failed to load producers",
|
||||||
|
map[string]any{"q": q, "page": page, "limit": limit},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := producerItems(res.Items)
|
||||||
|
|
||||||
|
if requestWantsHTML(c) {
|
||||||
|
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload(items, res.HasNextPage, page, q, limit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"hasNextPage": res.HasNextPage,
|
||||||
|
"nextPage": page + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
|
||||||
|
studioID := 0
|
||||||
|
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
||||||
|
id, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return browseQuery{}, fmt.Errorf("invalid studio id %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
if id < 0 {
|
||||||
|
return browseQuery{}, fmt.Errorf("invalid studio id %d", id)
|
||||||
|
}
|
||||||
|
studioID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
genres := make([]int, 0, len(c.QueryArray("genres")))
|
||||||
|
for _, g := range c.QueryArray("genres") {
|
||||||
|
id, err := strconv.Atoi(g)
|
||||||
|
if err != nil {
|
||||||
|
return browseQuery{}, fmt.Errorf("invalid genre id %q: %w", g, err)
|
||||||
|
}
|
||||||
|
if id > 0 {
|
||||||
|
genres = append(genres, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPage := c.DefaultQuery("page", "1")
|
||||||
|
page, err := strconv.Atoi(rawPage)
|
||||||
|
if err != nil {
|
||||||
|
return browseQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return browseQuery{
|
||||||
|
q: c.Query("q"),
|
||||||
|
animeType: c.Query("type"),
|
||||||
|
status: c.Query("status"),
|
||||||
|
orderBy: c.Query("order_by"),
|
||||||
|
sort: c.Query("sort"),
|
||||||
|
sfw: c.Query("sfw") != "false",
|
||||||
|
studioID: studioID,
|
||||||
|
genres: genres,
|
||||||
|
page: page,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalBrowseURL(rawURL *url.URL) (string, bool) {
|
||||||
|
if rawURL == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
query := rawURL.Query()
|
||||||
|
if _, exists := query["sfw"]; exists {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Set("sfw", "true")
|
||||||
|
encoded := query.Encode()
|
||||||
|
if encoded == "" {
|
||||||
|
return rawURL.Path, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawURL.Path + "?" + encoded, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseStudioName(ctx context.Context, svc Service, studioID int) string {
|
||||||
|
if studioID <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := svc.GetProducerNameByID(ctx, studioID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseTemplateData(
|
||||||
|
q browseQuery,
|
||||||
|
studioName string,
|
||||||
|
genresList []domain.Genre,
|
||||||
|
animes []domain.Anime,
|
||||||
|
user any,
|
||||||
|
watchlistMap map[int64]bool,
|
||||||
|
hasNextPage bool,
|
||||||
|
) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"CurrentPath": "/browse",
|
||||||
|
"Query": q.q,
|
||||||
|
"Type": q.animeType,
|
||||||
|
"Status": q.status,
|
||||||
|
"OrderBy": q.orderBy,
|
||||||
|
"Sort": q.sort,
|
||||||
|
"Genres": q.genres,
|
||||||
|
"Studio": q.studioID,
|
||||||
|
"StudioName": studioName,
|
||||||
|
"SFW": q.sfw,
|
||||||
|
"GenresList": genresList,
|
||||||
|
"Animes": animes,
|
||||||
|
"HasNextPage": hasNextPage,
|
||||||
|
"NextPage": q.page + 1,
|
||||||
|
"User": user,
|
||||||
|
"WatchlistMap": watchlistMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) searchBrowse(ctx context.Context, query browseQuery) (jikan.SearchResult, error) {
|
||||||
|
return h.svc.SearchAdvanced(
|
||||||
|
ctx,
|
||||||
|
query.q,
|
||||||
|
query.animeType,
|
||||||
|
query.status,
|
||||||
|
query.orderBy,
|
||||||
|
query.sort,
|
||||||
|
query.genres,
|
||||||
|
query.studioID,
|
||||||
|
query.sfw,
|
||||||
|
query.page,
|
||||||
|
24,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseScrollData(
|
||||||
|
query browseQuery,
|
||||||
|
studioName string,
|
||||||
|
animes []domain.Anime,
|
||||||
|
watchlistMap map[int64]bool,
|
||||||
|
hasNextPage bool,
|
||||||
|
) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"_fragment": "anime_card_scroll",
|
||||||
|
"Animes": animes,
|
||||||
|
"NextPage": query.page + 1,
|
||||||
|
"HasNextPage": hasNextPage,
|
||||||
|
"Query": query.q,
|
||||||
|
"Type": query.animeType,
|
||||||
|
"Status": query.status,
|
||||||
|
"OrderBy": query.orderBy,
|
||||||
|
"Sort": query.sort,
|
||||||
|
"Genres": query.genres,
|
||||||
|
"Studio": query.studioID,
|
||||||
|
"StudioName": studioName,
|
||||||
|
"SFW": query.sfw,
|
||||||
|
"WatchlistMap": watchlistMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) respondBrowseSearchError(c *gin.Context, query browseQuery, err error) {
|
||||||
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"browse_search_failed",
|
||||||
|
"anime",
|
||||||
|
"failed to load browse results",
|
||||||
|
map[string]any{
|
||||||
|
"q": query.q,
|
||||||
|
"type": query.animeType,
|
||||||
|
"status": query.status,
|
||||||
|
"order_by": query.orderBy,
|
||||||
|
"sort": query.sort,
|
||||||
|
"studio": query.studioID,
|
||||||
|
"sfw": query.sfw,
|
||||||
|
"page": query.page,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||||
|
if target, ok := canonicalBrowseURL(c.Request.URL); ok {
|
||||||
|
c.Redirect(http.StatusSeeOther, target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query, err := parseBrowseQuery(c)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.searchBrowse(c.Request.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
h.respondBrowseSearchError(c, query, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := server.CurrentUser(c)
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
animes := wrapAnimes(res.Animes)
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||||
|
studioName := browseStudioName(c.Request.Context(), h.svc, query.studioID)
|
||||||
|
|
||||||
|
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
|
||||||
|
c.HTML(http.StatusOK, "browse.gohtml", browseScrollData(query, studioName, animes, watchlistMap, res.HasNextPage))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
genresList, err := h.svc.GetGenres(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
observability.WarnContext(c.Request.Context(),
|
||||||
|
"genres_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{"q": query.q, "type": query.animeType, "status": query.status},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
browseData := browseTemplateData(query, studioName, genresList, animes, user, watchlistMap, res.HasNextPage)
|
||||||
|
|
||||||
|
if c.GetHeader("HX-Request") == "true" {
|
||||||
|
browseData["_fragment"] = "browse_content"
|
||||||
|
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||||
|
}
|
||||||
|
|
||||||
|
type quickSearchResult struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
InWatchlist bool `json:"in_watchlist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||||
|
query := c.Query("q")
|
||||||
|
if query == "" {
|
||||||
|
c.JSON(http.StatusOK, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
animes := wrapAnimes(res.Animes)
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||||
|
|
||||||
|
output := make([]quickSearchResult, len(animes))
|
||||||
|
for i, anime := range animes {
|
||||||
|
output[i] = quickSearchResult{
|
||||||
|
ID: anime.MalID,
|
||||||
|
Title: anime.DisplayTitle(),
|
||||||
|
Type: anime.Type,
|
||||||
|
Year: anime.Year,
|
||||||
|
Image: anime.Images.Webp.LargeImageURL,
|
||||||
|
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
anime, err := h.svc.GetRandomAnime(ctx)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"random_anime_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"failed to fetch random anime",
|
||||||
|
nil,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if anime.MalID == 0 {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inWatchlist := false
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
if userID != "" {
|
||||||
|
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
|
||||||
|
inWatchlist = watchlistMap[int64(anime.MalID)]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": anime,
|
||||||
|
"in_watchlist": inWatchlist,
|
||||||
|
})
|
||||||
|
}
|
||||||
39
internal/anime/browse_handler_test.go
Normal file
39
internal/anime/browse_handler_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanonicalBrowseURLAddsSFWTrueWhenMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rawURL, err := url.Parse("/browse?status=airing&order_by=popularity&sort=asc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := canonicalBrowseURL(rawURL)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("canonicalBrowseURL() should request redirect when sfw is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "/browse?order_by=popularity&sfw=true&sort=asc&status=airing"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("canonicalBrowseURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalBrowseURLSkipsWhenSFWAlreadyPresent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rawURL, err := url.Parse("/browse?status=airing&sfw=false")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := canonicalBrowseURL(rawURL)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("canonicalBrowseURL() unexpectedly requested redirect to %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
123
internal/anime/catalog_handler.go
Normal file
123
internal/anime/catalog_handler.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/internal/observability"
|
||||||
|
"mal/internal/server"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "search.gohtml", gin.H{
|
||||||
|
"User": server.CurrentUser(c),
|
||||||
|
"CurrentPath": "/search",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||||
|
user := server.CurrentUser(c)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||||
|
"CurrentPath": "/",
|
||||||
|
"User": user,
|
||||||
|
"WatchlistMap": map[int64]bool{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||||
|
h.renderCatalogSection(c, "Airing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||||
|
h.renderCatalogSection(c, "Popular")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||||
|
h.renderCatalogSection(c, "Continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
|
||||||
|
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
observability.WarnContext(c.Request.Context(),
|
||||||
|
"top_pick_for_you_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||||
|
|
||||||
|
data.Section = "TopPickForYou"
|
||||||
|
data.Fragment = "top_pick_for_you_section"
|
||||||
|
data.WatchlistMap = watchlistMap
|
||||||
|
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||||
|
if err != nil {
|
||||||
|
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||||
|
|
||||||
|
data.Section = section
|
||||||
|
data.Fragment = "catalog_section"
|
||||||
|
data.WatchlistMap = watchlistMap
|
||||||
|
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleTopPicks(c *gin.Context) {
|
||||||
|
user := server.CurrentUser(c)
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
|
||||||
|
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
observability.WarnContext(c.Request.Context(),
|
||||||
|
"top_picks_for_you_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "top_picks.gohtml", gin.H{
|
||||||
|
"CurrentPath": "/top-picks",
|
||||||
|
"User": user,
|
||||||
|
"Animes": data.Animes,
|
||||||
|
"WatchlistMap": watchlistMap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
||||||
|
observability.WarnContext(c.Request.Context(),
|
||||||
|
event,
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"section": section,
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package anime
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"mal/internal/db"
|
|
||||||
"mal/internal/domain"
|
|
||||||
"mal/internal/server"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type commandPaletteItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Subtitle string `json:"subtitle"`
|
|
||||||
Href string `json:"href"`
|
|
||||||
Image string `json:"image,omitempty"`
|
|
||||||
Icon string `json:"icon,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
if user == nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
query := strings.TrimSpace(c.Query("q"))
|
|
||||||
items := make([]commandPaletteItem, 0, 12)
|
|
||||||
|
|
||||||
if query != "" {
|
|
||||||
items = append(items, commandPaletteItem{
|
|
||||||
ID: "search:" + strings.ToLower(query),
|
|
||||||
Type: "search",
|
|
||||||
Label: fmt.Sprintf("Search anime for %q", query),
|
|
||||||
Subtitle: "Browse",
|
|
||||||
Href: "/browse?q=" + url.QueryEscape(query),
|
|
||||||
Icon: "search",
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(query) >= 2 {
|
|
||||||
items = append(items, h.commandPaletteAnimeResults(c, query)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
|
||||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
|
||||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
|
||||||
c.JSON(http.StatusOK, items)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
|
||||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
|
||||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
|
||||||
c.JSON(http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
|
|
||||||
all := []commandPaletteItem{
|
|
||||||
{ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"},
|
|
||||||
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
|
|
||||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"},
|
|
||||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"},
|
|
||||||
}
|
|
||||||
if query == "" {
|
|
||||||
return all
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered := make([]commandPaletteItem, 0, len(all))
|
|
||||||
for _, item := range all {
|
|
||||||
if commandPaletteMatches(query, item.Label, item.Subtitle) {
|
|
||||||
filtered = append(filtered, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
|
|
||||||
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
animes := wrapAnimes(res.Animes)
|
|
||||||
items := make([]commandPaletteItem, 0, len(animes))
|
|
||||||
for _, anime := range animes {
|
|
||||||
items = append(items, commandPaletteItem{
|
|
||||||
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
|
||||||
Type: "anime",
|
|
||||||
Label: anime.DisplayTitle(),
|
|
||||||
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
|
||||||
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
|
||||||
Image: anime.ImageURL(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
|
||||||
items := make([]commandPaletteItem, 0, 5)
|
|
||||||
|
|
||||||
watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5)
|
|
||||||
if err != nil {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range watchlist {
|
|
||||||
title := watchlistTitle(entry)
|
|
||||||
items = append(items, commandPaletteItem{
|
|
||||||
ID: fmt.Sprintf("watchlist:%d", entry.AnimeID),
|
|
||||||
Type: "watchlist",
|
|
||||||
Label: title,
|
|
||||||
Subtitle: watchlistStatusLabel(entry.Status),
|
|
||||||
Href: fmt.Sprintf("/anime/%d", entry.AnimeID),
|
|
||||||
Image: entry.ImageUrl,
|
|
||||||
})
|
|
||||||
if len(items) >= 5 {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
|
||||||
items := make([]commandPaletteItem, 0, 5)
|
|
||||||
|
|
||||||
rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5)
|
|
||||||
if err != nil {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
title := continueWatchingTitle(row)
|
|
||||||
episode := ""
|
|
||||||
href := fmt.Sprintf("/anime/%d/watch", row.AnimeID)
|
|
||||||
if row.CurrentEpisode.Valid {
|
|
||||||
episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64)
|
|
||||||
href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64)
|
|
||||||
}
|
|
||||||
items = append(items, commandPaletteItem{
|
|
||||||
ID: fmt.Sprintf("continue:%d", row.AnimeID),
|
|
||||||
Type: "continue",
|
|
||||||
Label: "Continue watching " + title,
|
|
||||||
Subtitle: "Resume" + episode,
|
|
||||||
Href: href,
|
|
||||||
Image: row.ImageUrl,
|
|
||||||
})
|
|
||||||
if len(items) >= 5 {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandPaletteMatches(query string, values ...string) bool {
|
|
||||||
needle := strings.ToLower(strings.TrimSpace(query))
|
|
||||||
for _, value := range values {
|
|
||||||
if strings.Contains(strings.ToLower(value), needle) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string {
|
|
||||||
return row.DisplayTitle()
|
|
||||||
}
|
|
||||||
|
|
||||||
func watchlistTitle(row domain.UserWatchListRow) string {
|
|
||||||
return row.DisplayTitle()
|
|
||||||
}
|
|
||||||
|
|
||||||
func watchlistStatusLabel(status string) string {
|
|
||||||
switch status {
|
|
||||||
case "watching":
|
|
||||||
return "Watching"
|
|
||||||
case "plan_to_watch":
|
|
||||||
return "Plan to Watch"
|
|
||||||
default:
|
|
||||||
return "Watchlist"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
335
internal/anime/details_handler.go
Normal file
335
internal/anime/details_handler.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"mal/internal/server"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
animeSectionTimeout = 12 * time.Second
|
||||||
|
watchOrderTimeout = 15 * time.Second
|
||||||
|
audioLookupTimeout = 8 * time.Second
|
||||||
|
episodeCountTimeout = 4 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type animeEpisodeCountDisplay struct {
|
||||||
|
Count int
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
func listedEpisodeCount(episodes []domain.EpisodeData) int {
|
||||||
|
count := 0
|
||||||
|
for _, episode := range episodes {
|
||||||
|
if episode.MalID <= 0 || episode.IsRecap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
|
||||||
|
if !anime.Airing || anime.Aired.From == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
firstAired, err := time.Parse(time.RFC3339, anime.Aired.From)
|
||||||
|
if err != nil || now.Before(firstAired) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count := int(now.Sub(firstAired)/(7*24*time.Hour)) + 1
|
||||||
|
if anime.Episodes > 0 && count > anime.Episodes {
|
||||||
|
return anime.Episodes
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
||||||
|
if h.episodeSvc != nil {
|
||||||
|
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
|
||||||
|
if err == nil {
|
||||||
|
if count := len(episodeList.Episodes); count > 0 {
|
||||||
|
return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
observability.Warn(
|
||||||
|
"anime_episode_availability_count_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": anime.MalID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.svc != nil && anime.Airing {
|
||||||
|
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID)
|
||||||
|
if err == nil {
|
||||||
|
if count := listedEpisodeCount(episodes); count > 0 {
|
||||||
|
return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
observability.Warn(
|
||||||
|
"anime_episode_count_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": anime.MalID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if anime.Episodes > 0 {
|
||||||
|
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
|
||||||
|
}
|
||||||
|
if count := releasedEpisodeCount(anime, now); count > 0 {
|
||||||
|
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
|
||||||
|
}
|
||||||
|
return animeEpisodeCountDisplay{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
||||||
|
if anime.Episodes > 0 {
|
||||||
|
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
|
||||||
|
}
|
||||||
|
if count := releasedEpisodeCount(anime, now); count > 0 {
|
||||||
|
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
|
||||||
|
}
|
||||||
|
return animeEpisodeCountDisplay{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||||
|
hasKnownSub := false
|
||||||
|
for _, episode := range episodes {
|
||||||
|
if episode.HasDub {
|
||||||
|
return "Dub available"
|
||||||
|
}
|
||||||
|
if episode.HasSub || episode.SubOnly {
|
||||||
|
hasKnownSub = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasKnownSub {
|
||||||
|
return "Subtitled only"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
|
||||||
|
if h.episodeSvc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"anime_audio_availability_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": anime.MalID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if episodeList.Source != "AllAnime" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return animeAudioAvailabilityLabel(episodeList.Episodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
section := c.Query("section")
|
||||||
|
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||||
|
h.handleAnimeDetailsSection(c, id, section)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.svc.WarmDetailSections(id)
|
||||||
|
|
||||||
|
user := server.CurrentUser(c)
|
||||||
|
status := ""
|
||||||
|
var watchlistIDs []int64
|
||||||
|
ep := 0
|
||||||
|
var cwSeconds float64
|
||||||
|
if user != nil {
|
||||||
|
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
|
||||||
|
if err == nil {
|
||||||
|
status = entry.Status
|
||||||
|
watchlistIDs = []int64{entry.AnimeID}
|
||||||
|
}
|
||||||
|
|
||||||
|
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
|
||||||
|
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||||
|
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||||
|
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
episodesCount := animeInitialEpisodeCount(anime, time.Now())
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||||
|
"Anime": anime,
|
||||||
|
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||||
|
"User": user,
|
||||||
|
"Status": status,
|
||||||
|
"WatchlistIDs": watchlistIDs,
|
||||||
|
"ContinueWatchingEp": ep,
|
||||||
|
"ContinueWatchingTime": cwSeconds,
|
||||||
|
"EpisodesCount": episodesCount.Count,
|
||||||
|
"EpisodesCountLabel": episodesCount.Label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) handleAnimeDetailsSection(c *gin.Context, id int, section string) {
|
||||||
|
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
data, tplName, err := h.loadAnimeDetailsSection(sectionCtx, id, section)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
observability.Warn(
|
||||||
|
"anime_section_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"section": section,
|
||||||
|
"anime_id": id,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
if section == "recommendations" {
|
||||||
|
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||||
|
"_fragment": "anime_recommendations_loading",
|
||||||
|
"AnimeID": id,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||||
|
"_fragment": tplName,
|
||||||
|
"Items": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, section string) (any, string, error) {
|
||||||
|
switch section {
|
||||||
|
case "characters":
|
||||||
|
data, err := h.svc.GetCharacters(ctx, id)
|
||||||
|
return data, "anime_characters", err
|
||||||
|
case "recommendations":
|
||||||
|
data, err := h.svc.GetRecommendations(ctx, id)
|
||||||
|
return data, "anime_recommendations", err
|
||||||
|
case "statistics":
|
||||||
|
data, err := h.svc.GetStatistics(ctx, id)
|
||||||
|
return data, "anime_statistics", err
|
||||||
|
case "episode-count":
|
||||||
|
anime, err := h.svc.GetAnimeByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return h.animeEpisodeCount(ctx, anime, time.Now()), "anime_episode_count", nil
|
||||||
|
case "audio-availability":
|
||||||
|
anime, err := h.svc.GetAnimeByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return h.animeAudioAvailability(ctx, anime), "anime_audio_availability", nil
|
||||||
|
case "themes":
|
||||||
|
data, err := h.svc.GetThemes(ctx, id)
|
||||||
|
return data, "anime_themes", err
|
||||||
|
default:
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Query("animeId"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := server.CurrentUserID(c)
|
||||||
|
mode := jikan.NormalizeWatchOrderMode(c.Query("mode"))
|
||||||
|
|
||||||
|
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
relations, err := h.svc.GetRelations(relationsCtx, id, mode)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"relations_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"anime_id": id,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||||
|
"_fragment": "watch_order_loading",
|
||||||
|
"AnimeID": id,
|
||||||
|
"Mode": string(mode),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(relations))
|
||||||
|
for _, relation := range relations {
|
||||||
|
if relation.Anime.MalID > 0 {
|
||||||
|
ids = append(ids, int64(relation.Anime.MalID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, ids)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||||
|
"_fragment": "watch_order",
|
||||||
|
"Relations": relations,
|
||||||
|
"AnimeID": id,
|
||||||
|
"Mode": string(mode),
|
||||||
|
"WatchlistMap": watchlistMap,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,38 +2,22 @@ package anime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"mal/integrations/jikan"
|
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/observability"
|
|
||||||
"mal/internal/server"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
animeSectionTimeout = 12 * time.Second
|
|
||||||
watchOrderTimeout = 15 * time.Second
|
|
||||||
audioLookupTimeout = 8 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
type AnimeHandler struct {
|
type AnimeHandler struct {
|
||||||
svc Service
|
svc Service
|
||||||
watchlistSvc domain.WatchlistService
|
watchlistSvc domain.WatchlistService
|
||||||
episodeSvc domain.EpisodeService
|
episodeSvc domain.EpisodeService
|
||||||
|
scheduleCache map[string]cachedWeekSchedule
|
||||||
scheduleCacheMu sync.Mutex
|
sync.Mutex
|
||||||
scheduleCache map[string]cachedWeekSchedule
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
domain.AnimeCatalogService
|
domain.AnimeCatalogService
|
||||||
domain.AnimeDiscoverService
|
|
||||||
domain.AnimeSearchService
|
domain.AnimeSearchService
|
||||||
domain.AnimeDetailsService
|
domain.AnimeDetailsService
|
||||||
WarmDetailSections(id int)
|
WarmDetailSections(id int)
|
||||||
@@ -44,7 +28,7 @@ func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeS
|
|||||||
svc: svc,
|
svc: svc,
|
||||||
watchlistSvc: watchlistSvc,
|
watchlistSvc: watchlistSvc,
|
||||||
episodeSvc: episodeSvc,
|
episodeSvc: episodeSvc,
|
||||||
scheduleCache: map[string]cachedWeekSchedule{},
|
scheduleCache: make(map[string]cachedWeekSchedule),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,766 +54,20 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
|
|||||||
return watchlistMap
|
return watchlistMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
|
||||||
hasKnownSub := false
|
|
||||||
for _, episode := range episodes {
|
|
||||||
if episode.HasDub {
|
|
||||||
return "Dub available"
|
|
||||||
}
|
|
||||||
if episode.HasSub || episode.SubOnly {
|
|
||||||
hasKnownSub = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasKnownSub {
|
|
||||||
return "Subtitled only"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
|
|
||||||
if h.episodeSvc == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"anime_audio_availability_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"anime_id": anime.MalID,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if episodeList.Source != "AllAnime" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return animeAudioAvailabilityLabel(episodeList.Episodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) Register(r *gin.Engine) {
|
func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||||
r.GET("/", h.HandleCatalog)
|
r.GET("/", h.HandleCatalog)
|
||||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
||||||
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
||||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||||
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
||||||
r.GET("/discover", h.HandleDiscover)
|
r.GET("/search", h.HandleSearch)
|
||||||
r.GET("/discover/top-picks", h.HandleDiscoverTopPicksForYou)
|
r.GET("/top-picks", h.HandleTopPicks)
|
||||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
|
||||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
|
||||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
|
||||||
r.GET("/schedule", h.HandleSchedule)
|
|
||||||
r.GET("/api/schedule", h.HandleScheduleSection)
|
|
||||||
r.GET("/browse", h.HandleBrowse)
|
r.GET("/browse", h.HandleBrowse)
|
||||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||||
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
||||||
r.GET("/api/search-quick", h.HandleQuickSearch)
|
r.GET("/api/search-quick", h.HandleQuickSearch)
|
||||||
r.GET("/api/command-palette", h.HandleCommandPalette)
|
r.GET("/api/search", h.HandleSearchAPI)
|
||||||
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
||||||
r.GET("/api/jikan/producers", h.HandleProducers)
|
r.GET("/api/jikan/producers", h.HandleProducers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
|
|
||||||
q := strings.TrimSpace(c.Query("q"))
|
|
||||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
if err != nil {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
||||||
if err != nil {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid limit")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if limit < 1 {
|
|
||||||
limit = 12
|
|
||||||
}
|
|
||||||
if limit > 12 {
|
|
||||||
limit = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"producers_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"q": q,
|
|
||||||
"page": page,
|
|
||||||
"limit": limit,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
|
||||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
|
||||||
"_fragment": "studio_dropdown_items",
|
|
||||||
"StudioItems": []any{},
|
|
||||||
"HasNextPage": false,
|
|
||||||
"Page": page,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"Query": q,
|
|
||||||
"Limit": limit,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
server.RespondError(
|
|
||||||
c,
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
"producers_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"failed to load producers",
|
|
||||||
map[string]any{"q": q, "page": page, "limit": limit},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type item struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]item, 0, len(res.Items))
|
|
||||||
for _, p := range res.Items {
|
|
||||||
name := jikan.ProducerListEntryName(p)
|
|
||||||
if p.MalID <= 0 || name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, item{ID: p.MalID, Name: name})
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
|
||||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
|
||||||
"_fragment": "studio_dropdown_items",
|
|
||||||
"StudioItems": items,
|
|
||||||
"HasNextPage": res.HasNextPage,
|
|
||||||
"Page": page,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"Query": q,
|
|
||||||
"Limit": limit,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"hasNextPage": res.HasNextPage,
|
|
||||||
"nextPage": page + 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
|
||||||
"CurrentPath": "/",
|
|
||||||
"User": user,
|
|
||||||
"WatchlistMap": map[int64]bool{},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
|
||||||
h.renderCatalogSection(c, "Airing")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
|
||||||
h.renderCatalogSection(c, "Popular")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
|
||||||
h.renderCatalogSection(c, "Continue")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
|
|
||||||
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"top_pick_for_you_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"user_id": userID,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
|
||||||
|
|
||||||
data.Section = "TopPickForYou"
|
|
||||||
data.Fragment = "top_pick_for_you_section"
|
|
||||||
data.WatchlistMap = watchlistMap
|
|
||||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
|
||||||
if err != nil {
|
|
||||||
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
|
||||||
|
|
||||||
data.Section = section
|
|
||||||
data.Fragment = "catalog_section"
|
|
||||||
data.WatchlistMap = watchlistMap
|
|
||||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
|
||||||
"CurrentPath": "/discover",
|
|
||||||
"User": user,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleDiscoverTopPicksForYou(c *gin.Context) {
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
|
|
||||||
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"top_picks_for_you_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"user_id": userID,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
|
||||||
"_fragment": "",
|
|
||||||
"CurrentPath": "/discover",
|
|
||||||
"User": user,
|
|
||||||
"Animes": data.Animes,
|
|
||||||
"WatchlistMap": watchlistMap,
|
|
||||||
"IsTopPicks": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
|
|
||||||
h.renderDiscoverSection(c, "Trending")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
|
|
||||||
h.renderDiscoverSection(c, "Upcoming")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
|
|
||||||
h.renderDiscoverSection(c, "Top")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
|
||||||
if err != nil {
|
|
||||||
h.abortSectionFetch(c, "discover_section_fetch_failed", userID, section, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
|
||||||
|
|
||||||
data.Section = section
|
|
||||||
data.Fragment = "discover_section"
|
|
||||||
data.WatchlistMap = watchlistMap
|
|
||||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
|
||||||
observability.Warn(
|
|
||||||
event,
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"section": section,
|
|
||||||
"user_id": userID,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
year, week := parseYearWeek(c)
|
|
||||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
|
||||||
"CurrentPath": "/schedule",
|
|
||||||
"User": user,
|
|
||||||
"ScheduleYear": year,
|
|
||||||
"ScheduleWeek": week,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
|
||||||
year, week := parseYearWeek(c)
|
|
||||||
timezone := scheduleTimezone(c)
|
|
||||||
|
|
||||||
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone)
|
|
||||||
if err != nil {
|
|
||||||
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
|
|
||||||
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
|
|
||||||
observability.Warn(
|
|
||||||
"animeschedule_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"year": year,
|
|
||||||
"week": week,
|
|
||||||
"timezone": timezone,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
|
||||||
"_fragment": "schedule_section_scraped",
|
|
||||||
"ScheduleDays": []any{},
|
|
||||||
"ScheduleYear": year,
|
|
||||||
"ScheduleWeek": week,
|
|
||||||
"PrevYear": prevYear,
|
|
||||||
"PrevWeek": prevWeek,
|
|
||||||
"NextYear": nextYear,
|
|
||||||
"NextWeek": nextWeek,
|
|
||||||
"ScheduleError": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
days := buildScheduleDays(schedule, schedule.Year, schedule.Week)
|
|
||||||
prevYear, prevWeek := adjacentISOWeek(schedule.Year, schedule.Week, -1)
|
|
||||||
nextYear, nextWeek := adjacentISOWeek(schedule.Year, schedule.Week, 1)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
|
||||||
"_fragment": "schedule_section_scraped",
|
|
||||||
"ScheduleDays": days,
|
|
||||||
"ScheduleYear": schedule.Year,
|
|
||||||
"ScheduleWeek": schedule.Week,
|
|
||||||
"PrevYear": prevYear,
|
|
||||||
"PrevWeek": prevWeek,
|
|
||||||
"NextYear": nextYear,
|
|
||||||
"NextWeek": nextWeek,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
|
||||||
q := c.Query("q")
|
|
||||||
animeType := c.Query("type")
|
|
||||||
status := c.Query("status")
|
|
||||||
orderBy := c.Query("order_by")
|
|
||||||
sort := c.Query("sort")
|
|
||||||
sfw := c.Query("sfw") != "false"
|
|
||||||
studioID := 0
|
|
||||||
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
|
||||||
id, err := strconv.Atoi(raw)
|
|
||||||
if err != nil || id < 0 {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid studio id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
studioID = id
|
|
||||||
}
|
|
||||||
|
|
||||||
var genres []int
|
|
||||||
for _, g := range c.QueryArray("genres") {
|
|
||||||
id, err := strconv.Atoi(g)
|
|
||||||
if err != nil {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if id > 0 {
|
|
||||||
genres = append(genres, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
if err != nil {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
|
|
||||||
if err != nil {
|
|
||||||
server.RespondError(
|
|
||||||
c,
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
"browse_search_failed",
|
|
||||||
"anime",
|
|
||||||
"failed to load browse results",
|
|
||||||
map[string]any{
|
|
||||||
"q": q,
|
|
||||||
"type": animeType,
|
|
||||||
"status": status,
|
|
||||||
"order_by": orderBy,
|
|
||||||
"sort": sort,
|
|
||||||
"studio": studioID,
|
|
||||||
"sfw": sfw,
|
|
||||||
"page": page,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
animes := wrapAnimes(res.Animes)
|
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
|
||||||
|
|
||||||
studioName := ""
|
|
||||||
if studioID > 0 {
|
|
||||||
name, err := h.svc.GetProducerNameByID(c.Request.Context(), studioID)
|
|
||||||
if err == nil {
|
|
||||||
studioName = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
|
||||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
|
||||||
"_fragment": "anime_card_scroll",
|
|
||||||
"Animes": animes,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"HasNextPage": res.HasNextPage,
|
|
||||||
"Query": q,
|
|
||||||
"Type": animeType,
|
|
||||||
"Status": status,
|
|
||||||
"OrderBy": orderBy,
|
|
||||||
"Sort": sort,
|
|
||||||
"Genres": genres,
|
|
||||||
"Studio": studioID,
|
|
||||||
"StudioName": studioName,
|
|
||||||
"SFW": sfw,
|
|
||||||
"WatchlistMap": watchlistMap,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
|
||||||
browseData := gin.H{
|
|
||||||
"CurrentPath": "/browse",
|
|
||||||
"Query": q,
|
|
||||||
"Type": animeType,
|
|
||||||
"Status": status,
|
|
||||||
"OrderBy": orderBy,
|
|
||||||
"Sort": sort,
|
|
||||||
"Genres": genres,
|
|
||||||
"Studio": studioID,
|
|
||||||
"StudioName": studioName,
|
|
||||||
"SFW": sfw,
|
|
||||||
"GenresList": genresList,
|
|
||||||
"Animes": animes,
|
|
||||||
"HasNextPage": res.HasNextPage,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"User": user,
|
|
||||||
"WatchlistMap": watchlistMap,
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GetHeader("HX-Request") == "true" {
|
|
||||||
browseData["_fragment"] = "browse_content"
|
|
||||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
section := c.Query("section")
|
|
||||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
|
||||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var data any
|
|
||||||
var tplName string
|
|
||||||
var err error
|
|
||||||
switch section {
|
|
||||||
case "characters":
|
|
||||||
data, err = h.svc.GetCharacters(sectionCtx, id)
|
|
||||||
tplName = "anime_characters"
|
|
||||||
case "recommendations":
|
|
||||||
data, err = h.svc.GetRecommendations(sectionCtx, id)
|
|
||||||
tplName = "anime_recommendations"
|
|
||||||
|
|
||||||
case "statistics":
|
|
||||||
data, err = h.svc.GetStatistics(sectionCtx, id)
|
|
||||||
tplName = "anime_statistics"
|
|
||||||
case "themes":
|
|
||||||
data, err = h.svc.GetThemes(sectionCtx, id)
|
|
||||||
tplName = "anime_themes"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"anime_section_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"section": section,
|
|
||||||
"anime_id": id,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
if section == "recommendations" {
|
|
||||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
|
||||||
"_fragment": "anime_recommendations_loading",
|
|
||||||
"AnimeID": id,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
|
||||||
"_fragment": tplName,
|
|
||||||
"Items": data,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.Status(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.svc.WarmDetailSections(id)
|
|
||||||
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
status := ""
|
|
||||||
var watchlistIDs []int64
|
|
||||||
ep := 0
|
|
||||||
var cwSeconds float64
|
|
||||||
if user != nil {
|
|
||||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
|
|
||||||
if err == nil {
|
|
||||||
status = entry.Status
|
|
||||||
watchlistIDs = []int64{entry.AnimeID}
|
|
||||||
}
|
|
||||||
|
|
||||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
|
|
||||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
|
||||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
|
||||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
|
||||||
"Anime": anime,
|
|
||||||
"AudioAvailability": audioAvailability,
|
|
||||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
|
||||||
"User": user,
|
|
||||||
"Status": status,
|
|
||||||
"WatchlistIDs": watchlistIDs,
|
|
||||||
"ContinueWatchingEp": ep,
|
|
||||||
"ContinueWatchingTime": cwSeconds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Query("animeId"))
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
|
|
||||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
relations, err := h.svc.GetRelations(relationsCtx, id)
|
|
||||||
if err != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"relations_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"anime_id": id,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
|
||||||
"_fragment": "watch_order_loading",
|
|
||||||
"AnimeID": id,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
relationAnimeIDs := make([]int64, 0, len(relations))
|
|
||||||
for _, relation := range relations {
|
|
||||||
if relation.Anime.MalID > 0 {
|
|
||||||
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
|
||||||
"_fragment": "watch_order",
|
|
||||||
"Relations": relations,
|
|
||||||
"AnimeID": id,
|
|
||||||
"WatchlistMap": watchlistMap,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
|
||||||
query := c.Query("q")
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(http.StatusOK, []any{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, []any{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
animes := wrapAnimes(res.Animes)
|
|
||||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
|
||||||
|
|
||||||
type quickSearchResult struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Year int `json:"year"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
InWatchlist bool `json:"in_watchlist"`
|
|
||||||
}
|
|
||||||
|
|
||||||
output := make([]quickSearchResult, len(animes))
|
|
||||||
for i, anime := range animes {
|
|
||||||
output[i] = quickSearchResult{
|
|
||||||
ID: anime.MalID,
|
|
||||||
Title: anime.DisplayTitle(),
|
|
||||||
Type: anime.Type,
|
|
||||||
Year: anime.Year,
|
|
||||||
Image: anime.ImageURL(),
|
|
||||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
anime, err := h.svc.GetRandomAnime(ctx)
|
|
||||||
if err != nil {
|
|
||||||
server.RespondError(
|
|
||||||
c,
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
"random_anime_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"failed to fetch random anime",
|
|
||||||
nil,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if anime.MalID == 0 {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
inWatchlist := false
|
|
||||||
userID := server.CurrentUserID(c)
|
|
||||||
if userID != "" {
|
|
||||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
|
|
||||||
inWatchlist = watchlistMap[int64(anime.MalID)]
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"data": anime,
|
|
||||||
"in_watchlist": inWatchlist,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
if err != nil {
|
|
||||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
|
||||||
if err != nil {
|
|
||||||
server.RespondError(
|
|
||||||
c,
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
"anime_reviews_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"failed to load reviews",
|
|
||||||
map[string]any{"anime_id": id, "page": page},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := server.CurrentUser(c)
|
|
||||||
|
|
||||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
|
||||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
|
||||||
"_fragment": "review_cards",
|
|
||||||
"Reviews": reviews,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"HasNextPage": hasNextPage,
|
|
||||||
"AnimeID": id,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
|
||||||
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", id),
|
|
||||||
"Reviews": reviews,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
"HasNextPage": hasNextPage,
|
|
||||||
"AnimeID": id,
|
|
||||||
"User": user,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import (
|
|||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stubEpisodeService struct {
|
type stubEpisodeService struct {
|
||||||
episodes domain.CanonicalEpisodeList
|
episodes domain.CanonicalEpisodeList
|
||||||
err error
|
err error
|
||||||
forced bool
|
called int
|
||||||
|
forceRefresh bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
|
func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
|
||||||
s.forced = forceRefresh
|
s.called++
|
||||||
|
s.forceRefresh = forceRefresh
|
||||||
if s.err != nil {
|
if s.err != nil {
|
||||||
return domain.CanonicalEpisodeList{}, s.err
|
return domain.CanonicalEpisodeList{}, s.err
|
||||||
}
|
}
|
||||||
@@ -26,6 +29,174 @@ func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type releasedCountTest struct {
|
||||||
|
name string
|
||||||
|
anime domain.Anime
|
||||||
|
now time.Time
|
||||||
|
want int
|
||||||
|
}
|
||||||
|
|
||||||
|
var releasedCountTests = []releasedCountTest{
|
||||||
|
{
|
||||||
|
name: "weekly airing count",
|
||||||
|
anime: domain.Anime{Anime: jikan.Anime{
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 24,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||||
|
}},
|
||||||
|
now: time.Date(2026, time.June, 13, 15, 0, 0, 0, time.UTC),
|
||||||
|
want: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "before first release",
|
||||||
|
anime: domain.Anime{Anime: jikan.Anime{
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||||
|
}},
|
||||||
|
now: time.Date(2026, time.April, 4, 14, 59, 0, 0, time.UTC),
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first release counts as one",
|
||||||
|
anime: domain.Anime{Anime: jikan.Anime{
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||||
|
}},
|
||||||
|
now: time.Date(2026, time.April, 4, 15, 0, 0, 0, time.UTC),
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caps at total episode count",
|
||||||
|
anime: domain.Anime{Anime: jikan.Anime{
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 12,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||||
|
}},
|
||||||
|
now: time.Date(2026, time.December, 1, 15, 0, 0, 0, time.UTC),
|
||||||
|
want: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown total still estimates current count",
|
||||||
|
anime: domain.Anime{Anime: jikan.Anime{
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||||
|
}},
|
||||||
|
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
|
||||||
|
want: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non airing anime is not estimated",
|
||||||
|
anime: domain.Anime{Anime: jikan.Anime{
|
||||||
|
Airing: false,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||||
|
}},
|
||||||
|
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid aired date is ignored",
|
||||||
|
anime: domain.Anime{Anime: jikan.Anime{
|
||||||
|
Airing: true,
|
||||||
|
Aired: jikan.Aired{From: "not-a-date"},
|
||||||
|
}},
|
||||||
|
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleasedEpisodeCount(t *testing.T) {
|
||||||
|
for _, tt := range releasedCountTests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := releasedEpisodeCount(tt.anime, tt.now)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("releasedEpisodeCount() = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListedEpisodeCount(t *testing.T) {
|
||||||
|
episodes := []domain.EpisodeData{
|
||||||
|
{MalID: 1, Title: "Episode 1"},
|
||||||
|
{MalID: 2, Title: "Episode 2"},
|
||||||
|
{MalID: 3, Title: "Recap", IsRecap: true},
|
||||||
|
{Title: "missing id"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := listedEpisodeCount(episodes)
|
||||||
|
if got != 2 {
|
||||||
|
t.Fatalf("listedEpisodeCount() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
|
||||||
|
episodeSvc := &stubEpisodeService{
|
||||||
|
episodes: domain.CanonicalEpisodeList{
|
||||||
|
Source: "AllAnime",
|
||||||
|
Episodes: []domain.CanonicalEpisode{
|
||||||
|
{Number: 1},
|
||||||
|
{Number: 2},
|
||||||
|
{Number: 3},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
||||||
|
|
||||||
|
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 59970,
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 12,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
|
||||||
|
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 3 || got.Label != "Available episodes" {
|
||||||
|
t.Fatalf("animeEpisodeCount() = %+v, want count=3 label=%q", got, "Available episodes")
|
||||||
|
}
|
||||||
|
if episodeSvc.called != 1 {
|
||||||
|
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
|
||||||
|
}
|
||||||
|
if episodeSvc.forceRefresh {
|
||||||
|
t.Fatal("animeEpisodeCount() should use fresh cache when available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
|
||||||
|
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
|
||||||
|
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
||||||
|
|
||||||
|
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 59970,
|
||||||
|
Airing: false,
|
||||||
|
Episodes: 12,
|
||||||
|
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 12 || got.Label != "Total episodes" {
|
||||||
|
t.Fatalf("animeEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) {
|
||||||
|
episodeSvc := &stubEpisodeService{
|
||||||
|
episodes: domain.CanonicalEpisodeList{
|
||||||
|
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
|
||||||
|
MalID: 59970,
|
||||||
|
Airing: true,
|
||||||
|
Episodes: 12,
|
||||||
|
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
|
||||||
|
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if got.Count != 12 || got.Label != "Total episodes" {
|
||||||
|
t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
|
||||||
|
}
|
||||||
|
if episodeSvc.called != 0 {
|
||||||
|
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
|
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -116,7 +287,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
|
|||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want)
|
t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want)
|
||||||
}
|
}
|
||||||
if !episodeSvc.forced {
|
if !episodeSvc.forceRefresh {
|
||||||
t.Fatal("animeAudioAvailability() did not force provider refresh")
|
t.Fatal("animeAudioAvailability() did not force provider refresh")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ var Module = fx.Options(
|
|||||||
NewAnimeService,
|
NewAnimeService,
|
||||||
fx.As(new(Service)),
|
fx.As(new(Service)),
|
||||||
fx.As(new(domain.AnimeCatalogService)),
|
fx.As(new(domain.AnimeCatalogService)),
|
||||||
fx.As(new(domain.AnimeDiscoverService)),
|
|
||||||
fx.As(new(domain.AnimeSearchService)),
|
fx.As(new(domain.AnimeSearchService)),
|
||||||
fx.As(new(domain.AnimeDetailsService)),
|
fx.As(new(domain.AnimeDetailsService)),
|
||||||
fx.As(new(domain.AnimePlaybackService)),
|
fx.As(new(domain.AnimePlaybackService)),
|
||||||
|
|||||||
@@ -1,503 +1,15 @@
|
|||||||
package anime
|
package anime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/integrations/jikan"
|
"context"
|
||||||
"mal/internal/db"
|
"mal/internal/anime/recommendations"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"math"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||||
forYouMaxSeeds = 8
|
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPickLimit)
|
||||||
forYouMaxRecommendations = 10
|
|
||||||
forYouCandidateFetchLimit = 60
|
|
||||||
forYouResultLimit = 18
|
|
||||||
forYouFullResultLimit = 60
|
|
||||||
forYouProfileSearchLimit = 8
|
|
||||||
forYouProfileGenreSearches = 2
|
|
||||||
forYouProfileThemeSearches = 2
|
|
||||||
forYouCollaborativeWeight = 1.4
|
|
||||||
forYouProfileSearchWeight = 0.8
|
|
||||||
forYouSeedRecencyWindow = 180 * 24 * time.Hour
|
|
||||||
forYouFreshReleaseWindow = 540 * 24 * time.Hour
|
|
||||||
forYouGenreMatchWeight = 1.8
|
|
||||||
forYouThemeMatchWeight = 1.0
|
|
||||||
forYouStudioMatchWeight = 0.7
|
|
||||||
forYouDemographicMatchWeight = 0.9
|
|
||||||
forYouRecentDiversityWindow = 3
|
|
||||||
forYouGenreDiversityPenalty = 1.7
|
|
||||||
forYouThemeDiversityPenalty = 1.2
|
|
||||||
forYouDemoDiversityPenalty = 1.0
|
|
||||||
forYouStudioDiversityPenalty = 0.7
|
|
||||||
)
|
|
||||||
|
|
||||||
type recommendationSeed struct {
|
|
||||||
animeID int
|
|
||||||
weight float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type weightedEntity struct {
|
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||||
id int
|
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPicksLimit)
|
||||||
weight float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type profileSearchQuery struct {
|
|
||||||
genreIDs []int
|
|
||||||
studioID int
|
|
||||||
weight float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type recommendationCandidate struct {
|
|
||||||
anime jikan.Anime
|
|
||||||
score float64
|
|
||||||
genreMatches int
|
|
||||||
themeMatches int
|
|
||||||
studioMatches int
|
|
||||||
demographicMatches int
|
|
||||||
}
|
|
||||||
|
|
||||||
type userTasteProfile struct {
|
|
||||||
genres map[int]float64
|
|
||||||
themes map[int]float64
|
|
||||||
studios map[int]float64
|
|
||||||
demographics map[int]float64
|
|
||||||
prefersAiring bool
|
|
||||||
prefersRecent bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRecommendationSeeds(
|
|
||||||
now time.Time,
|
|
||||||
watchlist []db.GetUserWatchListRow,
|
|
||||||
) []recommendationSeed {
|
|
||||||
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
|
|
||||||
|
|
||||||
for _, entry := range watchlist {
|
|
||||||
weight := recommendationEntryWeight(now, entry)
|
|
||||||
if weight <= 0 || entry.AnimeID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
seeds = append(seeds, recommendationSeed{
|
|
||||||
animeID: int(entry.AnimeID),
|
|
||||||
weight: weight,
|
|
||||||
})
|
|
||||||
if len(seeds) >= forYouMaxSeeds {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return seeds
|
|
||||||
}
|
|
||||||
|
|
||||||
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
|
|
||||||
status := strings.TrimSpace(entry.Status)
|
|
||||||
|
|
||||||
var statusWeight float64
|
|
||||||
switch status {
|
|
||||||
case "completed":
|
|
||||||
statusWeight = 1.0
|
|
||||||
case "watching":
|
|
||||||
statusWeight = 0.9
|
|
||||||
case "plan_to_watch":
|
|
||||||
statusWeight = 0.35
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
recencyWeight := 1.0
|
|
||||||
if !entry.UpdatedAt.IsZero() {
|
|
||||||
age := now.Sub(entry.UpdatedAt)
|
|
||||||
if age > 0 {
|
|
||||||
recencyWeight = math.Max(0.35, 1-(age.Hours()/forYouSeedRecencyWindow.Hours()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progressWeight := 0.6
|
|
||||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
|
||||||
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusWeight * recencyWeight * progressWeight
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTasteProfile(
|
|
||||||
now time.Time,
|
|
||||||
seeds []recommendationSeed,
|
|
||||||
seedAnimes []jikan.Anime,
|
|
||||||
) userTasteProfile {
|
|
||||||
profile := userTasteProfile{
|
|
||||||
genres: make(map[int]float64),
|
|
||||||
themes: make(map[int]float64),
|
|
||||||
studios: make(map[int]float64),
|
|
||||||
demographics: make(map[int]float64),
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalWeight float64
|
|
||||||
var airingWeight float64
|
|
||||||
var recentWeight float64
|
|
||||||
|
|
||||||
for i, anime := range seedAnimes {
|
|
||||||
seedWeight := 1.0
|
|
||||||
if i < len(seeds) && seeds[i].weight > 0 {
|
|
||||||
seedWeight = seeds[i].weight
|
|
||||||
}
|
|
||||||
|
|
||||||
addEntityWeights(profile.genres, anime.Genres, seedWeight)
|
|
||||||
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
|
|
||||||
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
|
|
||||||
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
|
|
||||||
|
|
||||||
if anime.Airing {
|
|
||||||
airingWeight += seedWeight
|
|
||||||
}
|
|
||||||
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
|
||||||
recentWeight += seedWeight
|
|
||||||
}
|
|
||||||
totalWeight += seedWeight
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalWeight > 0 {
|
|
||||||
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
|
||||||
profile.prefersRecent = recentWeight/totalWeight >= 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
|
|
||||||
for _, entity := range entities {
|
|
||||||
if entity.MalID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
target[entity.MalID] += weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
|
||||||
queries := make([]profileSearchQuery, 0, 6)
|
|
||||||
|
|
||||||
for _, entity := range strongestWeightedEntities(profile.genres, forYouProfileGenreSearches) {
|
|
||||||
queries = append(queries, profileSearchQuery{
|
|
||||||
genreIDs: []int{entity.id},
|
|
||||||
weight: entity.weight,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entity := range strongestWeightedEntities(profile.themes, forYouProfileThemeSearches) {
|
|
||||||
queries = append(queries, profileSearchQuery{
|
|
||||||
genreIDs: []int{entity.id},
|
|
||||||
weight: entity.weight * 0.8,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
|
|
||||||
queries = append(queries, profileSearchQuery{
|
|
||||||
genreIDs: []int{entity.id},
|
|
||||||
weight: entity.weight * 0.8,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
|
|
||||||
queries = append(queries, profileSearchQuery{
|
|
||||||
studioID: entity.id,
|
|
||||||
weight: entity.weight * 0.7,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return queries
|
|
||||||
}
|
|
||||||
|
|
||||||
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
|
|
||||||
if limit <= 0 || len(weights) == 0 {
|
|
||||||
return []weightedEntity{}
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]weightedEntity, 0, len(weights))
|
|
||||||
for id, weight := range weights {
|
|
||||||
if id <= 0 || weight <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, weightedEntity{id: id, weight: weight})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
if items[i].weight == items[j].weight {
|
|
||||||
return items[i].id < items[j].id
|
|
||||||
}
|
|
||||||
return items[i].weight > items[j].weight
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(items) > limit {
|
|
||||||
return items[:limit]
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func profileSearchRankWeight(rank int) float64 {
|
|
||||||
return math.Max(0.35, 1-(float64(rank)*0.08))
|
|
||||||
}
|
|
||||||
|
|
||||||
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
|
|
||||||
return (math.Log1p(collaborativeScore) * forYouCollaborativeWeight) +
|
|
||||||
(profileSearchScore * forYouProfileSearchWeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasTasteMetadata(anime jikan.Anime) bool {
|
|
||||||
return len(anime.Genres) > 0 ||
|
|
||||||
len(anime.Themes) > 0 ||
|
|
||||||
len(anime.Studios) > 0 ||
|
|
||||||
len(anime.Demographics) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func scoreRecommendationCandidate(
|
|
||||||
now time.Time,
|
|
||||||
profile userTasteProfile,
|
|
||||||
candidate jikan.Anime,
|
|
||||||
collaborativeScore float64,
|
|
||||||
profileSearchScore float64,
|
|
||||||
) recommendationCandidate {
|
|
||||||
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
|
||||||
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
|
||||||
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
|
||||||
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
|
||||||
|
|
||||||
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
|
||||||
score += genreScore * forYouGenreMatchWeight
|
|
||||||
score += themeScore * forYouThemeMatchWeight
|
|
||||||
score += studioScore * forYouStudioMatchWeight
|
|
||||||
score += demographicScore * forYouDemographicMatchWeight
|
|
||||||
|
|
||||||
if candidate.Score > 0 {
|
|
||||||
score += min(candidate.Score/10.0, 1.0)
|
|
||||||
}
|
|
||||||
if candidate.Popularity > 0 {
|
|
||||||
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
|
|
||||||
}
|
|
||||||
if profile.prefersAiring && candidate.Airing {
|
|
||||||
score += 0.5
|
|
||||||
}
|
|
||||||
if profile.prefersRecent && candidate.Year > 0 && now.Year()-candidate.Year <= 4 {
|
|
||||||
score += 0.45
|
|
||||||
}
|
|
||||||
if candidate.Year > 0 && now.Year()-candidate.Year > 15 {
|
|
||||||
score -= 0.2
|
|
||||||
}
|
|
||||||
if candidate.Status == "Not yet aired" {
|
|
||||||
score -= 0.35
|
|
||||||
}
|
|
||||||
if candidate.Aired.From != "" {
|
|
||||||
if airedAt, err := time.Parse(time.RFC3339, candidate.Aired.From); err == nil {
|
|
||||||
if now.Sub(airedAt) <= forYouFreshReleaseWindow {
|
|
||||||
score += 0.3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return recommendationCandidate{
|
|
||||||
anime: candidate,
|
|
||||||
score: score,
|
|
||||||
genreMatches: genreMatches,
|
|
||||||
themeMatches: themeMatches,
|
|
||||||
studioMatches: studioMatches,
|
|
||||||
demographicMatches: demographicMatches,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
|
||||||
var (
|
|
||||||
matches int
|
|
||||||
score float64
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, entity := range entities {
|
|
||||||
weight, ok := weights[entity.MalID]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
matches++
|
|
||||||
score += weight
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches, score
|
|
||||||
}
|
|
||||||
|
|
||||||
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
|
|
||||||
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
|
|
||||||
remaining := slices.Clone(candidates)
|
|
||||||
seenFeatures := newDiversityFeatureCounts()
|
|
||||||
recentFeatures := make([]diversityFeatureSet, 0, forYouRecentDiversityWindow)
|
|
||||||
|
|
||||||
for len(selected) < limit && len(remaining) > 0 {
|
|
||||||
bestIndex := bestDiverseCandidateIndex(remaining, seenFeatures, recentFeatures)
|
|
||||||
candidate := remaining[bestIndex]
|
|
||||||
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
|
|
||||||
|
|
||||||
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
|
|
||||||
return anime.MalID == candidate.anime.MalID
|
|
||||||
}) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
|
||||||
features := diversityFeatures(candidate.anime)
|
|
||||||
seenFeatures.add(features)
|
|
||||||
recentFeatures = append(recentFeatures, features)
|
|
||||||
if len(recentFeatures) > forYouRecentDiversityWindow {
|
|
||||||
recentFeatures = recentFeatures[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected
|
|
||||||
}
|
|
||||||
|
|
||||||
type diversityFeatureSet struct {
|
|
||||||
genres map[int]struct{}
|
|
||||||
themes map[int]struct{}
|
|
||||||
demographics map[int]struct{}
|
|
||||||
studios map[int]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type diversityFeatureCounts struct {
|
|
||||||
genres map[int]int
|
|
||||||
themes map[int]int
|
|
||||||
demographics map[int]int
|
|
||||||
studios map[int]int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDiversityFeatureCounts() diversityFeatureCounts {
|
|
||||||
return diversityFeatureCounts{
|
|
||||||
genres: make(map[int]int),
|
|
||||||
themes: make(map[int]int),
|
|
||||||
demographics: make(map[int]int),
|
|
||||||
studios: make(map[int]int),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
|
|
||||||
addDiversityCounts(counts.genres, features.genres)
|
|
||||||
addDiversityCounts(counts.themes, features.themes)
|
|
||||||
addDiversityCounts(counts.demographics, features.demographics)
|
|
||||||
addDiversityCounts(counts.studios, features.studios)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
|
|
||||||
for id := range features {
|
|
||||||
target[id]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bestDiverseCandidateIndex(
|
|
||||||
candidates []recommendationCandidate,
|
|
||||||
seen diversityFeatureCounts,
|
|
||||||
recent []diversityFeatureSet,
|
|
||||||
) int {
|
|
||||||
bestIndex := 0
|
|
||||||
bestScore := math.Inf(-1)
|
|
||||||
|
|
||||||
for i, candidate := range candidates {
|
|
||||||
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
|
|
||||||
if score == bestScore {
|
|
||||||
if candidate.score <= candidates[bestIndex].score {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if score > bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
|
|
||||||
return diversityFeatureSet{
|
|
||||||
genres: entityIDSet(anime.Genres),
|
|
||||||
themes: entityIDSet(anime.Themes),
|
|
||||||
demographics: entityIDSet(anime.Demographics),
|
|
||||||
studios: entityIDSet(anime.Studios),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
|
|
||||||
ids := make(map[int]struct{}, len(entities))
|
|
||||||
for _, entity := range entities {
|
|
||||||
if entity.MalID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ids[entity.MalID] = struct{}{}
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
func diversityPenalty(
|
|
||||||
features diversityFeatureSet,
|
|
||||||
seen diversityFeatureCounts,
|
|
||||||
recent []diversityFeatureSet,
|
|
||||||
) float64 {
|
|
||||||
penalty := 0.0
|
|
||||||
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), forYouGenreDiversityPenalty)
|
|
||||||
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), forYouThemeDiversityPenalty)
|
|
||||||
penalty += repeatedFeaturePenalty(
|
|
||||||
features.demographics,
|
|
||||||
seen.demographics,
|
|
||||||
recentDemographicCounts(recent),
|
|
||||||
forYouDemoDiversityPenalty,
|
|
||||||
)
|
|
||||||
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), forYouStudioDiversityPenalty)
|
|
||||||
|
|
||||||
return penalty
|
|
||||||
}
|
|
||||||
|
|
||||||
func repeatedFeaturePenalty(
|
|
||||||
features map[int]struct{},
|
|
||||||
seen map[int]int,
|
|
||||||
recent map[int]int,
|
|
||||||
weight float64,
|
|
||||||
) float64 {
|
|
||||||
total := 0.0
|
|
||||||
for id := range features {
|
|
||||||
total += float64(seen[id]) * weight * 0.35
|
|
||||||
total += float64(recent[id]) * weight
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
|
|
||||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
|
||||||
return features.genres
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
|
|
||||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
|
||||||
return features.themes
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
|
|
||||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
|
||||||
return features.demographics
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
|
|
||||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
|
||||||
return features.studios
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func recentFeatureCounts(
|
|
||||||
recent []diversityFeatureSet,
|
|
||||||
selectFeatures func(diversityFeatureSet) map[int]struct{},
|
|
||||||
) map[int]int {
|
|
||||||
counts := make(map[int]int)
|
|
||||||
for _, features := range recent {
|
|
||||||
addDiversityCounts(counts, selectFeatures(features))
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
internal/anime/recommendations/constants.go
Normal file
28
internal/anime/recommendations/constants.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSeeds = 8
|
||||||
|
maxRecommendations = 10
|
||||||
|
candidateFetchLimit = 60
|
||||||
|
candidateFetchBuffer = 6
|
||||||
|
TopPickLimit = 18
|
||||||
|
TopPicksLimit = 60
|
||||||
|
profileSearchLimit = 8
|
||||||
|
profileGenreSearches = 2
|
||||||
|
profileThemeSearches = 2
|
||||||
|
collaborativeWeight = 1.4
|
||||||
|
profileSearchWeight = 0.8
|
||||||
|
seedRecencyWindow = 180 * 24 * time.Hour
|
||||||
|
freshReleaseWindow = 540 * 24 * time.Hour
|
||||||
|
genreMatchWeight = 1.8
|
||||||
|
themeMatchWeight = 1.0
|
||||||
|
studioMatchWeight = 0.7
|
||||||
|
demographicMatchWeight = 0.9
|
||||||
|
recentDiversityWindow = 3
|
||||||
|
genreDiversityPenalty = 1.7
|
||||||
|
themeDiversityPenalty = 1.2
|
||||||
|
demoDiversityPenalty = 1.0
|
||||||
|
studioDiversityPenalty = 0.7
|
||||||
|
)
|
||||||
262
internal/anime/recommendations/engine.go
Normal file
262
internal/anime/recommendations/engine.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
type engine struct {
|
||||||
|
jikan *jikan.Client
|
||||||
|
repo domain.AnimeRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTopPicksForYou(
|
||||||
|
ctx context.Context,
|
||||||
|
jikanClient *jikan.Client,
|
||||||
|
repo domain.AnimeRepository,
|
||||||
|
userID string,
|
||||||
|
resultLimit int,
|
||||||
|
) (domain.CatalogSectionData, error) {
|
||||||
|
return engine{jikan: jikanClient, repo: repo}.getTopPicksForYou(ctx, userID, resultLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e engine) getTopPicksForYou(ctx context.Context, userID string, resultLimit int) (domain.CatalogSectionData, error) {
|
||||||
|
if strings.TrimSpace(userID) == "" {
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watchlist, err := e.repo.GetUserWatchList(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CatalogSectionData{}, fmt.Errorf("get user watchlist for %q: %w", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||||
|
if len(seedPool) == 0 {
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAnimes, err := e.fetchSeedAnimes(ctx, seedPool)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CatalogSectionData{}, fmt.Errorf("fetch seed animes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||||
|
store := newCandidateStore(watchlist)
|
||||||
|
|
||||||
|
if err := e.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
|
||||||
|
return domain.CatalogSectionData{}, fmt.Errorf("collect collaborative candidates: %w", err)
|
||||||
|
}
|
||||||
|
if err := e.collectProfileSearchCandidates(ctx, profile, store); err != nil {
|
||||||
|
return domain.CatalogSectionData{}, fmt.Errorf("collect profile search candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ranked := store.ranked()
|
||||||
|
if len(ranked) == 0 {
|
||||||
|
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := e.scoreRankedCandidates(ctx, now, profile, ranked, resultLimit)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CatalogSectionData{}, fmt.Errorf("score ranked candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.CatalogSectionData{
|
||||||
|
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e engine) fetchSeedAnimes(ctx context.Context, seedPool []recommendationSeed) ([]jikan.Anime, error) {
|
||||||
|
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||||
|
var g errgroup.Group
|
||||||
|
g.SetLimit(4)
|
||||||
|
|
||||||
|
for i, seed := range seedPool {
|
||||||
|
g.Go(func() error {
|
||||||
|
anime, err := e.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get seed anime %d: %w", seed.animeID, err)
|
||||||
|
}
|
||||||
|
seedAnimes[i] = anime
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
return nil, fmt.Errorf("wait for seed anime fetches: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return seedAnimes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e engine) collectCollaborativeCandidates(ctx context.Context, seedPool []recommendationSeed, store *candidateStore) error {
|
||||||
|
var g errgroup.Group
|
||||||
|
g.SetLimit(4)
|
||||||
|
|
||||||
|
for _, seed := range seedPool {
|
||||||
|
g.Go(func() error {
|
||||||
|
recs, err := e.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"collaborative_recommendations_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{"seed_id": seed.animeID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i, rec := range recs {
|
||||||
|
if i >= maxRecommendations {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id := rec.Entry.MalID
|
||||||
|
if id <= 0 || id == seed.animeID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
store.upsert(rankedCandidate{
|
||||||
|
id: id,
|
||||||
|
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("wait for collaborative candidate fetches: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e engine) collectProfileSearchCandidates(ctx context.Context, profile userTasteProfile, store *candidateStore) error {
|
||||||
|
queries := buildProfileSearchQueries(profile)
|
||||||
|
var g errgroup.Group
|
||||||
|
g.SetLimit(3)
|
||||||
|
|
||||||
|
for _, query := range queries {
|
||||||
|
g.Go(func() error {
|
||||||
|
res, err := e.jikan.SearchAdvanced(
|
||||||
|
ctx,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"score",
|
||||||
|
"desc",
|
||||||
|
query.genreIDs,
|
||||||
|
query.studioID,
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
profileSearchLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"top_pick_profile_search_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"genres": query.genreIDs,
|
||||||
|
"studio_id": query.studioID,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, anime := range res.Animes {
|
||||||
|
if anime.MalID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
store.upsert(rankedCandidate{
|
||||||
|
id: anime.MalID,
|
||||||
|
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||||
|
anime: anime,
|
||||||
|
hasAnime: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("wait for profile search candidate fetches: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e engine) scoreRankedCandidates(
|
||||||
|
ctx context.Context,
|
||||||
|
now time.Time,
|
||||||
|
profile userTasteProfile,
|
||||||
|
ranked []rankedCandidate,
|
||||||
|
resultLimit int,
|
||||||
|
) ([]recommendationCandidate, error) {
|
||||||
|
limit := min(len(ranked), candidateScoreLimit(resultLimit))
|
||||||
|
candidates := make([]recommendationCandidate, 0, limit)
|
||||||
|
var candidatesMu sync.Mutex
|
||||||
|
var g errgroup.Group
|
||||||
|
g.SetLimit(6)
|
||||||
|
|
||||||
|
for i := range limit {
|
||||||
|
item := ranked[i]
|
||||||
|
g.Go(func() error {
|
||||||
|
anime := item.anime
|
||||||
|
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||||
|
fetchedAnime, err := e.jikan.GetAnimeByID(ctx, item.id)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"recommendation_anime_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"",
|
||||||
|
map[string]any{"anime_id": item.id},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
anime = fetchedAnime
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := scoreRecommendationCandidate(
|
||||||
|
now,
|
||||||
|
profile,
|
||||||
|
anime,
|
||||||
|
item.collaborativeScore,
|
||||||
|
item.profileSearchScore,
|
||||||
|
)
|
||||||
|
candidatesMu.Lock()
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
candidatesMu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
return nil, fmt.Errorf("wait for candidate scoring: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
if candidates[i].score == candidates[j].score {
|
||||||
|
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||||
|
}
|
||||||
|
return candidates[i].score > candidates[j].score
|
||||||
|
})
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateScoreLimit(resultLimit int) int {
|
||||||
|
if resultLimit <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return min(candidateFetchLimit, resultLimit+candidateFetchBuffer)
|
||||||
|
}
|
||||||
171
internal/anime/recommendations/profile.go
Normal file
171
internal/anime/recommendations/profile.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/db"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildRecommendationSeeds(now time.Time, watchlist []db.GetUserWatchListRow) []recommendationSeed {
|
||||||
|
seeds := make([]recommendationSeed, 0, min(len(watchlist), maxSeeds))
|
||||||
|
|
||||||
|
for _, entry := range watchlist {
|
||||||
|
weight := recommendationEntryWeight(now, entry)
|
||||||
|
if weight <= 0 || entry.AnimeID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds = append(seeds, recommendationSeed{
|
||||||
|
animeID: int(entry.AnimeID),
|
||||||
|
weight: weight,
|
||||||
|
})
|
||||||
|
if len(seeds) >= maxSeeds {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeds
|
||||||
|
}
|
||||||
|
|
||||||
|
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
|
||||||
|
status := strings.TrimSpace(entry.Status)
|
||||||
|
|
||||||
|
var statusWeight float64
|
||||||
|
switch status {
|
||||||
|
case "completed":
|
||||||
|
statusWeight = 1.0
|
||||||
|
case "watching":
|
||||||
|
statusWeight = 0.9
|
||||||
|
case "plan_to_watch":
|
||||||
|
statusWeight = 0.35
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
recencyWeight := 1.0
|
||||||
|
if !entry.UpdatedAt.IsZero() {
|
||||||
|
age := now.Sub(entry.UpdatedAt)
|
||||||
|
if age > 0 {
|
||||||
|
recencyWeight = math.Max(0.35, 1-(age.Hours()/seedRecencyWindow.Hours()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressWeight := 0.6
|
||||||
|
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||||
|
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusWeight * recencyWeight * progressWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTasteProfile(now time.Time, seeds []recommendationSeed, seedAnimes []jikan.Anime) userTasteProfile {
|
||||||
|
profile := userTasteProfile{
|
||||||
|
genres: make(map[int]float64),
|
||||||
|
themes: make(map[int]float64),
|
||||||
|
studios: make(map[int]float64),
|
||||||
|
demographics: make(map[int]float64),
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalWeight float64
|
||||||
|
var airingWeight float64
|
||||||
|
var recentWeight float64
|
||||||
|
|
||||||
|
for i, anime := range seedAnimes {
|
||||||
|
seedWeight := 1.0
|
||||||
|
if i < len(seeds) && seeds[i].weight > 0 {
|
||||||
|
seedWeight = seeds[i].weight
|
||||||
|
}
|
||||||
|
|
||||||
|
addEntityWeights(profile.genres, anime.Genres, seedWeight)
|
||||||
|
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
|
||||||
|
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
|
||||||
|
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
|
||||||
|
|
||||||
|
if anime.Airing {
|
||||||
|
airingWeight += seedWeight
|
||||||
|
}
|
||||||
|
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
||||||
|
recentWeight += seedWeight
|
||||||
|
}
|
||||||
|
totalWeight += seedWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalWeight > 0 {
|
||||||
|
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
||||||
|
profile.prefersRecent = recentWeight/totalWeight >= 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.MalID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target[entity.MalID] += weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
||||||
|
queries := make([]profileSearchQuery, 0, 6)
|
||||||
|
|
||||||
|
for _, entity := range strongestWeightedEntities(profile.genres, profileGenreSearches) {
|
||||||
|
queries = append(queries, profileSearchQuery{
|
||||||
|
genreIDs: []int{entity.id},
|
||||||
|
weight: entity.weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entity := range strongestWeightedEntities(profile.themes, profileThemeSearches) {
|
||||||
|
queries = append(queries, profileSearchQuery{
|
||||||
|
genreIDs: []int{entity.id},
|
||||||
|
weight: entity.weight * 0.8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
|
||||||
|
queries = append(queries, profileSearchQuery{
|
||||||
|
genreIDs: []int{entity.id},
|
||||||
|
weight: entity.weight * 0.8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
|
||||||
|
queries = append(queries, profileSearchQuery{
|
||||||
|
studioID: entity.id,
|
||||||
|
weight: entity.weight * 0.7,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
|
||||||
|
if limit <= 0 || len(weights) == 0 {
|
||||||
|
return []weightedEntity{}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]weightedEntity, 0, len(weights))
|
||||||
|
for id, weight := range weights {
|
||||||
|
if id <= 0 || weight <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, weightedEntity{id: id, weight: weight})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].weight == items[j].weight {
|
||||||
|
return items[i].id < items[j].id
|
||||||
|
}
|
||||||
|
return items[i].weight > items[j].weight
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(items) > limit {
|
||||||
|
return items[:limit]
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package anime
|
package recommendations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -174,6 +175,18 @@ func TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCandidateScoreLimitTracksRequestedResultSize(t *testing.T) {
|
||||||
|
if got := candidateScoreLimit(TopPickLimit); got != TopPickLimit+candidateFetchBuffer {
|
||||||
|
t.Fatalf("expected top-pick scoring to fetch a small oversample, got %d", got)
|
||||||
|
}
|
||||||
|
if got := candidateScoreLimit(TopPicksLimit); got != candidateFetchLimit {
|
||||||
|
t.Fatalf("expected full top-picks scoring to keep existing cap, got %d", got)
|
||||||
|
}
|
||||||
|
if got := candidateScoreLimit(0); got != 0 {
|
||||||
|
t.Fatalf("expected zero result limit to skip scoring, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testRecommendationAnime(id int, genreID int) jikan.Anime {
|
func testRecommendationAnime(id int, genreID int) jikan.Anime {
|
||||||
return jikan.Anime{
|
return jikan.Anime{
|
||||||
MalID: id,
|
MalID: id,
|
||||||
@@ -207,10 +220,8 @@ func animeIDs(animes []domain.Anime) []int {
|
|||||||
|
|
||||||
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
|
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
for _, id := range query.genreIDs {
|
if slices.Contains(query.genreIDs, genreID) {
|
||||||
if id == genreID {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
167
internal/anime/recommendations/rerank.go
Normal file
167
internal/anime/recommendations/rerank.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
|
||||||
|
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
|
||||||
|
remaining := slices.Clone(candidates)
|
||||||
|
seen := newDiversityFeatureCounts()
|
||||||
|
recent := make([]diversityFeatureSet, 0, recentDiversityWindow)
|
||||||
|
|
||||||
|
for len(selected) < limit && len(remaining) > 0 {
|
||||||
|
bestIndex := bestDiverseCandidateIndex(remaining, seen, recent)
|
||||||
|
candidate := remaining[bestIndex]
|
||||||
|
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
|
||||||
|
|
||||||
|
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
|
||||||
|
return anime.MalID == candidate.anime.MalID
|
||||||
|
}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
||||||
|
features := diversityFeatures(candidate.anime)
|
||||||
|
seen.add(features)
|
||||||
|
recent = append(recent, features)
|
||||||
|
if len(recent) > recentDiversityWindow {
|
||||||
|
recent = recent[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
type diversityFeatureSet struct {
|
||||||
|
genres map[int]struct{}
|
||||||
|
themes map[int]struct{}
|
||||||
|
demographics map[int]struct{}
|
||||||
|
studios map[int]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type diversityFeatureCounts struct {
|
||||||
|
genres map[int]int
|
||||||
|
themes map[int]int
|
||||||
|
demographics map[int]int
|
||||||
|
studios map[int]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDiversityFeatureCounts() diversityFeatureCounts {
|
||||||
|
return diversityFeatureCounts{
|
||||||
|
genres: make(map[int]int),
|
||||||
|
themes: make(map[int]int),
|
||||||
|
demographics: make(map[int]int),
|
||||||
|
studios: make(map[int]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
|
||||||
|
addDiversityCounts(counts.genres, features.genres)
|
||||||
|
addDiversityCounts(counts.themes, features.themes)
|
||||||
|
addDiversityCounts(counts.demographics, features.demographics)
|
||||||
|
addDiversityCounts(counts.studios, features.studios)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
|
||||||
|
for id := range features {
|
||||||
|
target[id]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestDiverseCandidateIndex(candidates []recommendationCandidate, seen diversityFeatureCounts, recent []diversityFeatureSet) int {
|
||||||
|
bestIndex := 0
|
||||||
|
bestScore := math.Inf(-1)
|
||||||
|
|
||||||
|
for i, candidate := range candidates {
|
||||||
|
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
|
||||||
|
if score == bestScore {
|
||||||
|
if candidate.score <= candidates[bestIndex].score {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
|
||||||
|
return diversityFeatureSet{
|
||||||
|
genres: entityIDSet(anime.Genres),
|
||||||
|
themes: entityIDSet(anime.Themes),
|
||||||
|
demographics: entityIDSet(anime.Demographics),
|
||||||
|
studios: entityIDSet(anime.Studios),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
|
||||||
|
ids := make(map[int]struct{}, len(entities))
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.MalID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids[entity.MalID] = struct{}{}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func diversityPenalty(features diversityFeatureSet, seen diversityFeatureCounts, recent []diversityFeatureSet) float64 {
|
||||||
|
penalty := 0.0
|
||||||
|
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), genreDiversityPenalty)
|
||||||
|
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), themeDiversityPenalty)
|
||||||
|
penalty += repeatedFeaturePenalty(features.demographics, seen.demographics, recentDemographicCounts(recent), demoDiversityPenalty)
|
||||||
|
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), studioDiversityPenalty)
|
||||||
|
|
||||||
|
return penalty
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatedFeaturePenalty(features map[int]struct{}, seen map[int]int, recent map[int]int, weight float64) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for id := range features {
|
||||||
|
total += float64(seen[id]) * weight * 0.35
|
||||||
|
total += float64(recent[id]) * weight
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
|
||||||
|
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||||
|
return features.genres
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
|
||||||
|
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||||
|
return features.themes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
|
||||||
|
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||||
|
return features.demographics
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
|
||||||
|
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||||
|
return features.studios
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentFeatureCounts(
|
||||||
|
recent []diversityFeatureSet,
|
||||||
|
selectFeatures func(diversityFeatureSet) map[int]struct{},
|
||||||
|
) map[int]int {
|
||||||
|
counts := make(map[int]int)
|
||||||
|
for _, features := range recent {
|
||||||
|
addDiversityCounts(counts, selectFeatures(features))
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
117
internal/anime/recommendations/scoring.go
Normal file
117
internal/anime/recommendations/scoring.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func profileSearchRankWeight(rank int) float64 {
|
||||||
|
return math.Max(0.35, 1-(float64(rank)*0.08))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
|
||||||
|
return (math.Log1p(collaborativeScore) * collaborativeWeight) +
|
||||||
|
(profileSearchScore * profileSearchWeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTasteMetadata(anime jikan.Anime) bool {
|
||||||
|
return len(anime.Genres) > 0 ||
|
||||||
|
len(anime.Themes) > 0 ||
|
||||||
|
len(anime.Studios) > 0 ||
|
||||||
|
len(anime.Demographics) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreRecommendationCandidate(
|
||||||
|
now time.Time,
|
||||||
|
profile userTasteProfile,
|
||||||
|
candidate jikan.Anime,
|
||||||
|
collaborativeScore float64,
|
||||||
|
profileSearchScore float64,
|
||||||
|
) recommendationCandidate {
|
||||||
|
genres, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
||||||
|
themes, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
||||||
|
studios, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
||||||
|
demos, demoScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
||||||
|
|
||||||
|
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
||||||
|
score += genreScore * genreMatchWeight
|
||||||
|
score += themeScore * themeMatchWeight
|
||||||
|
score += studioScore * studioMatchWeight
|
||||||
|
score += demoScore * demographicMatchWeight
|
||||||
|
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
|
||||||
|
|
||||||
|
return recommendationCandidate{
|
||||||
|
anime: candidate,
|
||||||
|
score: score,
|
||||||
|
genreMatches: genres,
|
||||||
|
themeMatches: themes,
|
||||||
|
studioMatches: studios,
|
||||||
|
demographicMatches: demos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 {
|
||||||
|
var score float64
|
||||||
|
|
||||||
|
if candidate.Score > 0 {
|
||||||
|
score += min(candidate.Score/10.0, 1.0)
|
||||||
|
}
|
||||||
|
if candidate.Popularity > 0 {
|
||||||
|
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
|
||||||
|
}
|
||||||
|
if profile.prefersAiring && candidate.Airing {
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
if profile.prefersRecent && isRecentCandidate(now, candidate.Year) {
|
||||||
|
score += 0.45
|
||||||
|
}
|
||||||
|
if isClassicCandidate(now, candidate.Year) {
|
||||||
|
score -= 0.2
|
||||||
|
}
|
||||||
|
if candidate.Status == "Not yet aired" {
|
||||||
|
score -= 0.35
|
||||||
|
}
|
||||||
|
if isFreshRelease(now, candidate.Aired.From) {
|
||||||
|
score += 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecentCandidate(now time.Time, year int) bool {
|
||||||
|
return year > 0 && now.Year()-year <= 4
|
||||||
|
}
|
||||||
|
|
||||||
|
func isClassicCandidate(now time.Time, year int) bool {
|
||||||
|
return year > 0 && now.Year()-year > 15
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFreshRelease(now time.Time, airedFrom string) bool {
|
||||||
|
if airedFrom == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
airedAt, err := time.Parse(time.RFC3339, airedFrom)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return now.Sub(airedAt) <= freshReleaseWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
||||||
|
var matches int
|
||||||
|
var score float64
|
||||||
|
|
||||||
|
for _, entity := range entities {
|
||||||
|
weight, ok := weights[entity.MalID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches++
|
||||||
|
score += weight
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, score
|
||||||
|
}
|
||||||
136
internal/anime/recommendations/scoring_profile_extra_test.go
Normal file
136
internal/anime/recommendations/scoring_profile_extra_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileSearchRankWeightHasFloor(t *testing.T) {
|
||||||
|
if got := profileSearchRankWeight(0); got != 1 {
|
||||||
|
t.Fatalf("rank 0 weight = %f, want 1", got)
|
||||||
|
}
|
||||||
|
if got := profileSearchRankWeight(100); got != 0.35 {
|
||||||
|
t.Fatalf("rank 100 weight = %f, want floor 0.35", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankedCandidateRetrievalScoreUsesLogForCollaborativeSignal(t *testing.T) {
|
||||||
|
low := rankedCandidateRetrievalScore(1, 0)
|
||||||
|
high := rankedCandidateRetrievalScore(100, 0)
|
||||||
|
if high <= low {
|
||||||
|
t.Fatalf("expected higher collaborative score to rank higher, low=%f high=%f", low, high)
|
||||||
|
}
|
||||||
|
linearGrowth := 100.0 - 1.0
|
||||||
|
actualGrowth := high - low
|
||||||
|
if actualGrowth >= linearGrowth {
|
||||||
|
t.Fatalf("expected log scaling, growth=%f linear=%f", actualGrowth, linearGrowth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasTasteMetadata(t *testing.T) {
|
||||||
|
if hasTasteMetadata(jikan.Anime{}) {
|
||||||
|
t.Fatalf("empty anime should not have taste metadata")
|
||||||
|
}
|
||||||
|
if !hasTasteMetadata(jikan.Anime{Studios: []jikan.NamedEntity{{MalID: 1}}}) {
|
||||||
|
t.Fatalf("studio metadata should count as taste metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationCandidateScoreAdjustments(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
profile := userTasteProfile{prefersAiring: true, prefersRecent: true}
|
||||||
|
|
||||||
|
preferred := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
|
||||||
|
Score: 9,
|
||||||
|
Popularity: 10,
|
||||||
|
Airing: true,
|
||||||
|
Year: 2026,
|
||||||
|
Aired: jikan.Aired{From: now.Add(-24 * time.Hour).Format(time.RFC3339)},
|
||||||
|
})
|
||||||
|
penalized := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
|
||||||
|
Score: 9,
|
||||||
|
Year: 2000,
|
||||||
|
Status: "Not yet aired",
|
||||||
|
})
|
||||||
|
|
||||||
|
if preferred <= penalized {
|
||||||
|
t.Fatalf("expected preferred candidate to outscore penalized candidate, preferred=%f penalized=%f", preferred, penalized)
|
||||||
|
}
|
||||||
|
if !isRecentCandidate(now, 2024) || isRecentCandidate(now, 2010) {
|
||||||
|
t.Fatalf("recent candidate boundary failed")
|
||||||
|
}
|
||||||
|
if !isClassicCandidate(now, 2010) || isClassicCandidate(now, 2020) {
|
||||||
|
t.Fatalf("classic candidate boundary failed")
|
||||||
|
}
|
||||||
|
if !isFreshRelease(now, now.Add(-freshReleaseWindow+time.Hour).Format(time.RFC3339)) {
|
||||||
|
t.Fatalf("expected fresh release inside window")
|
||||||
|
}
|
||||||
|
if isFreshRelease(now, "not a date") {
|
||||||
|
t.Fatalf("invalid release timestamp should not be fresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeightedEntityMatchCountsAndScoresMatches(t *testing.T) {
|
||||||
|
matches, score := weightedEntityMatch(map[int]float64{1: 2.5, 3: 1.0}, []jikan.NamedEntity{
|
||||||
|
{MalID: 1, Name: "Action"},
|
||||||
|
{MalID: 2, Name: "Drama"},
|
||||||
|
{MalID: 3, Name: "Sports"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if matches != 2 || score != 3.5 {
|
||||||
|
t.Fatalf("weightedEntityMatch = matches:%d score:%f, want 2 and 3.5", matches, score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEntityWeightsSkipsInvalidIDsAndAccumulates(t *testing.T) {
|
||||||
|
target := map[int]float64{1: 1.0}
|
||||||
|
addEntityWeights(target, []jikan.NamedEntity{{MalID: 0}, {MalID: 1}, {MalID: 2}}, 0.5)
|
||||||
|
|
||||||
|
if target[1] != 1.5 || target[2] != 0.5 {
|
||||||
|
t.Fatalf("entity weights = %#v, want accumulated valid ids", target)
|
||||||
|
}
|
||||||
|
if _, ok := target[0]; ok {
|
||||||
|
t.Fatalf("invalid id should not be added: %#v", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrongestWeightedEntitiesSortsByWeightThenID(t *testing.T) {
|
||||||
|
got := strongestWeightedEntities(map[int]float64{3: 1, 2: 2, 1: 2, 4: -1}, 3)
|
||||||
|
want := []weightedEntity{{id: 1, weight: 2}, {id: 2, weight: 2}, {id: 3, weight: 1}}
|
||||||
|
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("got[%d] = %+v, want %+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreRecommendationCandidateIncludesMatchCounts(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
profile := userTasteProfile{
|
||||||
|
genres: map[int]float64{1: 1, 2: 1},
|
||||||
|
themes: map[int]float64{10: 1},
|
||||||
|
studios: map[int]float64{20: 1},
|
||||||
|
demographics: map[int]float64{30: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||||
|
Genres: []jikan.NamedEntity{{MalID: 1}, {MalID: 2}},
|
||||||
|
Themes: []jikan.NamedEntity{{MalID: 10}},
|
||||||
|
Studios: []jikan.NamedEntity{{MalID: 20}},
|
||||||
|
Demographics: []jikan.NamedEntity{{MalID: 30}},
|
||||||
|
}, 0, 0)
|
||||||
|
|
||||||
|
if candidate.genreMatches != 2 || candidate.themeMatches != 1 || candidate.studioMatches != 1 || candidate.demographicMatches != 1 {
|
||||||
|
t.Fatalf("match counts = genres:%d themes:%d studios:%d demos:%d", candidate.genreMatches, candidate.themeMatches, candidate.studioMatches, candidate.demographicMatches)
|
||||||
|
}
|
||||||
|
if math.Abs(candidate.score) < 0.001 {
|
||||||
|
t.Fatalf("expected non-zero score for metadata matches")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/anime/recommendations/store.go
Normal file
72
internal/anime/recommendations/store.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/internal/db"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type candidateStore struct {
|
||||||
|
watchlistAnimeIDs map[int]struct{}
|
||||||
|
byID map[int]rankedCandidate
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
|
||||||
|
watched := make(map[int]struct{}, len(watchlist))
|
||||||
|
for _, entry := range watchlist {
|
||||||
|
if entry.AnimeID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
watched[int(entry.AnimeID)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &candidateStore{
|
||||||
|
watchlistAnimeIDs: watched,
|
||||||
|
byID: map[int]rankedCandidate{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *candidateStore) upsert(candidate rankedCandidate) {
|
||||||
|
if candidate.id <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := s.watchlistAnimeIDs[candidate.id]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
current, ok := s.byID[candidate.id]
|
||||||
|
if !ok {
|
||||||
|
s.byID[candidate.id] = candidate
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current.collaborativeScore += candidate.collaborativeScore
|
||||||
|
current.profileSearchScore += candidate.profileSearchScore
|
||||||
|
if candidate.hasAnime {
|
||||||
|
current.anime = candidate.anime
|
||||||
|
current.hasAnime = true
|
||||||
|
}
|
||||||
|
s.byID[candidate.id] = current
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *candidateStore) ranked() []rankedCandidate {
|
||||||
|
ranked := make([]rankedCandidate, 0, len(s.byID))
|
||||||
|
for _, item := range s.byID {
|
||||||
|
ranked = append(ranked, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(ranked, func(i, j int) bool {
|
||||||
|
left := rankedCandidateRetrievalScore(ranked[i].collaborativeScore, ranked[i].profileSearchScore)
|
||||||
|
right := rankedCandidateRetrievalScore(ranked[j].collaborativeScore, ranked[j].profileSearchScore)
|
||||||
|
if left == right {
|
||||||
|
return ranked[i].id < ranked[j].id
|
||||||
|
}
|
||||||
|
return left > right
|
||||||
|
})
|
||||||
|
|
||||||
|
return ranked
|
||||||
|
}
|
||||||
45
internal/anime/recommendations/types.go
Normal file
45
internal/anime/recommendations/types.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package recommendations
|
||||||
|
|
||||||
|
import "mal/integrations/jikan"
|
||||||
|
|
||||||
|
type recommendationSeed struct {
|
||||||
|
animeID int
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type weightedEntity struct {
|
||||||
|
id int
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileSearchQuery struct {
|
||||||
|
genreIDs []int
|
||||||
|
studioID int
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type recommendationCandidate struct {
|
||||||
|
anime jikan.Anime
|
||||||
|
score float64
|
||||||
|
genreMatches int
|
||||||
|
themeMatches int
|
||||||
|
studioMatches int
|
||||||
|
demographicMatches int
|
||||||
|
}
|
||||||
|
|
||||||
|
type userTasteProfile struct {
|
||||||
|
genres map[int]float64
|
||||||
|
themes map[int]float64
|
||||||
|
studios map[int]float64
|
||||||
|
demographics map[int]float64
|
||||||
|
prefersAiring bool
|
||||||
|
prefersRecent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type rankedCandidate struct {
|
||||||
|
id int
|
||||||
|
collaborativeScore float64
|
||||||
|
profileSearchScore float64
|
||||||
|
anime jikan.Anime
|
||||||
|
hasAnime bool
|
||||||
|
}
|
||||||
81
internal/anime/reviews_handler.go
Normal file
81
internal/anime/reviews_handler.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mal/internal/server"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type reviewsQuery struct {
|
||||||
|
animeID int
|
||||||
|
page int
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseReviewsQuery(c *gin.Context) (reviewsQuery, error) {
|
||||||
|
rawID := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(rawID)
|
||||||
|
if err != nil {
|
||||||
|
return reviewsQuery{}, fmt.Errorf("invalid anime id %q: %w", rawID, err)
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
return reviewsQuery{}, fmt.Errorf("invalid anime id %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPage := c.DefaultQuery("page", "1")
|
||||||
|
page, err := strconv.Atoi(rawPage)
|
||||||
|
if err != nil {
|
||||||
|
return reviewsQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return reviewsQuery{animeID: id, page: page}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||||
|
query, err := parseReviewsQuery(c)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), query.animeID, query.page)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"anime_reviews_fetch_failed",
|
||||||
|
"anime",
|
||||||
|
"failed to load reviews",
|
||||||
|
map[string]any{"anime_id": query.animeID, "page": query.page},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := server.CurrentUser(c)
|
||||||
|
|
||||||
|
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
|
||||||
|
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||||
|
"_fragment": "review_cards",
|
||||||
|
"Reviews": reviews,
|
||||||
|
"NextPage": query.page + 1,
|
||||||
|
"HasNextPage": hasNextPage,
|
||||||
|
"AnimeID": query.animeID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||||
|
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", query.animeID),
|
||||||
|
"Reviews": reviews,
|
||||||
|
"NextPage": query.page + 1,
|
||||||
|
"HasNextPage": hasNextPage,
|
||||||
|
"AnimeID": query.animeID,
|
||||||
|
"User": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -45,9 +45,9 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
|
|||||||
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
||||||
const ttl = 10 * time.Minute
|
const ttl = 10 * time.Minute
|
||||||
|
|
||||||
h.scheduleCacheMu.Lock()
|
h.Lock()
|
||||||
cached, ok := h.scheduleCache[cacheKey]
|
cached, ok := h.scheduleCache[cacheKey]
|
||||||
h.scheduleCacheMu.Unlock()
|
h.Unlock()
|
||||||
|
|
||||||
if ok && time.Since(cached.fetchedAt) < ttl {
|
if ok && time.Since(cached.fetchedAt) < ttl {
|
||||||
return cached.value, nil
|
return cached.value, nil
|
||||||
@@ -58,9 +58,9 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
|
|||||||
return animeschedule.WeekSchedule{}, err
|
return animeschedule.WeekSchedule{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
h.scheduleCacheMu.Lock()
|
h.Lock()
|
||||||
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
||||||
h.scheduleCacheMu.Unlock()
|
h.Unlock()
|
||||||
|
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|||||||
80
internal/anime/search_api.go
Normal file
80
internal/anime/search_api.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mal/internal/server"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchAnimeLimit = 24
|
||||||
|
|
||||||
|
type searchItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Subtitle string `json:"subtitle"`
|
||||||
|
Href string `json:"href"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
InWatchlist bool `json:"inWatchlist,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchResponse struct {
|
||||||
|
Items []searchItem `json:"items"`
|
||||||
|
HasNextPage bool `json:"hasNextPage"`
|
||||||
|
NextPage int `json:"nextPage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) HandleSearchAPI(c *gin.Context) {
|
||||||
|
user := server.CurrentUser(c)
|
||||||
|
if user == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(c.Query("q"))
|
||||||
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if err != nil || page < 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" || len(query) < 2 {
|
||||||
|
c.JSON(http.StatusOK, searchResponse{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, hasNextPage := h.searchAnimeResults(c, user.ID, query, page)
|
||||||
|
c.JSON(http.StatusOK, searchResponse{
|
||||||
|
Items: items,
|
||||||
|
HasNextPage: hasNextPage,
|
||||||
|
NextPage: page + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) searchAnimeResults(c *gin.Context, userID string, query string, page int) ([]searchItem, bool) {
|
||||||
|
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, searchAnimeLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
animes := wrapAnimes(res.Animes)
|
||||||
|
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||||
|
items := make([]searchItem, 0, len(animes))
|
||||||
|
for _, anime := range animes {
|
||||||
|
items = append(items, searchItem{
|
||||||
|
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
||||||
|
Type: "anime",
|
||||||
|
Label: anime.DisplayTitle(),
|
||||||
|
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
||||||
|
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
||||||
|
Image: anime.Images.Webp.LargeImageURL,
|
||||||
|
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, res.HasNextPage
|
||||||
|
}
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
// Package anime provides anime catalog, discovery, search, and details services.
|
// Package anime provides anime catalog, search, and details services.
|
||||||
package anime
|
package anime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"fmt"
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/observability"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@@ -50,19 +47,25 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
|||||||
case "Popular":
|
case "Popular":
|
||||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||||
}
|
}
|
||||||
return err
|
if err != nil {
|
||||||
|
return fmt.Errorf("get catalog section %q: %w", section, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if userID != "" && section == "Continue" {
|
if userID != "" && section == "Continue" {
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
var err error
|
var err error
|
||||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
||||||
return err
|
if err != nil {
|
||||||
|
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
if err := g.Wait(); err != nil {
|
||||||
return domain.CatalogSectionData{}, err
|
return domain.CatalogSectionData{}, fmt.Errorf("wait for catalog section %q: %w", section, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
animes := wrapAnimes(res.Animes)
|
animes := wrapAnimes(res.Animes)
|
||||||
@@ -76,359 +79,10 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
|
|
||||||
var res jikan.TopAnimeResult
|
|
||||||
|
|
||||||
g, gCtx := errgroup.WithContext(ctx)
|
|
||||||
|
|
||||||
g.Go(func() error {
|
|
||||||
var err error
|
|
||||||
switch section {
|
|
||||||
case "Trending":
|
|
||||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
|
||||||
case "Upcoming":
|
|
||||||
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
|
|
||||||
case "Top":
|
|
||||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
|
||||||
return domain.DiscoverSectionData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
animes := wrapAnimes(res.Animes)
|
|
||||||
if len(animes) > 8 {
|
|
||||||
animes = animes[:8]
|
|
||||||
}
|
|
||||||
|
|
||||||
return domain.DiscoverSectionData{
|
|
||||||
Animes: animes,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
|
||||||
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
|
||||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *animeService) getTopPicksForYou(
|
|
||||||
ctx context.Context,
|
|
||||||
userID string,
|
|
||||||
resultLimit int,
|
|
||||||
) (domain.CatalogSectionData, error) {
|
|
||||||
if strings.TrimSpace(userID) == "" {
|
|
||||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return domain.CatalogSectionData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
|
||||||
if len(seedPool) == 0 {
|
|
||||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type rankedCandidate struct {
|
|
||||||
id int
|
|
||||||
collaborativeScore float64
|
|
||||||
profileSearchScore float64
|
|
||||||
anime jikan.Anime
|
|
||||||
hasAnime bool
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
|
||||||
for _, entry := range watchlist {
|
|
||||||
if entry.AnimeID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidatesByID := map[int]rankedCandidate{}
|
|
||||||
var candidatesByIDMu sync.Mutex
|
|
||||||
upsertCandidate := func(candidate rankedCandidate) {
|
|
||||||
if candidate.id <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, exists := watchlistAnimeIDs[candidate.id]; exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
candidatesByIDMu.Lock()
|
|
||||||
defer candidatesByIDMu.Unlock()
|
|
||||||
|
|
||||||
current, ok := candidatesByID[candidate.id]
|
|
||||||
if !ok {
|
|
||||||
candidatesByID[candidate.id] = candidate
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
current.collaborativeScore += candidate.collaborativeScore
|
|
||||||
current.profileSearchScore += candidate.profileSearchScore
|
|
||||||
if candidate.hasAnime {
|
|
||||||
current.anime = candidate.anime
|
|
||||||
current.hasAnime = true
|
|
||||||
}
|
|
||||||
candidatesByID[candidate.id] = current
|
|
||||||
}
|
|
||||||
|
|
||||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
|
||||||
var seedFetchGroup errgroup.Group
|
|
||||||
seedFetchGroup.SetLimit(4)
|
|
||||||
|
|
||||||
for i, seed := range seedPool {
|
|
||||||
seedFetchGroup.Go(func() error {
|
|
||||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
|
||||||
if fetchErr != nil {
|
|
||||||
return fetchErr
|
|
||||||
}
|
|
||||||
seedAnimes[i] = anime
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := seedFetchGroup.Wait(); err != nil {
|
|
||||||
return domain.CatalogSectionData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
|
||||||
|
|
||||||
var recommendationGroup errgroup.Group
|
|
||||||
recommendationGroup.SetLimit(4)
|
|
||||||
|
|
||||||
for _, seed := range seedPool {
|
|
||||||
recommendationGroup.Go(func() error {
|
|
||||||
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
|
||||||
if recErr != nil {
|
|
||||||
return recErr
|
|
||||||
}
|
|
||||||
for i, rec := range recs {
|
|
||||||
if i >= forYouMaxRecommendations {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
id := rec.Entry.MalID
|
|
||||||
if id <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if id == seed.animeID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
upsertCandidate(rankedCandidate{
|
|
||||||
id: id,
|
|
||||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := recommendationGroup.Wait(); err != nil {
|
|
||||||
return domain.CatalogSectionData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
profileQueries := buildProfileSearchQueries(profile)
|
|
||||||
var profileSearchGroup errgroup.Group
|
|
||||||
profileSearchGroup.SetLimit(3)
|
|
||||||
|
|
||||||
for _, query := range profileQueries {
|
|
||||||
profileSearchGroup.Go(func() error {
|
|
||||||
res, searchErr := s.jikan.SearchAdvanced(
|
|
||||||
ctx,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"score",
|
|
||||||
"desc",
|
|
||||||
query.genreIDs,
|
|
||||||
query.studioID,
|
|
||||||
true,
|
|
||||||
1,
|
|
||||||
forYouProfileSearchLimit,
|
|
||||||
)
|
|
||||||
if searchErr != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"top_pick_profile_search_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{
|
|
||||||
"genres": query.genreIDs,
|
|
||||||
"studio_id": query.studioID,
|
|
||||||
},
|
|
||||||
searchErr,
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, anime := range res.Animes {
|
|
||||||
if anime.MalID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
upsertCandidate(rankedCandidate{
|
|
||||||
id: anime.MalID,
|
|
||||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
|
||||||
anime: anime,
|
|
||||||
hasAnime: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := profileSearchGroup.Wait(); err != nil {
|
|
||||||
return domain.CatalogSectionData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(candidatesByID) == 0 {
|
|
||||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
|
|
||||||
for _, item := range candidatesByID {
|
|
||||||
rankedIDs = append(rankedIDs, item)
|
|
||||||
}
|
|
||||||
sort.Slice(rankedIDs, func(i, j int) bool {
|
|
||||||
left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore)
|
|
||||||
right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore)
|
|
||||||
if left == right {
|
|
||||||
return rankedIDs[i].id < rankedIDs[j].id
|
|
||||||
}
|
|
||||||
return left > right
|
|
||||||
})
|
|
||||||
|
|
||||||
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
|
|
||||||
candidates := make([]recommendationCandidate, 0, limit)
|
|
||||||
var candidatesMu sync.Mutex
|
|
||||||
var detailGroup errgroup.Group
|
|
||||||
detailGroup.SetLimit(6)
|
|
||||||
|
|
||||||
for i := 0; i < limit; i++ {
|
|
||||||
item := rankedIDs[i]
|
|
||||||
detailGroup.Go(func() error {
|
|
||||||
anime := item.anime
|
|
||||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
|
||||||
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
|
|
||||||
if fetchErr != nil {
|
|
||||||
observability.Warn(
|
|
||||||
"recommendation_anime_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{"anime_id": item.id},
|
|
||||||
fetchErr,
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
anime = fetchedAnime
|
|
||||||
}
|
|
||||||
|
|
||||||
candidate := scoreRecommendationCandidate(
|
|
||||||
now,
|
|
||||||
profile,
|
|
||||||
anime,
|
|
||||||
item.collaborativeScore,
|
|
||||||
item.profileSearchScore,
|
|
||||||
)
|
|
||||||
candidatesMu.Lock()
|
|
||||||
candidates = append(candidates, candidate)
|
|
||||||
candidatesMu.Unlock()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := detailGroup.Wait(); err != nil {
|
|
||||||
return domain.CatalogSectionData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(candidates, func(i, j int) bool {
|
|
||||||
if candidates[i].score == candidates[j].score {
|
|
||||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
|
||||||
}
|
|
||||||
return candidates[i].score > candidates[j].score
|
|
||||||
})
|
|
||||||
|
|
||||||
return domain.CatalogSectionData{
|
|
||||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
|
|
||||||
if strings.TrimSpace(userID) == "" {
|
|
||||||
return []domain.Anime{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ids := make([]int, 0, 50)
|
|
||||||
for _, entry := range watchlist {
|
|
||||||
status := strings.TrimSpace(entry.Status)
|
|
||||||
if status != "watching" && status != "plan_to_watch" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !entry.Airing.Valid || !entry.Airing.Bool {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if entry.AnimeID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ids = append(ids, int(entry.AnimeID))
|
|
||||||
if len(ids) >= 50 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return []domain.Anime{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
animes := make([]domain.Anime, 0, len(ids))
|
|
||||||
var g errgroup.Group
|
|
||||||
g.SetLimit(6)
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
for _, id := range ids {
|
|
||||||
g.Go(func() error {
|
|
||||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, id)
|
|
||||||
if fetchErr != nil {
|
|
||||||
return fetchErr
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
animes = append(animes, domain.Anime{Anime: anime})
|
|
||||||
mu.Unlock()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
|
||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
observability.Warn(
|
|
||||||
"schedule_partial_fetch_failed",
|
|
||||||
"anime",
|
|
||||||
"",
|
|
||||||
map[string]any{"user_id": userID, "count": len(ids)},
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return animes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return animes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||||
anime, err := s.jikan.GetAnimeByID(ctx, id)
|
anime, err := s.jikan.GetAnimeByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Anime{}, err
|
return domain.Anime{}, fmt.Errorf("get anime by id: %w", err)
|
||||||
}
|
}
|
||||||
return domain.Anime{Anime: anime}, nil
|
return domain.Anime{Anime: anime}, nil
|
||||||
}
|
}
|
||||||
@@ -440,7 +94,7 @@ func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status,
|
|||||||
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
|
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
|
||||||
res, err := s.jikan.GetProducerByID(ctx, id)
|
res, err := s.jikan.GetProducerByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("get producer name: %w", err)
|
||||||
}
|
}
|
||||||
for _, t := range res.Data.Titles {
|
for _, t := range res.Data.Titles {
|
||||||
if t.Title != "" {
|
if t.Title != "" {
|
||||||
@@ -457,7 +111,7 @@ func (s *animeService) GetProducers(ctx context.Context, query string, page int,
|
|||||||
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||||
genres, err := s.jikan.GetAnimeGenres(ctx)
|
genres, err := s.jikan.GetAnimeGenres(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("get genres: %w", err)
|
||||||
}
|
}
|
||||||
out := make([]domain.Genre, 0, len(genres))
|
out := make([]domain.Genre, 0, len(genres))
|
||||||
for _, g := range genres {
|
for _, g := range genres {
|
||||||
@@ -472,7 +126,7 @@ func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
|||||||
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.CharacterEntry, error) {
|
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.CharacterEntry, error) {
|
||||||
items, err := s.jikan.GetAnimeCharacters(ctx, id)
|
items, err := s.jikan.GetAnimeCharacters(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("get characters: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]domain.CharacterEntry, 0, len(items))
|
out := make([]domain.CharacterEntry, 0, len(items))
|
||||||
@@ -508,7 +162,7 @@ func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Char
|
|||||||
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.RecommendationEntry, error) {
|
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.RecommendationEntry, error) {
|
||||||
items, err := s.jikan.GetAnimeRecommendations(ctx, id)
|
items, err := s.jikan.GetAnimeRecommendations(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("get recommendations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]domain.RecommendationEntry, 0, len(items))
|
out := make([]domain.RecommendationEntry, 0, len(items))
|
||||||
@@ -525,8 +179,8 @@ func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
func (s *animeService) GetRelations(ctx context.Context, id int, mode jikan.WatchOrderMode) ([]jikan.RelationEntry, error) {
|
||||||
return s.jikan.GetFullRelations(ctx, id)
|
return s.jikan.GetFullRelations(ctx, id, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) WarmDetailSections(id int) {
|
func (s *animeService) WarmDetailSections(id int) {
|
||||||
@@ -541,7 +195,7 @@ func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan
|
|||||||
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
|
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
|
||||||
items, err := s.jikan.GetAnimeStaff(ctx, id)
|
items, err := s.jikan.GetAnimeStaff(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("get staff: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]domain.StaffEntry, 0, len(items))
|
out := make([]domain.StaffEntry, 0, len(items))
|
||||||
@@ -560,7 +214,7 @@ func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntr
|
|||||||
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
|
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
|
||||||
stats, err := s.jikan.GetAnimeStatistics(ctx, id)
|
stats, err := s.jikan.GetAnimeStatistics(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Statistics{}, err
|
return domain.Statistics{}, fmt.Errorf("get statistics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := domain.Statistics{
|
out := domain.Statistics{
|
||||||
@@ -583,7 +237,7 @@ func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statis
|
|||||||
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
|
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
|
||||||
themes, err := s.jikan.GetAnimeThemes(ctx, id)
|
themes, err := s.jikan.GetAnimeThemes(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ThemesData{}, err
|
return domain.ThemesData{}, fmt.Errorf("get themes: %w", err)
|
||||||
}
|
}
|
||||||
return domain.ThemesData{
|
return domain.ThemesData{
|
||||||
Openings: append([]string(nil), themes.Openings...),
|
Openings: append([]string(nil), themes.Openings...),
|
||||||
@@ -594,7 +248,7 @@ func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData
|
|||||||
func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]domain.ReviewEntry, bool, error) {
|
func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]domain.ReviewEntry, bool, error) {
|
||||||
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
|
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, fmt.Errorf("get reviews: %w", err)
|
||||||
}
|
}
|
||||||
out := make([]domain.ReviewEntry, 0, len(data))
|
out := make([]domain.ReviewEntry, 0, len(data))
|
||||||
for _, it := range data {
|
for _, it := range data {
|
||||||
@@ -652,13 +306,13 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
|
|||||||
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
|
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.Anime{}, err
|
return domain.Anime{}, fmt.Errorf("get random anime: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
|
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
|
||||||
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
|
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("get all episodes: %w", err)
|
||||||
}
|
}
|
||||||
result := make([]domain.EpisodeData, len(episodes))
|
result := make([]domain.EpisodeData, len(episodes))
|
||||||
for i, ep := range episodes {
|
for i, ep := range episodes {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Package app bootstraps and wires the application dependencies.
|
package internal
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
"mal/internal/config"
|
"mal/internal/config"
|
||||||
"mal/internal/database"
|
"mal/internal/database"
|
||||||
"mal/internal/episodes"
|
"mal/internal/episodes"
|
||||||
|
"mal/internal/observability"
|
||||||
"mal/internal/playback"
|
"mal/internal/playback"
|
||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
"mal/internal/watchlist"
|
"mal/internal/watchlist"
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
func NewApp() *fx.App {
|
func NewApp() *fx.App {
|
||||||
return fx.New(
|
return fx.New(
|
||||||
|
fx.WithLogger(observability.NewFxLogger),
|
||||||
config.Module,
|
config.Module,
|
||||||
database.Module,
|
database.Module,
|
||||||
audit.Module,
|
audit.Module,
|
||||||
@@ -34,6 +35,7 @@ func NewApp() *fx.App {
|
|||||||
playback.Module,
|
playback.Module,
|
||||||
templates.Module,
|
templates.Module,
|
||||||
server.Module,
|
server.Module,
|
||||||
|
fx.Invoke(RunMigrationsAndFixes),
|
||||||
fx.Provide(func(r *templates.Renderer) render.HTMLRender {
|
fx.Provide(func(r *templates.Renderer) render.HTMLRender {
|
||||||
return r
|
return r
|
||||||
}),
|
}),
|
||||||
@@ -2,6 +2,7 @@ package audit_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -13,29 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordInsertsAuditLog(t *testing.T) {
|
func TestRecordInsertsAuditLog(t *testing.T) {
|
||||||
tmp, err := os.CreateTemp("", "mal-audit-*.db")
|
sqlDB := openTestDB(t)
|
||||||
if err != nil {
|
svc := audit.NewAuditService(db.New(sqlDB))
|
||||||
t.Fatalf("CreateTemp: %v", err)
|
insertTestUser(t, sqlDB, "user-1")
|
||||||
}
|
|
||||||
_ = tmp.Close()
|
|
||||||
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
|
|
||||||
|
|
||||||
sqlDB, err := db.Open(tmp.Name())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("db.Open: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
||||||
|
|
||||||
if err := database.RunMigrations(sqlDB); err != nil {
|
|
||||||
t.Fatalf("RunMigrations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
queries := db.New(sqlDB)
|
|
||||||
svc := audit.NewAuditService(queries)
|
|
||||||
|
|
||||||
if _, err := sqlDB.Exec("INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", "user-1", "test", "hash"); err != nil {
|
|
||||||
t.Fatalf("insert user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
|
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
|
||||||
metadata, err := json.Marshal(struct {
|
metadata, err := json.Marshal(struct {
|
||||||
@@ -55,28 +36,95 @@ func TestRecordInsertsAuditLog(t *testing.T) {
|
|||||||
t.Fatalf("Record: %v", err)
|
t.Fatalf("Record: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := sqlDB.Query("SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", "user-1")
|
auditRow := queryAuditRow(t, sqlDB, "user-1")
|
||||||
|
assertAuditRow(t, auditRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
type auditRow struct {
|
||||||
|
action string
|
||||||
|
resourceType string
|
||||||
|
resourceID string
|
||||||
|
ip string
|
||||||
|
userAgent string
|
||||||
|
metadataJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "mal-audit-*.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp: %v", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
t.Fatalf("close temp db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Remove(tmp.Name()); err != nil {
|
||||||
|
t.Errorf("remove temp db: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sqlDB, err := db.Open(tmp.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := database.RunMigrations(sqlDB); err != nil {
|
||||||
|
t.Fatalf("RunMigrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTestUser(t *testing.T, sqlDB *sql.DB, userID string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if _, err := sqlDB.ExecContext(context.Background(), "INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", userID, "test", "hash"); err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryAuditRow(t *testing.T, sqlDB *sql.DB, userID string) auditRow {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := sqlDB.QueryContext(context.Background(), "SELECT action, resource_type, resource_id, ip, user_agent, metadata_json FROM audit_log WHERE user_id = ?", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Query: %v", err)
|
t.Fatalf("Query: %v", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
t.Errorf("close audit rows: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
t.Fatalf("expected audit row")
|
t.Fatalf("expected audit row")
|
||||||
}
|
}
|
||||||
|
|
||||||
var action, resourceType, resourceID, ip, userAgent, metadataJSON string
|
var row auditRow
|
||||||
if err := rows.Scan(&action, &resourceType, &resourceID, &ip, &userAgent, &metadataJSON); err != nil {
|
if err := rows.Scan(&row.action, &row.resourceType, &row.resourceID, &row.ip, &row.userAgent, &row.metadataJSON); err != nil {
|
||||||
t.Fatalf("Scan: %v", err)
|
t.Fatalf("Scan: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if action != "test_action" || resourceType != "thing" || resourceID != "123" {
|
return row
|
||||||
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", action, resourceType, resourceID)
|
}
|
||||||
|
|
||||||
|
func assertAuditRow(t *testing.T, row auditRow) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if row.action != "test_action" || row.resourceType != "thing" || row.resourceID != "123" {
|
||||||
|
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", row.action, row.resourceType, row.resourceID)
|
||||||
}
|
}
|
||||||
if ip != "127.0.0.1" || userAgent != "unit-test" {
|
if row.ip != "127.0.0.1" || row.userAgent != "unit-test" {
|
||||||
t.Fatalf("unexpected request info ip=%q userAgent=%q", ip, userAgent)
|
t.Fatalf("unexpected request info ip=%q userAgent=%q", row.ip, row.userAgent)
|
||||||
}
|
}
|
||||||
if metadataJSON == "" || metadataJSON == "null" {
|
if row.metadataJSON == "" || row.metadataJSON == "null" {
|
||||||
t.Fatalf("expected metadata_json, got %q", metadataJSON)
|
t.Fatalf("expected metadata_json, got %q", row.metadataJSON)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -54,7 +55,9 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
|
|||||||
func (h *AuthHandler) HandleLogout(c *gin.Context) {
|
func (h *AuthHandler) HandleLogout(c *gin.Context) {
|
||||||
sessionID, err := c.Cookie("session_id")
|
sessionID, err := c.Cookie("session_id")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = h.svc.Logout(c.Request.Context(), sessionID)
|
if err := h.svc.Logout(c.Request.Context(), sessionID); err != nil {
|
||||||
|
observability.WarnContext(c.Request.Context(), "logout_failed", "auth", "", nil, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie("session_id", "", -1, "/", "", false, true)
|
c.SetCookie("session_id", "", -1, "/", "", false, true)
|
||||||
|
|||||||
255
internal/auth/handler_middleware_test.go
Normal file
255
internal/auth/handler_middleware_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/domain"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleAPILogin(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
svc := &fakeAuthService{
|
||||||
|
apiToken: "token-1",
|
||||||
|
apiUser: &domain.User{User: db.User{ID: "user-1", Username: "alice", AvatarUrl: "avatar.png"}},
|
||||||
|
}
|
||||||
|
router := gin.New()
|
||||||
|
NewAuthHandler(svc).Register(router)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"correct","name":"phone"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), `"token":"token-1"`) {
|
||||||
|
t.Fatalf("response missing token: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if svc.apiLoginName != "phone" {
|
||||||
|
t.Fatalf("api token name = %q, want phone", svc.apiLoginName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPILoginRejectsInvalidRequests(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
loginErr error
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{name: "bad json", body: `{`, wantStatus: http.StatusBadRequest},
|
||||||
|
{name: "missing password", body: `{"username":"alice"}`, wantStatus: http.StatusBadRequest},
|
||||||
|
{name: "bad credentials", body: `{"username":"alice","password":"wrong"}`, loginErr: ErrWrongPassword, wantStatus: http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc := &fakeAuthService{apiLoginErr: tt.loginErr}
|
||||||
|
router := gin.New()
|
||||||
|
NewAuthHandler(svc).Register(router)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/auth/login", strings.NewReader(tt.body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != tt.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
svc := &fakeAuthService{}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(AuthMiddleware(svc))
|
||||||
|
router.GET("/static/app.js", func(c *gin.Context) { c.String(http.StatusOK, "asset") })
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/static/app.js", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if svc.validateSessionCalled || svc.validateAPITokenCalled {
|
||||||
|
t.Fatalf("public route should not authenticate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddlewareAuthenticatesAPIBearerToken(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
svc := &fakeAuthService{user: &domain.User{User: db.User{ID: "user-1", Username: "alice"}}}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(AuthMiddleware(svc))
|
||||||
|
router.GET("/api/me", func(c *gin.Context) {
|
||||||
|
user, _ := c.Get("User")
|
||||||
|
if user.(*domain.User).ID != "user-1" {
|
||||||
|
c.Status(http.StatusTeapot)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/me", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer api-token")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if svc.validatedAPIToken != "api-token" {
|
||||||
|
t.Fatalf("validated api token = %q, want api-token", svc.validatedAPIToken)
|
||||||
|
}
|
||||||
|
if svc.refreshSessionCalled {
|
||||||
|
t.Fatalf("bearer token auth should not refresh cookie session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
svc := &fakeAuthService{user: &domain.User{User: db.User{ID: "user-1", Username: "alice"}}}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(AuthMiddleware(svc))
|
||||||
|
router.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if svc.validatedSessionID != "session-1" {
|
||||||
|
t.Fatalf("validated session id = %q, want session-1", svc.validatedSessionID)
|
||||||
|
}
|
||||||
|
if svc.refreshedSessionID != "session-1" {
|
||||||
|
t.Fatalf("refreshed session id = %q, want session-1", svc.refreshedSessionID)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") {
|
||||||
|
t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
wantStatus int
|
||||||
|
wantHeader string
|
||||||
|
}{
|
||||||
|
{name: "api", method: http.MethodGet, path: "/api/me", wantStatus: http.StatusUnauthorized},
|
||||||
|
{name: "page", method: http.MethodGet, path: "/", wantStatus: http.StatusSeeOther, wantHeader: "/login"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(AuthMiddleware(&fakeAuthService{validateErr: errors.New("no auth")}))
|
||||||
|
router.Handle(tt.method, tt.path, func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), tt.method, tt.path, nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != tt.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantHeader != "" && rec.Header().Get("Location") != tt.wantHeader {
|
||||||
|
t.Fatalf("Location = %q, want %q", rec.Header().Get("Location"), tt.wantHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAuthService struct {
|
||||||
|
user *domain.User
|
||||||
|
|
||||||
|
apiToken string
|
||||||
|
apiUser *domain.User
|
||||||
|
|
||||||
|
loginErr error
|
||||||
|
apiLoginErr error
|
||||||
|
validateErr error
|
||||||
|
|
||||||
|
apiLoginName string
|
||||||
|
validatedSessionID string
|
||||||
|
validatedAPIToken string
|
||||||
|
refreshedSessionID string
|
||||||
|
loggedOutSessionID string
|
||||||
|
validateSessionCalled bool
|
||||||
|
validateAPITokenCalled bool
|
||||||
|
refreshSessionCalled bool
|
||||||
|
revokedAPITokensForUser string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuthService) Login(_ context.Context, _, _ string) (*domain.Session, error) {
|
||||||
|
if s.loginErr != nil {
|
||||||
|
return nil, s.loginErr
|
||||||
|
}
|
||||||
|
return &domain.Session{Session: db.Session{ID: "session-1", UserID: "user-1"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuthService) LoginForAPIToken(_ context.Context, _, _, name string) (string, *domain.User, error) {
|
||||||
|
s.apiLoginName = name
|
||||||
|
if s.apiLoginErr != nil {
|
||||||
|
return "", nil, s.apiLoginErr
|
||||||
|
}
|
||||||
|
return s.apiToken, s.apiUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuthService) ValidateSession(_ context.Context, sessionID string) (*domain.User, error) {
|
||||||
|
s.validateSessionCalled = true
|
||||||
|
s.validatedSessionID = sessionID
|
||||||
|
if s.validateErr != nil {
|
||||||
|
return nil, s.validateErr
|
||||||
|
}
|
||||||
|
return s.user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuthService) RefreshSession(_ context.Context, sessionID string) error {
|
||||||
|
s.refreshSessionCalled = true
|
||||||
|
s.refreshedSessionID = sessionID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuthService) ValidateAPIToken(_ context.Context, token string) (*domain.User, error) {
|
||||||
|
s.validateAPITokenCalled = true
|
||||||
|
s.validatedAPIToken = token
|
||||||
|
if s.validateErr != nil {
|
||||||
|
return nil, s.validateErr
|
||||||
|
}
|
||||||
|
return s.user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuthService) Logout(_ context.Context, sessionID string) error {
|
||||||
|
s.loggedOutSessionID = sessionID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuthService) RevokeAllAPITokensForUser(_ context.Context, userID string) error {
|
||||||
|
s.revokedAPITokensForUser = userID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -24,9 +24,6 @@ var publicRoutes = []publicRoute{
|
|||||||
{path: "/static", prefix: true},
|
{path: "/static", prefix: true},
|
||||||
{path: "/dist", prefix: true},
|
{path: "/dist", prefix: true},
|
||||||
|
|
||||||
// Observability endpoints.
|
|
||||||
{method: http.MethodGet, path: "/metrics"},
|
|
||||||
|
|
||||||
// Auth API.
|
// Auth API.
|
||||||
{method: http.MethodPost, path: "/api/auth/login"},
|
{method: http.MethodPost, path: "/api/auth/login"},
|
||||||
}
|
}
|
||||||
@@ -49,6 +46,33 @@ func isPublicRequest(method string, path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authenticateAPIRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, bool, error) {
|
||||||
|
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
|
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||||
|
token := strings.TrimSpace(authHeader[7:])
|
||||||
|
user, err := svc.ValidateAPIToken(c.Request.Context(), token)
|
||||||
|
return user, "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := c.Cookie("session_id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
|
||||||
|
return user, sessionID, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatePageRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, error) {
|
||||||
|
sessionID, err := c.Cookie("session_id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
|
||||||
|
return user, sessionID, err
|
||||||
|
}
|
||||||
|
|
||||||
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
@@ -65,18 +89,7 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
|||||||
|
|
||||||
// API routes can authenticate via Bearer token OR cookie session.
|
// API routes can authenticate via Bearer token OR cookie session.
|
||||||
if strings.HasPrefix(path, "/api/") {
|
if strings.HasPrefix(path, "/api/") {
|
||||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
user, sessionID, usesCookieSession, err = authenticateAPIRequest(c, svc)
|
||||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
|
||||||
token := strings.TrimSpace(authHeader[7:])
|
|
||||||
user, err = svc.ValidateAPIToken(c.Request.Context(), token)
|
|
||||||
} else if cookieSessionID, cookieErr := c.Cookie("session_id"); cookieErr == nil {
|
|
||||||
sessionID = cookieSessionID
|
|
||||||
usesCookieSession = true
|
|
||||||
user, err = svc.ValidateSession(c.Request.Context(), sessionID)
|
|
||||||
} else {
|
|
||||||
err = cookieErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -84,16 +97,8 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-API routes only use cookie sessions and redirect to /login.
|
// Non-API routes only use cookie sessions and redirect to /login.
|
||||||
cookieSessionID, cookieErr := c.Cookie("session_id")
|
user, sessionID, err = authenticatePageRequest(c, svc)
|
||||||
if cookieErr != nil {
|
|
||||||
c.Redirect(http.StatusSeeOther, "/login")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID = cookieSessionID
|
|
||||||
usesCookieSession = true
|
usesCookieSession = true
|
||||||
user, err = svc.ValidateSession(c.Request.Context(), sessionID)
|
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
c.Redirect(http.StatusSeeOther, "/login")
|
c.Redirect(http.StatusSeeOther, "/login")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/internal/domain"
|
|
||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,9 +11,7 @@ var Module = fx.Options(
|
|||||||
NewAuthRepository,
|
NewAuthRepository,
|
||||||
NewAuthService,
|
NewAuthService,
|
||||||
NewAuthHandler,
|
NewAuthHandler,
|
||||||
func(svc domain.AuthService) gin.HandlerFunc {
|
AuthMiddleware,
|
||||||
return AuthMiddleware(svc)
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
server.AsRouteRegister(func(h *AuthHandler) server.RouteRegister {
|
server.AsRouteRegister(func(h *AuthHandler) server.RouteRegister {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
|
"mal/internal/observability"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUserNotFound = fmt.Errorf("user not found")
|
||||||
|
ErrWrongPassword = fmt.Errorf("wrong password")
|
||||||
|
)
|
||||||
|
|
||||||
type authService struct {
|
type authService struct {
|
||||||
repo domain.AuthRepository
|
repo domain.AuthRepository
|
||||||
auditSvc domain.AuditService
|
auditSvc domain.AuditService
|
||||||
@@ -32,11 +38,11 @@ func (s *authService) Login(ctx context.Context, username, password string) (*do
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, errors.New("invalid credentials")
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, errors.New("invalid credentials")
|
return nil, ErrWrongPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID := uuid.New().String()
|
sessionID := uuid.New().String()
|
||||||
@@ -49,11 +55,11 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
|
|||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return "", nil, errors.New("invalid credentials")
|
return "", nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
return "", nil, errors.New("invalid credentials")
|
return "", nil, ErrWrongPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmedName := strings.TrimSpace(name)
|
trimmedName := strings.TrimSpace(name)
|
||||||
@@ -69,22 +75,25 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
|
|||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataBytes, err := json.Marshal(struct {
|
event := domain.AuditEvent{
|
||||||
|
UserID: user.ID,
|
||||||
|
Action: "api_token_created",
|
||||||
|
ResourceType: "api_token",
|
||||||
|
}
|
||||||
|
metadataBytes, marshalErr := json.Marshal(struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}{Name: trimmedName})
|
}{Name: trimmedName})
|
||||||
if err == nil {
|
if marshalErr == nil {
|
||||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
event.MetadataJSON = metadataBytes
|
||||||
UserID: user.ID,
|
}
|
||||||
Action: "api_token_created",
|
if err := s.auditSvc.Record(ctx, event); err != nil {
|
||||||
ResourceType: "api_token",
|
observability.Warn(
|
||||||
MetadataJSON: metadataBytes,
|
"audit_record_failed",
|
||||||
})
|
"auth",
|
||||||
} else {
|
"",
|
||||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
map[string]any{"user_id": user.ID, "action": "api_token_created"},
|
||||||
UserID: user.ID,
|
err,
|
||||||
Action: "api_token_created",
|
)
|
||||||
ResourceType: "api_token",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rawToken, user, nil
|
return rawToken, user, nil
|
||||||
@@ -100,7 +109,15 @@ func (s *authService) ValidateSession(ctx context.Context, sessionID string) (*d
|
|||||||
}
|
}
|
||||||
|
|
||||||
if session.ExpiresAt.Before(time.Now()) {
|
if session.ExpiresAt.Before(time.Now()) {
|
||||||
_ = s.repo.DeleteSession(ctx, sessionID)
|
if err := s.repo.DeleteSession(ctx, sessionID); err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"delete_expired_session_failed",
|
||||||
|
"auth",
|
||||||
|
"",
|
||||||
|
map[string]any{"session_id": sessionID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
return nil, errors.New("session expired")
|
return nil, errors.New("session expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +149,15 @@ func (s *authService) ValidateAPIToken(ctx context.Context, token string) (*doma
|
|||||||
return nil, errors.New("token not found")
|
return nil, errors.New("token not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.repo.TouchAPITokenLastUsedAt(ctx, t.ID)
|
if err := s.repo.TouchAPITokenLastUsedAt(ctx, t.ID); err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"touch_api_token_last_used_at_failed",
|
||||||
|
"auth",
|
||||||
|
"",
|
||||||
|
map[string]any{"token_id": t.ID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
return s.repo.GetUserByID(ctx, t.UserID)
|
return s.repo.GetUserByID(ctx, t.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +172,19 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri
|
|||||||
if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil {
|
if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Action: "api_token_revoked_all",
|
Action: "api_token_revoked_all",
|
||||||
ResourceType: "api_token",
|
ResourceType: "api_token",
|
||||||
})
|
}); err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"audit_record_failed",
|
||||||
|
"auth",
|
||||||
|
"",
|
||||||
|
map[string]any{"user_id": userID, "action": "api_token_revoked_all"},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
243
internal/auth/service_test.go
Normal file
243
internal/auth/service_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/domain"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthServiceLogin(t *testing.T) {
|
||||||
|
passwordHash := hashPassword(t, "correct")
|
||||||
|
repo := &fakeAuthRepository{
|
||||||
|
usersByUsername: map[string]*domain.User{
|
||||||
|
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewAuthService(repo, &fakeAuditService{})
|
||||||
|
|
||||||
|
session, err := svc.Login(context.Background(), "alice", "correct")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login: %v", err)
|
||||||
|
}
|
||||||
|
if session.UserID != "user-1" {
|
||||||
|
t.Fatalf("session user id = %q, want %q", session.UserID, "user-1")
|
||||||
|
}
|
||||||
|
if session.ID == "" {
|
||||||
|
t.Fatalf("expected generated session id")
|
||||||
|
}
|
||||||
|
if repo.createdSessionUserID != "user-1" {
|
||||||
|
t.Fatalf("created session user id = %q, want user-1", repo.createdSessionUserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceLoginRejectsMissingUserAndWrongPassword(t *testing.T) {
|
||||||
|
passwordHash := hashPassword(t, "correct")
|
||||||
|
repo := &fakeAuthRepository{
|
||||||
|
usersByUsername: map[string]*domain.User{
|
||||||
|
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewAuthService(repo, &fakeAuditService{})
|
||||||
|
|
||||||
|
if _, err := svc.Login(context.Background(), "missing", "correct"); !errors.Is(err, ErrUserNotFound) {
|
||||||
|
t.Fatalf("missing user error = %v, want %v", err, ErrUserNotFound)
|
||||||
|
}
|
||||||
|
if _, err := svc.Login(context.Background(), "alice", "wrong"); !errors.Is(err, ErrWrongPassword) {
|
||||||
|
t.Fatalf("wrong password error = %v, want %v", err, ErrWrongPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceValidateSession(t *testing.T) {
|
||||||
|
repo := &fakeAuthRepository{
|
||||||
|
usersByID: map[string]*domain.User{
|
||||||
|
"user-1": {User: db.User{ID: "user-1", Username: "alice"}},
|
||||||
|
},
|
||||||
|
sessions: map[string]*domain.Session{
|
||||||
|
"fresh": {Session: db.Session{ID: "fresh", UserID: "user-1", ExpiresAt: time.Now().Add(time.Hour)}},
|
||||||
|
"expired": {Session: db.Session{ID: "expired", UserID: "user-1", ExpiresAt: time.Now().Add(-time.Hour)}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewAuthService(repo, &fakeAuditService{})
|
||||||
|
|
||||||
|
user, err := svc.ValidateSession(context.Background(), "fresh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateSession fresh: %v", err)
|
||||||
|
}
|
||||||
|
if user == nil || user.ID != "user-1" {
|
||||||
|
t.Fatalf("validated user = %#v, want user-1", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ValidateSession(context.Background(), "expired"); err == nil || err.Error() != "session expired" {
|
||||||
|
t.Fatalf("expired session error = %v, want session expired", err)
|
||||||
|
}
|
||||||
|
if !repo.deletedSessions["expired"] {
|
||||||
|
t.Fatalf("expected expired session to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceLoginForAPITokenCreatesTokenAndAuditEvent(t *testing.T) {
|
||||||
|
passwordHash := hashPassword(t, "correct")
|
||||||
|
repo := &fakeAuthRepository{
|
||||||
|
usersByUsername: map[string]*domain.User{
|
||||||
|
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
auditSvc := &fakeAuditService{}
|
||||||
|
svc := NewAuthService(repo, auditSvc)
|
||||||
|
|
||||||
|
token, user, err := svc.LoginForAPIToken(context.Background(), "alice", "correct", " phone ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoginForAPIToken: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("expected raw token")
|
||||||
|
}
|
||||||
|
if user == nil || user.ID != "user-1" {
|
||||||
|
t.Fatalf("user = %#v, want user-1", user)
|
||||||
|
}
|
||||||
|
if repo.createdAPITokenName != "phone" {
|
||||||
|
t.Fatalf("api token name = %q, want phone", repo.createdAPITokenName)
|
||||||
|
}
|
||||||
|
if repo.createdAPITokenHash == "" || repo.createdAPITokenHash == token {
|
||||||
|
t.Fatalf("expected stored token hash, got %q", repo.createdAPITokenHash)
|
||||||
|
}
|
||||||
|
if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "api_token_created" {
|
||||||
|
t.Fatalf("audit events = %#v, want api_token_created", auditSvc.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceValidateAPIToken(t *testing.T) {
|
||||||
|
rawToken := "secret-token"
|
||||||
|
sum := sha256.Sum256([]byte(rawToken))
|
||||||
|
tokenHash := hex.EncodeToString(sum[:])
|
||||||
|
repo := &fakeAuthRepository{
|
||||||
|
usersByID: map[string]*domain.User{
|
||||||
|
"user-1": {User: db.User{ID: "user-1", Username: "alice"}},
|
||||||
|
},
|
||||||
|
apiTokensByHash: map[string]*domain.APIToken{
|
||||||
|
tokenHash: {ApiToken: db.ApiToken{ID: "token-1", UserID: "user-1", TokenHash: tokenHash}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewAuthService(repo, &fakeAuditService{})
|
||||||
|
|
||||||
|
user, err := svc.ValidateAPIToken(context.Background(), " "+rawToken+" ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateAPIToken: %v", err)
|
||||||
|
}
|
||||||
|
if user == nil || user.ID != "user-1" {
|
||||||
|
t.Fatalf("user = %#v, want user-1", user)
|
||||||
|
}
|
||||||
|
if repo.touchedTokenID != "token-1" {
|
||||||
|
t.Fatalf("touched token id = %q, want token-1", repo.touchedTokenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceRevokeAllAPITokensForUser(t *testing.T) {
|
||||||
|
repo := &fakeAuthRepository{}
|
||||||
|
auditSvc := &fakeAuditService{}
|
||||||
|
svc := NewAuthService(repo, auditSvc)
|
||||||
|
|
||||||
|
if err := svc.RevokeAllAPITokensForUser(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("RevokeAllAPITokensForUser: %v", err)
|
||||||
|
}
|
||||||
|
if repo.revokedUserID != "user-1" {
|
||||||
|
t.Fatalf("revoked user id = %q, want user-1", repo.revokedUserID)
|
||||||
|
}
|
||||||
|
if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "api_token_revoked_all" {
|
||||||
|
t.Fatalf("audit events = %#v, want api_token_revoked_all", auditSvc.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.RevokeAllAPITokensForUser(context.Background(), " "); err == nil || err.Error() != "user id missing" {
|
||||||
|
t.Fatalf("blank user id error = %v, want user id missing", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(t *testing.T, password string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateFromPassword: %v", err)
|
||||||
|
}
|
||||||
|
return string(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAuthRepository struct {
|
||||||
|
usersByUsername map[string]*domain.User
|
||||||
|
usersByID map[string]*domain.User
|
||||||
|
sessions map[string]*domain.Session
|
||||||
|
apiTokensByHash map[string]*domain.APIToken
|
||||||
|
|
||||||
|
createdSessionUserID string
|
||||||
|
createdAPITokenHash string
|
||||||
|
createdAPITokenName string
|
||||||
|
touchedTokenID string
|
||||||
|
revokedUserID string
|
||||||
|
deletedSessions map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) GetUserByUsername(_ context.Context, username string) (*domain.User, error) {
|
||||||
|
return r.usersByUsername[username], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) GetUserByID(_ context.Context, id string) (*domain.User, error) {
|
||||||
|
return r.usersByID[id], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) CreateSession(_ context.Context, userID string, sessionID string) (*domain.Session, error) {
|
||||||
|
r.createdSessionUserID = userID
|
||||||
|
return &domain.Session{Session: db.Session{ID: sessionID, UserID: userID, ExpiresAt: time.Now().Add(domain.SessionLifetime)}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) GetSession(_ context.Context, sessionID string) (*domain.Session, error) {
|
||||||
|
return r.sessions[sessionID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) RefreshSession(_ context.Context, _ string, _ time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) DeleteSession(_ context.Context, sessionID string) error {
|
||||||
|
if r.deletedSessions == nil {
|
||||||
|
r.deletedSessions = make(map[string]bool)
|
||||||
|
}
|
||||||
|
r.deletedSessions[sessionID] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) CreateAPIToken(_ context.Context, userID, tokenHash, name string) (*domain.APIToken, error) {
|
||||||
|
r.createdAPITokenHash = tokenHash
|
||||||
|
r.createdAPITokenName = name
|
||||||
|
return &domain.APIToken{ApiToken: db.ApiToken{ID: "token-1", UserID: userID, TokenHash: tokenHash, Name: name}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) GetAPITokenByHash(_ context.Context, tokenHash string) (*domain.APIToken, error) {
|
||||||
|
return r.apiTokensByHash[tokenHash], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) TouchAPITokenLastUsedAt(_ context.Context, tokenID string) error {
|
||||||
|
r.touchedTokenID = tokenID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuthRepository) RevokeAllAPITokensForUser(_ context.Context, userID string) error {
|
||||||
|
r.revokedUserID = userID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAuditService struct {
|
||||||
|
events []domain.AuditEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAuditService) Record(_ context.Context, event domain.AuditEvent) error {
|
||||||
|
s.events = append(s.events, event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"mal/internal/database"
|
||||||
|
dbfixes "mal/internal/database/fixes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DefaultAvatarURL(username string) string {
|
func DefaultAvatarURL(username string) string {
|
||||||
seed := url.QueryEscape(strings.TrimSpace(username))
|
params := url.Values{}
|
||||||
return "https://api.dicebear.com/9.x/dylan/svg?seed=" + seed
|
params.Set("seed", strings.TrimSpace(username))
|
||||||
|
return "https://api.dicebear.com/9.x/dylan/svg?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
|
||||||
|
return database.RunMigrationsAndFixes(sqlDB, dbfixes.Dependencies{
|
||||||
|
DefaultAvatarURL: DefaultAvatarURL,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mal/internal/config"
|
"mal/internal/config"
|
||||||
|
dbfixes "mal/internal/database/fixes"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ var Module = fx.Options(
|
|||||||
ProvideSQLDB,
|
ProvideSQLDB,
|
||||||
ProvideQueries,
|
ProvideQueries,
|
||||||
),
|
),
|
||||||
fx.Invoke(RunMigrationsAndFixes),
|
fx.Invoke(RegisterJikanCacheCleanupWorker),
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
|
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
|
||||||
@@ -38,6 +39,7 @@ func ProvideQueries(sqlDB *sql.DB) *db.Queries {
|
|||||||
|
|
||||||
func RunMigrations(sqlDB *sql.DB) error {
|
func RunMigrations(sqlDB *sql.DB) error {
|
||||||
goose.SetBaseFS(migrationsFS)
|
goose.SetBaseFS(migrationsFS)
|
||||||
|
goose.SetLogger(goose.NopLogger())
|
||||||
|
|
||||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||||
return fmt.Errorf("failed to set goose dialect: %w", err)
|
return fmt.Errorf("failed to set goose dialect: %w", err)
|
||||||
@@ -48,11 +50,21 @@ func RunMigrations(sqlDB *sql.DB) error {
|
|||||||
return fmt.Errorf("failed to run migrations: %w", err)
|
return fmt.Errorf("failed to run migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version, err := goose.GetDBVersion(sqlDB)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database migration version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
observability.Info("db_migrations_complete", "database", "", map[string]any{"version": version})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
|
func RunMigrationsAndFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||||
if err := RunMigrations(sqlDB); err != nil {
|
if err := RunMigrations(sqlDB); err != nil {
|
||||||
return err
|
return fmt.Errorf("run migrations: %w", err)
|
||||||
}
|
}
|
||||||
return RunDataFixes(sqlDB)
|
if err := RunDataFixes(sqlDB, deps); err != nil {
|
||||||
|
return fmt.Errorf("run data fixes: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"mal/internal/db"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
@@ -12,7 +14,11 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = sqlDB.Close() }()
|
defer func() {
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
if err := RunMigrations(sqlDB); err != nil {
|
if err := RunMigrations(sqlDB); err != nil {
|
||||||
@@ -28,7 +34,7 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(indexName, func(t *testing.T) {
|
t.Run(indexName, func(t *testing.T) {
|
||||||
var count int
|
var count int
|
||||||
err := sqlDB.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName).Scan(&count)
|
err := sqlDB.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query index: %v", err)
|
t.Fatalf("query index: %v", err)
|
||||||
}
|
}
|
||||||
@@ -38,3 +44,81 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanupExpiredJikanCache(t *testing.T) {
|
||||||
|
sqlDB := newMigratedTestDB(t)
|
||||||
|
defer closeTestDB(t, sqlDB)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, row := range []struct {
|
||||||
|
key string
|
||||||
|
expiresAt string
|
||||||
|
}{
|
||||||
|
{key: "expired", expiresAt: "2000-01-01T00:00:00Z"},
|
||||||
|
{key: "fresh", expiresAt: "2999-01-01T00:00:00Z"},
|
||||||
|
} {
|
||||||
|
_, err := sqlDB.ExecContext(ctx, `INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`, row.key, "{}", row.expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert %s cache row: %v", row.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupExpiredJikanCache(ctx, db.New(sqlDB))
|
||||||
|
|
||||||
|
keys := jikanCacheKeys(ctx, t, sqlDB)
|
||||||
|
if len(keys) != 1 || keys[0] != "fresh" {
|
||||||
|
t.Fatalf("remaining cache keys = %v, want [fresh]", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMigratedTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if err := RunMigrations(sqlDB); err != nil {
|
||||||
|
closeTestDB(t, sqlDB)
|
||||||
|
t.Fatalf("RunMigrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTestDB(t *testing.T, sqlDB *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Errorf("close sqlite: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jikanCacheKeys(ctx context.Context, t *testing.T, sqlDB *sql.DB) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
rows, err := sqlDB.QueryContext(ctx, `SELECT key FROM jikan_cache ORDER BY key`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query cache keys: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
t.Errorf("close rows: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
if err := rows.Scan(&key); err != nil {
|
||||||
|
t.Fatalf("scan key: %v", err)
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("iterate keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import (
|
|||||||
|
|
||||||
dbfixes "mal/internal/database/fixes"
|
dbfixes "mal/internal/database/fixes"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
|
errlog "mal/pkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RunDataFixes(sqlDB *sql.DB) error {
|
func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -21,12 +22,12 @@ func RunDataFixes(sqlDB *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := ensureDataFixTable(ctx, sqlDB); err != nil {
|
if err := ensureDataFixTable(ctx, sqlDB); err != nil {
|
||||||
return err
|
return fmt.Errorf("ensure data fix table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
applied, err := loadAppliedFixes(ctx, sqlDB)
|
applied, err := loadAppliedFixes(ctx, sqlDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("load applied data fixes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fix := range fixes {
|
for _, fix := range fixes {
|
||||||
@@ -42,11 +43,11 @@ func RunDataFixes(sqlDB *sql.DB) error {
|
|||||||
"id": fix.ID,
|
"id": fix.ID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err := fix.Apply(ctx, sqlDB); err != nil {
|
if err := fix.Apply(ctx, sqlDB, deps); err != nil {
|
||||||
return fmt.Errorf("data fix %s failed: %w", fix.ID, err)
|
return fmt.Errorf("data fix %s failed: %w", fix.ID, err)
|
||||||
}
|
}
|
||||||
if err := markFixApplied(ctx, sqlDB, fix.ID); err != nil {
|
if err := markFixApplied(ctx, sqlDB, fix.ID); err != nil {
|
||||||
return err
|
return fmt.Errorf("mark data fix %s applied: %w", fix.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ func loadAppliedFixes(ctx context.Context, sqlDB *sql.DB) (map[string]bool, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load applied data fixes: %w", err)
|
return nil, fmt.Errorf("load applied data fixes: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer errlog.Close(rows, "failed to close applied data fixes rows")
|
||||||
|
|
||||||
applied := make(map[string]bool)
|
applied := make(map[string]bool)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func init() {
|
func init() {
|
||||||
Register(Fix{
|
Register(Fix{
|
||||||
ID: "20260526_episode_availability_backfill_next_refresh_at",
|
ID: "20260526_episode_availability_backfill_next_refresh_at",
|
||||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
Apply: func(ctx context.Context, sqlDB *sql.DB, _ Dependencies) error {
|
||||||
// Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata),
|
// Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata),
|
||||||
// which can result in "never refresh again" behavior on the server.
|
// which can result in "never refresh again" behavior on the server.
|
||||||
_, err := sqlDB.ExecContext(ctx, `
|
_, err := sqlDB.ExecContext(ctx, `
|
||||||
|
|||||||
@@ -4,18 +4,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mal/internal"
|
errlog "mal/pkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Register(Fix{
|
Register(Fix{
|
||||||
ID: "20260528_backfill_avatar_url",
|
ID: "20260528_backfill_avatar_url",
|
||||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
Apply: func(ctx context.Context, sqlDB *sql.DB, deps Dependencies) error {
|
||||||
|
if deps.DefaultAvatarURL == nil {
|
||||||
|
return fmt.Errorf("default avatar URL dependency is required")
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := sqlDB.QueryContext(ctx, `SELECT id, username FROM user WHERE avatar_url = ''`)
|
rows, err := sqlDB.QueryContext(ctx, `SELECT id, username FROM user WHERE avatar_url = ''`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("query users missing avatar_url: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
defer errlog.Close(rows, "failed to close avatar backfill rows")
|
||||||
|
|
||||||
type userRow struct {
|
type userRow struct {
|
||||||
id string
|
id string
|
||||||
@@ -25,16 +29,16 @@ func init() {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r userRow
|
var r userRow
|
||||||
if err := rows.Scan(&r.id, &r.username); err != nil {
|
if err := rows.Scan(&r.id, &r.username); err != nil {
|
||||||
return err
|
return fmt.Errorf("scan user missing avatar_url: %w", err)
|
||||||
}
|
}
|
||||||
toUpdate = append(toUpdate, r)
|
toUpdate = append(toUpdate, r)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return err
|
return fmt.Errorf("iterate users missing avatar_url: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range toUpdate {
|
for _, u := range toUpdate {
|
||||||
avatarURL := internal.DefaultAvatarURL(u.username)
|
avatarURL := deps.DefaultAvatarURL(u.username)
|
||||||
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET avatar_url = ? WHERE id = ?`, avatarURL, u.id); err != nil {
|
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET avatar_url = ? WHERE id = ?`, avatarURL, u.id); err != nil {
|
||||||
return fmt.Errorf("update avatar_url for user %s: %w", u.id, err)
|
return fmt.Errorf("update avatar_url for user %s: %w", u.id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,75 +7,87 @@ import (
|
|||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/config"
|
"mal/internal/config"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/observability"
|
errlog "mal/pkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type animeDurationRow struct {
|
||||||
|
id int64
|
||||||
|
titleOriginal string
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Register(Fix{
|
Register(Fix{
|
||||||
ID: "20260608_backfill_anime_duration_seconds",
|
ID: "20260608_backfill_anime_duration_seconds",
|
||||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
Apply: func(ctx context.Context, sqlDB *sql.DB, _ Dependencies) error {
|
||||||
rows, err := sqlDB.QueryContext(ctx, `
|
return applyAnimeDurationSecondsBackfill(ctx, sqlDB)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAnimeDurationSecondsBackfill(ctx context.Context, sqlDB *sql.DB) error {
|
||||||
|
toUpdate, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list anime missing duration_seconds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := jikan.NewClient(config.Config{}, db.New(sqlDB))
|
||||||
|
for _, row := range toUpdate {
|
||||||
|
anime, err := client.GetAnimeByID(ctx, int(row.id))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch anime %d for duration backfill: %w", row.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
durationSeconds := anime.DurationSeconds()
|
||||||
|
if durationSeconds <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := sqlDB.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE anime SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL`,
|
||||||
|
durationSeconds,
|
||||||
|
row.id,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("update anime %d duration_seconds: %w", row.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAnimeMissingDurationSeconds(ctx context.Context, sqlDB *sql.DB) ([]animeDurationRow, error) {
|
||||||
|
rows, err := sqlDB.QueryContext(ctx, `
|
||||||
SELECT id, title_original, title_english, title_japanese, image_url, airing
|
SELECT id, title_original, title_english, title_japanese, image_url, airing
|
||||||
FROM anime
|
FROM anime
|
||||||
WHERE duration_seconds IS NULL;
|
WHERE duration_seconds IS NULL;
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("query anime rows missing duration_seconds: %w", err)
|
return nil, fmt.Errorf("query anime rows missing duration_seconds: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
defer errlog.Close(rows, "failed to close anime duration backfill rows")
|
||||||
|
|
||||||
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
|
var toUpdate []animeDurationRow
|
||||||
|
for rows.Next() {
|
||||||
|
var row animeDurationRow
|
||||||
|
var titleEnglish sql.NullString
|
||||||
|
var titleJapanese sql.NullString
|
||||||
|
var imageURL string
|
||||||
|
var airing sql.NullBool
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.id,
|
||||||
|
&row.titleOriginal,
|
||||||
|
&titleEnglish,
|
||||||
|
&titleJapanese,
|
||||||
|
&imageURL,
|
||||||
|
&airing,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan anime row missing duration_seconds: %w", err)
|
||||||
|
}
|
||||||
|
toUpdate = append(toUpdate, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate anime rows missing duration_seconds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
type animeRow struct {
|
return toUpdate, nil
|
||||||
id int64
|
|
||||||
titleOriginal string
|
|
||||||
}
|
|
||||||
|
|
||||||
var toUpdate []animeRow
|
|
||||||
for rows.Next() {
|
|
||||||
var row animeRow
|
|
||||||
var titleEnglish sql.NullString
|
|
||||||
var titleJapanese sql.NullString
|
|
||||||
var imageURL string
|
|
||||||
var airing sql.NullBool
|
|
||||||
if err := rows.Scan(
|
|
||||||
&row.id,
|
|
||||||
&row.titleOriginal,
|
|
||||||
&titleEnglish,
|
|
||||||
&titleJapanese,
|
|
||||||
&imageURL,
|
|
||||||
&airing,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("scan anime row missing duration_seconds: %w", err)
|
|
||||||
}
|
|
||||||
toUpdate = append(toUpdate, row)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return fmt.Errorf("iterate anime rows missing duration_seconds: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range toUpdate {
|
|
||||||
anime, err := client.GetAnimeByID(ctx, int(row.id))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch anime %d for duration backfill: %w", row.id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
durationSeconds := anime.DurationSeconds()
|
|
||||||
if durationSeconds <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := sqlDB.ExecContext(
|
|
||||||
ctx,
|
|
||||||
`UPDATE anime SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL`,
|
|
||||||
durationSeconds,
|
|
||||||
row.id,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("update anime %d duration_seconds: %w", row.id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import (
|
|||||||
|
|
||||||
type Fix struct {
|
type Fix struct {
|
||||||
ID string
|
ID string
|
||||||
Apply func(ctx context.Context, sqlDB *sql.DB) error
|
Apply func(ctx context.Context, sqlDB *sql.DB, deps Dependencies) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dependencies struct {
|
||||||
|
DefaultAvatarURL func(username string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
var registered []Fix
|
var registered []Fix
|
||||||
|
|||||||
71
internal/database/jikan_cache_cleanup.go
Normal file
71
internal/database/jikan_cache_cleanup.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/observability"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
jikanCacheCleanupInterval = time.Hour
|
||||||
|
jikanCacheCleanupTimeout = 30 * time.Second
|
||||||
|
jikanCacheCleanupWorker = "jikan_cache_cleanup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterJikanCacheCleanupWorker(lc fx.Lifecycle, queries *db.Queries) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(startCtx context.Context) error {
|
||||||
|
go func() {
|
||||||
|
<-startCtx.Done()
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
go runJikanCacheCleanupWorker(ctx, queries)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(context.Context) error {
|
||||||
|
cancel()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runJikanCacheCleanupWorker(ctx context.Context, queries *db.Queries) {
|
||||||
|
observability.Info("jikan_cache_cleanup_worker_start", "database", "", nil)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(jikanCacheCleanupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
cleanupExpiredJikanCache(ctx, queries)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
observability.Info("jikan_cache_cleanup_worker_stop", "database", "", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupExpiredJikanCache(ctx context.Context, queries *db.Queries) {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(ctx, jikanCacheCleanupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := queries.DeleteExpiredJikanCache(cleanupCtx)
|
||||||
|
if err != nil {
|
||||||
|
observability.Warn(
|
||||||
|
"jikan_cache_cleanup_failed",
|
||||||
|
"database",
|
||||||
|
"",
|
||||||
|
map[string]any{
|
||||||
|
"worker": jikanCacheCleanupWorker,
|
||||||
|
},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anime_relation_related_anime_id
|
||||||
|
ON anime_relation(related_anime_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP INDEX IF EXISTS idx_anime_relation_related_anime_id;
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (q *Queries) GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error) {
|
|
||||||
if userID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
needle, pattern := commandPalettePattern(query)
|
|
||||||
rows, err := q.db.QueryContext(ctx, `
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.user_id,
|
|
||||||
c.anime_id,
|
|
||||||
c.current_episode,
|
|
||||||
c.current_time_seconds,
|
|
||||||
c.duration_seconds,
|
|
||||||
c.created_at,
|
|
||||||
c.updated_at,
|
|
||||||
a.title_original,
|
|
||||||
a.title_english,
|
|
||||||
a.title_japanese,
|
|
||||||
a.image_url,
|
|
||||||
a.duration_seconds as anime_duration_seconds
|
|
||||||
FROM continue_watching_entry c
|
|
||||||
JOIN anime a ON c.anime_id = a.id
|
|
||||||
WHERE c.user_id = ?
|
|
||||||
AND (
|
|
||||||
? = ''
|
|
||||||
OR lower(a.title_original) LIKE ?
|
|
||||||
OR lower(coalesce(a.title_english, '')) LIKE ?
|
|
||||||
OR lower(coalesce(a.title_japanese, '')) LIKE ?
|
|
||||||
OR lower('Continue watching') LIKE ?
|
|
||||||
)
|
|
||||||
ORDER BY c.updated_at DESC
|
|
||||||
LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
|
|
||||||
for rows.Next() {
|
|
||||||
var item GetContinueWatchingEntriesRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&item.ID,
|
|
||||||
&item.UserID,
|
|
||||||
&item.AnimeID,
|
|
||||||
&item.CurrentEpisode,
|
|
||||||
&item.CurrentTimeSeconds,
|
|
||||||
&item.DurationSeconds,
|
|
||||||
&item.CreatedAt,
|
|
||||||
&item.UpdatedAt,
|
|
||||||
&item.TitleOriginal,
|
|
||||||
&item.TitleEnglish,
|
|
||||||
&item.TitleJapanese,
|
|
||||||
&item.ImageUrl,
|
|
||||||
&item.AnimeDurationSeconds,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error) {
|
|
||||||
if userID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
needle, pattern := commandPalettePattern(query)
|
|
||||||
rows, err := q.db.QueryContext(ctx, `
|
|
||||||
SELECT
|
|
||||||
e.id,
|
|
||||||
e.user_id,
|
|
||||||
e.anime_id,
|
|
||||||
e.status,
|
|
||||||
e.created_at,
|
|
||||||
e.updated_at,
|
|
||||||
e.current_episode,
|
|
||||||
e.last_episode_at,
|
|
||||||
e.current_time_seconds,
|
|
||||||
a.title_original,
|
|
||||||
a.title_english,
|
|
||||||
a.title_japanese,
|
|
||||||
a.image_url,
|
|
||||||
a.airing
|
|
||||||
FROM watch_list_entry e
|
|
||||||
JOIN anime a ON e.anime_id = a.id
|
|
||||||
WHERE e.user_id = ?
|
|
||||||
AND e.status IN ('watching', 'plan_to_watch')
|
|
||||||
AND (
|
|
||||||
? = ''
|
|
||||||
OR lower(a.title_original) LIKE ?
|
|
||||||
OR lower(coalesce(a.title_english, '')) LIKE ?
|
|
||||||
OR lower(coalesce(a.title_japanese, '')) LIKE ?
|
|
||||||
OR lower(e.status) LIKE ?
|
|
||||||
)
|
|
||||||
ORDER BY
|
|
||||||
CASE e.status
|
|
||||||
WHEN 'watching' THEN 0
|
|
||||||
WHEN 'plan_to_watch' THEN 1
|
|
||||||
ELSE 2
|
|
||||||
END,
|
|
||||||
e.updated_at DESC
|
|
||||||
LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
items := make([]GetUserWatchListRow, 0, int(limit))
|
|
||||||
for rows.Next() {
|
|
||||||
var item GetUserWatchListRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&item.ID,
|
|
||||||
&item.UserID,
|
|
||||||
&item.AnimeID,
|
|
||||||
&item.Status,
|
|
||||||
&item.CreatedAt,
|
|
||||||
&item.UpdatedAt,
|
|
||||||
&item.CurrentEpisode,
|
|
||||||
&item.LastEpisodeAt,
|
|
||||||
&item.CurrentTimeSeconds,
|
|
||||||
&item.TitleOriginal,
|
|
||||||
&item.TitleEnglish,
|
|
||||||
&item.TitleJapanese,
|
|
||||||
&item.ImageUrl,
|
|
||||||
&item.Airing,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandPalettePattern(query string) (string, string) {
|
|
||||||
needle := strings.ToLower(strings.TrimSpace(query))
|
|
||||||
return needle, "%" + needle + "%"
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetCommandPaletteContinueWatchingFiltersAndLimits(t *testing.T) {
|
|
||||||
sqlDB := openCommandPaletteTestDB(t)
|
|
||||||
|
|
||||||
got, err := New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "continue", 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCommandPaletteContinueWatching: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].AnimeID != 20 {
|
|
||||||
t.Fatalf("continue rows = %+v, want latest anime 20 only", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err = New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "nar", 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCommandPaletteContinueWatching filtered: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].AnimeID != 10 {
|
|
||||||
t.Fatalf("filtered continue rows = %+v, want anime 10", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCommandPaletteWatchlistFiltersAndOrders(t *testing.T) {
|
|
||||||
sqlDB := openCommandPaletteTestDB(t)
|
|
||||||
|
|
||||||
got, err := New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "", 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCommandPaletteWatchlist: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("watchlist rows len = %d, want 2", len(got))
|
|
||||||
}
|
|
||||||
if got[0].AnimeID != 10 || got[1].AnimeID != 20 {
|
|
||||||
t.Fatalf("watchlist order = [%d %d], want watching anime 10 before plan anime 20", got[0].AnimeID, got[1].AnimeID)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err = New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "plan", 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCommandPaletteWatchlist filtered: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].AnimeID != 20 {
|
|
||||||
t.Fatalf("filtered watchlist rows = %+v, want anime 20", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openCommandPaletteTestDB(t *testing.T) *sql.DB {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
||||||
|
|
||||||
_, err = sqlDB.Exec(`
|
|
||||||
CREATE TABLE anime (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
title_original TEXT NOT NULL,
|
|
||||||
title_english TEXT,
|
|
||||||
title_japanese TEXT,
|
|
||||||
image_url TEXT NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
airing BOOLEAN DEFAULT 0,
|
|
||||||
duration_seconds REAL
|
|
||||||
);
|
|
||||||
CREATE TABLE watch_list_entry (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
anime_id INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
updated_at DATETIME NOT NULL,
|
|
||||||
current_episode INTEGER,
|
|
||||||
last_episode_at DATETIME,
|
|
||||||
current_time_seconds REAL NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE continue_watching_entry (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
anime_id INTEGER NOT NULL,
|
|
||||||
current_episode INTEGER,
|
|
||||||
current_time_seconds REAL NOT NULL DEFAULT 0,
|
|
||||||
duration_seconds REAL,
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
updated_at DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, duration_seconds) VALUES
|
|
||||||
(10, 'Naruto', NULL, NULL, 'naruto.jpg', 0, 1440),
|
|
||||||
(20, 'Frieren', 'Frieren: Beyond Journey''s End', NULL, 'frieren.jpg', 0, 1440),
|
|
||||||
(30, 'Dropped Show', NULL, NULL, 'dropped.jpg', 0, 1440);
|
|
||||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, created_at, updated_at, current_episode, current_time_seconds) VALUES
|
|
||||||
('w1', 'user-a', 10, 'watching', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 3, 0),
|
|
||||||
('w2', 'user-a', 20, 'plan_to_watch', '2026-01-02 00:00:00', '2026-01-03 00:00:00', 0, 0),
|
|
||||||
('w3', 'user-a', 30, 'dropped', '2026-01-04 00:00:00', '2026-01-04 00:00:00', 0, 0),
|
|
||||||
('w4', 'user-b', 10, 'watching', '2026-01-05 00:00:00', '2026-01-05 00:00:00', 1, 0);
|
|
||||||
INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, duration_seconds, created_at, updated_at) VALUES
|
|
||||||
('c1', 'user-a', 10, 4, 120, 1440, '2026-01-01 00:00:00', '2026-01-01 00:00:00'),
|
|
||||||
('c2', 'user-a', 20, 1, 60, 1440, '2026-01-02 00:00:00', '2026-01-03 00:00:00'),
|
|
||||||
('c3', 'user-b', 10, 1, 30, 1440, '2026-01-04 00:00:00', '2026-01-04 00:00:00');
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seed command palette db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sqlDB
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,15 @@ func NullStringOr(n sql.NullString, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisplayTitle returns the English title, falling back to Japanese then original
|
// DisplayTitle returns the English title, falling back to original then Japanese.
|
||||||
func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal string) string {
|
func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal string) string {
|
||||||
return NullStringOr(titleEnglish, NullStringOr(titleJapanese, titleOriginal))
|
if titleEnglish.Valid && titleEnglish.String != "" {
|
||||||
|
return titleEnglish.String
|
||||||
|
}
|
||||||
|
if titleOriginal != "" {
|
||||||
|
return titleOriginal
|
||||||
|
}
|
||||||
|
return NullStringOr(titleJapanese, titleOriginal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r GetUserWatchListRow) DisplayTitle() string {
|
func (r GetUserWatchListRow) DisplayTitle() string {
|
||||||
|
|||||||
30
internal/db/helpers_test.go
Normal file
30
internal/db/helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisplayTitlePrefersOriginalBeforeJapanese(t *testing.T) {
|
||||||
|
got := DisplayTitle(
|
||||||
|
sql.NullString{},
|
||||||
|
sql.NullString{String: "サイバーパンク エッジランナーズ", Valid: true},
|
||||||
|
"Cyberpunk: Edgerunners",
|
||||||
|
)
|
||||||
|
|
||||||
|
if got != "Cyberpunk: Edgerunners" {
|
||||||
|
t.Fatalf("DisplayTitle() = %q, want original title", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayTitlePrefersEnglish(t *testing.T) {
|
||||||
|
got := DisplayTitle(
|
||||||
|
sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true},
|
||||||
|
sql.NullString{String: "葬送のフリーレン", Valid: true},
|
||||||
|
"Sousou no Frieren",
|
||||||
|
)
|
||||||
|
|
||||||
|
if got != "Frieren: Beyond Journey's End" {
|
||||||
|
t.Fatalf("DisplayTitle() = %q, want English title", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ type Querier interface {
|
|||||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||||
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
|
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
|
||||||
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
|
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
|
||||||
|
DeleteExpiredFailedEpisodeProviderMappings(ctx context.Context) error
|
||||||
DeleteExpiredJikanCache(ctx context.Context) error
|
DeleteExpiredJikanCache(ctx context.Context) error
|
||||||
DeleteSession(ctx context.Context, id string) error
|
DeleteSession(ctx context.Context, id string) error
|
||||||
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
|
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
|
||||||
@@ -26,12 +27,11 @@ type Querier interface {
|
|||||||
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
||||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
||||||
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||||
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error)
|
|
||||||
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error)
|
|
||||||
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
||||||
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
|
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
|
||||||
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
|
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
|
||||||
GetJikanCache(ctx context.Context, key string) (string, error)
|
GetJikanCache(ctx context.Context, key string) (string, error)
|
||||||
|
GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error)
|
||||||
GetJikanCacheStale(ctx context.Context, key string) (string, error)
|
GetJikanCacheStale(ctx context.Context, key string) (string, error)
|
||||||
GetSession(ctx context.Context, id string) (Session, error)
|
GetSession(ctx context.Context, id string) (Session, error)
|
||||||
GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Context, limit int64) ([]int64, error)
|
GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Context, limit int64) ([]int64, error)
|
||||||
|
|||||||
@@ -233,7 +233,14 @@ WHERE key = ? AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1;
|
|||||||
|
|
||||||
-- name: GetJikanCacheStale :one
|
-- name: GetJikanCacheStale :one
|
||||||
SELECT data FROM jikan_cache
|
SELECT data FROM jikan_cache
|
||||||
WHERE key = ? LIMIT 1;
|
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetJikanCacheStats :one
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_rows,
|
||||||
|
COUNT(*) FILTER (WHERE datetime(expires_at) <= CURRENT_TIMESTAMP) AS expired_rows,
|
||||||
|
COALESCE(unixepoch(MIN(expires_at)), 0) AS oldest_expires_at_seconds
|
||||||
|
FROM jikan_cache;
|
||||||
|
|
||||||
-- name: SetJikanCache :exec
|
-- name: SetJikanCache :exec
|
||||||
INSERT INTO jikan_cache (key, data, expires_at)
|
INSERT INTO jikan_cache (key, data, expires_at)
|
||||||
@@ -333,6 +340,11 @@ SELECT anime_id, provider, provider_show_id, failed_until, last_error, updated_a
|
|||||||
FROM episode_provider_mapping
|
FROM episode_provider_mapping
|
||||||
WHERE anime_id = ? AND provider = ? LIMIT 1;
|
WHERE anime_id = ? AND provider = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: DeleteExpiredFailedEpisodeProviderMappings :exec
|
||||||
|
DELETE FROM episode_provider_mapping
|
||||||
|
WHERE provider_show_id = ''
|
||||||
|
AND failed_until <= CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
-- name: GetTrackedAiringAnimeIDsDueForEpisodeRefresh :many
|
-- name: GetTrackedAiringAnimeIDsDueForEpisodeRefresh :many
|
||||||
WITH tracked AS (
|
WITH tracked AS (
|
||||||
SELECT DISTINCT w.anime_id
|
SELECT DISTINCT w.anime_id
|
||||||
@@ -357,4 +369,4 @@ LIMIT ?;
|
|||||||
|
|
||||||
-- name: GetAllCachedAnime :many
|
-- name: GetAllCachedAnime :many
|
||||||
SELECT data FROM jikan_cache
|
SELECT data FROM jikan_cache
|
||||||
WHERE key LIKE 'anime:%' LIMIT 1000;
|
WHERE key LIKE 'anime:%' AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1000;
|
||||||
|
|||||||
@@ -149,6 +149,17 @@ func (q *Queries) DeleteContinueWatchingEntry(ctx context.Context, arg DeleteCon
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteExpiredFailedEpisodeProviderMappings = `-- name: DeleteExpiredFailedEpisodeProviderMappings :exec
|
||||||
|
DELETE FROM episode_provider_mapping
|
||||||
|
WHERE provider_show_id = ''
|
||||||
|
AND failed_until <= CURRENT_TIMESTAMP
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteExpiredFailedEpisodeProviderMappings(ctx context.Context) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteExpiredFailedEpisodeProviderMappings)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteExpiredJikanCache = `-- name: DeleteExpiredJikanCache :exec
|
const deleteExpiredJikanCache = `-- name: DeleteExpiredJikanCache :exec
|
||||||
DELETE FROM jikan_cache WHERE datetime(expires_at) <= CURRENT_TIMESTAMP
|
DELETE FROM jikan_cache WHERE datetime(expires_at) <= CURRENT_TIMESTAMP
|
||||||
`
|
`
|
||||||
@@ -227,7 +238,7 @@ func (q *Queries) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiT
|
|||||||
|
|
||||||
const getAllCachedAnime = `-- name: GetAllCachedAnime :many
|
const getAllCachedAnime = `-- name: GetAllCachedAnime :many
|
||||||
SELECT data FROM jikan_cache
|
SELECT data FROM jikan_cache
|
||||||
WHERE key LIKE 'anime:%' LIMIT 1000
|
WHERE key LIKE 'anime:%' AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1000
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
|
func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
|
||||||
@@ -235,7 +246,6 @@ func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []string
|
var items []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var data string
|
var data string
|
||||||
@@ -308,7 +318,6 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []GetAnimeNeedingRelationSyncRow
|
var items []GetAnimeNeedingRelationSyncRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetAnimeNeedingRelationSyncRow
|
var i GetAnimeNeedingRelationSyncRow
|
||||||
@@ -344,7 +353,6 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []AuditLog
|
var items []AuditLog
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i AuditLog
|
var i AuditLog
|
||||||
@@ -414,7 +422,6 @@ func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []GetContinueWatchingEntriesRow
|
var items []GetContinueWatchingEntriesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetContinueWatchingEntriesRow
|
var i GetContinueWatchingEntriesRow
|
||||||
@@ -485,7 +492,6 @@ func (q *Queries) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]A
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []AnimeFetchRetry
|
var items []AnimeFetchRetry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i AnimeFetchRetry
|
var i AnimeFetchRetry
|
||||||
@@ -570,9 +576,30 @@ func (q *Queries) GetJikanCache(ctx context.Context, key string) (string, error)
|
|||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getJikanCacheStats = `-- name: GetJikanCacheStats :one
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_rows,
|
||||||
|
COUNT(*) FILTER (WHERE datetime(expires_at) <= CURRENT_TIMESTAMP) AS expired_rows,
|
||||||
|
COALESCE(unixepoch(MIN(expires_at)), 0) AS oldest_expires_at_seconds
|
||||||
|
FROM jikan_cache
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetJikanCacheStatsRow struct {
|
||||||
|
TotalRows int64 `json:"total_rows"`
|
||||||
|
ExpiredRows int64 `json:"expired_rows"`
|
||||||
|
OldestExpiresAtSeconds int64 `json:"oldest_expires_at_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getJikanCacheStats)
|
||||||
|
var i GetJikanCacheStatsRow
|
||||||
|
err := row.Scan(&i.TotalRows, &i.ExpiredRows, &i.OldestExpiresAtSeconds)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getJikanCacheStale = `-- name: GetJikanCacheStale :one
|
const getJikanCacheStale = `-- name: GetJikanCacheStale :one
|
||||||
SELECT data FROM jikan_cache
|
SELECT data FROM jikan_cache
|
||||||
WHERE key = ? LIMIT 1
|
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) {
|
func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) {
|
||||||
@@ -626,7 +653,6 @@ func (q *Queries) GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Conte
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []int64
|
var items []int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var anime_id int64
|
var anime_id int64
|
||||||
@@ -703,7 +729,6 @@ func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetU
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []GetUpcomingSeasonsRow
|
var items []GetUpcomingSeasonsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetUpcomingSeasonsRow
|
var i GetUpcomingSeasonsRow
|
||||||
@@ -803,7 +828,6 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []GetUserWatchListRow
|
var items []GetUserWatchListRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetUserWatchListRow
|
var i GetUserWatchListRow
|
||||||
@@ -899,7 +923,6 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var items []GetWatchingAnimeRow
|
var items []GetWatchingAnimeRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetWatchingAnimeRow
|
var i GetWatchingAnimeRow
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user