Compare commits
1318 Commits
2e26a82aa7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95b5df5f5 | ||
| 2712775d4f | |||
| 80e509e2a0 | |||
| 0b333fcba8 | |||
| 7c5622ab5c | |||
| 557c46880e | |||
| e9b134fc9c | |||
| f03db2888f | |||
| 45341ae212 | |||
| 74930976c4 | |||
| de5d292599 | |||
| eab4d5e302 | |||
| 528af5bf8b | |||
| ee7efc33b7 | |||
| c36d180961 | |||
| d7385c01b1 | |||
| 5c64227f30 | |||
| 8b8f85bea7 | |||
| e75845b78a | |||
| a577567fdc | |||
| 3626edc231 | |||
| 30be4551ee | |||
| 610b5029a3 | |||
| 0e3d4b4b11 | |||
| 78bd003b7c | |||
| 352f32bca7 | |||
| 18bcaf9e31 | |||
| 15e08e1460 | |||
| c4f276b72d | |||
| c13e5541a6 | |||
| 6dfca350f0 | |||
| 1b19bf65de | |||
| 92c316f4a7 | |||
| eb90710dc6 | |||
| e556b0fe09 | |||
| b32873fc76 | |||
| bf13a4a1f8 | |||
| 73b4436f61 | |||
| 522a99ddf8 | |||
| 5679be866a | |||
| 51bdf5c53a | |||
| f3b6489d03 | |||
| 9487ff124a | |||
| dbda4b96a4 | |||
| a4bc38990d | |||
| 05ede20893 | |||
| 03e1990f11 | |||
| 5a3797b5df | |||
| 61900a508c | |||
| 413759c454 | |||
| 7215b1a4ee | |||
| 6b6256b839 | |||
| 28bb28c5e5 | |||
| 65358aa809 | |||
| e39e73bc6b | |||
| f5b38c519b | |||
| a595ade533 | |||
| 8ec647943e | |||
| 722f936734 | |||
| 8e11afb3b5 | |||
| 80b5aec448 | |||
| d06d6de4c4 | |||
| 1110cb1809 | |||
| 0d2459fa3c | |||
| 9f370448ed | |||
| 6a7cd55b47 | |||
| 217478c894 | |||
| 137c6ab644 | |||
| 539dd917f8 | |||
| 1317bbb532 | |||
| d3ddf5d228 | |||
| 3c82c32e87 | |||
| 7d59674649 | |||
| 7278676a2e | |||
| 0abee703a1 | |||
| 8c2562496a | |||
| a0d11e3a7b | |||
| a9bb16d8b5 | |||
| a31d71bca6 | |||
| 11ec256e18 | |||
| 7dfc54b3e3 | |||
| d2d94b919d | |||
| a3cfae33f2 | |||
| 04a9b0b0bd | |||
| 57a6aa8f7a | |||
| 9c19f0da1e | |||
| d1eafdd6de | |||
| a527981447 | |||
| 43fb5509f1 | |||
| b796560f77 | |||
| e1de04f72e | |||
| f4cacd7390 | |||
| 42fcc94773 | |||
| 992705a2b6 | |||
| 54f92725d9 | |||
| 9955c7b000 | |||
| 9ac41d3f9f | |||
| 489d025426 | |||
| 85e0dd5913 | |||
| 8ababab46b | |||
| 2952bd0e89 | |||
| 66fa70e6ad | |||
| 72c5c99737 | |||
| 3ed092c5c5 | |||
| 660ae022ea | |||
| f4bad31290 | |||
| 1af0ec351d | |||
| 11bdd9751b | |||
| 22f61a8c98 | |||
| b14aac130d | |||
| bf38ca59b7 | |||
| fda5620a54 | |||
| 021b39dc0d | |||
| 6681f535ac | |||
| 904483da15 | |||
| e43e8edaca | |||
| c7fceda2a8 | |||
| 5c77d90010 | |||
| 4516417c6b | |||
| 289ad25064 | |||
| 8561e0dca0 | |||
| 2476a21b86 | |||
| 2d4e4c6ae3 | |||
| 22b26da0dc | |||
| 37717fec69 | |||
| 40eb3a146e | |||
| db9ac7c28c | |||
| fbbee45650 | |||
| 54fb72e001 | |||
| 89447cbc15 | |||
| ba0e5c6d21 | |||
| ca67562a77 | |||
| 32fed1eee2 | |||
| 01776f4eef | |||
| 58abaaceb0 | |||
| 372c93ab52 | |||
| 025e926482 | |||
| 49abffb21a | |||
| 1800335719 | |||
| fed8bf76a0 | |||
| 08f58cf377 | |||
| d61ed31d1e | |||
| 0bf78f1ea3 | |||
| 886e412be5 | |||
| 2918fa5051 | |||
| 223c0fa89e | |||
| 72ee744161 | |||
| df9fd64123 | |||
| 3a824c9c7e | |||
| 59756095c2 | |||
| 5e12a496e7 | |||
| 86ba058825 | |||
| 4afa0b46f9 | |||
| 078dfe9a58 | |||
| 7f74e4bc55 | |||
| c6afc2bc53 | |||
| ed7dfea834 | |||
| be71ec91c8 | |||
| 1e6ac5e854 | |||
| 748c823172 | |||
| 44d4b7c342 | |||
| 37c5e66217 | |||
| cdcf6a5a2a | |||
| 094d560c30 | |||
| c5ae4e7d69 | |||
| 02dcde5786 | |||
| 4bae78ca56 | |||
| e9a167b2c9 | |||
| a74087aa2a | |||
| 6edb93d5af | |||
| 63221d72dc | |||
| b59ad79117 | |||
| ae88b92151 | |||
| f661c2ed6c | |||
| d81f59de05 | |||
| b648afa471 | |||
| 34923212c5 | |||
| 4ded4e789b | |||
| 42fc505068 | |||
| 6d5e9d0a2c | |||
| 1d9840f2b9 | |||
| 177081edd0 | |||
| 549f964e8e | |||
| 83dd4510df | |||
| 6465dc137c | |||
| ae00c04cf9 | |||
| 531566291b | |||
| 1010dd06d2 | |||
| 9acb9218c2 | |||
| 88c066398e | |||
| 87bc6228e7 | |||
| 1272800ca2 | |||
| 1e4dcd2208 | |||
| 490b2e3ed5 | |||
| ae1b323732 | |||
| aaf9cf41f3 | |||
| e502056f2f | |||
| cbf084e935 | |||
| 75520aa1a3 | |||
| 0480b70dfc | |||
| b4c759103b | |||
| eeb85d4aca | |||
| e54b9cef5e | |||
| 538a3f0138 | |||
| 2ad8605c9c | |||
| 3c4f5375ed | |||
| 5338c61a5d | |||
| f8e3f89e42 | |||
| ff4998f3f1 | |||
| c1475dd71c | |||
| 8345143bb0 | |||
| 34d2b37e94 | |||
| 07eae2d971 | |||
| b2f00e879a | |||
| 17a65c4926 | |||
| 0ed4ab3f82 | |||
| cf5462312e | |||
| d08961efbc | |||
| 379e2ff313 | |||
| 33c91e91ca | |||
| 0d31d46fae | |||
| 3aa8ce4638 | |||
| adcae02c18 | |||
| e9917730b8 | |||
| b171df07f2 | |||
| aba83d1109 | |||
| b67c4f211b | |||
|
|
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 | |||
| 32b08245ca | |||
| a732e13ee8 | |||
| 4bb31e6f00 | |||
| 998a1d212a | |||
| 243ea85dbf | |||
| b0dee41eb8 | |||
| c6f24e07f0 | |||
| c68ee462e9 | |||
| 306d209701 | |||
| 860c8df639 | |||
| c7252f7297 | |||
| ad69ac7e6c | |||
| 7b59435f33 | |||
| 894c1adf97 | |||
| 2463d88c53 | |||
| f062395eac | |||
| ac8e583e23 | |||
| 9f973c178a | |||
| 734b39efd7 | |||
| e1da6ddb19 | |||
| c0dbe6be2a | |||
| 01af5b60bf | |||
| 7e76c28396 | |||
| 0c3be8a8d3 | |||
| f2822f068f | |||
| 6e7e1fc001 | |||
| f6ed2c673d | |||
| c777de9f06 | |||
| 2213293b57 | |||
| 1dd37a5aa2 | |||
| 1e5fe7f879 | |||
| 87b67744be | |||
| fbc4b3c90c | |||
| 697fa38577 | |||
| a9fa4d8d90 | |||
| 42017681f5 | |||
| 69c1c9d507 | |||
| b0aec3eaa9 | |||
| fe92997160 | |||
| 43019f2771 | |||
| c5035eb2a0 | |||
| a981028c5d | |||
| 7010f72a26 | |||
| a5875de32e | |||
| 52b04b17d1 | |||
| 812371dca4 | |||
| c1574160e8 | |||
| 6bd74a18f7 | |||
| 726861f933 | |||
| 8792cdc457 | |||
| d3ec9d9f6e | |||
| 0fe3a71627 | |||
| 577f289f81 | |||
| 3d855ab1eb | |||
| a0fff22e90 | |||
| dbe029e15e | |||
| b87f743615 | |||
| 61ff4a4fa2 | |||
| 07f6080451 | |||
| d420b9b741 | |||
| b216a74abf | |||
| 582c8d841c | |||
| a2e99dc4ec | |||
| da62913a93 | |||
| 952bc0b9c0 | |||
| b6668015e3 | |||
| cf4348e27e | |||
| 73939d3ba3 | |||
| cb353083aa | |||
| 40e74bd793 | |||
| 68086904a7 | |||
| 8351d850c5 | |||
| 426453612f | |||
| 9472cb5362 | |||
| 5584dcff12 | |||
| f6ea4d65d6 | |||
| 698ee33227 | |||
| 57c92f43c9 | |||
| 5ded3f9bd4 | |||
| abfda4d1db | |||
| 70cdbf3ceb | |||
| 4c340aed14 | |||
| 949618aecd | |||
| 36d8d48e90 | |||
| 0a44dfb474 | |||
| 25abee8930 | |||
| 54eebe5245 | |||
| e10e6a47c9 | |||
| 3b695e5d19 | |||
| 6c665f1624 | |||
| 75e5f69b33 | |||
| 4fc9d22ef7 | |||
| 216f9063bf | |||
| 40d57fd7ee | |||
| 0cb7ac19e4 | |||
| b3dec5ebf7 | |||
| 54708b93a4 | |||
| abe8f8e867 | |||
| e8d5da949c | |||
| a672ee861d | |||
| 180ab2bc6a | |||
| b620b68eec | |||
| 1d51e5f86d | |||
| 62f6d1cf0f | |||
| cbbfcc9828 | |||
| 56642c4150 | |||
| 2b30c06582 | |||
| 6ba16dd08e | |||
| 1b412798ef | |||
| de22cc6971 | |||
| 306468b4fd | |||
| c040b29819 | |||
| 1240af74fc | |||
| 08c92a940a | |||
| d4ca95de1a | |||
| ce8c618eba | |||
| 3a33df5d9b | |||
| a617feb269 | |||
| 2dccc72f5c | |||
| d7f844563b | |||
| 2eadc2835c | |||
| 6c3a379786 | |||
| ee715162ac | |||
| 02059ea607 | |||
| 69c9f87080 | |||
| b5c58f062d | |||
| c8bcabe804 | |||
| 8881491e18 | |||
| f6ac3ed818 | |||
| cba35c6a02 | |||
| ec7abdd760 | |||
| 2c5cc957c8 | |||
| 1810d11983 | |||
| f261a88e6e | |||
| a5de6de778 | |||
| e1e39d365b | |||
| d20d7a683c | |||
| 8f754d9467 | |||
| f0633dba46 | |||
| aad3b775cd | |||
| 5e1b577b34 | |||
| 4151525acf | |||
| c6427bf866 | |||
| 7b33a6d977 | |||
| 5d32173d7a | |||
| 480ce55117 | |||
| 7aa8aca4b5 | |||
| f88353f725 | |||
| 420817a849 | |||
| ed1677e5ec | |||
| 595c5e1eaa | |||
| 8fed96c6cf | |||
| 264314bd17 | |||
| 85afbe8e69 | |||
| 89bf8f90c3 | |||
| bb2dc447ac | |||
| 6efd7dca8c | |||
| 91641b9a95 | |||
| c47ac97c2b | |||
| fe4a200927 | |||
| 2a24565848 | |||
| 73b59856bf | |||
| c5f36887a1 | |||
| 328bc6229b | |||
| 8eb4c6156d | |||
| 34a9946959 | |||
| 6406b85a7e | |||
| bd6bd22478 | |||
| cf87a6770d | |||
| 10992a61a3 | |||
| 75fb9b020a | |||
| 44dfc1a122 | |||
| 38f9d70df2 | |||
| 090af0e97c | |||
| 02d936e798 | |||
| 2a5dc21afa | |||
| 2acf980da0 | |||
| 8675b9c20b | |||
| 796ce42576 | |||
| 649c754a78 | |||
| be0dfaf734 | |||
| ed59c9f8b5 | |||
| 3fa6f76de0 | |||
| 0de1a4d01b | |||
| 9f50a145aa | |||
| eb6c74f3a3 | |||
| d649989fc2 | |||
| b6319af4e0 | |||
| 90d1296d78 | |||
| 663e29113d | |||
| ec349f3714 | |||
| 6cecd9bac8 | |||
| 420c97704f | |||
| ac45080fd9 | |||
| 0ed3968f99 | |||
| eaa8574c03 | |||
| e22c2e7647 | |||
| 8b905f0080 | |||
| ec26913e65 | |||
| 120c0ce801 | |||
| 4fc8688837 | |||
| 29e684a65a | |||
| 6f26a59ca3 | |||
| f34ecf29e2 | |||
| bc75dcb138 | |||
| fbb4e90bd5 | |||
| 9c75282b59 | |||
| 07929eeeb5 | |||
| fee7653386 | |||
| ff33221867 | |||
| 4df0de7ce2 | |||
| 1b70e6500c | |||
| 3609c80cbc | |||
| 37db709b64 | |||
| fe92fefe5c | |||
| c0746b7300 | |||
| 47d3c17570 | |||
| fcfabd0980 | |||
| f50b5564fb | |||
| 92812c7f42 | |||
| 0074669d50 | |||
| 46c82ef0b7 | |||
| ecf1ae1f32 | |||
| 5b4ed5facc | |||
| 9e5bc8cdfc | |||
| ab1ee0c26c | |||
| cfd767e772 | |||
| 4512bbdbe5 | |||
| f578f2b495 | |||
| d9c4363205 | |||
| a02f45260b | |||
| 6bff7bfaa3 | |||
| 616e98dbd7 | |||
| 6241b27959 | |||
| da8c75f1aa | |||
| b5f48389b7 | |||
| 347f88ce0a | |||
| 8a9295c03a | |||
| 8cc4e42ff1 | |||
| 43ec2d106e | |||
| 4291c4999e | |||
| 68b7426fe9 | |||
| 274b0b055d | |||
| 30c9de86f1 | |||
| a613862b9e | |||
| 07725c01c9 | |||
| 54434e8066 | |||
| 3123fd374e | |||
| 608d9e6598 | |||
| 9380e27a8f | |||
| 2d79c3e1ad | |||
| 068ff8ec54 | |||
| b22aa29547 | |||
| f11bc865b5 | |||
| 6d3773794d | |||
| 93d567710f | |||
| bc230f753e | |||
| 909f43ff75 | |||
| 1ef16a42ff | |||
| e94d6a4e67 | |||
| eeb9e796fa | |||
| 302683b7ac | |||
| 2b3d6cc38a | |||
| 362d53f4c7 | |||
| 44fa4bbe35 | |||
| c70fb28d2d | |||
| d4883d9228 | |||
| 5ae2a41c1b | |||
| 37356851c2 | |||
| 3588582e85 | |||
| 1ae42bf3fd | |||
| e109194340 | |||
| d2d14a27c2 | |||
| a8a179185d | |||
| 58d63fd30a | |||
| c9dcec9a44 | |||
| 45a7a18b0d | |||
| a7822611a2 | |||
| 27bda397f9 | |||
| ec4ea435c9 | |||
| c529c3354f | |||
| 6cbb6209c4 | |||
| 653f8f85e1 | |||
| c2b92ad5a4 | |||
| e24d832cf0 | |||
| 6545e898bb | |||
| 99133f5d3e | |||
| c9e4925850 | |||
| f46999a2f6 | |||
| 7f6256c7bd | |||
| 2cd270e7e3 | |||
| 23852e53a6 | |||
| b32c8d632d | |||
| 1d211dd118 | |||
| 57a4272a92 | |||
| b856af7b7e | |||
| 3893718c39 | |||
| a95a339c0c | |||
| 5fe34eb1c9 | |||
| 7f67ebcf8c | |||
| 2dae9fdd37 | |||
| 98ea153d0b | |||
| 217aabb1f5 | |||
| 05bf1616be | |||
| 32274d79f3 | |||
| cda8bd55c2 | |||
| a6054ccf88 | |||
| abe91b71e6 | |||
| ec205ebe24 | |||
| 2816a85457 | |||
| 4773b2e406 | |||
| e945d1952c | |||
| c81d01d592 | |||
| 9311933b48 | |||
| e9bb8ee05c | |||
| f0d95f7b82 | |||
| c8cbaf58a9 | |||
| 8cee6af44a | |||
| e82e0b0bec | |||
| 29b139fb03 | |||
| 55929d0c3c | |||
| f5e4048158 | |||
| e13df784e0 | |||
| 71ec3d3548 | |||
| 2e9eaf8fef | |||
| 1c17d6334f | |||
| 579373642c | |||
| 95264a371d | |||
| b8eee45c9c | |||
| 5b4c7f3729 | |||
| 3ccbcd2023 | |||
| fa465630ab | |||
| 108f037818 | |||
| 894f2166ae | |||
| a39dbed851 | |||
| 3c9eb791b9 | |||
| 7fb34525cc | |||
| 367a4592c5 | |||
| c3a80af469 | |||
| 3dccde9e7e | |||
| b8bbee6a33 | |||
| c083e35672 | |||
| 316ed27938 | |||
| a85327bd25 | |||
| 9ca8e9e8bf | |||
| 93a2f181e5 | |||
| 9dac4cbc44 | |||
| d87b3a79e5 | |||
| dd0a8bd306 | |||
| bfae7745a6 | |||
| 6187afad71 | |||
| 6d1322b2b7 | |||
| a1890e22cb | |||
| 480d780701 | |||
| bc7fe27eb5 | |||
| c67797e72d | |||
|
|
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 | |||
|
|
12076f4cbb | ||
| 30a00eb348 | |||
| 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 = ""
|
||||
@@ -0,0 +1,134 @@
|
||||
# Fix 06: improve player loading and recovery states
|
||||
|
||||
Priority: P1
|
||||
Risk: Low to medium
|
||||
Primary benefit: Perceived performance, accessibility, and error recovery
|
||||
|
||||
## Issue
|
||||
|
||||
The player displays an animated spinner over a black frame while loading. During the audited cold start, this state lasted almost ten seconds. The spinner does not explain whether the application is finding a source, loading metadata, buffering media, retrying, or stuck. It also lacks a useful live status for assistive technology.
|
||||
|
||||
Several player buttons also appeared without accessible names in the browser accessibility tree.
|
||||
|
||||
## Goal
|
||||
|
||||
Make waits understandable without adding noisy progress theater. The loading UI should expose a small state machine, announce meaningful changes, and always provide a recovery path.
|
||||
|
||||
## Proposed state model
|
||||
|
||||
```text
|
||||
idle
|
||||
resolving_source
|
||||
loading_media
|
||||
buffering
|
||||
retrying
|
||||
ready
|
||||
unavailable
|
||||
```
|
||||
|
||||
State meanings:
|
||||
|
||||
- `resolving_source`: waiting for the episode API/provider result.
|
||||
- `loading_media`: source is known; waiting for manifest/metadata.
|
||||
- `buffering`: metadata exists but playback has stalled.
|
||||
- `retrying`: refreshing the source after a media error.
|
||||
- `unavailable`: all bounded attempts failed.
|
||||
- `ready`: overlay hidden.
|
||||
|
||||
Do not attempt to show a fake percentage. The application does not know enough to estimate completion accurately.
|
||||
|
||||
## Visual behavior
|
||||
|
||||
- Show the spinner immediately to acknowledge the action.
|
||||
- Keep text visually quiet for short waits.
|
||||
- After roughly 3 seconds, show a short status such as “Loading video…” or “Finding a source…”.
|
||||
- After roughly 8-10 seconds, show “This is taking longer than usual” and a Retry action.
|
||||
- On terminal failure, show Retry and Back to details.
|
||||
- Keep episode title/context visible where possible.
|
||||
- Avoid replacing the whole page; recovery stays inside the player.
|
||||
|
||||
## Accessibility behavior
|
||||
|
||||
Use a stable status element inside the player:
|
||||
|
||||
```html
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<span data-loading-message>Loading video…</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Do not announce every buffering event; debounce announcements to avoid screen-reader noise.
|
||||
- Use `aria-busy="true"` on the player container while a transition is active.
|
||||
- Move focus only for terminal errors, not normal loading.
|
||||
- Give play/pause, mute, fullscreen, seek, and navigation buttons stable accessible names.
|
||||
- Ensure disabled controls expose native `disabled` or an equivalent semantic state.
|
||||
|
||||
## State ownership
|
||||
|
||||
Centralize overlay changes in one module/function. Currently media event handlers manipulate `loading.style.display` directly in several places. Replace scattered writes with a function such as `setPlayerLoadState(state, options)`.
|
||||
|
||||
That function should:
|
||||
|
||||
- Update `data-load-state` on the player root.
|
||||
- Toggle visibility.
|
||||
- Set `aria-busy`.
|
||||
- Update status copy.
|
||||
- Start/cancel the long-wait timer.
|
||||
- Expose Retry only in retryable states.
|
||||
|
||||
## Retry policy
|
||||
|
||||
- Episode payload failure: one ordinary fallback navigation, unless already on that URL.
|
||||
- Media source error: refresh the current mode source once.
|
||||
- Repeated media failure: stop automatic retries and show Retry.
|
||||
- User Retry: force source refresh, reset media element, and reload.
|
||||
- Never create an infinite retry loop.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `templates/components/video_player.gohtml`
|
||||
- `static/player/main.ts`
|
||||
- `static/player/source.ts`
|
||||
- `static/player/video.ts`
|
||||
- `static/player/state.ts`
|
||||
- `static/player/controls.ts`
|
||||
- Player browser-flow tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Loader appears immediately on source change.
|
||||
- Status text appears only after the delay.
|
||||
- Ready metadata hides the loader and clears timers.
|
||||
- Waiting after ready enters buffered/loading state without repeated announcements.
|
||||
- One media error attempts one refresh.
|
||||
- Repeated failure exposes Retry and stops looping.
|
||||
- Retry performs a forced source refresh.
|
||||
- Every icon-only control has an accessible name.
|
||||
- `aria-busy` accurately follows transition state.
|
||||
|
||||
## Observability
|
||||
|
||||
Record client-side phase durations without sensitive URLs:
|
||||
|
||||
- Source resolution.
|
||||
- Media metadata wait.
|
||||
- Buffering count and duration.
|
||||
- Retry count.
|
||||
- Terminal unavailable state.
|
||||
|
||||
The existing HLS profiling hook can remain for HLS-specific detail; add source-independent timing for direct media too.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ship the centralized state function and accessible labels first without changing retry counts. Then enable delayed copy and user Retry. This separates semantic/visual regressions from source-refresh behavior changes. Verify with direct media and HLS sources.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No indefinite spinner exists without text or a recovery action.
|
||||
- A useful status appears after 3 seconds.
|
||||
- Retry appears after the long-wait threshold or a terminal failure.
|
||||
- Player root exposes `aria-busy` during loading.
|
||||
- All icon-only player controls have accessible names.
|
||||
- Retry loops are bounded.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Fix 07: represent recommendation refresh state
|
||||
|
||||
Priority: P1
|
||||
Risk: Medium
|
||||
Primary benefit: Honest background behavior and automatic completion
|
||||
|
||||
## Issue
|
||||
|
||||
Recommendation computation already runs in the background, which is the right architecture. The cache contract loses an important distinction, however:
|
||||
|
||||
- The user has no usable watchlist signal.
|
||||
- Recommendations are currently being computed.
|
||||
- Computation failed.
|
||||
- Stale recommendations exist while refresh runs.
|
||||
|
||||
On a cold cache, `getCachedTopPicksForYou` starts a goroutine and immediately returns an empty anime slice. The UI therefore renders “No top picks yet—add a few anime” even when the user just added an anime and a roughly ten-second computation is active. Results appear only after a manual reload.
|
||||
|
||||
## Goal
|
||||
|
||||
Preserve asynchronous computation while returning an explicit state that lets the UI show the truth and update automatically.
|
||||
|
||||
## Proposed domain model
|
||||
|
||||
Add a typed state to recommendation results:
|
||||
|
||||
```text
|
||||
empty no usable signals after a completed computation
|
||||
refreshing no result yet; computation in progress
|
||||
ready current result available
|
||||
stale previous result shown while refresh runs
|
||||
failed no result and the latest computation failed
|
||||
```
|
||||
|
||||
Include only the minimum supporting metadata needed by the UI, such as `retryAfter` or `updatedAt`. Do not expose internal provider errors.
|
||||
|
||||
## Cache behavior
|
||||
|
||||
### First request, no entry
|
||||
|
||||
1. Create an entry with `refreshing=true`.
|
||||
2. Start the background computation.
|
||||
3. Return `refreshing` with no items.
|
||||
|
||||
### Fresh data
|
||||
|
||||
Return `ready` immediately.
|
||||
|
||||
### Expired data
|
||||
|
||||
1. Preserve the old data.
|
||||
2. Mark refresh in progress.
|
||||
3. Return `stale` with old items.
|
||||
4. Refresh in the background.
|
||||
|
||||
### Watchlist mutation
|
||||
|
||||
Do not delete the cache entry. Mark it stale and start refresh immediately. This preserves existing recommendations while the new profile is computed.
|
||||
|
||||
### Completed empty result
|
||||
|
||||
Store a completed `empty` result so every request does not restart computation.
|
||||
|
||||
### Failure
|
||||
|
||||
- With stale data: keep serving stale and record refresh failure.
|
||||
- Without data: return `failed` with Retry.
|
||||
- Use bounded retry/backoff; do not restart on every poll.
|
||||
|
||||
## HTMX update flow
|
||||
|
||||
The simplest UI is an outer fragment that polls only while state is `refreshing`:
|
||||
|
||||
```html
|
||||
<section
|
||||
hx-get="/api/catalog/top-pick"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="outerHTML"
|
||||
aria-busy="true">
|
||||
<p role="status">Preparing recommendations…</p>
|
||||
</section>
|
||||
```
|
||||
|
||||
When the server returns `ready`, `stale`, `empty`, or `failed`, render markup without the polling trigger. Replacing the polling element naturally stops polling. HTTP status `286` can also stop polling, but removing the trigger from the final fragment is easier to inspect and does not depend on status-specific behavior.
|
||||
|
||||
Use a two-second interval rather than aggressive sub-second polling. The job takes seconds and does not need real-time granularity.
|
||||
|
||||
For the full Top Picks page, use the same state-aware fragment or a small page-specific status endpoint. Avoid duplicating state rules between home and `/top-picks`.
|
||||
|
||||
## Request synchronization
|
||||
|
||||
Ensure one client poll is active at a time. If a poll response can exceed the interval, use an HTMX synchronization policy or a longer interval. Server-side cache locking/singleflight remains authoritative; client synchronization is only a traffic guard.
|
||||
|
||||
## Copy
|
||||
|
||||
- Refreshing with no data: “Preparing recommendations…”
|
||||
- Stale: keep cards visible; optional subtle “Updating…” text.
|
||||
- Completed empty: current “Add a few anime…” guidance.
|
||||
- Failed with no data: “Recommendations could not be prepared.” plus Retry.
|
||||
|
||||
Do not show provider or scoring implementation details to the user.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/anime/recommendations.go`
|
||||
- Recommendation/cache domain types
|
||||
- Recommendation invalidator interface and watchlist integration
|
||||
- `internal/anime/catalog_handler.go`
|
||||
- `templates/index.gohtml`
|
||||
- `templates/top_picks.gohtml`
|
||||
- Recommendation and handler tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Cold cache returns `refreshing`, not `empty`.
|
||||
- Completed empty computation returns `empty` and is cached.
|
||||
- Fresh data returns `ready`.
|
||||
- Expired data returns stale cards and starts exactly one refresh.
|
||||
- Watchlist mutation preserves stale cards and starts refresh immediately.
|
||||
- Failure preserves stale data when available.
|
||||
- Final fragments do not continue polling.
|
||||
- Home and Top Picks use the same state rules.
|
||||
|
||||
## Observability
|
||||
|
||||
Track:
|
||||
|
||||
- Refresh started/completed/failed.
|
||||
- Refresh duration.
|
||||
- Ready/stale/refreshing/empty responses.
|
||||
- Poll count per refresh.
|
||||
- Number of refreshes joined or suppressed.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ship the state contract, server rendering, and templates together. During rollout, tolerate missing/zero state as `empty` only for compatibility with old tests; remove that fallback after all call sites are migrated.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A cold recommendation build never displays the completed-empty message.
|
||||
- Results appear without manual page reload.
|
||||
- Existing cards remain visible during refresh.
|
||||
- Exactly one computation runs per cache key.
|
||||
- Polling stops after a terminal state.
|
||||
- Failure has a visible bounded Retry path.
|
||||
|
||||
## Reference
|
||||
|
||||
- [HTMX polling and progress pattern](https://github.com/bigskysoftware/htmx/blob/v2.0.4/www/content/examples/progress-bar.md)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Fix 08: cache Simulcast discovery
|
||||
|
||||
Priority: P2
|
||||
Risk: Low to medium
|
||||
Primary benefit: Simulcast page latency and provider load
|
||||
|
||||
## Issue
|
||||
|
||||
`HandleSimulcast` first calls `LatestAvailableSeason`, which requests the next calendar season from AllAnime. It then calls `GetSimulcast`, which requests the selected season. These calls are serial.
|
||||
|
||||
The audit measured 1.38s before the page was ready. Seasonal data changes slowly relative to page views, so repeated synchronous provider requests are unnecessary.
|
||||
|
||||
## Goal
|
||||
|
||||
Cache seasonal show lists and latest-season discovery, reuse the next-season lookup when it is also the selected season, and serve stale data while refresh occurs.
|
||||
|
||||
## Proposed cache
|
||||
|
||||
Use one shared seasonal cache keyed by:
|
||||
|
||||
```text
|
||||
normalized season | year
|
||||
```
|
||||
|
||||
The latest-season check should call the same cached seasonal fetch function. If it fetches next season, `GetSimulcast` can reuse that entry rather than issuing another provider request.
|
||||
|
||||
Suggested policy:
|
||||
|
||||
- Fresh TTL: 30 minutes.
|
||||
- Stale window: 6 hours.
|
||||
- Maximum entries: 40 seasons, comfortably covering active navigation without unbounded growth.
|
||||
- Empty next-season result: cache for 10 minutes, because availability can appear around season boundaries.
|
||||
- Provider error: serve stale data when available.
|
||||
|
||||
## Stale-while-refresh behavior
|
||||
|
||||
1. Fresh entry: return immediately.
|
||||
2. Stale entry: return stale immediately and start one background refresh.
|
||||
3. Missing entry: fetch synchronously because there is no usable page data.
|
||||
4. Failed missing fetch: preserve current error behavior.
|
||||
|
||||
Use singleflight keyed by season/year to collapse concurrent misses.
|
||||
|
||||
## Latest-season caching
|
||||
|
||||
The latest season is a derived value. It can either:
|
||||
|
||||
- Be cached separately for 30-60 minutes, or
|
||||
- Be recomputed cheaply from the shared next-season cache.
|
||||
|
||||
Prefer the second option so there is one source of truth. Around a calendar-season rollover, use a shorter empty-result TTL to discover newly published shows promptly.
|
||||
|
||||
## Data ownership
|
||||
|
||||
Cache provider show values before converting them into template/domain anime values. Clone slices on read so handlers cannot mutate cached storage accidentally.
|
||||
|
||||
An in-memory cache is sufficient initially. Persisting seasonal data in SQLite adds migration and invalidation complexity without evidence that restart cold starts are a major problem.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/anime/discovery.go`
|
||||
- `internal/anime/simulcast.go`
|
||||
- `internal/anime/catalog_handler.go`
|
||||
- Anime module wiring if a cache dependency is separated
|
||||
- Simulcast/cache tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Two requests for the same season call the provider once within TTL.
|
||||
- Latest-season lookup populates the shared seasonal cache.
|
||||
- Concurrent misses collapse into one provider call.
|
||||
- Stale data is served while one refresh runs.
|
||||
- Empty next season uses the shorter TTL.
|
||||
- Different season/year keys do not collide.
|
||||
- Cached slices are not mutable by callers.
|
||||
- Provider failure serves stale data when available.
|
||||
|
||||
## Observability
|
||||
|
||||
Track seasonal cache hit, miss, stale-hit, refresh duration, provider error, and empty-next-season result. Do not log full provider payloads.
|
||||
|
||||
## Rollout
|
||||
|
||||
Ship in-memory caching without changing routes or templates. A configuration flag is optional but probably unnecessary because the fallback is simply cache miss behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Warm Simulcast requests do not call AllAnime.
|
||||
- Latest-season and selected-season requests reuse the same cached fetch path.
|
||||
- Warm page readiness is below 500ms locally and trends below 1 second at p75 in production.
|
||||
- Seasonal data refreshes within the chosen TTL.
|
||||
- Cache memory is bounded.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Fix 09: deduplicate canonical episode refreshes
|
||||
|
||||
Priority: P2
|
||||
Risk: Medium
|
||||
Primary benefit: Provider load and cold anime-detail completion
|
||||
|
||||
## Issue
|
||||
|
||||
The anime detail page loads release information and audio availability as independent HTMX fragments. Both can call `EpisodeService.GetCanonicalEpisodes` at nearly the same time.
|
||||
|
||||
During the cold audit, logs showed two episode refreshes for the same anime starting together. The Jikan layer collapsed part of its own refresh, but provider-ID resolution, availability checks, merging, and cache writes still ran twice.
|
||||
|
||||
## Goal
|
||||
|
||||
Allow independent fragments to remain asynchronous while ensuring only one canonical-episode refresh runs for a given anime and refresh mode.
|
||||
|
||||
## Where to deduplicate
|
||||
|
||||
Keep the existing fast cache checks outside the shared refresh. Only enter request collapsing after the service has decided that provider/Jikan refresh work is required.
|
||||
|
||||
Suggested key:
|
||||
|
||||
```text
|
||||
anime_id | refresh_policy
|
||||
```
|
||||
|
||||
At minimum distinguish ordinary refresh from an explicit force refresh. A force refresh must not silently join a request that is intentionally serving an existing fresh cache entry.
|
||||
|
||||
## Proposed flow
|
||||
|
||||
1. Validate anime input.
|
||||
2. Read the canonical episode cache.
|
||||
3. Return immediately when it is fresh and the caller did not force refresh.
|
||||
4. Enter a keyed singleflight refresh.
|
||||
5. Recheck the cache inside the shared function; another request may have completed between steps 2 and 4.
|
||||
6. Fetch Jikan/provider data once.
|
||||
7. Merge and write the canonical cache once.
|
||||
8. Return cloned result data to all waiting callers.
|
||||
|
||||
The second cache check is important. It prevents back-to-back refreshes caused by requests that both observed the same stale entry before either joined the shared call.
|
||||
|
||||
## Context and cancellation
|
||||
|
||||
Do not let one browser cancellation terminate useful work for all joined callers.
|
||||
|
||||
Recommended policy:
|
||||
|
||||
- Create a service-owned bounded refresh context using a strict timeout.
|
||||
- Preserve tracing/request values when safe, but detach individual request cancellation.
|
||||
- Let each waiting caller stop waiting when its own context is cancelled.
|
||||
- Allow the shared refresh to finish for the cache and remaining callers.
|
||||
- Stop all refreshes during application shutdown through a service lifecycle context if one is available.
|
||||
|
||||
Using the first caller's context directly is simpler but creates a fragile leader/follower relationship: closing one detail fragment can fail the other fragment's refresh.
|
||||
|
||||
## Result ownership
|
||||
|
||||
Canonical episode results contain slices. Return a clone to callers or treat the type as immutable by contract. Do not let one handler mutate data shared with another joined request or cache entry.
|
||||
|
||||
## Error behavior
|
||||
|
||||
- If refresh fails and stale canonical data is allowed by current policy, serve stale as today.
|
||||
- All joined callers should observe the same refresh error/result.
|
||||
- Do not negative-cache provider failures beyond the existing failure/backoff policy.
|
||||
- Preserve failure counters and next-refresh timestamps exactly once.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/episodes/service/service.go`
|
||||
- Optional helper under `internal/episodes/service/`
|
||||
- Episode service constructor/state
|
||||
- Episode service concurrency tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Two concurrent ordinary refreshes call Jikan/provider once.
|
||||
- Cache is rechecked inside the shared refresh.
|
||||
- Force and ordinary refresh policies do not collide incorrectly.
|
||||
- One caller cancellation does not cancel the refresh for another caller.
|
||||
- All waiters receive equivalent cloned results.
|
||||
- Failed refresh records failure once.
|
||||
- Stale fallback behavior remains unchanged.
|
||||
- Race detector passes under concurrent refresh tests.
|
||||
|
||||
Run the focused tests with `-race` because this change introduces shared state.
|
||||
|
||||
## Observability
|
||||
|
||||
Track:
|
||||
|
||||
- Refresh owner count.
|
||||
- Joined caller count.
|
||||
- Cache hit after joining/recheck.
|
||||
- Shared refresh duration and result.
|
||||
- Caller cancellation while waiting.
|
||||
|
||||
## Rollout
|
||||
|
||||
No route or template changes are required. Ship behind the service boundary, then verify that duplicate `episodes_refresh_start` events for one anime disappear under concurrent fragment loads.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Concurrent release-info and audio-availability loads trigger one canonical refresh.
|
||||
- No additional provider request occurs after the inner cache recheck.
|
||||
- Caller cancellation does not poison unrelated waiters.
|
||||
- Existing stale/failure semantics remain intact.
|
||||
- `go test -race ./internal/episodes/...` passes.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Fix 10: parallelize watch-data assembly
|
||||
|
||||
Priority: P2
|
||||
Risk: Medium
|
||||
Primary benefit: Watch-page server response time
|
||||
|
||||
## Issue
|
||||
|
||||
After anime metadata and canonical episodes are available, `BuildWatchData` performs several independent operations serially:
|
||||
|
||||
- Stream/mode-source resolution.
|
||||
- Watch progress and watchlist status lookup.
|
||||
- Season/relation loading.
|
||||
- Skip-segment lookup.
|
||||
|
||||
The audited watch page spent roughly 0.46s in the server on the first tested request. Media startup was the larger problem, but shortening server work makes the shell and source token available sooner.
|
||||
|
||||
## Goal
|
||||
|
||||
Run independent work concurrently without hiding errors, breaking cancellation, or increasing provider load uncontrollably.
|
||||
|
||||
## Dependency boundary
|
||||
|
||||
Keep these steps serial:
|
||||
|
||||
1. Fetch anime metadata.
|
||||
2. Ensure the local anime row.
|
||||
3. Build title candidates.
|
||||
4. Load canonical episodes.
|
||||
5. Resolve requested mode/fallback policy from episode availability.
|
||||
|
||||
After step 5, these operations can run concurrently:
|
||||
|
||||
- Resolve stream source for the requested/fallback mode.
|
||||
- Load watch progress/status.
|
||||
- Load seasons/relations.
|
||||
- Load skip-segment overrides.
|
||||
|
||||
Build the final payload only after all required results have joined.
|
||||
|
||||
## Error classification
|
||||
|
||||
Not every operation has the same severity:
|
||||
|
||||
- Stream resolution: required for playable success; preserve current no-stream error behavior.
|
||||
- Progress lookup: user-state concern; decide whether failure should fail the page or render from zero. Do not silently change existing semantics.
|
||||
- Seasons/relations: optional enhancement; current code already logs and returns an empty list on failure.
|
||||
- Skip segments: optional enhancement; current code logs and continues.
|
||||
|
||||
Represent these policies explicitly instead of relying on whichever goroutine returns first.
|
||||
|
||||
## Concurrency mechanism
|
||||
|
||||
Use a small fixed group of goroutines tied to the request context. Four operations are enough; do not introduce a generic worker pool.
|
||||
|
||||
Each goroutine writes only to its own result variable. Join before reading any result. Run the race detector.
|
||||
|
||||
If using an error group, do not let an optional seasons error cancel required stream resolution. Either:
|
||||
|
||||
- Handle optional errors inside their goroutines and return nil, or
|
||||
- Use separate result wrappers and join all operations before applying severity rules.
|
||||
|
||||
## Provider pressure
|
||||
|
||||
Stream resolution and season/relation loading may both touch external systems when caches are cold. Concurrency can reduce latency but increase burst pressure. Mitigations:
|
||||
|
||||
- Land Fix 05 source caching and Fix 09 deduplication first where practical.
|
||||
- Rely on existing Jikan rate limiting/cache behavior.
|
||||
- Keep concurrency bounded to the fixed set above.
|
||||
- Measure upstream retry/rate-limit events before and after rollout.
|
||||
|
||||
## Cancellation
|
||||
|
||||
- Request cancellation should stop all request-owned operations.
|
||||
- Background warm-up should remain separately bounded and should not inherit an already cancelled context.
|
||||
- Do not detach operations that are useful only for the current response.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/playback/watch_data.go`
|
||||
- Playback service tests
|
||||
- Optional observability timing helpers
|
||||
|
||||
## Tests
|
||||
|
||||
- All independent functions start before the slowest one completes.
|
||||
- Payload matches the previous serial implementation.
|
||||
- Required stream failure produces the same page error.
|
||||
- Optional season/segment failures continue with empty data and log once.
|
||||
- Request cancellation stops outstanding request work.
|
||||
- No result variable race occurs.
|
||||
- Existing mode fallback remains correct.
|
||||
|
||||
Use deterministic fake functions/channels rather than timing-sensitive sleeps in concurrency tests.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans/timers for:
|
||||
|
||||
- Anime metadata.
|
||||
- Canonical episodes.
|
||||
- Stream resolution.
|
||||
- Progress lookup.
|
||||
- Seasons lookup.
|
||||
- Segment lookup.
|
||||
- Total `BuildWatchData`.
|
||||
|
||||
The goal is to confirm that total time approaches the slowest parallel branch rather than the sum of all branches.
|
||||
|
||||
## Rollout
|
||||
|
||||
Refactor with behavior-preserving tests first. Ship after source caching if possible. Compare total watch-data duration, upstream request count, and rate-limit errors.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Independent branches overlap in tests and tracing.
|
||||
- Watch-page data is behaviorally identical.
|
||||
- Optional failures retain current fallback behavior.
|
||||
- Server watch-page duration decreases without increased provider error rate.
|
||||
- `go test -race ./internal/playback/...` passes.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Fix 11: make watchlist updates local-first
|
||||
|
||||
Priority: P2
|
||||
Risk: Low to medium
|
||||
Primary benefit: Existing-anime watchlist mutation latency
|
||||
|
||||
## Issue
|
||||
|
||||
`watchlistService.UpdateEntry` calls `GetAnimeByID` before entering the transaction for every update. It later checks whether the anime row already exists.
|
||||
|
||||
This means changing an existing watchlist entry can wait on Jikan even though all required anime data is already stored locally. During the audit, adding an uncached anime took roughly 0.59s on the server. The frontend hides much of this with optimistic UI, but unnecessary network dependency still increases failure and rollback risk.
|
||||
|
||||
## Goal
|
||||
|
||||
- Existing anime rows: update watchlist state using SQLite only.
|
||||
- Missing anime rows: fetch metadata, insert/upsert anime, then create the watchlist entry.
|
||||
- Preserve atomic watchlist writes and optimistic client rollback.
|
||||
|
||||
## Proposed flow
|
||||
|
||||
```text
|
||||
validate request
|
||||
-> read anime row
|
||||
-> exists: skip Jikan
|
||||
-> missing: fetch Jikan metadata
|
||||
-> transaction
|
||||
-> ensure anime row when fetched
|
||||
-> read existing watchlist progress
|
||||
-> upsert watchlist entry
|
||||
-> invalidate/start recommendation refresh
|
||||
```
|
||||
|
||||
## Transaction strategy
|
||||
|
||||
Perform the initial existence check outside the write transaction so a slow network request never holds a SQLite transaction open.
|
||||
|
||||
Inside the transaction:
|
||||
|
||||
- Recheck or use `INSERT ... ON CONFLICT` when metadata was fetched; another request may have inserted the anime meanwhile.
|
||||
- Preserve existing progress fields exactly as the current implementation does.
|
||||
- Upsert the watchlist entry atomically.
|
||||
|
||||
The preflight read is an optimization hint, not an integrity guarantee. Database constraints and the transaction remain authoritative.
|
||||
|
||||
## Missing-anime failure
|
||||
|
||||
If the anime row is missing and Jikan fetch fails, return a clear service error before attempting a foreign-key-invalid watchlist insert. The client should roll back its optimistic state and show the existing failure toast.
|
||||
|
||||
Do not create placeholder anime rows unless a separate product decision defines acceptable placeholder title/image semantics. The current schema requires meaningful non-null anime data.
|
||||
|
||||
## Existing-anime refresh policy
|
||||
|
||||
Skipping Jikan means local metadata may age. That is a separate concern from changing watchlist status. If metadata refresh is needed:
|
||||
|
||||
- Queue/best-effort refresh in the background after the mutation, or
|
||||
- Let existing catalog/detail refresh paths maintain metadata.
|
||||
|
||||
Do not keep the user action blocked solely to refresh display metadata.
|
||||
|
||||
## Recommendation invalidation
|
||||
|
||||
Invalidate or start recommendation refresh only after the transaction commits. Integrate with Fix 07 so existing recommendations become stale and refresh begins immediately.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/watchlist/service.go`
|
||||
- Watchlist repository interface only if a more direct existence query is added
|
||||
- Watchlist service tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Existing anime update does not call Jikan.
|
||||
- Missing anime fetches Jikan once and inserts anime before watchlist entry.
|
||||
- Concurrent missing-anime updates remain correct under conflict.
|
||||
- Missing anime plus provider failure returns a clear error and writes nothing.
|
||||
- Existing progress is preserved.
|
||||
- Recommendation invalidation occurs only after commit.
|
||||
- Transaction failure does not invalidate recommendations.
|
||||
|
||||
## Observability
|
||||
|
||||
Track local-hit versus metadata-fetch update paths and mutation duration. This should make it obvious whether most watchlist actions are DB-only.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is an internal service refactor with no contract change. Preserve the existing API and optimistic frontend. Verify mutation latency and rollback rate after release.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Status changes for known anime issue no external request.
|
||||
- Missing anime is inserted safely before the watchlist entry.
|
||||
- Failed metadata fetch writes nothing.
|
||||
- Existing progress remains unchanged.
|
||||
- Recommendation invalidation happens after a successful commit only.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Fix 12: reduce the Reviews payload
|
||||
|
||||
Priority: P2
|
||||
Risk: Medium
|
||||
Primary benefit: Initial HTML transfer, DOM size, and scanability
|
||||
|
||||
## Issue
|
||||
|
||||
The audited Reviews page took 1.20s and returned about 234KB of initial HTML. Every review body is rendered in full. One long review can dominate the viewport and response size before the user has chosen to read it.
|
||||
|
||||
HTTP compression from Fix 02 will produce a large immediate improvement because review text compresses well, but it will not reduce DOM size, layout work, or the visual wall of text.
|
||||
|
||||
## Goal
|
||||
|
||||
- Preserve access to full reviews.
|
||||
- Render a compact, useful initial list.
|
||||
- Fetch a full review body only when requested.
|
||||
- Keep pagination/infinite loading intact.
|
||||
- Preserve server-side HTML escaping and accessibility.
|
||||
|
||||
## Recommended design
|
||||
|
||||
Render every review card returned by the upstream page, but render only a bounded preview body initially.
|
||||
|
||||
Each card includes:
|
||||
|
||||
- Existing author/date/score/tags/reactions.
|
||||
- Review preview, for example 900-1200 Unicode characters.
|
||||
- “Read more” button only when truncated.
|
||||
- Stable review ID and source page in the expansion request.
|
||||
|
||||
When expanded, HTMX requests a server-rendered full-body fragment and swaps only that card's body. The full body can include a “Collapse” action that restores the preview locally or requests the preview fragment.
|
||||
|
||||
Do not use a CSS line clamp alone: it improves appearance but still transfers and lays out the full text.
|
||||
|
||||
## Preview generation
|
||||
|
||||
Generate previews on the server using Unicode runes, not byte slicing. Prefer these rules:
|
||||
|
||||
1. Normalize only presentation-safe whitespace; preserve paragraph intent.
|
||||
2. Stop near the configured limit at a word boundary.
|
||||
3. Add an ellipsis only when truncated.
|
||||
4. Keep the original full review unchanged in the domain/provider cache.
|
||||
5. Return `IsTruncated` explicitly to the template.
|
||||
|
||||
Avoid summarization or rewriting. The preview must be a faithful prefix of the user's review.
|
||||
|
||||
## Full-review endpoint
|
||||
|
||||
One possible route:
|
||||
|
||||
```text
|
||||
GET /anime/:animeID/reviews/:reviewID?source_page=N
|
||||
```
|
||||
|
||||
The handler:
|
||||
|
||||
1. Validates anime ID, review ID, and source page.
|
||||
2. Loads the relevant Jikan review page through the existing cache.
|
||||
3. Finds the exact review ID.
|
||||
4. Renders a full-review-body fragment.
|
||||
5. Returns `404` if the review is absent instead of guessing another page.
|
||||
|
||||
The source page parameter prevents scanning every upstream page. Do not trust it without validating that the returned review ID matches.
|
||||
|
||||
An even simpler alternative is linking to the existing external review URL. Use that only if leaving the application is acceptable product behavior.
|
||||
|
||||
## Pagination behavior
|
||||
|
||||
Continue rendering every review metadata card from each upstream page, so no reviews are skipped. The existing intersection sentinel can continue loading subsequent pages. Those appended cards should also use previews.
|
||||
|
||||
Prevent repeated sentinel requests with the current `once` behavior and preserve an explicit failure state if the next page cannot load.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Use a real button for Read more/Collapse.
|
||||
- Set `aria-expanded` and `aria-controls` against a stable review-body ID.
|
||||
- After expansion, keep focus on the same control or move it only if necessary.
|
||||
- Do not hide spoiler labels when truncating.
|
||||
- Expanded text remains ordinary readable HTML text, not a modal.
|
||||
|
||||
## Caching
|
||||
|
||||
- Initial and expansion handlers should use the existing Jikan cache.
|
||||
- Do not add shared caching of user-specific page chrome.
|
||||
- Compression applies to both full pages and fragments.
|
||||
- A small server-side preview computation needs no separate cache unless profiling proves otherwise.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `internal/anime/reviews_handler.go`
|
||||
- `internal/anime/service.go` or a presentation mapper
|
||||
- Review display/domain types
|
||||
- `templates/reviews.gohtml`
|
||||
- Anime handler registration
|
||||
- Review handler/template tests
|
||||
|
||||
## Tests
|
||||
|
||||
- Short review renders fully without Read more.
|
||||
- Long review truncates at a valid Unicode/word boundary.
|
||||
- Preview is a faithful prefix.
|
||||
- Full endpoint returns the exact review by ID and page.
|
||||
- Wrong review/page combination returns `404`.
|
||||
- HTMX fragment contains full escaped text.
|
||||
- Appended pagination cards also use previews.
|
||||
- Spoiler/preliminary labels and reactions remain intact.
|
||||
- `aria-expanded`/`aria-controls` are correct.
|
||||
|
||||
## Observability
|
||||
|
||||
Track initial Reviews response bytes, server duration, expansion requests, and next-page failures. Do not log review text.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Ship HTTP compression first.
|
||||
2. Add preview fields and templates behind a conservative preview length.
|
||||
3. Add full expansion endpoint and browser verification.
|
||||
4. Adjust preview length from real usage, not solely byte targets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Initial uncompressed Reviews HTML is below 100KB for the audited anime.
|
||||
- Full review text remains available on demand.
|
||||
- No review is skipped because of previewing.
|
||||
- Expansion requires no full page reload.
|
||||
- Unicode text, spoiler state, and keyboard behavior remain correct.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Frontend performance fix pack
|
||||
|
||||
This folder turns the findings in [`../audit.md`](../audit.md) into implementation-ready plans. Each plan explains the problem, the proposed design, affected code, edge cases, tests, rollout strategy, observability, and acceptance criteria.
|
||||
|
||||
These are planning documents only. No application source has been changed.
|
||||
|
||||
## Recommended order
|
||||
|
||||
| Order | Priority | Fix | Expected outcome | Depends on |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | P0 | [Minify frontend bundles](01-minify-frontend-bundles.md) | Smaller JavaScript, especially on the watch page | None |
|
||||
| 2 | P0 | [Compress and cache static responses](02-static-compression-and-caching.md) | Avoid transferring 1.16MB of raw player JavaScript and repeated revalidation | Fix 1 recommended |
|
||||
| 3 | P1 | [Optimize logo and icon assets](03-image-asset-optimization.md) | Remove roughly 500KB of oversized first-load images | None |
|
||||
| 4 | P0 | [Make manual episode changes asynchronous](04-async-episode-navigation.md) | Remove full-page reloads between episodes | None |
|
||||
| 5 | P0 | [Cache and prefetch playback sources](05-playback-source-cache-and-prefetch.md) | Reduce time from player shell to playable video | Fix 4 recommended |
|
||||
| 6 | P1 | [Improve player loading and recovery states](06-player-loading-and-recovery.md) | Make unavoidable waits understandable and accessible | Can ship independently |
|
||||
| 7 | P1 | [Represent recommendation refresh state](07-recommendation-refresh-state.md) | Stop showing a false empty state during background work | None |
|
||||
| 8 | P2 | [Cache Simulcast discovery](08-simulcast-cache.md) | Remove repeated serial provider requests | None |
|
||||
| 9 | P2 | [Deduplicate canonical episode refreshes](09-canonical-episode-singleflight.md) | Prevent duplicate provider work on anime details | None |
|
||||
| 10 | P2 | [Parallelize watch-data assembly](10-parallel-watch-data-assembly.md) | Reduce watch-page server time without changing behavior | Fix 9 recommended |
|
||||
| 11 | P2 | [Make watchlist updates local-first](11-watchlist-local-first-update.md) | Keep existing-anime status changes DB-only | None |
|
||||
| 12 | P2 | [Reduce Reviews payload](12-reviews-payload-reduction.md) | Lower initial HTML size and rendering cost | Fix 2 recommended |
|
||||
|
||||
## Delivery slices
|
||||
|
||||
### Slice A: low-risk transfer wins
|
||||
|
||||
Ship fixes 1-3 together. They do not change product behavior and should be the safest first release. Capture bundle sizes and response headers before and after deployment.
|
||||
|
||||
### Slice B: playback responsiveness
|
||||
|
||||
Ship fix 4 first, then fix 5. Fix 6 can ship with either. Keep ordinary page navigation as a fallback until asynchronous transitions have proven reliable.
|
||||
|
||||
### Slice C: honest background work
|
||||
|
||||
Ship fix 7. It changes recommendation-state semantics, the handler contract, and the rendered UI together.
|
||||
|
||||
### Slice D: provider and payload cleanup
|
||||
|
||||
Ship fixes 8-12 independently. They are smaller optimizations and should not block the P0 work.
|
||||
|
||||
## Shared performance gates
|
||||
|
||||
- Do not replace the current server-rendered architecture with a client-side router.
|
||||
- Keep every asynchronous action recoverable through an ordinary URL or retry action.
|
||||
- Bound all caches by item count and TTL.
|
||||
- Never cache user-specific HTML in shared static caches.
|
||||
- Preserve request cancellation through `context.Context` and `AbortController`.
|
||||
- Add measurements before optimizing so regressions are visible.
|
||||
- Validate on a throttled connection as well as localhost; localhost hides transfer and network latency.
|
||||
|
||||
## Suggested release targets
|
||||
|
||||
These targets are directional and should be confirmed under representative production conditions:
|
||||
|
||||
- Home catalog ready: under 1 second at p75.
|
||||
- Search results after debounce: under 500ms at p75.
|
||||
- Watch page shell: under 800ms at p75.
|
||||
- Warm episode change: under 1.5 seconds to playable at p75.
|
||||
- Cold episode start: under 4 seconds to playable at p75, with a useful status shown after 3 seconds.
|
||||
- Versioned static assets: no revalidation during their one-year cache lifetime.
|
||||
- Reviews initial HTML: under 100KB uncompressed.
|
||||
|
||||
@@ -2,7 +2,13 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
server
|
||||
main_server
|
||||
create_user
|
||||
*.log
|
||||
*.pid
|
||||
.DS_Store
|
||||
.git
|
||||
|
||||
@@ -25,7 +25,6 @@ jobs:
|
||||
http = false
|
||||
insecure = true
|
||||
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -53,11 +52,6 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Install Kustomize
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
sudo mv kustomize /usr/local/bin/
|
||||
|
||||
- name: Update Kustomize
|
||||
run: |
|
||||
IMAGE_TAG=$(echo '${{ steps.meta.outputs.json }}' | jq -r '.tags[] | select(startswith("reg.milasholsting.dk/apps/mal:sha-"))' | cut -d: -f2)
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,11 +5,13 @@ node_modules
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
dist/
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
|
||||
# logs
|
||||
logs
|
||||
|
||||
@@ -3,15 +3,58 @@ version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- cyclop
|
||||
- dogsled
|
||||
- dupl
|
||||
- errcheck
|
||||
- funlen
|
||||
- gocognit
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- govet
|
||||
- ineffassign
|
||||
- maintidx
|
||||
- makezero
|
||||
- nakedret
|
||||
- nilerr
|
||||
- noctx
|
||||
- prealloc
|
||||
- predeclared
|
||||
- revive
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- wastedassign
|
||||
- whitespace
|
||||
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:
|
||||
enable-all-rules: false
|
||||
rules:
|
||||
@@ -39,7 +82,7 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules$
|
||||
- node_modules/
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
|
||||
5
.mise.toml
Normal file
5
.mise.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[tools]
|
||||
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",
|
||||
"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",
|
||||
"plugins": ["typescript", "unicorn", "oxc"],
|
||||
"plugins": ["eslint", "import", "typescript", "unicorn", "oxc", "promise", "node"],
|
||||
"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": {
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-floating-promises": "off"
|
||||
"import/exports-last": "off",
|
||||
"import/group-exports": "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": {
|
||||
"builtin": true
|
||||
}
|
||||
"overrides": [
|
||||
{
|
||||
"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
|
||||
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
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -32,14 +29,11 @@ RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Ensure dist is clean at build time (belt + suspenders)
|
||||
RUN rm -rf dist/ && bun run build:assets
|
||||
|
||||
# Generate sqlc code
|
||||
RUN sqlc generate
|
||||
RUN rm -rf dist/ && bun run build:assets && bun run build:ts
|
||||
|
||||
# Build the server and CLI tools
|
||||
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
|
||||
|
||||
@@ -55,13 +49,15 @@ RUN mkdir -p /app/data
|
||||
ENV DATABASE_FILE=/app/data/mal.db
|
||||
|
||||
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/static ./static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
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
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
ENTRYPOINT ["/app/main_server"]
|
||||
|
||||
207
README.md
207
README.md
@@ -1,71 +1,200 @@
|
||||
# MyAnimeList
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/static/assets/readme-logo-dark.svg" />
|
||||
<img src="/static/assets/readme-logo-light.svg" alt="MyAnimeList logo" width="120" />
|
||||
</picture>
|
||||
<img src="/static/assets/logo-128.png" alt="MyAnimeList logo" width="120" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>A local-first anime catalog, watchlist, recommendation, and playback app.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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="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="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>
|
||||
|
||||
---
|
||||
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 |
|
||||
| ----------------- | ------------------------------------------------ |
|
||||
| `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 |
|
||||
### What This Project Is
|
||||
|
||||
## 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
|
||||
mise install
|
||||
bun install
|
||||
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
|
||||
gofmt -l .
|
||||
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 .
|
||||
echo "PLAYBACK_PROXY_SECRET=$(openssl rand -base64 32)" >> .env
|
||||
```
|
||||
|
||||
## 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>
|
||||
|
||||
### Public Watchlist JSON
|
||||
|
||||
`GET /api/public/users/{user_id}/watchlist` returns a versioned, agent-friendly JSON view of a
|
||||
user's complete watchlist. It includes status summaries, episode progress,
|
||||
added/completed/last-watched dates, and localized titles.
|
||||
|
||||
`GET /watchlist/export` downloads the current user's watchlist as `watchlist.json`.
|
||||
|
||||
This endpoint is intentionally unauthenticated. A deployment that should keep watchlist and
|
||||
playback data private must restrict access to `/api/public/` at the network or reverse-proxy layer.
|
||||
|
||||
### 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).
|
||||
|
||||
71
SECURITY.md
Normal file
71
SECURITY.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
|
||||
The read-only `/api/public/users/{user_id}/watchlist` endpoint intentionally exposes watchlist
|
||||
statuses, episode progress, and watchlist dates without authentication. Restrict `/api/public/` at
|
||||
the network or reverse-proxy layer when that data should not be public.
|
||||
|
||||
## 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.
|
||||
160
bun.lock
160
bun.lock
@@ -3,14 +3,14 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "myanimelist-ui",
|
||||
"name": "mal",
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16",
|
||||
"htmx.org": "1.9.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"jiti": "^2.7.0",
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.67.0",
|
||||
@@ -81,139 +81,147 @@
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw=="],
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.73.0", "", { "os": "android", "cpu": "arm" }, "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="],
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.73.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="],
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.73.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="],
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.73.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="],
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.73.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="],
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw=="],
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="],
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w=="],
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="],
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.73.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ=="],
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA=="],
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="],
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.73.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="],
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="],
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="],
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.73.0", "", { "os": "none", "cpu": "arm64" }, "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="],
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.73.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg=="],
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.73.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="],
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.73.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
||||
|
||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
|
||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
||||
|
||||
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
|
||||
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="],
|
||||
|
||||
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
|
||||
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="],
|
||||
|
||||
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
|
||||
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
|
||||
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="],
|
||||
|
||||
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
|
||||
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
|
||||
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="],
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
|
||||
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
|
||||
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="],
|
||||
|
||||
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
|
||||
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="],
|
||||
|
||||
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
|
||||
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="],
|
||||
|
||||
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
|
||||
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="],
|
||||
|
||||
"@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.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@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.2", "", { "dependencies": { "@parcel/watcher": "2.5.1", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "enhanced-resolve": "5.21.6", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.2" }, "bin": { "tailwindcss": "./dist/index.mjs" } }, "sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw=="],
|
||||
|
||||
"@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.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
|
||||
|
||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="],
|
||||
"lefthook": ["lefthook@2.1.9", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.9", "lefthook-darwin-x64": "2.1.9", "lefthook-freebsd-arm64": "2.1.9", "lefthook-freebsd-x64": "2.1.9", "lefthook-linux-arm64": "2.1.9", "lefthook-linux-x64": "2.1.9", "lefthook-openbsd-arm64": "2.1.9", "lefthook-openbsd-x64": "2.1.9", "lefthook-windows-arm64": "2.1.9", "lefthook-windows-x64": "2.1.9" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ=="],
|
||||
|
||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
|
||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg=="],
|
||||
|
||||
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ=="],
|
||||
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ=="],
|
||||
|
||||
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA=="],
|
||||
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.9", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA=="],
|
||||
|
||||
"lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg=="],
|
||||
"lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw=="],
|
||||
|
||||
"lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g=="],
|
||||
"lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg=="],
|
||||
|
||||
"lefthook-linux-x64": ["lefthook-linux-x64@2.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw=="],
|
||||
"lefthook-linux-x64": ["lefthook-linux-x64@2.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w=="],
|
||||
|
||||
"lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.6", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A=="],
|
||||
"lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.9", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw=="],
|
||||
|
||||
"lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg=="],
|
||||
"lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.9", "", { "os": "openbsd", "cpu": "x64" }, "sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA=="],
|
||||
|
||||
"lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw=="],
|
||||
"lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA=="],
|
||||
|
||||
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
|
||||
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.9", "", { "os": "win32", "cpu": "x64" }, "sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
@@ -241,42 +249,48 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="],
|
||||
|
||||
"oxlint": ["oxlint@1.67.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.67.0", "@oxlint/binding-android-arm64": "1.67.0", "@oxlint/binding-darwin-arm64": "1.67.0", "@oxlint/binding-darwin-x64": "1.67.0", "@oxlint/binding-freebsd-x64": "1.67.0", "@oxlint/binding-linux-arm-gnueabihf": "1.67.0", "@oxlint/binding-linux-arm-musleabihf": "1.67.0", "@oxlint/binding-linux-arm64-gnu": "1.67.0", "@oxlint/binding-linux-arm64-musl": "1.67.0", "@oxlint/binding-linux-ppc64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-gnu": "1.67.0", "@oxlint/binding-linux-riscv64-musl": "1.67.0", "@oxlint/binding-linux-s390x-gnu": "1.67.0", "@oxlint/binding-linux-x64-gnu": "1.67.0", "@oxlint/binding-linux-x64-musl": "1.67.0", "@oxlint/binding-openharmony-arm64": "1.67.0", "@oxlint/binding-win32-arm64-msvc": "1.67.0", "@oxlint/binding-win32-ia32-msvc": "1.67.0", "@oxlint/binding-win32-x64-msvc": "1.67.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ=="],
|
||||
"oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="],
|
||||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"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.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"mal/internal/app"
|
||||
"mal/internal"
|
||||
"mal/internal/observability"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"io"
|
||||
"mal/internal"
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/db"
|
||||
"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() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
observability.Error("cli_config_load_failed", "cmd/user", "", nil, err)
|
||||
if err := godotenv.Load(); err != nil {
|
||||
observability.Warn("env_file_load_failed", "user", "", nil, err)
|
||||
}
|
||||
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
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 {
|
||||
cmd, err := parseArgs(args)
|
||||
if err != nil {
|
||||
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(args)}, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, usage())
|
||||
return 2
|
||||
func run(args []string) error {
|
||||
if len(args) == 1 && args[0] == "run-fixes" {
|
||||
return runFixes()
|
||||
}
|
||||
|
||||
switch cmd.kind {
|
||||
case commandUpdateAvatar:
|
||||
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
|
||||
if len(args) != 1 && len(args) != 2 {
|
||||
return errors.New("usage: create-user <username> [password]")
|
||||
}
|
||||
}
|
||||
|
||||
type commandKind string
|
||||
|
||||
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) {
|
||||
username := strings.TrimSpace(args[0])
|
||||
password := ""
|
||||
if len(args) == 2 {
|
||||
switch args[1] {
|
||||
case string(commandUpdateAvatar):
|
||||
return command{kind: commandUpdateAvatar}, nil
|
||||
case string(commandRunFixes):
|
||||
return command{kind: commandRunFixes}, nil
|
||||
}
|
||||
password = args[1]
|
||||
}
|
||||
if username == "" {
|
||||
return errors.New("username must not be empty")
|
||||
}
|
||||
|
||||
if len(args) == 3 {
|
||||
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)
|
||||
sqlDB, err := openDatabase()
|
||||
if err != nil {
|
||||
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if existingID != "" {
|
||||
if !promptConfirmOverwrite(username) {
|
||||
fmt.Println("Operation cancelled.")
|
||||
return nil
|
||||
}
|
||||
if err := updateUserPassword(dbConn, existingID, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
||||
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
|
||||
return fmt.Errorf("prepare database: %w", err)
|
||||
}
|
||||
|
||||
return createOrUpdateUser(sqlDB, username, password)
|
||||
}
|
||||
|
||||
func runFixes() error {
|
||||
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
|
||||
}
|
||||
|
||||
if err := createUser(dbConn, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
||||
return nil
|
||||
return updateUserPassword(ctx, sqlDB, userID, username, password)
|
||||
}
|
||||
|
||||
func lookupUserID(dbConn *sql.DB, username string) (string, error) {
|
||||
var id string
|
||||
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)
|
||||
func createUser(ctx context.Context, sqlDB *sql.DB, username, password string) error {
|
||||
password, err := resolvePassword(password)
|
||||
if err != nil {
|
||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, 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 {
|
||||
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, 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
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
avatarURL := internal.DefaultAvatarURL(username)
|
||||
_, err = dbConn.Exec(
|
||||
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
|
||||
id,
|
||||
username,
|
||||
string(hash),
|
||||
avatarURL,
|
||||
_, err = sqlDB.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)`,
|
||||
uuid.NewString(), username, string(passwordHash), internal.DefaultAvatarURL(username),
|
||||
)
|
||||
if err != nil {
|
||||
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
fmt.Printf("Created user %q\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAvatars(dbConn *sql.DB) {
|
||||
rows, err := dbConn.Query("SELECT id, username FROM user")
|
||||
func updateUserPassword(ctx context.Context, sqlDB *sql.DB, userID, username, password string) error {
|
||||
password, err := resolvePassword(password)
|
||||
if err != nil {
|
||||
observability.Error("cli_users_list_failed", "cmd/user", "", nil, 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++
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
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) {
|
||||
if err := database.RunMigrationsAndFixes(dbConn); err != nil {
|
||||
observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
func resolvePassword(password string) (string, error) {
|
||||
if password != "" {
|
||||
return password, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var appliedAt string
|
||||
if err := rows.Scan(&id, &appliedAt); err != nil {
|
||||
observability.Error("cli_data_fix_scan_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("%s applied_at=%s\n", id, appliedAt)
|
||||
count++
|
||||
if len(passwordBytes) == 0 {
|
||||
return "", errors.New("password must not be empty")
|
||||
}
|
||||
return string(passwordBytes), nil
|
||||
}
|
||||
|
||||
func confirmPasswordUpdate(username string) (bool, error) {
|
||||
fmt.Printf("User %q already exists. Change password? [Y/n] ", username)
|
||||
answer, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return false, fmt.Errorf("read confirmation: %w", err)
|
||||
}
|
||||
|
||||
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 "$@"
|
||||
@@ -9,4 +9,4 @@ spec:
|
||||
destination:
|
||||
name: mal
|
||||
create: true
|
||||
refreshAfter: 1h
|
||||
refreshAfter: 1h
|
||||
|
||||
@@ -8,7 +8,7 @@ spec:
|
||||
app: mal
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: mal
|
||||
protocol: TCP
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
- name: mal
|
||||
protocol: TCP
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
|
||||
@@ -17,4 +17,4 @@ namespace: mal
|
||||
images:
|
||||
- name: main
|
||||
newName: reg.milasholsting.dk/apps/mal
|
||||
newTag: sha-6f3ca3e
|
||||
newTag: sha-2712775
|
||||
|
||||
@@ -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/pressly/goose/v3 v3.27.1
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -56,6 +57,6 @@ require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
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
|
||||
)
|
||||
|
||||
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.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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
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/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=
|
||||
@@ -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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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 document, response.Request.URL.String(), nil
|
||||
return document, response, nil
|
||||
}
|
||||
|
||||
type timetableAnimeAPI struct {
|
||||
|
||||
@@ -3,6 +3,9 @@ package jikan
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/internal/observability"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -38,10 +41,48 @@ func (c *Client) WarmAnimeRecommendations(id int) {
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
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.
|
||||
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
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) {
|
||||
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
|
||||
if c.getCache(ctx, cacheKey, &cached) && cached.MalID != 0 {
|
||||
return cached, nil
|
||||
@@ -95,6 +136,14 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
return anime, nil
|
||||
@@ -105,6 +154,8 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
|
||||
func (c *Client) refreshAnimeByIDAsync(id int) {
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
import "time"
|
||||
|
||||
// Cache TTLs used by the Jikan client for endpoint responses.
|
||||
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
|
||||
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
|
||||
const producerCacheTTL = time.Hour * 24 * 30
|
||||
@@ -5,34 +5,29 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
jcache "mal/integrations/jikan/cache"
|
||||
"mal/integrations/jikan/rate"
|
||||
jtransport "mal/integrations/jikan/transport"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
netutil "mal/pkg/net"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var traceEnabled bool
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
db db.Querier
|
||||
retrySignal chan struct{} // signals retry worker to process queued retries
|
||||
mu sync.Mutex
|
||||
lastReqTime time.Time // rate limiting: last request timestamp
|
||||
sf singleflight.Group
|
||||
refreshSem chan struct{}
|
||||
metrics *observability.Metrics
|
||||
baseURL string
|
||||
db db.Querier
|
||||
retrySignal chan struct{} // signals retry worker to process queued retries
|
||||
sf singleflight.Group
|
||||
refreshSem chan struct{}
|
||||
cache *jcache.Store
|
||||
fetcher *jtransport.Client
|
||||
traceEnabled bool
|
||||
|
||||
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
||||
randomPool []Anime
|
||||
@@ -42,132 +37,78 @@ type Client struct {
|
||||
|
||||
const jikanSlowLogThreshold = 750 * time.Millisecond
|
||||
|
||||
func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client {
|
||||
traceEnabled = cfg.JikanTrace
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.jikan.moe/v4",
|
||||
db: queries,
|
||||
metrics: metrics,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
refreshSem: make(chan struct{}, 4),
|
||||
randomPool: make([]Anime, 0),
|
||||
type APIError = jtransport.APIError
|
||||
|
||||
func NewClient(cfg config.Config, queries *db.Queries) *Client {
|
||||
limiter := rate.NewLimiter(400 * time.Millisecond)
|
||||
client := &Client{
|
||||
baseURL: "https://api.jikan.moe/v4",
|
||||
db: queries,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
refreshSem: make(chan struct{}, 4),
|
||||
cache: jcache.NewStore(queries),
|
||||
traceEnabled: cfg.JikanTrace,
|
||||
randomPool: make([]Anime, 0),
|
||||
}
|
||||
}
|
||||
client.fetcher = jtransport.NewClient(jtransport.Config{
|
||||
HTTPClient: jtransport.NewHTTPClient(),
|
||||
Limiter: limiter,
|
||||
TraceEnabled: client.jikanTraceEnabled,
|
||||
})
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
|
||||
return client
|
||||
}
|
||||
|
||||
// IsRetryableError returns true if the error should trigger a retry.
|
||||
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
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return isRetryableStatus(apiErr.StatusCode)
|
||||
if source == "fresh" {
|
||||
return duration < 50*time.Millisecond
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
if source == "refresh" {
|
||||
return duration < jikanSlowLogThreshold
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
func jikanCacheLogLevel(source string, err error) observability.LogLevel {
|
||||
if err != nil {
|
||||
return 0, false
|
||||
return observability.LogLevelError
|
||||
}
|
||||
|
||||
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 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" {
|
||||
if source != "fresh" && source != "refresh" {
|
||||
// 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(
|
||||
level,
|
||||
jikanCacheLogLevel(source, err),
|
||||
"jikan_cache",
|
||||
"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.
|
||||
func (c *Client) notifyRetryWorker() {
|
||||
select {
|
||||
@@ -239,121 +143,76 @@ func (c *Client) EnqueueAnimeFetchRetry(parentCtx context.Context, animeID int,
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
message := cause.Error()
|
||||
if len(message) > 400 {
|
||||
message = message[:400]
|
||||
}
|
||||
|
||||
err := c.db.EnqueueAnimeFetchRetry(ctx, db.EnqueueAnimeFetchRetryParams{
|
||||
AnimeID: int64(animeID),
|
||||
LastError: truncateErrorMessage(cause.Error()),
|
||||
LastError: message,
|
||||
})
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"jikan_retry_enqueue_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"anime_id": animeID},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
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
|
||||
return c.cache.Get(parentCtx, key, out)
|
||||
}
|
||||
|
||||
// getStaleCache retrieves expired-but-available cache by key.
|
||||
func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) bool {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
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
|
||||
return c.cache.GetStale(parentCtx, key, out)
|
||||
}
|
||||
|
||||
// setCache stores data in cache with specified TTL.
|
||||
func (c *Client) setCache(parentCtx context.Context, key string, data any, ttl time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
c.cache.Set(parentCtx, key, data, ttl)
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
||||
return c.fetcher.FetchWithRetry(ctx, urlStr, out)
|
||||
}
|
||||
|
||||
_ = c.db.SetJikanCache(ctx, db.SetJikanCacheParams{
|
||||
Key: key,
|
||||
Data: string(bytes),
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
})
|
||||
var emptyResultChecks = map[reflect.Type]func(any) bool{
|
||||
reflect.TypeFor[*TopAnimeResponse](): func(out any) bool {
|
||||
return len(out.(*TopAnimeResponse).Data) == 0
|
||||
},
|
||||
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.
|
||||
func isEmptyResult(out any) bool {
|
||||
switch v := out.(type) {
|
||||
case *TopAnimeResponse:
|
||||
return len(v.Data) == 0
|
||||
case *SearchResponse:
|
||||
return len(v.Data) == 0
|
||||
case *AnimeResponse:
|
||||
return v.Data.MalID == 0
|
||||
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
|
||||
if out == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
outType := reflect.TypeOf(out)
|
||||
if check, ok := emptyResultChecks[outType]; ok {
|
||||
return check(out)
|
||||
}
|
||||
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 {
|
||||
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 !isEmptyResult(out) {
|
||||
return json.Marshal(out)
|
||||
@@ -383,7 +242,7 @@ func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Don't cache empty results to avoid caching failures
|
||||
// Don't cache empty results to avoid caching failures.
|
||||
if isEmptyResult(out) {
|
||||
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 {
|
||||
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 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.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()
|
||||
if c.getCache(ctx, cacheKey, out) {
|
||||
if !isEmptyResult(out) {
|
||||
logJikanCache(cacheKey, "fresh", startedAt, nil)
|
||||
c.logJikanCache(cacheKey, "fresh", startedAt, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, out); err != nil {
|
||||
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
|
||||
}
|
||||
logJikanCache(cacheKey, "miss", startedAt, err)
|
||||
c.logJikanCache(cacheKey, "miss", startedAt, err)
|
||||
return err
|
||||
}
|
||||
|
||||
logJikanCache(cacheKey, "refresh", startedAt, nil)
|
||||
c.logJikanCache(cacheKey, "refresh", startedAt, 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"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -23,44 +22,19 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open 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)
|
||||
}
|
||||
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, observability.NewMetrics())
|
||||
client := NewClient(config.Config{}, queries)
|
||||
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
||||
staleBytes, err := json.Marshal(stale)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stale response: %v", err)
|
||||
}
|
||||
insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour))
|
||||
|
||||
_, err = sqlDB.Exec(
|
||||
`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{
|
||||
client.fetcher.HTTPClient = &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := `{"data":[{"mal_id":2,"title":"fresh"}]}`
|
||||
return &http.Response{
|
||||
@@ -78,11 +52,169 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
||||
if len(got.Data) != 1 || got.Data[0].Title != "stale" {
|
||||
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 TestGetJikanCacheStatsCountsRowsAndExpiry(t *testing.T) {
|
||||
sqlDB := newTestCacheDB(t)
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
oldest := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC)
|
||||
insertCachedResponse(t, sqlDB, "expired", TopAnimeResponse{Data: []Anime{{MalID: 1}}}, oldest)
|
||||
insertCachedResponse(t, sqlDB, "fresh", TopAnimeResponse{Data: []Anime{{MalID: 2}}}, time.Now().Add(time.Hour))
|
||||
|
||||
stats, err := db.New(sqlDB).GetJikanCacheStats(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetJikanCacheStats: %v", err)
|
||||
}
|
||||
if stats.TotalRows != 2 {
|
||||
t.Fatalf("TotalRows = %d, want 2", stats.TotalRows)
|
||||
}
|
||||
if stats.ExpiredRows != 1 {
|
||||
t.Fatalf("ExpiredRows = %d, want 1", stats.ExpiredRows)
|
||||
}
|
||||
if stats.OldestExpiresAtSeconds != oldest.Unix() {
|
||||
t.Fatalf("OldestExpiresAtSeconds = %d, want %d", stats.OldestExpiresAtSeconds, oldest.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
for time.Now().Before(deadline) {
|
||||
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
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -90,6 +222,8 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
||||
|
||||
var rawData 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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package jikan
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"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)
|
||||
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)
|
||||
return result, err
|
||||
|
||||
@@ -27,6 +27,21 @@ type ProducerListResult struct {
|
||||
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) {
|
||||
if 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) {
|
||||
q := strings.TrimSpace(query)
|
||||
cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit)
|
||||
reqURL := fmt.Sprintf("%s/producers?page=%d&limit=%d", c.baseURL, page, limit)
|
||||
if q != "" {
|
||||
reqURL += "&q=" + url.QueryEscape(q)
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
setQueryValue(params, "q", q)
|
||||
reqURL := buildRequestURL(c.baseURL, "/producers", params)
|
||||
|
||||
var result ProducersResponse
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,6 +21,22 @@ const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
|
||||
const watchOrderCacheTTL = time.Hour * 24
|
||||
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.
|
||||
func watchOrderTypeLabel(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
@@ -28,31 +45,25 @@ func watchOrderTypeLabel(value string) string {
|
||||
return "TV"
|
||||
case "movie":
|
||||
return "Movie"
|
||||
case "ona":
|
||||
return "ONA"
|
||||
case "ova":
|
||||
return "OVA"
|
||||
default:
|
||||
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) {
|
||||
cacheKey := relationCacheKey(id)
|
||||
cacheKey := fmt.Sprintf("relations:watch-order:%d", id)
|
||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.fetcher.HTTPClient, watchOrderURL)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
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) {
|
||||
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.
|
||||
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
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
@@ -148,84 +167,134 @@ func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEnt
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
|
||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
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,
|
||||
)
|
||||
func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) ([]RelationEntry, error) {
|
||||
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
type fetchResult struct {
|
||||
index int
|
||||
anime Anime
|
||||
entry watchorder.WatchOrderEntry
|
||||
}
|
||||
observability.Warn(
|
||||
"relations_watch_order_fallback_current_only",
|
||||
"jikan",
|
||||
"",
|
||||
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)
|
||||
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 {
|
||||
if len(allowedEntries) >= maxWatchOrderEntries {
|
||||
break
|
||||
}
|
||||
if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] {
|
||||
if seen[entry.ID] {
|
||||
continue
|
||||
}
|
||||
typ := strings.ToLower(strings.TrimSpace(entry.Type))
|
||||
if !allTypes && typ != "tv" && typ != "movie" {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[entry.ID] = true
|
||||
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.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 {
|
||||
anime, err := c.GetAnimeByID(gCtx, entry.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case results <- fetchResult{index: i, anime: anime, entry: entry}:
|
||||
case <-gCtx.Done():
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = g.Wait()
|
||||
if err := g.Wait(); err != nil {
|
||||
observability.Warn("relations_fetch_group_failed", "jikan", "", nil, err)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return fetched[i].index < fetched[j].index
|
||||
})
|
||||
|
||||
relations := make([]RelationEntry, 0, len(fetched)+1)
|
||||
for _, res := range fetched {
|
||||
return fetched
|
||||
}
|
||||
|
||||
func buildRelations(results []fetchResult, id int) []RelationEntry {
|
||||
relations := make([]RelationEntry, 0, len(results)+1)
|
||||
for _, res := range results {
|
||||
relations = append(relations, RelationEntry{
|
||||
Anime: res.anime,
|
||||
Relation: watchOrderTypeLabel(res.entry.Type),
|
||||
@@ -234,18 +303,46 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
})
|
||||
}
|
||||
|
||||
if !seen[id] {
|
||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return relations
|
||||
}
|
||||
|
||||
relations = append([]RelationEntry{{
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
}}, relations...)
|
||||
func (c *Client) ensureCurrent(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
|
||||
if seen[id] {
|
||||
return relations, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -257,6 +354,14 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
|
||||
func (c *Client) WarmFullRelations(id int) {
|
||||
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
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"mal/integrations/watchorder"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runBoolCases(t *testing.T, tests []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}, fn func(string) bool) {
|
||||
t.Helper()
|
||||
func TestNormalizeWatchOrderMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want WatchOrderMode
|
||||
}{
|
||||
{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 {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := fn(testCase.input)
|
||||
got := NormalizeWatchOrderMode(testCase.input)
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "tv", input: "tv", want: true},
|
||||
{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},
|
||||
func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) {
|
||||
result := watchorder.WatchOrderResult{
|
||||
WatchOrder: []watchorder.WatchOrderEntry{
|
||||
{ID: 1, Type: "TV"},
|
||||
{ID: 2, Type: "Special"},
|
||||
{ID: 3, Type: " Movie "},
|
||||
{ID: 4, Type: "ONA"},
|
||||
},
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -44,6 +111,8 @@ func TestWatchOrderTypeLabel(t *testing.T) {
|
||||
}{
|
||||
{name: "tv", input: "tv", want: "TV"},
|
||||
{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"},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
func normalizePage(page, limit int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -17,46 +16,47 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
||||
limit = 0
|
||||
}
|
||||
|
||||
genresParam := ""
|
||||
if len(genres) > 0 {
|
||||
ids := make([]string, len(genres))
|
||||
for i, g := range genres {
|
||||
ids[i] = strconv.Itoa(g)
|
||||
}
|
||||
genresParam = strings.Join(ids, ",")
|
||||
return page, limit
|
||||
}
|
||||
|
||||
func joinGenreIDs(genres []int) string {
|
||||
if len(genres) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
var result SearchResponse
|
||||
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
|
||||
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)
|
||||
}
|
||||
reqURL := advancedURL(c.baseURL, query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||
return SearchResult{}, err
|
||||
@@ -67,37 +67,3 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, 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"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"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)
|
||||
|
||||
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)
|
||||
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
|
||||
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()
|
||||
defer c.poolMu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
c.poolInitialized = true
|
||||
c.poolMu.Unlock()
|
||||
|
||||
// 1. Try to load all cached anime from the database
|
||||
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)
|
||||
}
|
||||
}
|
||||
return top.Animes
|
||||
}
|
||||
|
||||
if len(loadedAnimes) > 0 {
|
||||
c.poolMu.Lock()
|
||||
c.randomPool = append(c.randomPool, loadedAnimes...)
|
||||
c.poolMu.Unlock()
|
||||
}
|
||||
func (c *Client) fetchCurrentSeasonAnime(ctx context.Context) []Anime {
|
||||
now, err := c.GetSeasonsNow(ctx, 1)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Fetch Top Anime page 1 & 2 to ensure we have a robust baseline of high-quality popular anime
|
||||
go func() {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return now.Animes
|
||||
}
|
||||
|
||||
var fetchedAnimes []Anime
|
||||
func (c *Client) appendUniqueRandomPool(animes []Anime) {
|
||||
c.poolMu.Lock()
|
||||
defer c.poolMu.Unlock()
|
||||
|
||||
top, err := c.GetTopAnime(bgCtx, 1)
|
||||
if err == nil && len(top.Animes) > 0 {
|
||||
fetchedAnimes = append(fetchedAnimes, top.Animes...)
|
||||
seen := make(map[int]bool, len(c.randomPool)+len(animes))
|
||||
for _, anime := range c.randomPool {
|
||||
seen[anime.MalID] = true
|
||||
}
|
||||
|
||||
for _, anime := range animes {
|
||||
if seen[anime.MalID] {
|
||||
continue
|
||||
}
|
||||
|
||||
top2, err := c.GetTopAnime(bgCtx, 2)
|
||||
if err == nil && len(top2.Animes) > 0 {
|
||||
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
|
||||
c.randomPool = append(c.randomPool, anime)
|
||||
seen[anime.MalID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
if !initialized {
|
||||
_ = c.seedRandomPool(ctx)
|
||||
c.seedRandomPool(ctx)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
MalID int `json:"mal_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -33,12 +56,18 @@ type Aired struct {
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
type TitleEntry struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type Anime struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
TitleEnglish string `json:"title_english"`
|
||||
TitleJapanese string `json:"title_japanese"`
|
||||
TitleSynonyms []string `json:"title_synonyms"`
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
TitleEnglish string `json:"title_english"`
|
||||
TitleJapanese string `json:"title_japanese"`
|
||||
TitleSynonyms []string `json:"title_synonyms"`
|
||||
Titles []TitleEntry `json:"titles"`
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
@@ -156,40 +185,6 @@ type RecommendationsResponse struct {
|
||||
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.
|
||||
func (a Anime) ShortRating() string {
|
||||
if a.Rating == "" {
|
||||
@@ -230,35 +225,34 @@ func (a Anime) DurationSeconds() float64 {
|
||||
return 0
|
||||
}
|
||||
var hours, minutes int
|
||||
var isHours bool
|
||||
var currentNum string
|
||||
var currentValue int
|
||||
hasValue := false
|
||||
|
||||
for _, c := range a.Duration {
|
||||
if c >= '0' && c <= '9' {
|
||||
currentNum += string(c)
|
||||
} else if c == ' ' && currentNum != "" {
|
||||
val, _ := strconv.Atoi(currentNum)
|
||||
if isHours {
|
||||
hours = val
|
||||
} else {
|
||||
minutes = val
|
||||
}
|
||||
currentNum = ""
|
||||
} else if len(currentNum) > 0 && (c == 'h' || c == 'H') {
|
||||
isHours = true
|
||||
val, _ := strconv.Atoi(currentNum)
|
||||
hours = val
|
||||
currentNum = ""
|
||||
for token := range strings.FieldsSeq(strings.ToLower(a.Duration)) {
|
||||
value, err := strconv.Atoi(token)
|
||||
if err == nil {
|
||||
currentValue = value
|
||||
hasValue = true
|
||||
continue
|
||||
}
|
||||
if !hasValue {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(token, "h"):
|
||||
hours = currentValue
|
||||
hasValue = false
|
||||
case strings.HasPrefix(token, "m"):
|
||||
minutes = currentValue
|
||||
hasValue = false
|
||||
}
|
||||
}
|
||||
if currentNum != "" {
|
||||
val, _ := strconv.Atoi(currentNum)
|
||||
if isHours {
|
||||
hours = val
|
||||
} else {
|
||||
minutes = val
|
||||
}
|
||||
|
||||
if hasValue {
|
||||
minutes = currentValue
|
||||
}
|
||||
|
||||
return float64(hours*60+minutes) * 60
|
||||
}
|
||||
|
||||
@@ -327,6 +321,7 @@ type Episode struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
Episode string `json:"episode"`
|
||||
Aired string `json:"aired"`
|
||||
Filler bool `json:"filler"`
|
||||
Recap bool `json:"recap"`
|
||||
Images *EpisodeImages `json:"images,omitempty"`
|
||||
@@ -455,13 +450,16 @@ type ReviewsResponse struct {
|
||||
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 {
|
||||
if a.TitleEnglish != "" {
|
||||
return a.TitleEnglish
|
||||
}
|
||||
if a.TitleJapanese != "" {
|
||||
return a.TitleJapanese
|
||||
if a.Title != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
127
integrations/playback/allanime/availability.go
Normal file
127
integrations/playback/allanime/availability.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mal/internal/domain"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AvailableEpisodes struct {
|
||||
Sub []string
|
||||
Dub []string
|
||||
Raw []string
|
||||
Titles map[int]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, Titles: available.Titles}, nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
|
||||
graphqlQuery := `query($showId: String!, $start: Float!, $end: Float!) {
|
||||
show(_id: $showId) {
|
||||
availableEpisodesDetail
|
||||
}
|
||||
episodeInfos(showId: $showId, episodeNumStart: $start, episodeNumEnd: $end) {
|
||||
episodeIdNum
|
||||
notes
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{
|
||||
"showId": showID,
|
||||
"start": 1,
|
||||
"end": 100000,
|
||||
})
|
||||
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")
|
||||
}
|
||||
|
||||
titles := map[int]string{}
|
||||
infos, _ := data["episodeInfos"].([]any)
|
||||
for _, value := range infos {
|
||||
info, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
number := numericInt(info["episodeIdNum"])
|
||||
title := plainText(stringValue(info["notes"]))
|
||||
if number > 0 && title != "" {
|
||||
titles[number] = title
|
||||
}
|
||||
}
|
||||
|
||||
return AvailableEpisodes{
|
||||
Sub: stringsFrom(detail["sub"]),
|
||||
Dub: stringsFrom(detail["dub"]),
|
||||
Raw: stringsFrom(detail["raw"]),
|
||||
Titles: titles,
|
||||
}, 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) {
|
||||
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}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseEpisodeNumbers() = %v, want %v", got, want)
|
||||
t.Fatalf("episodeNums() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,32 @@
|
||||
// Package allanime provides an integration with the AllAnime API for episode playback.
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/internal/domain"
|
||||
"mal/pkg"
|
||||
errlog "mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
allAnimeBaseURL = "https://api.allanime.day"
|
||||
allAnimeReferer = "https://allmanga.to/"
|
||||
allAnimeSiteURL = "https://allanime.day"
|
||||
allAnimeReferer = "https://youtu-chan.com"
|
||||
allAnimeOrigin = "https://youtu-chan.com"
|
||||
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 {
|
||||
httpClient *http.Client
|
||||
utlsClient *http.Client
|
||||
extractor *providerExtractor
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewAllAnimeProvider() *AllAnimeProvider {
|
||||
@@ -60,6 +39,7 @@ func NewAllAnimeProvider() *AllAnimeProvider {
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
baseURL: allAnimeBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,139 +47,30 @@ func (c *AllAnimeProvider) Name() string {
|
||||
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"`
|
||||
func (c *AllAnimeProvider) apiBaseURL() string {
|
||||
if c.baseURL != "" {
|
||||
return c.baseURL
|
||||
}
|
||||
|
||||
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
|
||||
return allAnimeBaseURL
|
||||
}
|
||||
|
||||
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
|
||||
// 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 == "" {
|
||||
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
||||
}
|
||||
|
||||
// 2. Get sources
|
||||
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
|
||||
if err != nil || len(sources) == 0 {
|
||||
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
|
||||
}
|
||||
|
||||
// 3. Return the first usable source
|
||||
primary := sources[0]
|
||||
|
||||
result := &domain.StreamResult{
|
||||
URL: primary.URL,
|
||||
Referer: primary.Referer,
|
||||
Type: primary.Type,
|
||||
}
|
||||
|
||||
for _, sub := range primary.Subtitles {
|
||||
@@ -212,65 +83,6 @@ func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCan
|
||||
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) {
|
||||
if mode, ok := variables["translationType"].(string); ok {
|
||||
variables["translationType"] = strings.ToLower(mode)
|
||||
@@ -286,7 +98,7 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
||||
return nil, fmt.Errorf("marshal graphql payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiBaseURL()+"/api", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create graphql request: %w", err)
|
||||
}
|
||||
@@ -295,13 +107,13 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
||||
req.Header.Set("Referer", allAnimeReferer)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graphql status %d", resp.StatusCode)
|
||||
if statusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graphql status %d", statusCode)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
@@ -316,487 +128,19 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
|
||||
|
||||
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) {
|
||||
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (int, []byte, error) {
|
||||
resp, err := client.Do(req)
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/json"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"mal/internal/domain"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -20,167 +24,209 @@ func isLikelyMP4(data []byte) bool {
|
||||
return string(data[4:8]) == "ftyp"
|
||||
}
|
||||
|
||||
func TestDecodeSourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
type stringTransformTestCase struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
encoded string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty returns empty",
|
||||
encoded: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "with double prefix stripped",
|
||||
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",
|
||||
},
|
||||
}
|
||||
type sourceReferencesTestCase struct {
|
||||
name string
|
||||
rawURLs []any
|
||||
wantRefs []sourceReference
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
|
||||
} = (*AllAnimeProvider)(nil)
|
||||
|
||||
func runStringTransformTests(t *testing.T, tests []stringTransformTestCase, fn func(string) string) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := decodeSourceURL(tt.encoded)
|
||||
got := fn(tt.input)
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantType string
|
||||
}{
|
||||
tests := []stringTransformTestCase{
|
||||
{
|
||||
name: "m3u8 extension",
|
||||
url: "https://example.com/video.m3u8",
|
||||
wantType: "m3u8",
|
||||
name: "m3u8 extension",
|
||||
input: "https://example.com/video.m3u8",
|
||||
want: "m3u8",
|
||||
},
|
||||
{
|
||||
name: "master m3u8",
|
||||
url: "https://example.com/master.m3u8",
|
||||
wantType: "m3u8",
|
||||
name: "master m3u8",
|
||||
input: "https://example.com/master.m3u8",
|
||||
want: "m3u8",
|
||||
},
|
||||
{
|
||||
name: "mp4 extension",
|
||||
url: "https://example.com/video.mp4",
|
||||
wantType: "mp4",
|
||||
name: "mp4 extension",
|
||||
input: "https://example.com/video.mp4",
|
||||
want: "mp4",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
url: "https://example.com/video.avi",
|
||||
wantType: "unknown",
|
||||
name: "unknown",
|
||||
input: "https://example.com/video.avi",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "empty returns unknown",
|
||||
url: "",
|
||||
wantType: "unknown",
|
||||
name: "empty returns unknown",
|
||||
input: "",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "case insensitive - M3U8",
|
||||
url: "https://example.com/MASTER.M3U8",
|
||||
wantType: "m3u8",
|
||||
name: "case insensitive - M3U8",
|
||||
input: "https://example.com/MASTER.M3U8",
|
||||
want: "m3u8",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
runStringTransformTests(t, tests, detectStreamType)
|
||||
}
|
||||
|
||||
func TestDetectEmbedType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantType string
|
||||
}{
|
||||
tests := []stringTransformTestCase{
|
||||
{
|
||||
name: "streamwish",
|
||||
url: "https://streamwish.com/e/abc123",
|
||||
wantType: "embed",
|
||||
name: "streamwish",
|
||||
input: "https://streamwish.com/e/abc123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "streamsb",
|
||||
url: "https://streamsb.com/e/abc123",
|
||||
wantType: "embed",
|
||||
name: "streamsb",
|
||||
input: "https://streamsb.com/e/abc123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "mp4upload",
|
||||
url: "https://mp4upload.com/e/abc123",
|
||||
wantType: "embed",
|
||||
name: "mp4upload",
|
||||
input: "https://mp4upload.com/e/abc123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "ok.ru",
|
||||
url: "https://ok.ru/video/123",
|
||||
wantType: "embed",
|
||||
name: "ok.ru",
|
||||
input: "https://ok.ru/video/123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "gogoplay",
|
||||
url: "https://gogoplay.io/embed/123",
|
||||
wantType: "embed",
|
||||
name: "gogoplay",
|
||||
input: "https://gogoplay.io/embed/123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "streamlare",
|
||||
url: "https://streamlare.com/e/abc",
|
||||
wantType: "embed",
|
||||
name: "streamlare",
|
||||
input: "https://streamlare.com/e/abc",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "unknown host",
|
||||
url: "https://unknown.com/video",
|
||||
wantType: "unknown",
|
||||
name: "unknown host",
|
||||
input: "https://unknown.com/video",
|
||||
want: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
runStringTransformTests(t, tests, detectEmbedType)
|
||||
}
|
||||
|
||||
func TestBuildStreamSource(t *testing.T) {
|
||||
@@ -204,14 +250,44 @@ func TestBuildStreamSource(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetStreamsRequiresExactMalIDMatch(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":"wrong-show","malId":"1","name":"Wrong Anime"}]}}}`), nil
|
||||
}),
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatal("GetStreams should not fetch episode sources without an exact MAL ID match")
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := provider.GetStreams(context.Background(), 62076, []string{"Super no Ura de Yani Suu Futari"}, "1", "sub")
|
||||
if err == nil || !strings.Contains(err.Error(), "show not found") {
|
||||
t.Fatalf("GetStreams() error = %v, want show not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURLs []any
|
||||
wantRefs []sourceReference
|
||||
}{
|
||||
tests := []sourceReferencesTestCase{
|
||||
{
|
||||
name: "empty returns empty",
|
||||
rawURLs: nil,
|
||||
@@ -263,26 +339,7 @@ func TestBuildSourceReferences(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
runSourceReferenceTests(t, tests)
|
||||
}
|
||||
|
||||
func TestBuildSourceReferencesOrder(t *testing.T) {
|
||||
@@ -295,7 +352,7 @@ func TestBuildSourceReferencesOrder(t *testing.T) {
|
||||
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"}
|
||||
if len(got) != len(wantOrder) {
|
||||
@@ -391,22 +448,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) {
|
||||
t.Parallel()
|
||||
|
||||
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)
|
||||
if err == nil {
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(decrypted, &result); err != nil {
|
||||
t.Logf("decrypted (not valid json): %s", string(decrypted))
|
||||
} else {
|
||||
t.Logf("decrypted: %+v", result)
|
||||
}
|
||||
} else {
|
||||
t.Logf("expected decryption to succeed or fail gracefully: %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("decryptTobeparsed: %v", err)
|
||||
}
|
||||
|
||||
if string(decrypted) != string(plaintext) {
|
||||
t.Fatalf("decrypted = %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -441,21 +517,16 @@ func TestTryDecryptCTR(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
_ = plainText
|
||||
ctrIV := append([]byte{}, iv...)
|
||||
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")
|
||||
}
|
||||
|
||||
311
integrations/playback/allanime/crypto.go
Normal file
311
integrations/playback/allanime/crypto.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
aaEpoch = "4128"
|
||||
aaBuildID = "9"
|
||||
aaKeyAHex = "b1a9a4d051988f1b1b12dbb747439d9bd64b09ea17835600a7eaa4de87c1ad87"
|
||||
aaKeyB64 = "k7DLdv5SGiuEyGUtcncl5wQOR7r4aenLfDV3AOBKlAU="
|
||||
)
|
||||
|
||||
var (
|
||||
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
||||
aaCryptoVersion byte = 1
|
||||
)
|
||||
|
||||
func xorKey() ([]byte, error) {
|
||||
keyA, err := hex.DecodeString(aaKeyAHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode key_a: %w", err)
|
||||
}
|
||||
|
||||
keyB, err := base64.StdEncoding.DecodeString(aaKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode key_b: %w", err)
|
||||
}
|
||||
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = keyA[i] ^ keyB[i]
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func makeAALease(queryHash string) (string, error) {
|
||||
aesKey, err := xorKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ts := (time.Now().UnixMilli() / 300_000) * 300_000
|
||||
|
||||
ivSeed := fmt.Sprintf("%s:%s:%s:%d", aaEpoch, aaBuildID, queryHash, ts)
|
||||
ivHash := sha256.Sum256([]byte(ivSeed))
|
||||
iv := ivHash[:12]
|
||||
|
||||
payload := fmt.Sprintf(`{"v":%d,"ts":%d,"epoch":%s,"buildId":"%s","qh":"%s"}`, aaCryptoVersion, ts, aaEpoch, aaBuildID, queryHash)
|
||||
|
||||
block, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create gcm: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, iv, []byte(payload), nil)
|
||||
|
||||
envelope := append([]byte{aaCryptoVersion}, iv...)
|
||||
envelope = append(envelope, ciphertext...)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(envelope), nil
|
||||
}
|
||||
|
||||
type aesCandidate struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
candidates := make([]aesCandidate, 0, len(aesKeys)+1)
|
||||
|
||||
xorK, err := xorKey()
|
||||
if err == nil {
|
||||
candidates = append(candidates, aesCandidate{key: xorK})
|
||||
}
|
||||
|
||||
for _, keyStr := range aesKeys {
|
||||
k := sha256.Sum256([]byte(keyStr))
|
||||
candidates = append(candidates, aesCandidate{key: k[:]})
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
block, err := aes.NewCipher(candidate.key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if version == 1 {
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err == nil {
|
||||
combined := append(append([]byte{}, cipherText...), raw[len(raw)-16:]...)
|
||||
plainText, openErr := gcm.Open(nil, iv, combined, nil)
|
||||
if openErr == nil && json.Valid(plainText) {
|
||||
return plainText, nil
|
||||
}
|
||||
}
|
||||
|
||||
plainText := tryDecryptCTR(block, iv, cipherText)
|
||||
if 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 _, ok := parsed["data"]; ok {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
errlog "mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -19,10 +21,27 @@ type providerExtractor struct {
|
||||
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 {
|
||||
return &providerExtractor{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: allAnimeBaseURL,
|
||||
baseURL: allAnimeSiteURL,
|
||||
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
|
||||
if err != nil {
|
||||
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) parseProviderResponse(ctx context.Context, response string) []StreamSource {
|
||||
sources := make([]StreamSource, 0)
|
||||
providerReferer := e.referer
|
||||
func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL string) ([]StreamSource, error) {
|
||||
resp, err := doProxiedRequest(ctx, e.httpClient, rawURL, e.referer)
|
||||
if err != nil {
|
||||
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
|
||||
if err := json.Unmarshal([]byte(response), &root); err != nil {
|
||||
return sources
|
||||
return []StreamSource{}
|
||||
}
|
||||
|
||||
type linkItem struct {
|
||||
link string
|
||||
resolutionStr string
|
||||
}
|
||||
type hlsItem struct {
|
||||
url string
|
||||
hardsubLang string
|
||||
}
|
||||
data := collectData(root, e.referer)
|
||||
sources := linkSources(data.links, data.referer)
|
||||
sources = append(sources, e.hlsSources(ctx, data.hls, data.referer)...)
|
||||
|
||||
linkItems := make([]linkItem, 0)
|
||||
hlsItems := make([]hlsItem, 0)
|
||||
subtitles := make([]Subtitle, 0)
|
||||
attachSubtitles(sources, data.subtitles)
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
// provider payload
|
||||
func collectData(root any, fallbackReferer string) providerResponseData {
|
||||
data := providerResponseData{referer: fallbackReferer}
|
||||
|
||||
var walk func(v any)
|
||||
walk = func(v any) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
if ref, ok := x["Referer"].(string); ok && strings.TrimSpace(ref) != "" {
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
collectMapData(x, &data)
|
||||
for _, child := range x {
|
||||
walk(child)
|
||||
}
|
||||
@@ -133,42 +137,104 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
}
|
||||
|
||||
walk(root)
|
||||
|
||||
if providerReferer == "" {
|
||||
providerReferer = e.referer
|
||||
if data.referer == "" {
|
||||
data.referer = fallbackReferer
|
||||
}
|
||||
|
||||
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)
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
quality := strings.TrimSpace(item.resolutionStr)
|
||||
sourceType := detectStreamType(link)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(link)
|
||||
}
|
||||
|
||||
sources = append(sources, StreamSource{
|
||||
URL: link,
|
||||
Quality: quality,
|
||||
Quality: strings.TrimSpace(item.resolutionStr),
|
||||
Provider: "wixmp",
|
||||
Type: sourceType,
|
||||
Referer: providerReferer,
|
||||
Type: sourceType(link),
|
||||
Referer: referer,
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range hlsItems {
|
||||
if strings.TrimSpace(item.url) == "" {
|
||||
continue
|
||||
}
|
||||
if item.hardsubLang != "en-US" {
|
||||
return sources
|
||||
}
|
||||
|
||||
func sourceType(link string) string {
|
||||
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
|
||||
}
|
||||
|
||||
playlistURL := strings.TrimSpace(item.url)
|
||||
if strings.Contains(playlistURL, "master.m3u8") {
|
||||
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
|
||||
parsed, err := e.parseM3U8(ctx, playlistURL, referer)
|
||||
if err == nil {
|
||||
sources = append(sources, parsed...)
|
||||
}
|
||||
@@ -180,17 +246,30 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
Quality: "auto",
|
||||
Provider: "hls",
|
||||
Type: "m3u8",
|
||||
Referer: providerReferer,
|
||||
Referer: referer,
|
||||
})
|
||||
}
|
||||
|
||||
if len(subtitles) > 0 && len(sources) > 0 {
|
||||
for idx := range sources {
|
||||
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -199,37 +278,31 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
||||
if err != nil {
|
||||
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
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(body), "\n")
|
||||
baseURL := masterURL
|
||||
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
|
||||
baseURL = masterURL[:idx+1]
|
||||
}
|
||||
return parseM3U8Sources(string(body), masterURL, referer), nil
|
||||
}
|
||||
|
||||
currentBandwidth := 0
|
||||
sources := make([]StreamSource, 0)
|
||||
func parseM3U8Sources(body string, masterURL string, referer string) []StreamSource {
|
||||
lines := strings.Split(body, "\n")
|
||||
baseURL := playlistBaseURL(masterURL)
|
||||
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
|
||||
bw := 0
|
||||
sources := make([]StreamSource, 0)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") {
|
||||
match := bwPattern.FindStringSubmatch(trimmed)
|
||||
if len(match) >= 2 {
|
||||
value, convErr := strconv.Atoi(match[1])
|
||||
if convErr == nil {
|
||||
currentBandwidth = value
|
||||
}
|
||||
}
|
||||
if bandwidth, ok := streamBandwidth(trimmed, bwPattern); ok {
|
||||
bw = bandwidth
|
||||
continue
|
||||
}
|
||||
|
||||
// skip empty lines and non-stream lines
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
@@ -239,27 +312,128 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
||||
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{
|
||||
URL: streamURL,
|
||||
Quality: quality,
|
||||
Quality: quality(bw),
|
||||
Provider: "hls",
|
||||
Type: "m3u8",
|
||||
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))
|
||||
}
|
||||
|
||||
796
integrations/playback/allanime/http_test.go
Normal file
796
integrations/playback/allanime/http_test.go
Normal file
@@ -0,0 +1,796 @@
|
||||
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{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", req.Method)
|
||||
}
|
||||
if req.Header.Get("Referer") != allAnimeReferer {
|
||||
t.Errorf("Referer = %q", req.Header.Get("Referer"))
|
||||
}
|
||||
if req.Header.Get("x-build-id") != "9" {
|
||||
t.Errorf("x-build-id = %q", req.Header.Get("x-build-id"))
|
||||
}
|
||||
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{
|
||||
httpClient: &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)
|
||||
}
|
||||
|
||||
ep, ok := result["episode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("result missing episode key")
|
||||
}
|
||||
sources := nestedSlice(ep, "sourceUrls")
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources, want 1", len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphqlRequestWithHash_Non200(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
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, 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{
|
||||
httpClient: &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) {
|
||||
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) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
if strings.Contains(string(body), `"extensions":`) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
||||
}
|
||||
fallbackCalled = true
|
||||
return mockStringResponse(http.StatusOK, sourceResponse), 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
|
||||
}),
|
||||
},
|
||||
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
|
||||
wantTitle string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "sub and dub available",
|
||||
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]}},"episodeInfos":[{"episodeIdNum":1,"notes":"The Beginning"}]}}`,
|
||||
wantSub: 3,
|
||||
wantDub: 1,
|
||||
wantTitle: "The Beginning",
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
assertEpisodeTitle(t, available, tt.wantTitle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEpisodeTitle(t *testing.T, available AvailableEpisodes, want string) {
|
||||
t.Helper()
|
||||
if want != "" && available.Titles[1] != want {
|
||||
t.Errorf("episode 1 title = %q, want %q", available.Titles[1], want)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
if strings.Contains(string(body), `"search":`) {
|
||||
return mockStringResponse(http.StatusOK, searchBody), nil
|
||||
}
|
||||
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
|
||||
}),
|
||||
},
|
||||
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) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
if strings.Contains(string(body), `"search":`) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
|
||||
}
|
||||
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)),
|
||||
}
|
||||
}
|
||||
126
integrations/playback/allanime/search.go
Normal file
126
integrations/playback/allanime/search.go
Normal file
@@ -0,0 +1,126 @@
|
||||
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, c.apiBaseURL()+"/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) 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)
|
||||
}
|
||||
164
integrations/playback/allanime/sequels.go
Normal file
164
integrations/playback/allanime/sequels.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
stdhtml "html"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type ProviderShow struct {
|
||||
ID string
|
||||
Name string
|
||||
EnglishName string
|
||||
Description string
|
||||
MalID int
|
||||
Status string
|
||||
Thumbnail string
|
||||
Type string
|
||||
Year int
|
||||
EpisodeCount int
|
||||
SubEpisodes []int
|
||||
DubEpisodes []int
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) DirectSequels(ctx context.Context, show ProviderShow) ([]string, error) {
|
||||
query := `query($showId: String!) { show(_id: $showId) { relatedShows } }`
|
||||
result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": show.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _ := result["data"].(map[string]any)
|
||||
rawShow, _ := data["show"].(map[string]any)
|
||||
relations, _ := rawShow["relatedShows"].([]any)
|
||||
ids := make([]string, 0, len(relations))
|
||||
for _, raw := range relations {
|
||||
relation, _ := raw.(map[string]any)
|
||||
id, _ := relation["showId"].(string)
|
||||
if relation["relation"] == "sequel" && id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetProviderShow(ctx context.Context, showID string) (ProviderShow, error) {
|
||||
query := `query($showId: String!) { show(_id: $showId) { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } }`
|
||||
result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": showID})
|
||||
if err != nil {
|
||||
return ProviderShow{}, err
|
||||
}
|
||||
data, _ := result["data"].(map[string]any)
|
||||
raw, _ := data["show"].(map[string]any)
|
||||
if raw == nil {
|
||||
return ProviderShow{}, fmt.Errorf("allanime: show %s not found", showID)
|
||||
}
|
||||
return providerShowFrom(raw), nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) SeasonalShows(ctx context.Context, season string, year int) ([]ProviderShow, error) {
|
||||
const pageSize = 40
|
||||
query := `query($search: SearchInput, $page: Int) { shows(search: $search, limit: 40, page: $page, countryOrigin: ALL) { edges { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } } }`
|
||||
if season == "" {
|
||||
return nil, errors.New("allanime: season is required")
|
||||
}
|
||||
search := map[string]any{
|
||||
"allowAdult": false,
|
||||
"allowUnknown": false,
|
||||
"season": strings.ToUpper(season[:1]) + strings.ToLower(season[1:]),
|
||||
"types": []string{"TV"},
|
||||
"includeTypes": true,
|
||||
}
|
||||
out := make([]ProviderShow, 0)
|
||||
seen := make(map[int]bool)
|
||||
for page := 1; page <= 20; page++ {
|
||||
result, err := c.graphqlRequest(ctx, query, map[string]any{"search": search, "page": page})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _ := result["data"].(map[string]any)
|
||||
shows, _ := data["shows"].(map[string]any)
|
||||
edges, _ := shows["edges"].([]any)
|
||||
newestYear := 0
|
||||
for _, edge := range edges {
|
||||
raw, _ := edge.(map[string]any)
|
||||
show := providerShowFrom(raw)
|
||||
newestYear = max(newestYear, show.Year)
|
||||
if seen[show.MalID] || !isPlayableSeasonShow(show, year) {
|
||||
continue
|
||||
}
|
||||
seen[show.MalID] = true
|
||||
out = append(out, show)
|
||||
}
|
||||
if len(edges) < pageSize || (newestYear > 0 && newestYear < year) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isPlayableSeasonShow(show ProviderShow, year int) bool {
|
||||
return show.Year == year && show.MalID > 0 && show.Type == "TV" && max(len(show.SubEpisodes), len(show.DubEpisodes)) > 0
|
||||
}
|
||||
|
||||
func providerShowFrom(raw map[string]any) ProviderShow {
|
||||
detail, _ := raw["availableEpisodesDetail"].(map[string]any)
|
||||
season, _ := raw["season"].(map[string]any)
|
||||
return ProviderShow{
|
||||
ID: stringValue(raw["_id"]),
|
||||
Name: stringValue(raw["name"]),
|
||||
EnglishName: stringValue(raw["englishName"]),
|
||||
Description: plainText(stringValue(raw["description"])),
|
||||
MalID: intValue(raw["malId"]),
|
||||
Status: stringValue(raw["status"]),
|
||||
Thumbnail: stringValue(raw["thumbnail"]),
|
||||
Type: stringValue(raw["type"]),
|
||||
Year: numericInt(season["year"]),
|
||||
EpisodeCount: intValue(raw["episodeCount"]),
|
||||
SubEpisodes: episodeNums(stringsFrom(detail["sub"])),
|
||||
DubEpisodes: episodeNums(stringsFrom(detail["dub"])),
|
||||
}
|
||||
}
|
||||
|
||||
func plainText(value string) string {
|
||||
tokenizer := html.NewTokenizer(bytes.NewBufferString(value))
|
||||
var out strings.Builder
|
||||
for {
|
||||
switch tokenizer.Next() {
|
||||
case html.ErrorToken:
|
||||
return out.String()
|
||||
case html.TextToken:
|
||||
text := strings.Join(strings.Fields(stdhtml.UnescapeString(string(tokenizer.Text()))), " ")
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if out.Len() > 0 {
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
out.WriteString(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
valueString, _ := value.(string)
|
||||
return valueString
|
||||
}
|
||||
|
||||
func intValue(value any) int {
|
||||
n, _ := strconv.Atoi(stringValue(value))
|
||||
return n
|
||||
}
|
||||
|
||||
func numericInt(value any) int {
|
||||
if n := intValue(value); n > 0 {
|
||||
return n
|
||||
}
|
||||
n, _ := value.(float64)
|
||||
return int(n)
|
||||
}
|
||||
100
integrations/playback/allanime/sequels_test.go
Normal file
100
integrations/playback/allanime/sequels_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDirectSequelsReturnsOnlyPlayableSequels(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"show":{"relatedShows":[
|
||||
{"relation":"sequel","showId":"season-2"},
|
||||
{"relation":"side story","showId":"ova"}
|
||||
]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewAllAnimeProvider()
|
||||
provider.httpClient = server.Client()
|
||||
provider.baseURL = server.URL
|
||||
providerShow := ProviderShow{ID: "season-1"}
|
||||
|
||||
got, err := provider.DirectSequels(context.Background(), providerShow)
|
||||
if err != nil {
|
||||
t.Fatalf("DirectSequels: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "season-2" {
|
||||
t.Fatalf("DirectSequels = %v, want [season-2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProviderShowKeepsOnlyPositiveIntegerEpisodes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"show":{"_id":"season-2","name":"Example Season 2","englishName":"Example Season 2","description":"A useful<br><i>synopsis</i> & summary.","malId":"42","status":"Finished","episodeCount":"2","availableEpisodesDetail":{"sub":["2","1","0","1.5"],"dub":["1"],"raw":[]}}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewAllAnimeProvider()
|
||||
provider.httpClient = server.Client()
|
||||
provider.baseURL = server.URL
|
||||
|
||||
got, err := provider.GetProviderShow(context.Background(), "season-2")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderShow: %v", err)
|
||||
}
|
||||
if got.MalID != 42 || len(got.SubEpisodes) != 2 || len(got.DubEpisodes) != 1 {
|
||||
t.Fatalf("GetProviderShow = %+v", got)
|
||||
}
|
||||
if got.Description != "A useful synopsis & summary." {
|
||||
t.Fatalf("Description = %q", got.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeasonalShowsReturnsPlayableTVAnime(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var request struct {
|
||||
Variables struct {
|
||||
Page int `json:"page"`
|
||||
Search map[string]any `json:"search"`
|
||||
} `json:"variables"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if _, ok := request.Variables.Search["year"]; ok {
|
||||
t.Fatal("seasonal request must filter normalized years locally")
|
||||
}
|
||||
edges := make([]map[string]any, 0)
|
||||
switch request.Variables.Page {
|
||||
case 1:
|
||||
for range 40 {
|
||||
edges = append(edges, map[string]any{"_id": "empty", "malId": "12", "type": "TV", "availableEpisodesDetail": map[string]any{"sub": []string{}, "dub": []string{}}})
|
||||
}
|
||||
case 2:
|
||||
edges = append(edges,
|
||||
map[string]any{"_id": "tv", "name": "Summer Show", "malId": "10", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": "2026"}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
|
||||
map[string]any{"_id": "old", "name": "Old Summer Show", "malId": "11", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": 2025}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
|
||||
)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"shows": map[string]any{"edges": edges}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewAllAnimeProvider()
|
||||
provider.httpClient = server.Client()
|
||||
provider.baseURL = server.URL
|
||||
|
||||
got, err := provider.SeasonalShows(context.Background(), "summer", 2026)
|
||||
if err != nil {
|
||||
t.Fatalf("SeasonalShows: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].MalID != 10 || got[0].Year != 2026 {
|
||||
t.Fatalf("SeasonalShows = %+v", got)
|
||||
}
|
||||
}
|
||||
345
integrations/playback/allanime/sources.go
Normal file
345
integrations/playback/allanime/sources.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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 {
|
||||
referer := allAnimeReferer
|
||||
if strings.Contains(strings.ToLower(provider), "yt-mp4") {
|
||||
referer = allAnimeSiteURL
|
||||
}
|
||||
return StreamSource{
|
||||
URL: url,
|
||||
Provider: provider,
|
||||
Type: sourceType,
|
||||
Referer: referer,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := c.newHashRequest(ctx, showID, episode, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create POST request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Referer", allAnimeReferer)
|
||||
req.Header.Set("Origin", allAnimeOrigin)
|
||||
req.Header.Set("x-build-id", "9")
|
||||
|
||||
statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute POST request (aaReq)", "read response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("POST 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 (c *AllAnimeProvider) newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
|
||||
aaReq, err := makeAALease(episodeQueryHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create aa lease: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"variables": map[string]any{
|
||||
"showId": showID,
|
||||
"translationType": strings.ToLower(mode),
|
||||
"episodeString": episode,
|
||||
},
|
||||
"extensions": map[string]any{
|
||||
"persistedQuery": map[string]any{
|
||||
"version": 1,
|
||||
"sha256Hash": episodeQueryHash,
|
||||
},
|
||||
"aaReq": aaReq,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
return http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/api", allAnimeBaseURL), bytes.NewReader(body))
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
272
integrations/tvmaze/client.go
Normal file
272
integrations/tvmaze/client.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package tvmaze
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
seasonNumberPattern = regexp.MustCompile(`(?i)(?:\bseason\s+(\d+)|\b(\d+)(?:st|nd|rd|th)\s+season)\b`)
|
||||
seasonSuffixPattern = regexp.MustCompile(`(?i)\s+(?:(?:season\s+\d+)|(?:\d+(?:st|nd|rd|th)\s+season))(?:\s+part\s+\d+)?\s*$`)
|
||||
partNumberPattern = regexp.MustCompile(`(?i)\bpart\s+(\d+)\b`)
|
||||
)
|
||||
|
||||
const baseURL = "https://api.tvmaze.com"
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
Show show `json:"show"`
|
||||
}
|
||||
|
||||
type show struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type episode struct {
|
||||
Name string `json:"name"`
|
||||
Season int `json:"season"`
|
||||
Airdate string `json:"airdate"`
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Name() string {
|
||||
return "TVmaze"
|
||||
}
|
||||
|
||||
func (c *Client) ResolveEpisodeProviderID(ctx context.Context, _ int, titleCandidates []string) (string, error) {
|
||||
matches := map[int]struct{}{}
|
||||
for _, candidate := range titleSearchCandidates(titleCandidates, 6) {
|
||||
results, err := c.search(ctx, candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
addExactMatches(matches, results, normalizeTitle(candidate))
|
||||
if len(matches) == 1 {
|
||||
for id := range matches {
|
||||
return strconv.Itoa(id), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) > 1 {
|
||||
return "", errors.New("tvmaze: multiple exact show matches")
|
||||
}
|
||||
return "", errors.New("tvmaze: no exact show match")
|
||||
}
|
||||
|
||||
func uniqueTitleCandidates(candidates []string, limit int) []string {
|
||||
seen := map[string]struct{}{}
|
||||
unique := make([]string, 0, min(len(candidates), limit))
|
||||
for _, candidate := range candidates {
|
||||
normalized := normalizeTitle(candidate)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
unique = append(unique, candidate)
|
||||
if len(unique) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func addExactMatches(matches map[int]struct{}, results []searchResult, normalizedTitle string) {
|
||||
for _, result := range results {
|
||||
if result.Show.ID <= 0 || normalizeTitle(result.Show.Name) != normalizedTitle {
|
||||
continue
|
||||
}
|
||||
if result.Show.Type != "" && !strings.EqualFold(result.Show.Type, "Animation") {
|
||||
continue
|
||||
}
|
||||
matches[result.Show.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime domain.Anime, episodeCount int) (map[int]string, error) {
|
||||
var episodes []episode
|
||||
if err := c.getJSON(ctx, "/shows/"+url.PathEscape(providerID)+"/episodes", &episodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
episodes, err := episodesForAnimeSeason(episodes, anime, episodeCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
titles := make(map[int]string, len(episodes))
|
||||
for i, item := range episodes {
|
||||
title := strings.TrimSpace(item.Name)
|
||||
if title != "" {
|
||||
titles[i+1] = title
|
||||
}
|
||||
}
|
||||
if len(titles) == 0 {
|
||||
return nil, errors.New("tvmaze: show has no episode titles")
|
||||
}
|
||||
return titles, nil
|
||||
}
|
||||
|
||||
func titleSearchCandidates(candidates []string, limit int) []string {
|
||||
expanded := make([]string, 0, len(candidates)*2)
|
||||
for _, candidate := range candidates {
|
||||
parent := strings.TrimSpace(seasonSuffixPattern.ReplaceAllString(candidate, ""))
|
||||
if parent != "" && parent != strings.TrimSpace(candidate) {
|
||||
expanded = append(expanded, parent)
|
||||
}
|
||||
expanded = append(expanded, candidate)
|
||||
}
|
||||
return uniqueTitleCandidates(expanded, limit)
|
||||
}
|
||||
|
||||
func episodesForAnimeSeason(episodes []episode, anime domain.Anime, episodeCount int) ([]episode, error) {
|
||||
seasons := groupEpisodesBySeason(episodes)
|
||||
season := animeSeasonNumber(anime)
|
||||
if season == 0 {
|
||||
season = seasonForYear(seasons, anime.Year)
|
||||
}
|
||||
if season == 0 {
|
||||
season = 1
|
||||
}
|
||||
|
||||
selected := seasons[season]
|
||||
if len(selected) == 0 {
|
||||
return nil, fmt.Errorf("tvmaze: season %d has no episodes", season)
|
||||
}
|
||||
if episodeCount <= 0 || episodeCount >= len(selected) {
|
||||
return selected, nil
|
||||
}
|
||||
if animePartNumber(anime) > 1 {
|
||||
return selected[len(selected)-episodeCount:], nil
|
||||
}
|
||||
return selected[:episodeCount], nil
|
||||
}
|
||||
|
||||
func groupEpisodesBySeason(episodes []episode) map[int][]episode {
|
||||
seasons := map[int][]episode{}
|
||||
for _, item := range episodes {
|
||||
if item.Season > 0 {
|
||||
seasons[item.Season] = append(seasons[item.Season], item)
|
||||
}
|
||||
}
|
||||
return seasons
|
||||
}
|
||||
|
||||
func animeSeasonNumber(anime domain.Anime) int {
|
||||
return titleNumber(animeTitles(anime), seasonNumberPattern)
|
||||
}
|
||||
|
||||
func animePartNumber(anime domain.Anime) int {
|
||||
return titleNumber(animeTitles(anime), partNumberPattern)
|
||||
}
|
||||
|
||||
func titleNumber(titles []string, pattern *regexp.Regexp) int {
|
||||
for _, title := range titles {
|
||||
match := pattern.FindStringSubmatch(title)
|
||||
if len(match) <= 1 {
|
||||
continue
|
||||
}
|
||||
for _, value := range match[1:] {
|
||||
number, err := strconv.Atoi(value)
|
||||
if err == nil && number > 0 {
|
||||
return number
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func animeTitles(anime domain.Anime) []string {
|
||||
titles := make([]string, 0, 3+len(anime.TitleSynonyms))
|
||||
titles = append(titles, anime.Title, anime.TitleEnglish, anime.TitleJapanese)
|
||||
titles = append(titles, anime.TitleSynonyms...)
|
||||
return titles
|
||||
}
|
||||
|
||||
func seasonForYear(seasons map[int][]episode, year int) int {
|
||||
if year <= 0 {
|
||||
return 0
|
||||
}
|
||||
matchedSeason := 0
|
||||
for season, episodes := range seasons {
|
||||
if len(episodes) == 0 || !strings.HasPrefix(episodes[0].Airdate, strconv.Itoa(year)+"-") {
|
||||
continue
|
||||
}
|
||||
if matchedSeason != 0 {
|
||||
return 0
|
||||
}
|
||||
matchedSeason = season
|
||||
}
|
||||
return matchedSeason
|
||||
}
|
||||
|
||||
func (c *Client) search(ctx context.Context, title string) ([]searchResult, error) {
|
||||
var results []searchResult
|
||||
err := c.getJSON(ctx, "/search/shows?q="+url.QueryEscape(title), &results)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL()+path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tvmaze: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "mal/1.0")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tvmaze: request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tvmaze: status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(target); err != nil {
|
||||
return fmt.Errorf("tvmaze: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) apiURL() string {
|
||||
if c.baseURL != "" {
|
||||
return c.baseURL
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
func normalizeTitle(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, strings.TrimSpace(value))
|
||||
}
|
||||
107
integrations/tvmaze/client_test.go
Normal file
107
integrations/tvmaze/client_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package tvmaze
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
func TestResolveEpisodeProviderIDRequiresExactAnimatedTitle(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/search/shows" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"show":{"id":1,"name":"May I Ask for One Final Thing Extra","type":"Animation"}},
|
||||
{"show":{"id":80712,"name":"May I Ask for One Final Thing?","type":"Animation"}},
|
||||
{"show":{"id":99,"name":"May I Ask for One Final Thing?","type":"Scripted"}}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(server)
|
||||
id, err := client.ResolveEpisodeProviderID(context.Background(), 59846, []string{"May I Ask for One Final Thing?"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "80712" {
|
||||
t.Fatalf("id = %q, want 80712", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEpisodeTitlesUsesAiringOrder(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/shows/80712/episodes" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"season":1,"number":1,"name":"Wrong season"},
|
||||
{"season":2,"number":1,"name":" First title "},
|
||||
{"season":2,"number":2,"name":"Second title"},
|
||||
{"season":2,"number":3,"name":"Future title"},
|
||||
{"season":3,"number":1,"name":"Another wrong season"}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(server)
|
||||
titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "80712", domain.Anime{Anime: jikan.Anime{
|
||||
Title: "Example 2nd Season",
|
||||
}}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[int]string{1: "First title", 2: "Second title"}
|
||||
if !reflect.DeepEqual(titles, want) {
|
||||
t.Fatalf("titles = %#v, want %#v", titles, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEpisodeTitlesSelectsSplitCourWithinSeason(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"season":2,"number":1,"name":"Cour one A"},
|
||||
{"season":2,"number":2,"name":"Cour one B"},
|
||||
{"season":2,"number":3,"name":"Cour two A"},
|
||||
{"season":2,"number":4,"name":"Cour two B"}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(server)
|
||||
titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "14459", domain.Anime{Anime: jikan.Anime{
|
||||
Title: "Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
|
||||
}}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[int]string{1: "Cour two A", 2: "Cour two B"}
|
||||
if !reflect.DeepEqual(titles, want) {
|
||||
t.Fatalf("titles = %#v, want %#v", titles, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleSearchCandidatesUseParentShowName(t *testing.T) {
|
||||
got := titleSearchCandidates([]string{
|
||||
"Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
|
||||
}, 6)
|
||||
want := []string{
|
||||
"Re:Zero kara Hajimeru Isekai Seikatsu",
|
||||
"Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestClient(server *httptest.Server) *Client {
|
||||
return &Client{httpClient: server.Client(), baseURL: server.URL}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
errlog "mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -155,23 +156,19 @@ func extractRows(doc *goquery.Document) []watchOrderRow {
|
||||
}
|
||||
|
||||
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{
|
||||
id: id,
|
||||
typeID: typeID,
|
||||
title: title,
|
||||
alternativeTitle: alternativeTitle,
|
||||
alternativeTitle: alt,
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
// (e.g. Cloudflare blocking, rate limits)
|
||||
func shouldTryProxy(err error) bool {
|
||||
@@ -205,7 +202,9 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
if !hasWatchOrderTable(doc) {
|
||||
if doc.Find("#wo_list").Length() == 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
177
internal/anime/catalog_handler.go
Normal file
177
internal/anime/catalog_handler.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *AnimeHandler) HandleSimulcast(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
|
||||
"CurrentPath": "/simulcast",
|
||||
"User": server.CurrentUser(c),
|
||||
"SimulcastURL": simulcastURL(c),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleSimulcastContent(c *gin.Context) {
|
||||
now := time.Now()
|
||||
current := calendarSeason(now.Year(), int(now.Month()))
|
||||
latest := h.discoverySvc.LatestAvailableSeason(c.Request.Context(), current)
|
||||
selected := seasonSelection(c.Query("season"), c.Query("year"), current, latest)
|
||||
data, err := h.discoverySvc.GetSimulcast(c.Request.Context(), selected)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(), "simulcast_fetch_failed", "anime", "", nil, err)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), server.CurrentUserID(c), data.Animes)
|
||||
previous, next := seasonNavigation(selected, 2018, latest)
|
||||
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
|
||||
"_fragment": "simulcast_content",
|
||||
"CurrentPath": "/simulcast",
|
||||
"User": server.CurrentUser(c),
|
||||
"Animes": data.Animes,
|
||||
"Season": data.Season,
|
||||
"SeasonLabel": strings.ToUpper(data.Season[:1]) + data.Season[1:],
|
||||
"Year": data.Year,
|
||||
"SeasonOptions": seasonOptions(2018, latest),
|
||||
"Previous": previous,
|
||||
"Next": next,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func simulcastURL(c *gin.Context) string {
|
||||
query := url.Values{}
|
||||
if season := c.Query("season"); season != "" {
|
||||
query.Set("season", season)
|
||||
}
|
||||
if year := c.Query("year"); year != "" {
|
||||
query.Set("year", year)
|
||||
}
|
||||
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
return "/api/simulcast?" + encoded
|
||||
}
|
||||
return "/api/simulcast"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
64
internal/anime/catalog_handler_test.go
Normal file
64
internal/anime/catalog_handler_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/playback/allanime"
|
||||
"mal/templates"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type countingSeasonalProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *countingSeasonalProvider) SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error) {
|
||||
p.calls++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSimulcastPageDefersProviderFetch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
provider := &countingSeasonalProvider{}
|
||||
handler := NewAnimeHandler(nil, nil, nil, newSeasonDiscoveryService(provider))
|
||||
renderer, err := templates.ProvideRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("ProvideRenderer: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.HTMLRender = renderer
|
||||
router.GET("/simulcast", handler.HandleSimulcast)
|
||||
router.GET("/api/simulcast", handler.HandleSimulcastContent)
|
||||
|
||||
page := httptest.NewRecorder()
|
||||
router.ServeHTTP(page, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/simulcast?season=winter&year=2024", nil))
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("page status = %d, want %d", page.Code, http.StatusOK)
|
||||
}
|
||||
if provider.calls != 0 {
|
||||
t.Fatalf("provider calls during page render = %d, want 0", provider.calls)
|
||||
}
|
||||
if !strings.Contains(page.Body.String(), `hx-get="/api/simulcast?season=winter&year=2024"`) {
|
||||
t.Fatalf("page did not preserve the selected season in the deferred request:\n%s", page.Body.String())
|
||||
}
|
||||
|
||||
fragment := httptest.NewRecorder()
|
||||
router.ServeHTTP(fragment, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/simulcast?season=winter&year=2024", nil))
|
||||
if fragment.Code != http.StatusOK {
|
||||
t.Fatalf("fragment status = %d, want %d", fragment.Code, http.StatusOK)
|
||||
}
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("provider calls after fragment render = %d, want 2", provider.calls)
|
||||
}
|
||||
if strings.Contains(fragment.Body.String(), "<!DOCTYPE html>") {
|
||||
t.Fatal("fragment response unexpectedly rendered the full page")
|
||||
}
|
||||
if !strings.Contains(fragment.Body.String(), `id="simulcast-content"`) {
|
||||
t.Fatalf("fragment response is missing its replacement root:\n%s", fragment.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
351
internal/anime/details_handler.go
Normal file
351
internal/anime/details_handler.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
}
|
||||
|
||||
type animeReleaseInfoDisplay struct {
|
||||
Count int
|
||||
Label string
|
||||
Status string
|
||||
}
|
||||
|
||||
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 {
|
||||
info := h.animeReleaseInfo(ctx, anime, now)
|
||||
return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) animeReleaseInfo(ctx context.Context, anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
|
||||
if h.episodeSvc != nil {
|
||||
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
|
||||
if err == nil {
|
||||
return releaseInfoFromCanonical(anime, episodeList)
|
||||
} else {
|
||||
observability.Warn(
|
||||
"anime_episode_availability_count_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return animeInitialReleaseInfo(anime, now)
|
||||
}
|
||||
|
||||
func releaseInfoFromCanonical(anime domain.Anime, episodeList domain.CanonicalEpisodeList) animeReleaseInfoDisplay {
|
||||
info := animeReleaseInfoDisplay{Status: trustedAnimeStatus(anime, len(episodeList.Episodes))}
|
||||
if count := len(episodeList.Episodes); count > 0 {
|
||||
info.Count = count
|
||||
info.Label = canonicalEpisodeCountLabel(episodeList.Source)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func canonicalEpisodeCountLabel(source string) string {
|
||||
if source == "jikan_fallback" || source == "legacy_disabled" {
|
||||
return "Estimated aired episodes"
|
||||
}
|
||||
return "Available episodes"
|
||||
}
|
||||
|
||||
func animeInitialReleaseInfo(anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
|
||||
if isCurrentlyAiring(anime) {
|
||||
return animeReleaseInfoDisplay{}
|
||||
}
|
||||
|
||||
info := animeReleaseInfoDisplay{Status: strings.TrimSpace(anime.Status)}
|
||||
if anime.Episodes > 0 {
|
||||
info.Count = anime.Episodes
|
||||
info.Label = "Total episodes"
|
||||
return info
|
||||
}
|
||||
if count := releasedEpisodeCount(anime, now); count > 0 {
|
||||
info.Count = count
|
||||
info.Label = "Estimated aired episodes"
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
||||
info := animeInitialReleaseInfo(anime, now)
|
||||
return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
|
||||
}
|
||||
|
||||
func trustedAnimeStatus(anime domain.Anime, canonicalEpisodes int) string {
|
||||
if canonicalEpisodes == 0 && isCurrentlyAiring(anime) {
|
||||
return "Not yet aired"
|
||||
}
|
||||
if status := strings.TrimSpace(anime.Status); status != "" {
|
||||
return status
|
||||
}
|
||||
if anime.Airing {
|
||||
return "Currently Airing"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCurrentlyAiring(anime domain.Anime) bool {
|
||||
return anime.Airing || strings.EqualFold(strings.TrimSpace(anime.Status), "Currently Airing")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
releaseInfo := animeInitialReleaseInfo(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,
|
||||
"ReleaseInfo": releaseInfo,
|
||||
})
|
||||
}
|
||||
|
||||
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", "release-info":
|
||||
anime, err := h.svc.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return h.animeReleaseInfo(ctx, anime, time.Now()), "anime_release_info", 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,
|
||||
})
|
||||
}
|
||||
22
internal/anime/discovery.go
Normal file
22
internal/anime/discovery.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/playback/allanime"
|
||||
)
|
||||
|
||||
type seasonProvider interface {
|
||||
SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error)
|
||||
}
|
||||
|
||||
type SeasonDiscoveryService struct {
|
||||
provider seasonProvider
|
||||
}
|
||||
|
||||
func NewSeasonDiscoveryService(provider *allanime.AllAnimeProvider) *SeasonDiscoveryService {
|
||||
return newSeasonDiscoveryService(provider)
|
||||
}
|
||||
|
||||
func newSeasonDiscoveryService(provider seasonProvider) *SeasonDiscoveryService {
|
||||
return &SeasonDiscoveryService{provider: provider}
|
||||
}
|
||||
@@ -2,49 +2,31 @@ package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type AnimeHandler struct {
|
||||
svc Service
|
||||
watchlistSvc domain.WatchlistService
|
||||
episodeSvc domain.EpisodeService
|
||||
|
||||
scheduleCacheMu sync.Mutex
|
||||
scheduleCache map[string]cachedWeekSchedule
|
||||
discoverySvc *SeasonDiscoveryService
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
domain.AnimeCatalogService
|
||||
domain.AnimeDiscoverService
|
||||
domain.AnimeSearchService
|
||||
domain.AnimeDetailsService
|
||||
WarmDetailSections(id int)
|
||||
}
|
||||
|
||||
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
|
||||
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService, discoverySvc *SeasonDiscoveryService) *AnimeHandler {
|
||||
return &AnimeHandler{
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
episodeSvc: episodeSvc,
|
||||
scheduleCache: map[string]cachedWeekSchedule{},
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
episodeSvc: episodeSvc,
|
||||
discoverySvc: discoverySvc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,766 +52,22 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
|
||||
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) {
|
||||
r.GET("/", h.HandleCatalog)
|
||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
||||
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
||||
r.GET("/discover", h.HandleDiscover)
|
||||
r.GET("/discover/top-picks", h.HandleDiscoverTopPicksForYou)
|
||||
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("/search", h.HandleSearch)
|
||||
r.GET("/top-picks", h.HandleTopPicks)
|
||||
r.GET("/browse", h.HandleBrowse)
|
||||
r.GET("/simulcast", h.HandleSimulcast)
|
||||
r.GET("/api/simulcast", h.HandleSimulcastContent)
|
||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
||||
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/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/internal/domain"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type stubEpisodeService struct {
|
||||
episodes domain.CanonicalEpisodeList
|
||||
err error
|
||||
forced bool
|
||||
episodes domain.CanonicalEpisodeList
|
||||
err error
|
||||
called int
|
||||
forceRefresh bool
|
||||
}
|
||||
|
||||
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 {
|
||||
return domain.CanonicalEpisodeList{}, s.err
|
||||
}
|
||||
@@ -26,6 +29,272 @@ func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) e
|
||||
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 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, nil)
|
||||
|
||||
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 TestAnimeReleaseInfoUsesCanonicalEpisodes(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Source: "AllAnime",
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1},
|
||||
{Number: 2},
|
||||
{Number: 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||
|
||||
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 59970,
|
||||
Status: "Currently Airing",
|
||||
Airing: true,
|
||||
Episodes: 12,
|
||||
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 3 || got.Label != "Available episodes" || got.Status != "Currently Airing" {
|
||||
t.Fatalf("animeReleaseInfo() = %+v, want 3 available episodes and current status", got)
|
||||
}
|
||||
if episodeSvc.called != 1 {
|
||||
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeReleaseInfoDoesNotCallJikanFallbackAvailable(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Source: "jikan_fallback",
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||
|
||||
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 62076,
|
||||
Status: "Currently Airing",
|
||||
Airing: true,
|
||||
}}, time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 1 || got.Label != "Estimated aired episodes" {
|
||||
t.Fatalf("animeReleaseInfo() = %+v, want estimated aired episode count", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeReleaseInfoMarksAiringAnimeWithoutCanonicalEpisodesAsNotYetAired(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Source: "jikan_fallback",
|
||||
ReleaseChecked: true,
|
||||
Episodes: []domain.CanonicalEpisode{},
|
||||
},
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||
|
||||
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 62076,
|
||||
Status: "Currently Airing",
|
||||
Airing: true,
|
||||
Episodes: 6,
|
||||
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 0 || got.Label != "" || got.Status != "Not yet aired" {
|
||||
t.Fatalf("animeReleaseInfo() = %+v, want not-yet-aired status without count", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeEpisodeCountStopsWhenCanonicalEpisodesAreEmpty(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Source: "AllAnime",
|
||||
Episodes: []domain.CanonicalEpisode{},
|
||||
},
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||
|
||||
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 62076,
|
||||
Airing: true,
|
||||
Episodes: 12,
|
||||
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 0 || got.Label != "" {
|
||||
t.Fatalf("animeEpisodeCount() = %+v, want empty display", got)
|
||||
}
|
||||
if episodeSvc.called != 1 {
|
||||
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeInitialReleaseInfoDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
|
||||
got := animeInitialReleaseInfo(domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 62076,
|
||||
Status: "Currently Airing",
|
||||
Airing: true,
|
||||
Episodes: 6,
|
||||
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
|
||||
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 0 || got.Label != "" || got.Status != "" {
|
||||
t.Fatalf("animeInitialReleaseInfo() = %+v, want empty unverified airing display", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||
|
||||
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 TestAnimeInitialEpisodeCountDoesNotTrustCurrentlyAiringMetadata(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,
|
||||
Status: "Currently Airing",
|
||||
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 != 0 || got.Label != "" {
|
||||
t.Fatalf("animeInitialEpisodeCount() = %+v, want empty display", got)
|
||||
}
|
||||
if episodeSvc.called != 0 {
|
||||
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -108,7 +377,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
|
||||
},
|
||||
err: tt.err,
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
|
||||
|
||||
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
|
||||
Anime: jikan.Anime{MalID: 52991},
|
||||
@@ -116,7 +385,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
|
||||
if 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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
var Module = fx.Options(
|
||||
fx.Provide(
|
||||
NewAnimeRepository,
|
||||
NewSeasonDiscoveryService,
|
||||
fx.Annotate(
|
||||
NewAnimeService,
|
||||
fx.As(new(Service)),
|
||||
fx.As(new(domain.AnimeCatalogService)),
|
||||
fx.As(new(domain.AnimeDiscoverService)),
|
||||
fx.As(new(domain.AnimeSearchService)),
|
||||
fx.As(new(domain.AnimeDetailsService)),
|
||||
fx.As(new(domain.AnimePlaybackService)),
|
||||
fx.As(new(domain.RecommendationInvalidator)),
|
||||
),
|
||||
NewAnimeHandler,
|
||||
),
|
||||
|
||||
@@ -1,503 +1,139 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"context"
|
||||
"mal/internal/anime/recommendations"
|
||||
"mal/internal/domain"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"mal/internal/observability"
|
||||
"maps"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
forYouMaxSeeds = 8
|
||||
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 recommendationComputeFunc func(context.Context, string, int) (domain.CatalogSectionData, error)
|
||||
|
||||
type recommendationSeed struct {
|
||||
animeID int
|
||||
weight float64
|
||||
type topPicksCacheKey struct {
|
||||
userID string
|
||||
limit int
|
||||
}
|
||||
|
||||
type weightedEntity struct {
|
||||
id int
|
||||
weight float64
|
||||
type topPicksCacheEntry struct {
|
||||
data domain.CatalogSectionData
|
||||
updatedAt time.Time
|
||||
hasData bool
|
||||
refreshing bool
|
||||
}
|
||||
|
||||
type profileSearchQuery struct {
|
||||
genreIDs []int
|
||||
studioID int
|
||||
weight float64
|
||||
type topPicksCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[topPicksCacheKey]*topPicksCacheEntry
|
||||
}
|
||||
|
||||
type recommendationCandidate struct {
|
||||
anime jikan.Anime
|
||||
score float64
|
||||
genreMatches int
|
||||
themeMatches int
|
||||
studioMatches int
|
||||
demographicMatches int
|
||||
const topPicksRefreshTimeout = 30 * time.Second
|
||||
|
||||
func (s *animeService) GetTopPickForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
data := s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit)
|
||||
if len(data.Animes) > recommendations.TopPickLimit {
|
||||
data.Animes = data.Animes[:recommendations.TopPickLimit]
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type userTasteProfile struct {
|
||||
genres map[int]float64
|
||||
themes map[int]float64
|
||||
studios map[int]float64
|
||||
demographics map[int]float64
|
||||
prefersAiring bool
|
||||
prefersRecent bool
|
||||
func (s *animeService) GetTopPicksForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit), nil
|
||||
}
|
||||
|
||||
func buildRecommendationSeeds(
|
||||
now time.Time,
|
||||
watchlist []db.GetUserWatchListRow,
|
||||
) []recommendationSeed {
|
||||
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
|
||||
func (s *animeService) fetchTopPicksForYou(ctx context.Context, userID string, limit int) (domain.CatalogSectionData, error) {
|
||||
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, limit)
|
||||
}
|
||||
|
||||
for _, entry := range watchlist {
|
||||
weight := recommendationEntryWeight(now, entry)
|
||||
if weight <= 0 || entry.AnimeID <= 0 {
|
||||
continue
|
||||
func (s *animeService) getCachedTopPicksForYou(userID string, limit int) domain.CatalogSectionData {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}
|
||||
}
|
||||
|
||||
key := topPicksCacheKey{userID: userID, limit: limit}
|
||||
now := time.Now()
|
||||
|
||||
s.topPicksCache.mu.Lock()
|
||||
entry := s.topPicksCache.entries[key]
|
||||
if entry != nil && entry.hasData {
|
||||
data := cloneCatalogSectionData(entry.data)
|
||||
if now.Sub(entry.updatedAt) >= s.topPicksCacheTTL && !entry.refreshing {
|
||||
entry.refreshing = true
|
||||
go s.refreshTopPicksForYou(key)
|
||||
}
|
||||
s.topPicksCache.mu.Unlock()
|
||||
return data
|
||||
}
|
||||
|
||||
seeds = append(seeds, recommendationSeed{
|
||||
animeID: int(entry.AnimeID),
|
||||
weight: weight,
|
||||
})
|
||||
if len(seeds) >= forYouMaxSeeds {
|
||||
break
|
||||
if entry == nil {
|
||||
entry = &topPicksCacheEntry{}
|
||||
s.topPicksCache.entries[key] = entry
|
||||
}
|
||||
if !entry.refreshing {
|
||||
entry.refreshing = true
|
||||
go s.refreshTopPicksForYou(key)
|
||||
}
|
||||
s.topPicksCache.mu.Unlock()
|
||||
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}
|
||||
}
|
||||
|
||||
func (s *animeService) refreshTopPicksForYou(key topPicksCacheKey) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), topPicksRefreshTimeout)
|
||||
defer cancel()
|
||||
|
||||
data, err := s.computeTopPicks(ctx, key.userID, key.limit)
|
||||
if err != nil {
|
||||
observability.WarnContext(ctx,
|
||||
"top_picks_refresh_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"user_id": key.userID, "limit": key.limit},
|
||||
err,
|
||||
)
|
||||
s.topPicksCache.mu.Lock()
|
||||
if entry := s.topPicksCache.entries[key]; entry != nil {
|
||||
entry.refreshing = false
|
||||
}
|
||||
s.topPicksCache.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.topPicksCache.mu.Lock()
|
||||
s.topPicksCache.entries[key] = &topPicksCacheEntry{
|
||||
data: cloneCatalogSectionData(data),
|
||||
updatedAt: time.Now(),
|
||||
hasData: true,
|
||||
}
|
||||
s.topPicksCache.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *animeService) InvalidateTopPicksForUser(userID string) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.topPicksCache.mu.Lock()
|
||||
defer s.topPicksCache.mu.Unlock()
|
||||
for key := range s.topPicksCache.entries {
|
||||
if key.userID == userID {
|
||||
delete(s.topPicksCache.entries, key)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
func cloneCatalogSectionData(data domain.CatalogSectionData) domain.CatalogSectionData {
|
||||
data.Animes = append([]domain.Anime(nil), data.Animes...)
|
||||
data.ContinueWatching = append(data.ContinueWatching[:0:0], data.ContinueWatching...)
|
||||
if data.WatchlistMap != nil {
|
||||
watchlistMap := make(map[int64]bool, len(data.WatchlistMap))
|
||||
maps.Copy(watchlistMap, data.WatchlistMap)
|
||||
data.WatchlistMap = watchlistMap
|
||||
}
|
||||
|
||||
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
|
||||
return data
|
||||
}
|
||||
|
||||
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 (
|
||||
"database/sql"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -73,6 +74,47 @@ func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreRecommendationCandidateBuildsRationaleFromProfileMatches(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
profile := userTasteProfile{
|
||||
genres: map[int]float64{1: 2.0},
|
||||
themes: map[int]float64{10: 1.5},
|
||||
studios: map[int]float64{20: 1.0},
|
||||
demographics: map[int]float64{30: 1.0},
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||
MalID: 10,
|
||||
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||
Themes: []jikan.NamedEntity{{MalID: 10, Name: "School"}},
|
||||
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
|
||||
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
|
||||
}, 5.0, 0)
|
||||
|
||||
want := []string{"Action", "School", "Production I.G", "Shounen"}
|
||||
if !slices.Equal(candidate.rationale, want) {
|
||||
t.Fatalf("expected profile match rationale %v, got %v", want, candidate.rationale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreRecommendationCandidateOmitsRationaleWhenSignalsAreWeak(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
profile := userTasteProfile{
|
||||
genres: map[int]float64{1: 2.0},
|
||||
themes: map[int]float64{},
|
||||
studios: map[int]float64{},
|
||||
demographics: map[int]float64{},
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||
MalID: 10,
|
||||
}, 5.0, 0)
|
||||
|
||||
if len(candidate.rationale) != 0 {
|
||||
t.Fatalf("expected no rationale for weak signals, got %v", candidate.rationale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -174,6 +216,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 {
|
||||
return jikan.Anime{
|
||||
MalID: id,
|
||||
@@ -207,10 +261,8 @@ func animeIDs(animes []domain.Anime) []int {
|
||||
|
||||
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
|
||||
for _, query := range queries {
|
||||
for _, id := range query.genreIDs {
|
||||
if id == genreID {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(query.genreIDs, genreID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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, RecommendationRationale: candidate.rationale})
|
||||
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
|
||||
}
|
||||
150
internal/anime/recommendations/scoring.go
Normal file
150
internal/anime/recommendations/scoring.go
Normal file
@@ -0,0 +1,150 @@
|
||||
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,
|
||||
rationale: buildRecommendationRationale(profile, candidate),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRecommendationRationale(profile userTasteProfile, candidate jikan.Anime) []string {
|
||||
rationale := make([]string, 0, 4)
|
||||
rationale = append(rationale, matchedEntityNames(profile.genres, candidate.Genres)...)
|
||||
rationale = append(rationale, matchedEntityNames(profile.themes, candidate.Themes)...)
|
||||
rationale = append(rationale, matchedEntityNames(profile.studios, candidate.Studios)...)
|
||||
rationale = append(rationale, matchedEntityNames(profile.demographics, candidate.Demographics)...)
|
||||
|
||||
if len(rationale) > 4 {
|
||||
return rationale[:4]
|
||||
}
|
||||
return rationale
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func matchedEntityNames(weights map[int]float64, entities []jikan.NamedEntity) []string {
|
||||
if len(weights) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
names := make([]string, 0, 1)
|
||||
for _, entity := range entities {
|
||||
if entity.Name == "" || weights[entity.MalID] <= 0 {
|
||||
continue
|
||||
}
|
||||
names = append(names, entity.Name)
|
||||
if len(names) >= 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
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
|
||||
}
|
||||
46
internal/anime/recommendations/types.go
Normal file
46
internal/anime/recommendations/types.go
Normal file
@@ -0,0 +1,46 @@
|
||||
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
|
||||
rationale []string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
164
internal/anime/recommendations_cache_test.go
Normal file
164
internal/anime/recommendations_cache_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/anime/recommendations"
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
func TestGetTopPicksForYouReturnsEmptyOnCacheMissAndRefreshesInBackground(t *testing.T) {
|
||||
refreshed := make(chan struct{}, 1)
|
||||
svc := NewAnimeService(nil, nil)
|
||||
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||
refreshed <- struct{}{}
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 7}}}}, nil
|
||||
}
|
||||
|
||||
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopPicksForYou cache miss: %v", err)
|
||||
}
|
||||
if len(got.Animes) != 0 {
|
||||
t.Fatalf("cache miss animes = %+v, want empty while refresh runs", got.Animes)
|
||||
}
|
||||
|
||||
waitForRefresh(t, refreshed)
|
||||
|
||||
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopPicksForYou cache hit: %v", err)
|
||||
}
|
||||
if len(got.Animes) != 1 || got.Animes[0].MalID != 7 {
|
||||
t.Fatalf("cache hit animes = %+v, want anime 7", got.Animes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopPickAndTopPicksShareCache(t *testing.T) {
|
||||
refreshed := make(chan struct{}, 1)
|
||||
limits := make(chan int, 1)
|
||||
svc := NewAnimeService(nil, nil)
|
||||
svc.computeTopPicks = func(_ context.Context, _ string, limit int) (domain.CatalogSectionData, error) {
|
||||
limits <- limit
|
||||
animes := make([]domain.Anime, recommendations.TopPickLimit+1)
|
||||
for i := range animes {
|
||||
animes[i].MalID = i + 1
|
||||
}
|
||||
refreshed <- struct{}{}
|
||||
return domain.CatalogSectionData{Animes: animes}, nil
|
||||
}
|
||||
|
||||
if _, err := svc.GetTopPickForYou(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("GetTopPickForYou cache miss: %v", err)
|
||||
}
|
||||
waitForRefresh(t, refreshed)
|
||||
|
||||
carousel, err := svc.GetTopPickForYou(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopPickForYou cache hit: %v", err)
|
||||
}
|
||||
if len(carousel.Animes) != recommendations.TopPickLimit {
|
||||
t.Fatalf("carousel animes = %d, want %d", len(carousel.Animes), recommendations.TopPickLimit)
|
||||
}
|
||||
|
||||
all, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopPicksForYou shared cache: %v", err)
|
||||
}
|
||||
if len(all.Animes) != recommendations.TopPickLimit+1 {
|
||||
t.Fatalf("all animes = %d, want %d", len(all.Animes), recommendations.TopPickLimit+1)
|
||||
}
|
||||
|
||||
if limit := <-limits; limit != recommendations.TopPicksLimit {
|
||||
t.Fatalf("computed limit = %d, want %d", limit, recommendations.TopPicksLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTopPicksForYouReturnsStaleDataWhenRefreshFails(t *testing.T) {
|
||||
svc := NewAnimeService(nil, nil)
|
||||
svc.topPicksCacheTTL = time.Nanosecond
|
||||
refreshed := make(chan struct{}, 2)
|
||||
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||
refreshed <- struct{}{}
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 11}}}}, nil
|
||||
}
|
||||
|
||||
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("prime cache: %v", err)
|
||||
}
|
||||
waitForRefresh(t, refreshed)
|
||||
|
||||
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||
refreshed <- struct{}{}
|
||||
return domain.CatalogSectionData{}, errors.New("provider unavailable")
|
||||
}
|
||||
time.Sleep(time.Nanosecond)
|
||||
|
||||
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("stale GetTopPicksForYou: %v", err)
|
||||
}
|
||||
if len(got.Animes) != 1 || got.Animes[0].MalID != 11 {
|
||||
t.Fatalf("stale animes = %+v, want anime 11", got.Animes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTopPicksForUserRefreshesNextRequest(t *testing.T) {
|
||||
refreshed := make(chan struct{}, 2)
|
||||
results := []int{3, 4}
|
||||
|
||||
svc := newPickSvc(refreshed, &results)
|
||||
|
||||
primePickCache(t, svc, refreshed)
|
||||
|
||||
svc.InvalidateTopPicksForUser("user-1")
|
||||
|
||||
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopPicksForYou after invalidation: %v", err)
|
||||
}
|
||||
if len(got.Animes) != 0 {
|
||||
t.Fatalf("invalidated cache animes = %+v, want empty while refresh runs", got.Animes)
|
||||
}
|
||||
waitForRefresh(t, refreshed)
|
||||
|
||||
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopPicksForYou refreshed cache: %v", err)
|
||||
}
|
||||
if len(got.Animes) != 1 || got.Animes[0].MalID != 4 {
|
||||
t.Fatalf("refreshed animes = %+v, want anime 4", got.Animes)
|
||||
}
|
||||
}
|
||||
|
||||
func newPickSvc(refreshed chan struct{}, results *[]int) *animeService {
|
||||
svc := NewAnimeService(nil, nil)
|
||||
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
|
||||
id := (*results)[0]
|
||||
*results = (*results)[1:]
|
||||
refreshed <- struct{}{}
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: id}}}}, nil
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func primePickCache(t *testing.T, svc *animeService, refreshed chan struct{}) {
|
||||
t.Helper()
|
||||
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("prime cache: %v", err)
|
||||
}
|
||||
waitForRefresh(t, refreshed)
|
||||
}
|
||||
|
||||
func waitForRefresh(t *testing.T, refreshed chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-refreshed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("background refresh did not run")
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,10 @@ func (r *animeRepository) GetWatchListEntry(ctx context.Context, params db.GetWa
|
||||
func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||
return r.queries.GetContinueWatchingEntries(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *animeRepository) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||
return r.queries.GetContinueWatchingCarouselEntries(ctx, db.GetContinueWatchingCarouselEntriesParams{
|
||||
UserID: userID,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/animeschedule"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type cachedWeekSchedule struct {
|
||||
fetchedAt time.Time
|
||||
value animeschedule.WeekSchedule
|
||||
}
|
||||
|
||||
func parseYearWeek(c *gin.Context) (int, int) {
|
||||
year, _ := strconv.Atoi(c.Query("year"))
|
||||
week, _ := strconv.Atoi(c.Query("week"))
|
||||
if year <= 0 || week <= 0 {
|
||||
now := time.Now()
|
||||
y, w := now.ISOWeek()
|
||||
if year <= 0 {
|
||||
year = y
|
||||
}
|
||||
if week <= 0 {
|
||||
week = w
|
||||
}
|
||||
}
|
||||
return year, week
|
||||
}
|
||||
|
||||
func scheduleTimezone(c *gin.Context) string {
|
||||
timezone := strings.TrimSpace(c.Query("timezone"))
|
||||
if timezone == "" {
|
||||
return "UTC"
|
||||
}
|
||||
return timezone
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) {
|
||||
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
||||
const ttl = 10 * time.Minute
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
cached, ok := h.scheduleCache[cacheKey]
|
||||
h.scheduleCacheMu.Unlock()
|
||||
|
||||
if ok && time.Since(cached.fetchedAt) < ttl {
|
||||
return cached.value, nil
|
||||
}
|
||||
|
||||
value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone)
|
||||
if err != nil {
|
||||
return animeschedule.WeekSchedule{}, err
|
||||
}
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
||||
h.scheduleCacheMu.Unlock()
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type scheduleDayView struct {
|
||||
DateLabel string
|
||||
WeekdayLabel string
|
||||
Entries []animeschedule.Entry
|
||||
}
|
||||
|
||||
func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) []scheduleDayView {
|
||||
start := isoWeekStartMonday(year, week)
|
||||
order := []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday}
|
||||
out := make([]scheduleDayView, 0, 7)
|
||||
for i, wd := range order {
|
||||
date := start.AddDate(0, 0, i)
|
||||
entries := schedule.Days[wd]
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() {
|
||||
return entries[i].AirsAt.Before(entries[j].AirsAt)
|
||||
}
|
||||
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
|
||||
})
|
||||
out = append(out, scheduleDayView{
|
||||
DateLabel: strings.ToUpper(date.Format("02 Jan")),
|
||||
WeekdayLabel: wd.String(),
|
||||
Entries: entries,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localTimeMinutes(localTime string) int {
|
||||
for _, layout := range []string{"15:04", "03:04 PM"} {
|
||||
t, err := time.Parse(layout, localTime)
|
||||
if err == nil {
|
||||
return t.Hour()*60 + t.Minute()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isoWeekStartMonday(year int, week int) time.Time {
|
||||
// ISO week 1 is the week with the year's first Thursday in it.
|
||||
jan4 := time.Date(year, 1, 4, 12, 0, 0, 0, time.Local)
|
||||
// Move back to Monday
|
||||
offset := int(time.Monday - jan4.Weekday())
|
||||
if offset > 0 {
|
||||
offset -= 7
|
||||
}
|
||||
week1Monday := jan4.AddDate(0, 0, offset)
|
||||
return week1Monday.AddDate(0, 0, (week-1)*7)
|
||||
}
|
||||
|
||||
func adjacentISOWeek(year int, week int, deltaWeeks int) (int, int) {
|
||||
target := isoWeekStartMonday(year, week).AddDate(0, 0, deltaWeeks*7)
|
||||
return target.ISOWeek()
|
||||
}
|
||||
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,27 +1,29 @@
|
||||
// Package anime provides anime catalog, discovery, search, and details services.
|
||||
// Package anime provides anime catalog, search, and details services.
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type animeService struct {
|
||||
jikan *jikan.Client
|
||||
repo domain.AnimeRepository
|
||||
jikan *jikan.Client
|
||||
repo domain.AnimeRepository
|
||||
topPicksCache *topPicksCache
|
||||
topPicksCacheTTL time.Duration
|
||||
computeTopPicks recommendationComputeFunc
|
||||
}
|
||||
|
||||
const continueWatchingCarouselLimit int64 = 24
|
||||
|
||||
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
||||
out := make([]domain.Anime, 0, len(in))
|
||||
for _, a := range in {
|
||||
@@ -31,7 +33,14 @@ func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
||||
}
|
||||
|
||||
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
|
||||
return &animeService{jikan: jikan, repo: repo}
|
||||
svc := &animeService{
|
||||
jikan: jikan,
|
||||
repo: repo,
|
||||
topPicksCache: &topPicksCache{entries: map[topPicksCacheKey]*topPicksCacheEntry{}},
|
||||
topPicksCacheTTL: 15 * time.Minute,
|
||||
}
|
||||
svc.computeTopPicks = svc.fetchTopPicksForYou
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
|
||||
@@ -50,19 +59,25 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
case "Popular":
|
||||
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" {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
||||
return err
|
||||
cw, err = s.repo.GetContinueWatchingCarouselEntries(gCtx, userID, continueWatchingCarouselLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
|
||||
}
|
||||
return 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)
|
||||
@@ -76,359 +91,10 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
}, 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) {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Anime{}, err
|
||||
return domain.Anime{}, fmt.Errorf("get anime by id: %w", err)
|
||||
}
|
||||
return domain.Anime{Anime: anime}, nil
|
||||
}
|
||||
@@ -440,7 +106,7 @@ func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status,
|
||||
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
|
||||
res, err := s.jikan.GetProducerByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("get producer name: %w", err)
|
||||
}
|
||||
for _, t := range res.Data.Titles {
|
||||
if t.Title != "" {
|
||||
@@ -457,7 +123,7 @@ func (s *animeService) GetProducers(ctx context.Context, query string, page int,
|
||||
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
genres, err := s.jikan.GetAnimeGenres(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get genres: %w", err)
|
||||
}
|
||||
out := make([]domain.Genre, 0, len(genres))
|
||||
for _, g := range genres {
|
||||
@@ -472,7 +138,7 @@ func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.CharacterEntry, error) {
|
||||
items, err := s.jikan.GetAnimeCharacters(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get characters: %w", err)
|
||||
}
|
||||
|
||||
out := make([]domain.CharacterEntry, 0, len(items))
|
||||
@@ -508,7 +174,7 @@ func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Char
|
||||
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.RecommendationEntry, error) {
|
||||
items, err := s.jikan.GetAnimeRecommendations(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get recommendations: %w", err)
|
||||
}
|
||||
|
||||
out := make([]domain.RecommendationEntry, 0, len(items))
|
||||
@@ -525,8 +191,8 @@ func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id)
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int, mode jikan.WatchOrderMode) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id, mode)
|
||||
}
|
||||
|
||||
func (s *animeService) WarmDetailSections(id int) {
|
||||
@@ -541,7 +207,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) {
|
||||
items, err := s.jikan.GetAnimeStaff(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get staff: %w", err)
|
||||
}
|
||||
|
||||
out := make([]domain.StaffEntry, 0, len(items))
|
||||
@@ -560,7 +226,7 @@ func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntr
|
||||
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
|
||||
stats, err := s.jikan.GetAnimeStatistics(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Statistics{}, err
|
||||
return domain.Statistics{}, fmt.Errorf("get statistics: %w", err)
|
||||
}
|
||||
|
||||
out := domain.Statistics{
|
||||
@@ -583,7 +249,7 @@ func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statis
|
||||
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
|
||||
themes, err := s.jikan.GetAnimeThemes(ctx, id)
|
||||
if err != nil {
|
||||
return domain.ThemesData{}, err
|
||||
return domain.ThemesData{}, fmt.Errorf("get themes: %w", err)
|
||||
}
|
||||
return domain.ThemesData{
|
||||
Openings: append([]string(nil), themes.Openings...),
|
||||
@@ -594,7 +260,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) {
|
||||
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return nil, false, fmt.Errorf("get reviews: %w", err)
|
||||
}
|
||||
out := make([]domain.ReviewEntry, 0, len(data))
|
||||
for _, it := range data {
|
||||
@@ -652,13 +318,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{}, err
|
||||
return domain.Anime{}, fmt.Errorf("get random anime: %w", err)
|
||||
}
|
||||
|
||||
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
|
||||
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get all episodes: %w", err)
|
||||
}
|
||||
result := make([]domain.EpisodeData, len(episodes))
|
||||
for i, ep := range episodes {
|
||||
|
||||
56
internal/anime/service_test.go
Normal file
56
internal/anime/service_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/internal/db"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type catalogRepoStub struct {
|
||||
watchlist []db.GetUserWatchListRow
|
||||
allContinueRows []db.GetContinueWatchingEntriesRow
|
||||
carouselContinueRows []db.GetContinueWatchingEntriesRow
|
||||
carouselLimit int64
|
||||
}
|
||||
|
||||
func (r *catalogRepoStub) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
|
||||
return r.watchlist, nil
|
||||
}
|
||||
|
||||
func (r *catalogRepoStub) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {
|
||||
return db.WatchListEntry{}, nil
|
||||
}
|
||||
|
||||
func (r *catalogRepoStub) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||
return r.allContinueRows, nil
|
||||
}
|
||||
|
||||
func (r *catalogRepoStub) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||
r.carouselLimit = limit
|
||||
return r.carouselContinueRows, nil
|
||||
}
|
||||
|
||||
func TestGetCatalogSectionLimitsContinueWatchingCarousel(t *testing.T) {
|
||||
repo := &catalogRepoStub{
|
||||
allContinueRows: make([]db.GetContinueWatchingEntriesRow, 30),
|
||||
carouselContinueRows: []db.GetContinueWatchingEntriesRow{
|
||||
{AnimeID: 30},
|
||||
{AnimeID: 29},
|
||||
},
|
||||
}
|
||||
svc := NewAnimeService(nil, repo)
|
||||
|
||||
got, err := svc.GetCatalogSection(context.Background(), "user-1", "Continue")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCatalogSection: %v", err)
|
||||
}
|
||||
if repo.carouselLimit != continueWatchingCarouselLimit {
|
||||
t.Fatalf("carousel limit = %d, want %d", repo.carouselLimit, continueWatchingCarouselLimit)
|
||||
}
|
||||
if len(got.ContinueWatching) != len(repo.carouselContinueRows) {
|
||||
t.Fatalf("len(ContinueWatching) = %d, want %d", len(got.ContinueWatching), len(repo.carouselContinueRows))
|
||||
}
|
||||
if got.ContinueWatching[0].AnimeID != 30 || got.ContinueWatching[1].AnimeID != 29 {
|
||||
t.Fatalf("ContinueWatching = %+v, want anime IDs [30 29]", got.ContinueWatching)
|
||||
}
|
||||
}
|
||||
144
internal/anime/simulcast.go
Normal file
144
internal/anime/simulcast.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type animeSeason struct {
|
||||
Season string
|
||||
Year int
|
||||
}
|
||||
|
||||
type seasonOption struct {
|
||||
Season string
|
||||
Year int
|
||||
Label string
|
||||
}
|
||||
|
||||
var seasons = []string{"winter", "spring", "summer", "fall"}
|
||||
|
||||
func seasonOptions(firstYear int, latest animeSeason) []seasonOption {
|
||||
options := make([]seasonOption, 0, (latest.Year-firstYear+1)*len(seasons))
|
||||
for year := latest.Year; year >= firstYear; year-- {
|
||||
start := len(seasons) - 1
|
||||
if year == latest.Year {
|
||||
start = seasonIndex(latest.Season)
|
||||
}
|
||||
for i := start; i >= 0; i-- {
|
||||
season := seasons[i]
|
||||
options = append(options, seasonOption{
|
||||
Season: season,
|
||||
Year: year,
|
||||
Label: strings.ToUpper(season[:1]) + season[1:] + " " + strconv.Itoa(year),
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func seasonIndex(season string) int {
|
||||
for i, candidate := range seasons {
|
||||
if strings.EqualFold(candidate, season) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func calendarSeason(year, month int) animeSeason {
|
||||
index := (month - 1) / 3
|
||||
if index < 0 || index >= len(seasons) {
|
||||
index = 0
|
||||
}
|
||||
return animeSeason{Season: seasons[index], Year: year}
|
||||
}
|
||||
|
||||
func adjacentSeason(season string, year, direction int) animeSeason {
|
||||
index := 0
|
||||
for i, candidate := range seasons {
|
||||
if strings.EqualFold(candidate, season) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
index += direction
|
||||
if index < 0 {
|
||||
index = len(seasons) - 1
|
||||
year--
|
||||
} else if index >= len(seasons) {
|
||||
index = 0
|
||||
year++
|
||||
}
|
||||
return animeSeason{Season: seasons[index], Year: year}
|
||||
}
|
||||
|
||||
func seasonNavigation(selected animeSeason, firstYear int, latest animeSeason) (*animeSeason, *animeSeason) {
|
||||
var previous *animeSeason
|
||||
var next *animeSeason
|
||||
if selected.Year > firstYear || selected.Season != "winter" {
|
||||
value := adjacentSeason(selected.Season, selected.Year, -1)
|
||||
previous = &value
|
||||
}
|
||||
value := adjacentSeason(selected.Season, selected.Year, 1)
|
||||
if value.Year < latest.Year || value.Year == latest.Year && seasonIndex(value.Season) <= seasonIndex(latest.Season) {
|
||||
next = &value
|
||||
}
|
||||
return previous, next
|
||||
}
|
||||
|
||||
func seasonSelection(rawSeason, rawYear string, current, latest animeSeason) animeSeason {
|
||||
season := strings.ToLower(strings.TrimSpace(rawSeason))
|
||||
validSeason := slices.Contains(seasons, season)
|
||||
year, err := strconv.Atoi(rawYear)
|
||||
if !validSeason || err != nil || year < 2000 || year > latest.Year || year == latest.Year && seasonIndex(season) > seasonIndex(latest.Season) {
|
||||
return current
|
||||
}
|
||||
return animeSeason{Season: season, Year: year}
|
||||
}
|
||||
|
||||
func (s *SeasonDiscoveryService) LatestAvailableSeason(ctx context.Context, current animeSeason) animeSeason {
|
||||
next := adjacentSeason(current.Season, current.Year, 1)
|
||||
shows, err := s.provider.SeasonalShows(ctx, next.Season, next.Year)
|
||||
if err == nil && len(shows) > 0 {
|
||||
return next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
type SimulcastData struct {
|
||||
Animes []domain.Anime
|
||||
Season string
|
||||
Year int
|
||||
}
|
||||
|
||||
func (s *SeasonDiscoveryService) GetSimulcast(ctx context.Context, selected animeSeason) (SimulcastData, error) {
|
||||
shows, err := s.provider.SeasonalShows(ctx, selected.Season, selected.Year)
|
||||
if err != nil {
|
||||
return SimulcastData{}, err
|
||||
}
|
||||
animes := make([]domain.Anime, 0, len(shows))
|
||||
for _, show := range shows {
|
||||
anime := jikan.Anime{
|
||||
MalID: show.MalID,
|
||||
Title: show.Name,
|
||||
TitleEnglish: show.EnglishName,
|
||||
Synopsis: show.Description,
|
||||
Status: show.Status,
|
||||
Type: show.Type,
|
||||
Year: show.Year,
|
||||
Episodes: show.EpisodeCount,
|
||||
}
|
||||
anime.Images.Webp.LargeImageURL = show.Thumbnail
|
||||
animes = append(animes, domain.Anime{Anime: anime})
|
||||
}
|
||||
return SimulcastData{
|
||||
Animes: animes,
|
||||
Season: selected.Season,
|
||||
Year: selected.Year,
|
||||
}, nil
|
||||
}
|
||||
88
internal/anime/simulcast_test.go
Normal file
88
internal/anime/simulcast_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/playback/allanime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type seasonalProviderStub struct {
|
||||
shows map[string][]allanime.ProviderShow
|
||||
}
|
||||
|
||||
func (p seasonalProviderStub) SeasonalShows(_ context.Context, season string, year int) ([]allanime.ProviderShow, error) {
|
||||
return p.shows[fmt.Sprintf("%s-%d", season, year)], nil
|
||||
}
|
||||
|
||||
func TestAdjacentSeasonCrossesYearBoundary(t *testing.T) {
|
||||
previous := adjacentSeason("winter", 2026, -1)
|
||||
if previous.Season != "fall" || previous.Year != 2025 {
|
||||
t.Fatalf("previous = %+v", previous)
|
||||
}
|
||||
|
||||
next := adjacentSeason("fall", 2026, 1)
|
||||
if next.Season != "winter" || next.Year != 2027 {
|
||||
t.Fatalf("next = %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeasonSelectionRejectsInvalidValues(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)
|
||||
current := calendarSeason(now.Year(), int(now.Month()))
|
||||
got := seasonSelection("monsoon", "nope", current, current)
|
||||
if got.Season != "summer" || got.Year != 2026 {
|
||||
t.Fatalf("seasonSelection = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeasonSelectionDefaultsToCalendarSeasonWhenNewerSeasonIsAvailable(t *testing.T) {
|
||||
current := animeSeason{Season: "spring", Year: 2026}
|
||||
latest := animeSeason{Season: "summer", Year: 2026}
|
||||
got := seasonSelection("", "", current, latest)
|
||||
if got != current {
|
||||
t.Fatalf("seasonSelection = %+v, want %+v", got, current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestAvailableSeasonIncludesPlayableNextSeason(t *testing.T) {
|
||||
provider := seasonalProviderStub{shows: map[string][]allanime.ProviderShow{
|
||||
"summer-2026": {{MalID: 1}},
|
||||
}}
|
||||
svc := newSeasonDiscoveryService(provider)
|
||||
got := svc.LatestAvailableSeason(context.Background(), animeSeason{Season: "spring", Year: 2026})
|
||||
if got.Season != "summer" || got.Year != 2026 {
|
||||
t.Fatalf("LatestAvailableSeason = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeasonOptionsRunNewestFirstWithoutFutureSeasons(t *testing.T) {
|
||||
got := seasonOptions(2025, animeSeason{Season: "summer", Year: 2026})
|
||||
want := []string{"Summer 2026", "Spring 2026", "Winter 2026", "Fall 2025", "Summer 2025", "Spring 2025", "Winter 2025"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(seasonOptions) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].Label != want[i] {
|
||||
t.Fatalf("seasonOptions[%d] = %q, want %q", i, got[i].Label, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeasonNavigationStopsAtSupportedRange(t *testing.T) {
|
||||
current := animeSeason{Season: "summer", Year: 2026}
|
||||
if previous, _ := seasonNavigation(animeSeason{Season: "winter", Year: 2018}, 2018, current); previous != nil {
|
||||
t.Fatalf("previous = %+v, want nil", previous)
|
||||
}
|
||||
if _, next := seasonNavigation(current, 2018, current); next != nil {
|
||||
t.Fatalf("next = %+v, want nil", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSeasonDefaultsToCalendarSeason(t *testing.T) {
|
||||
got := calendarSeason(2026, 7)
|
||||
if got.Season != "summer" || got.Year != 2026 {
|
||||
t.Fatalf("calendarSeason = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package app bootstraps and wires the application dependencies.
|
||||
package app
|
||||
package internal
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/episodes"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/playback"
|
||||
"mal/internal/server"
|
||||
"mal/internal/watchlist"
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
func NewApp() *fx.App {
|
||||
return fx.New(
|
||||
fx.WithLogger(observability.NewFxLogger),
|
||||
config.Module,
|
||||
database.Module,
|
||||
audit.Module,
|
||||
@@ -34,6 +35,7 @@ func NewApp() *fx.App {
|
||||
playback.Module,
|
||||
templates.Module,
|
||||
server.Module,
|
||||
fx.Invoke(RunMigrationsAndFixes),
|
||||
fx.Provide(func(r *templates.Renderer) render.HTMLRender {
|
||||
return r
|
||||
}),
|
||||
@@ -2,6 +2,7 @@ package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -13,29 +14,9 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordInsertsAuditLog(t *testing.T) {
|
||||
tmp, err := os.CreateTemp("", "mal-audit-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
_ = 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)
|
||||
}
|
||||
sqlDB := openTestDB(t)
|
||||
svc := audit.NewAuditService(db.New(sqlDB))
|
||||
insertTestUser(t, sqlDB, "user-1")
|
||||
|
||||
ctx := audit.WithRequestInfo(context.Background(), "127.0.0.1", "unit-test")
|
||||
metadata, err := json.Marshal(struct {
|
||||
@@ -55,28 +36,95 @@ func TestRecordInsertsAuditLog(t *testing.T) {
|
||||
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 {
|
||||
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() {
|
||||
t.Fatalf("expected audit row")
|
||||
}
|
||||
|
||||
var action, resourceType, resourceID, ip, userAgent, metadataJSON string
|
||||
if err := rows.Scan(&action, &resourceType, &resourceID, &ip, &userAgent, &metadataJSON); err != nil {
|
||||
var row auditRow
|
||||
if err := rows.Scan(&row.action, &row.resourceType, &row.resourceID, &row.ip, &row.userAgent, &row.metadataJSON); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
|
||||
if action != "test_action" || resourceType != "thing" || resourceID != "123" {
|
||||
t.Fatalf("unexpected row action=%q resourceType=%q resourceID=%q", action, resourceType, resourceID)
|
||||
return row
|
||||
}
|
||||
|
||||
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" {
|
||||
t.Fatalf("unexpected request info ip=%q userAgent=%q", ip, userAgent)
|
||||
if row.ip != "127.0.0.1" || row.userAgent != "unit-test" {
|
||||
t.Fatalf("unexpected request info ip=%q userAgent=%q", row.ip, row.userAgent)
|
||||
}
|
||||
if metadataJSON == "" || metadataJSON == "null" {
|
||||
t.Fatalf("expected metadata_json, got %q", metadataJSON)
|
||||
if row.metadataJSON == "" || row.metadataJSON == "null" {
|
||||
t.Fatalf("expected metadata_json, got %q", row.metadataJSON)
|
||||
}
|
||||
}
|
||||
|
||||
58
internal/auth/cookie.go
Normal file
58
internal/auth/cookie.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mal/internal/domain"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const sessionCookieName = "session_id"
|
||||
|
||||
func setSessionCookie(c *gin.Context, value string, maxAge int) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
Secure: isHTTPSRequest(c.Request),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func setPersistentSessionCookie(c *gin.Context, value string) {
|
||||
setSessionCookie(c, value, int(domain.SessionLifetime.Seconds()))
|
||||
}
|
||||
|
||||
func clearSessionCookie(c *gin.Context) {
|
||||
setSessionCookie(c, "", -1)
|
||||
}
|
||||
|
||||
func isHTTPSRequest(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if i := strings.IndexByte(proto, ','); i >= 0 {
|
||||
proto = proto[:i]
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(proto), "https") {
|
||||
return true
|
||||
}
|
||||
|
||||
forwarded := r.Header.Values("Forwarded")
|
||||
for _, value := range forwarded {
|
||||
for part := range strings.SplitSeq(value, ";") {
|
||||
key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if ok && strings.EqualFold(key, "proto") && strings.EqualFold(strings.Trim(val, `"`), "https") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user