Compare commits
752 Commits
dev
...
8fd7c1104c
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2e26a82aa7 | |||
| b319b2d93d | |||
| e13330367d | |||
| 600c8dec2e | |||
| 162265a3f3 | |||
| 9e3185c04e | |||
| b8a89b7d2d | |||
| 295afa6b59 | |||
| 633ed066d4 | |||
| 15ac8e4065 | |||
| f12df9b515 | |||
| b81bc63042 | |||
| 4e375adcee | |||
| b87a8feb1b | |||
| 7142e7745e | |||
| 5311640056 | |||
| 24d77cfe98 | |||
| 5c10bd1a5a | |||
| 550d594f00 | |||
| a328d72665 | |||
| 97477807d4 | |||
| 731b13a2aa | |||
| b01eec3925 | |||
| ac02fb9b71 | |||
| 44786455b4 | |||
| 037a8abd1b | |||
| 33b0d4b3c6 | |||
| 9b2846af33 | |||
| 81966520a1 | |||
| 072f565c1b | |||
| c9b3df573e | |||
| d6390acf3c | |||
| 103b6acb9a | |||
| cd38bbad16 | |||
| 407bda720e | |||
| 26509e6741 | |||
| 6c5bfd95c1 | |||
| 2b7aef0072 | |||
| 0482a43ac7 | |||
| 61218c2676 | |||
| 64d62e79ce | |||
| 77971d611c | |||
| 7d3aea8625 | |||
| 0cd8f8563d | |||
| 31a59b60b8 | |||
| cd55def040 | |||
| 388a1623aa | |||
| ce9b6efe46 | |||
| b2f6db8ae1 | |||
| 2df8b7863d | |||
| 351640e604 | |||
| 41be0fc923 | |||
| e235f36a45 | |||
| 8e66581f6c | |||
| 9b92f37cb1 | |||
| ed48aa340c | |||
| c13895b7cd | |||
| 7ebfe4807b | |||
| 1327cb3b86 | |||
| 16ee2ed0ee | |||
| 91e0280ec7 | |||
| f880205f5c | |||
| fcdfd0a623 | |||
| 32d7301788 | |||
| 136afa05a5 | |||
| 63802bfc5a | |||
| 3f482b69be | |||
| b0429ead6e | |||
| d15e1a33b6 | |||
| d82eeecfc0 | |||
| 41be636c4d | |||
| 95b1e2b93e | |||
| 1e6e619a3f | |||
| 8c0f345bde | |||
| 322cdac21d | |||
| b7f10e71da | |||
| 2863c3e7ef | |||
| e269d15199 | |||
| 4a1467467c | |||
| 34f52428a2 | |||
| b9ad50b67a | |||
| be27625a3c | |||
| 085fe3e83d | |||
| 0e92c2ce25 | |||
| 9e4e3214f7 | |||
| fa078c7de6 | |||
| 0cc9207755 | |||
| b9e1cc9aeb | |||
| 04b7a1e3ee | |||
| 433ed28514 | |||
| b35acfcce3 | |||
| 0f85c1b405 | |||
| 7c548c4d31 | |||
| 6253bc5b63 | |||
| 28c453847a | |||
| 399f68a7f2 | |||
| f818bd4395 | |||
| d77952522a | |||
| ab519a5361 | |||
| 6303d3c83c | |||
| cc2b885c76 | |||
| e3051d8860 | |||
| 5cf7fe7e8e | |||
| 555c4f2f9b | |||
| 65405402a8 | |||
| 2f7af1f739 | |||
| be7994b806 | |||
| e200fa5fa6 | |||
| fbc9eeeb86 | |||
| 704b03655b | |||
| 9383e132e7 | |||
| 420e748bab | |||
| 0bb4da858b | |||
| 8c3ff3df94 | |||
| c044c30bd8 | |||
| faf0a4db9f | |||
| 9e8d479ce0 | |||
| 0d25099b91 | |||
| 532e03d354 | |||
| 0a0b4895de | |||
| bf28c307c9 | |||
| 8454d01b09 | |||
| 324dcc29b5 | |||
| 0fd478cadb | |||
| 23e7a417b2 | |||
| 089d79bc5f | |||
| 0ec987f39f | |||
| e0126c964e | |||
| 7ff407bafa | |||
| b6604629fc | |||
| 8a207d383c | |||
| 59b1e0513b | |||
| 10c2d50d23 | |||
| cd26b24252 | |||
| 9c8075eedd | |||
| 6bb9b06ebf | |||
| 198786d743 | |||
| d6b96068fb | |||
| 4aac57d40d | |||
| 219dbe0f4b | |||
| a71fab0c35 | |||
| f80a52b171 | |||
| e6ab45da74 | |||
| bc90145fca | |||
| 7a6765c1bd | |||
| 0695fb7472 | |||
| 3853e4a327 | |||
| 5909a46803 | |||
| 2068e6b0b7 | |||
| 2091f0f365 | |||
| 7e6153afa1 | |||
| 4b690ebd99 | |||
| 5b8988ff14 | |||
| e3fe31fff7 | |||
| cef7d1055a | |||
| c2831f8aca | |||
| 47a7aa8e81 | |||
| 3b6d1b6439 | |||
| 34b8b96a62 | |||
| 2df19af6ad | |||
| d528f6b372 | |||
| 86586ed344 | |||
| 4a4ed6ef02 | |||
| 3accf85f99 | |||
| 931ee7f493 | |||
| a57b0b79de | |||
| 5a11343a19 | |||
| ea63544998 | |||
| 95a434cd04 | |||
| c2650aae07 | |||
| e500af6102 | |||
| df1e65f5c2 | |||
| 1c4ade5e6c | |||
| 4c2c54229b | |||
| 2172d32dc6 | |||
| d66eb79295 | |||
| 3c121cb1ac | |||
| bd979cdb68 | |||
| fbf94970fa | |||
| ecd11f70c3 | |||
| 6a5cf4f375 | |||
| 7aff463580 | |||
| 1536590fa2 | |||
| c2afb6eafc | |||
| a92d2b46c8 | |||
| fdfe082e45 | |||
| 44563959ca | |||
| a3a9b01794 | |||
| 003c94f62f | |||
| dba96e6713 | |||
| b4c31b04dd | |||
| 78378f79fa | |||
| 193c8d78a1 | |||
| a4f46c67a2 | |||
| 429974dc33 | |||
| 1df47ccc02 | |||
| e25aba4d70 | |||
| 580b17e5b9 | |||
| 2b167a8df8 | |||
| f44d6def6b | |||
| 23eb2f9a1b | |||
| a92bb0287c | |||
| 73cad8f7d5 | |||
| c23b298f26 | |||
| 318de9cb74 | |||
| 228003b013 | |||
| feeeb89cfc | |||
| 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 | |||
|
|
f04b148b43 | ||
|
|
6f3ca3e21b | ||
| 331d6fbbb9 | |||
| 6450233fea | |||
| 25bd91934c | |||
| 95116de349 | |||
| 91db8a5fe0 | |||
| f70e2e4bcd | |||
| eb9e682b75 | |||
| 509ce93904 | |||
| 447f540b44 | |||
| a5fdd8b999 | |||
| 95ca4dd892 | |||
| e9576d7584 | |||
| 5a054d250e | |||
| 65a7b0f50d | |||
| b8521d2219 | |||
| edbd83f8e8 | |||
| c9059be57b | |||
| afbe74d975 | |||
| 9938bf6c57 | |||
| 91bf399ebc | |||
| b63a5c48a2 | |||
| 2a266c6b1e | |||
| 28df1fc5f7 | |||
| 1165458cfa | |||
| 8bed032a44 | |||
| f2a319af4d | |||
| 627421255d | |||
| cce840e7f5 | |||
| 7279eac949 | |||
| 4ffa6af298 | |||
| 7bff60f08a | |||
| 4e8ba7205b | |||
| c6090604ef | |||
| 30441c3e1f | |||
| 6da80df655 | |||
| 083c0ee0c9 | |||
| 8785c19b66 | |||
| 3e79f62805 | |||
| 50159286b4 | |||
| 749a275dc0 | |||
| 71dd130744 | |||
| f2b4a7994a | |||
| 518370842c | |||
| 68225cbb52 | |||
| e24ae1d113 | |||
| 9c3636f31a | |||
| ff8f760750 | |||
| 5f4010901a | |||
| 57be9a5d70 | |||
| 6dd84976de | |||
| a303c131f1 | |||
| dfe3c6b7d8 | |||
| 51bfc9d2af | |||
| 90e7a9323a | |||
| 1feee731cf | |||
| fa91c2a22d | |||
| f196862aeb | |||
| 118c028873 | |||
| 28251876e1 | |||
| 3331c96c06 | |||
| 4fc79bc692 | |||
| 96307d2979 | |||
| e08a0e1f71 | |||
| d64dbaf7df | |||
| d787625435 | |||
| 3f496ac65c | |||
| 8daad49061 | |||
| e99070c6d4 | |||
| 513bfe07f2 | |||
| 1e9874a482 | |||
| 26ff84d70f | |||
| 82072b256d | |||
| f8ba6db3d6 | |||
| a190ca417d | |||
| 4bf31fb511 | |||
| 46cff45d0e | |||
| ab5476d3d2 | |||
| f4061c0213 | |||
| 1eb28dad64 | |||
| 76a32e1dc4 | |||
| 4af68021f6 | |||
| 36213edd60 | |||
| f5dfb91ffe | |||
| f5fd50d472 | |||
| 698fcc9b5b | |||
| b95427998c | |||
| b6e06870aa | |||
| 246fa7439d | |||
| 53abdace1c | |||
| 76a92894e8 | |||
| 3a0e04dda9 | |||
| 29c0c0bb18 | |||
| e54d6b8142 | |||
| f4a9453514 | |||
| a9dfb77bc4 | |||
| 48b5523d95 | |||
| 345c3b05f7 | |||
| 585b02b37a | |||
| c480a9be1f | |||
| fe39e094d8 | |||
| f9c1fc9391 | |||
| 900e56d7ca | |||
| 019a519b81 | |||
| 28bfbe5257 | |||
| 6932d4b8d0 | |||
| 83f64a1dfe | |||
| 44a36e3fb7 | |||
| 931398fa67 | |||
| f13f7b7fc6 | |||
| e0749066ec | |||
| 233beb609c | |||
| e87b79bbe1 | |||
| 624a02c49d | |||
| 5d7518afd9 | |||
| 4606c790f1 | |||
| 05e963151c | |||
| 6012ba824f | |||
| 2324d2a8e6 | |||
| 36f1961c9e | |||
| aa650068b1 | |||
| 0edc8feb8d | |||
| 258c676e89 | |||
| fc1883a6c3 | |||
| e022b60920 | |||
| ea831b3e2d | |||
| 6e41bb2789 | |||
| 650b2e614a | |||
| 7c1045df93 | |||
| 31b763b714 | |||
| 679c26e43f | |||
| bdf09ccdb7 | |||
| ae0ac66c2a | |||
| 2cf5bc2017 | |||
| e25b0acf7d | |||
| 54aca51e2b | |||
| 3cd7302c9c | |||
| df0c00a2f9 | |||
| 125b2e2510 | |||
| 7e3e138fee | |||
| 79a518d941 | |||
| cfaf6e6640 | |||
| da9bb56d80 | |||
| 4403301f72 | |||
| c0606ef938 | |||
| 2ac8660435 | |||
| 9da9edae7f | |||
| 323c503581 | |||
| 0e1bf7a36f | |||
| f6f95bc164 | |||
| 391a4f750c | |||
| 905e00ef6a | |||
| 07a6b6e4aa | |||
| ad3817dfee | |||
| 065e3fd7d6 | |||
| bfb8cc0274 | |||
| 7a18461ca6 | |||
| f33c2e18af | |||
| c2e4cae253 | |||
| 767e056aad |
@@ -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)
|
||||
|
||||
97
.golangci.yml
Normal file
97
.golangci.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
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:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: early-return
|
||||
- name: error-naming
|
||||
- name: error-return
|
||||
- name: if-return
|
||||
- name: increment-decrement
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: time-naming
|
||||
- name: unnecessary-stmt
|
||||
- name: var-declaration
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules/
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
4
.oxfmtrc.json
Normal file
4
.oxfmtrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": []
|
||||
}
|
||||
4
.oxlintignore
Normal file
4
.oxlintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/**
|
||||
node_modules/**
|
||||
server
|
||||
*.js
|
||||
15
.oxlintrc.json
Normal file
15
.oxlintrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-floating-promises": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
20
Dockerfile
20
Dockerfile
@@ -5,11 +5,16 @@ WORKDIR /app
|
||||
# Enable CGO for sqlite3
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
# Install sqlc for code generation
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
unzip \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install build dependencies for bun + assets
|
||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/*
|
||||
# Install bun (for building frontend assets)
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
@@ -26,11 +31,9 @@ COPY . .
|
||||
# Ensure dist is clean at build time (belt + suspenders)
|
||||
RUN rm -rf dist/ && bun run build:assets
|
||||
|
||||
# Generate sqlc code
|
||||
RUN sqlc generate
|
||||
|
||||
# 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
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -46,11 +49,12 @@ 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/templates ./templates
|
||||
COPY --from=builder /app/static ./static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/internal/database/migrations ./migrations
|
||||
COPY docker/entrypoint.sh ./entrypoint.sh
|
||||
COPY entrypoint.sh ./entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
133
README.md
133
README.md
@@ -1,136 +1,55 @@
|
||||
# MyAnimeList
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<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="140" />
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<strong>MyAnimeList</strong><br />
|
||||
My personal anime tracker, built because nothing else felt right.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p align="center">
|
||||
<img src="/static/assets/logo.png" alt="MyAnimeList logo" width="120" />
|
||||
</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="Tailwind" src="https://img.shields.io/badge/tailwind-4-06B6D4?style=flat-square&logo=tailwindcss" />
|
||||
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
|
||||
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
|
||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
|
||||
</p>
|
||||
|
||||
---
|
||||
MyAnimeList is a small self-hosted anime tracker and playback app. It keeps the catalog, watchlist, progress tracking, and player in one place, backed by a single SQLite database and a single Go server.
|
||||
|
||||
## Why this project exists
|
||||
Most of the UI is rendered on the server. HTMX handles lightweight updates like search, pagination, and watchlist changes, while TypeScript is kept for the parts that need real browser state: the video player, command palette, theme handling, and skip segment editor. The app also includes local users, API tokens, subtitle support, playlist rewriting, provider integrations, migrations, and startup data fixes.
|
||||
|
||||
I built this for myself.
|
||||
## Running
|
||||
|
||||
I was frustrated with the UI and UX of every tracker I tried. Even when something looked decent, it still felt awkward to use day-to-day, or it was missing pieces I considered essential. I wanted one place that matched how I actually watch anime: search fast, get context fast, update status fast, and move on.
|
||||
|
||||
So this project is personal first and public second. I put it on GitHub because I like shipping in the open, not because it was originally designed as a general-purpose product for everyone.
|
||||
|
||||
Technically, I also wanted to prove that a small, server-rendered Go app could stay reliable even when upstream anime APIs are inconsistent. A lot of this code exists because real APIs rate-limit, timeout, and occasionally fail at the worst possible moment.
|
||||
|
||||
## What the application offers
|
||||
|
||||
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, watchlist management, continue-watching, and in-app playback in one server-rendered interface.
|
||||
|
||||
The interface is minimal and functional, featuring a dark theme and quick access to tracking tools.
|
||||
|
||||
## Technical approach
|
||||
|
||||
The application is written in Go and rendered on the server with `html/template`, with SQLite as the primary datastore and `sqlc` for typed query generation. Styling uses Tailwind CSS v4. HTMX and small TypeScript modules handle incremental interactions, which keeps the interface responsive without moving the entire product into a heavy client-side architecture.
|
||||
|
||||
The external anime data source is Jikan (`https://api.jikan.moe/v4`). Because reliability is a first-class concern, the client layer includes request pacing, bounded retries, backoff behavior, stale-cache fallback, and a persisted retry queue for failed fetches. Playback proxying uses uTLS to bypass Cloudflare protections.
|
||||
|
||||
Upstream APIs can fail transiently with `429` and `5xx` responses, so the app favors graceful degradation over hard failure. Cached values are used when fresh requests fail, retryable failures are persisted and replayed in a background worker, and relation synchronization is incremental so one bad fetch does not block the rest of the graph.
|
||||
|
||||
## Repository structure
|
||||
|
||||
The codebase follows standard Go project layout conventions.
|
||||
|
||||
| Path | Purpose |
|
||||
| ----------------- | ------------------------------------------------ |
|
||||
| `api/*` | Feature routes: anime, auth, playback, watchlist |
|
||||
| `cmd/server` | Application entrypoint and CLI commands |
|
||||
| `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 |
|
||||
| `static` / `dist` | Frontend assets |
|
||||
|
||||
## Getting started
|
||||
|
||||
Requires Go `1.25+`, Bun, and [just](https://github.com/casey/just) (`brew install just`).
|
||||
Requires Go `1.25+`, Bun, [`just`](https://github.com/casey/just), and a C compiler for SQLite.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mkelvers/mal.git && cd mal
|
||||
openssl rand -base32 32
|
||||
PLAYBACK_PROXY_SECRET="your-32-char-secret" go run ./cmd/server
|
||||
bun install
|
||||
just build
|
||||
go run ./cmd/user <username> <password>
|
||||
just dev
|
||||
```
|
||||
|
||||
The app runs at `http://localhost:3000`.
|
||||
The app starts on `http://localhost:3000` by default. Configuration comes from environment variables, and a local `.env` file is loaded automatically. The most useful options are `PORT`, `DATABASE_FILE`, `PLAYBACK_PROXY_SECRET`, `EPISODE_AVAILABILITY_MODE`, and `ANIMESCHEDULE_API_TOKEN`.
|
||||
|
||||
### Tasks
|
||||
## Development
|
||||
|
||||
The justfile automates common tasks:
|
||||
The codebase is split between Go feature packages, external integrations, server-rendered templates, and a small frontend asset pipeline. `cmd/server` starts the web app, `cmd/user` contains local admin tools, `internal` holds the application modules, `integrations` holds provider clients, and `templates`, `static`, and `dist` contain the UI.
|
||||
|
||||
The common development commands are in the `justfile`.
|
||||
|
||||
```bash
|
||||
just fmt # format go code
|
||||
just lint # go fmt && go vet
|
||||
just test # run go tests
|
||||
just build # build go binary + frontend
|
||||
just check # lint, test, typecheck, build
|
||||
just dev # build and run
|
||||
just install-hooks # install pre-push hooks
|
||||
just fmt
|
||||
just test
|
||||
just lint-go
|
||||
just lint-ts
|
||||
just typecheck
|
||||
just build
|
||||
```
|
||||
|
||||
### Docker
|
||||
Run the full local check with:
|
||||
|
||||
```bash
|
||||
docker build -t mal .
|
||||
docker run --rm -p 3000:3000 -e PLAYBACK_PROXY_SECRET="$(openssl rand -base32 32)" mal
|
||||
|
||||
# persistent data
|
||||
docker run --rm -p 3000:3000 \
|
||||
-e DATABASE_FILE=/app/data/mal.db \
|
||||
-e PLAYBACK_PROXY_SECRET="your-secret" \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
mal
|
||||
|
||||
docker exec mal ./cmd/user <username> <password>
|
||||
just check
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ------------------- | ----------------------------------------------------------- |
|
||||
| `PORT` | `3000` | HTTP listen port |
|
||||
| `DATABASE_FILE` | `mal.db` | SQLite database file path |
|
||||
| `ENV` | _(empty)_ | Set to `production` to enable secure session cookies |
|
||||
| `MIGRATIONS_DIR` | _(auto-discovered)_ | Optional explicit path to migration files |
|
||||
| `PLAYBACK_PROXY_SECRET` | _(required)_ | HMAC secret for signed playback proxy tokens (min 32 chars) |
|
||||
| `MAL_JIKAN_TRACE` | `false` | Log all Jikan cache/upstream timings when enabled |
|
||||
|
||||
## Testing
|
||||
|
||||
Run locally with `just check` or manually:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Migrations run automatically on startup.
|
||||
|
||||
## Security
|
||||
|
||||
Keep secrets out of version control, do not publish real credentials in documentation or screenshots, and report security issues privately before public disclosure.
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the MIT License. See `LICENSE` for details.
|
||||
MIT. See [`LICENSE`](LICENSE).
|
||||
|
||||
334
bun.lock
334
bun.lock
@@ -5,49 +5,23 @@
|
||||
"": {
|
||||
"name": "myanimelist-ui",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"htmx.org": "1.9.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.2.4",
|
||||
"@toolwind/anchors": "^1.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"jiti": "^2.7.0",
|
||||
"lefthook": "^2.1.6",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.67.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -58,6 +32,94 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.23.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.23.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.23.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.23.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="],
|
||||
|
||||
"@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-arm64": ["@oxlint/binding-android-arm64@1.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g=="],
|
||||
|
||||
"@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-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg=="],
|
||||
|
||||
"@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-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug=="],
|
||||
|
||||
"@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-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-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA=="],
|
||||
|
||||
"@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-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ=="],
|
||||
|
||||
"@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-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
|
||||
@@ -86,154 +148,54 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
|
||||
"@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.2.4", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.4" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA=="],
|
||||
"@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.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||
"@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.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
|
||||
"@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.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
|
||||
"@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.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
|
||||
"@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.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
|
||||
"@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.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
|
||||
"@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.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
|
||||
"@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.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
|
||||
"@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.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
|
||||
"@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.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
|
||||
"@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.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
|
||||
"@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.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
|
||||
"@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.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
|
||||
"@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.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
|
||||
|
||||
"@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||
|
||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||
|
||||
"eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
"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=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"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-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
|
||||
@@ -256,8 +218,6 @@
|
||||
|
||||
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -282,88 +242,44 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
"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=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
"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=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
"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=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"@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=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
"@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/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
"@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/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# cmd
|
||||
|
||||
Executables live here.
|
||||
Application entrypoints.
|
||||
|
||||
| binary | purpose |
|
||||
| ------------ | ----------------- |
|
||||
| `cmd/server` | web server |
|
||||
| `cmd/user` | user creation CLI |
|
||||
| ------------ | -------------------------------- |
|
||||
| `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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package main runs the MAL web server.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
228
cmd/user/main.go
228
cmd/user/main.go
@@ -1,87 +1,198 @@
|
||||
// Package main provides small CLI utilities for local admin tasks.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"mal/internal"
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbConn, err := db.Open(db.GetDBFile())
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open db: %v", err)
|
||||
observability.Error("cli_config_load_failed", "cmd/user", "", nil, 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() }()
|
||||
|
||||
if len(os.Args) == 2 && os.Args[1] == "update-avatar" {
|
||||
updateAvatars(dbConn)
|
||||
return
|
||||
os.Exit(run(dbConn, os.Args))
|
||||
}
|
||||
|
||||
if len(os.Args) != 3 {
|
||||
log.Fatalf("Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar")
|
||||
func run(dbConn *sql.DB, args []string) int {
|
||||
ctx := context.Background()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
username := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
var existingID string
|
||||
err = dbConn.QueryRow("SELECT id FROM user WHERE username = ?", username).Scan(&existingID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Fatalf("database error: %v", err)
|
||||
switch cmd.kind {
|
||||
case commandUpdateAvatar:
|
||||
updateAvatars(ctx, dbConn)
|
||||
return 0
|
||||
case commandRunFixes:
|
||||
runFixes(ctx, dbConn)
|
||||
return 0
|
||||
case commandCreateOrUpdateUser:
|
||||
if err := createOrUpdateUser(ctx, dbConn, cmd.username, cmd.password); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
observability.Error("cli_command_unreachable", "cmd/user", "", map[string]any{"kind": cmd.kind}, errors.New("unhandled command"))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
type commandKind string
|
||||
|
||||
const (
|
||||
commandUpdateAvatar commandKind = "update-avatar"
|
||||
commandRunFixes commandKind = "run-fixes"
|
||||
commandCreateOrUpdateUser commandKind = "create-or-update-user"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
kind commandKind
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func parseArgs(args []string) (command, error) {
|
||||
if len(args) == 2 {
|
||||
switch args[1] {
|
||||
case string(commandUpdateAvatar):
|
||||
return command{kind: commandUpdateAvatar}, nil
|
||||
case string(commandRunFixes):
|
||||
return command{kind: commandRunFixes}, nil
|
||||
}
|
||||
}
|
||||
|
||||
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(ctx context.Context, dbConn *sql.DB, username string, password string) error {
|
||||
existingID, err := lookupUserID(ctx, dbConn, username)
|
||||
if err != nil {
|
||||
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if existingID != "" {
|
||||
if !promptConfirmOverwrite(username) {
|
||||
fmt.Println("Operation cancelled.")
|
||||
return nil
|
||||
}
|
||||
if err := updateUserPassword(ctx, dbConn, existingID, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := createUser(ctx, dbConn, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupUserID(ctx context.Context, dbConn *sql.DB, username string) (string, error) {
|
||||
var id string
|
||||
err := dbConn.QueryRowContext(ctx, "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))
|
||||
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Operation cancelled.")
|
||||
return
|
||||
return response == "y" || response == "yes"
|
||||
}
|
||||
|
||||
func updateUserPassword(ctx context.Context, dbConn *sql.DB, userID string, username string, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to hash password: %v", err)
|
||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = dbConn.Exec("UPDATE user SET password_hash = ? WHERE id = ?", string(hash), existingID)
|
||||
_, err = dbConn.ExecContext(ctx, "UPDATE user SET password_hash = ? WHERE id = ?", string(hash), userID)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to update user: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
||||
return
|
||||
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createUser(ctx context.Context, dbConn *sql.DB, username string, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to hash password: %v", err)
|
||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
|
||||
_, err = dbConn.Exec("INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)", id, username, string(hash), avatarURL)
|
||||
avatarURL := internal.DefaultAvatarURL(username)
|
||||
_, err = dbConn.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
|
||||
id,
|
||||
username,
|
||||
string(hash),
|
||||
avatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create user: %v", err)
|
||||
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
||||
}
|
||||
|
||||
func updateAvatars(dbConn *sql.DB) {
|
||||
rows, err := dbConn.Query("SELECT id, username FROM user")
|
||||
func updateAvatars(ctx context.Context, dbConn *sql.DB) {
|
||||
rows, err := dbConn.QueryContext(ctx, "SELECT id, username FROM user")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to fetch users: %v", err)
|
||||
observability.Error("cli_users_list_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
@@ -89,20 +200,55 @@ func updateAvatars(dbConn *sql.DB) {
|
||||
for rows.Next() {
|
||||
var id, username string
|
||||
if err := rows.Scan(&id, &username); err != nil {
|
||||
log.Fatalf("failed to scan user: %v", err)
|
||||
observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
avatarURL := fmt.Sprintf("https://api.dicebear.com/9.x/dylan/svg?seed=%s", username)
|
||||
_, err := dbConn.Exec("UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
|
||||
avatarURL := internal.DefaultAvatarURL(username)
|
||||
_, err := dbConn.ExecContext(ctx, "UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to update avatar for %s: %v", username, err)
|
||||
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Fatalf("iteration error: %v", err)
|
||||
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated avatars for %d user(s)\n", count)
|
||||
}
|
||||
|
||||
func runFixes(ctx context.Context, 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)
|
||||
}
|
||||
|
||||
rows, err := dbConn.QueryContext(ctx, "SELECT id, applied_at FROM data_fixes ORDER BY id ASC")
|
||||
if err != nil {
|
||||
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ namespace: mal
|
||||
images:
|
||||
- name: main
|
||||
newName: reg.milasholsting.dk/apps/mal
|
||||
newTag: latest
|
||||
newTag: sha-30a00eb
|
||||
|
||||
@@ -9,3 +9,4 @@ if [ ! -x /app/main_server ]; then
|
||||
fi
|
||||
|
||||
exec /app/main_server
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'server', '*.js'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
prettier,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
rules: {
|
||||
...eslintConfigPrettier.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
# MAL Firefox Extension (dev)
|
||||
|
||||
## Load in Firefox
|
||||
|
||||
1. Open `about:debugging#/runtime/this-firefox`
|
||||
2. Click **Load Temporary Add-on…**
|
||||
3. Select `extensions/mal-firefox/manifest.json`
|
||||
|
||||
## Usage
|
||||
|
||||
- Click the toolbar icon to open the popup and log in.
|
||||
- After login, select text on any page → right click → **MyAnimeList** → **Add to Watchlist** → pick a status.
|
||||
@@ -1,103 +0,0 @@
|
||||
const MENU_ROOT_ID = 'mal-root';
|
||||
const MENU_WATCHLIST_ID = 'mal-watchlist';
|
||||
const MENU_STATUS_PREFIX = 'mal-status:';
|
||||
const STATUSES = [
|
||||
{ value: 'watching', label: 'Watching' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'on_hold', label: 'On Hold' },
|
||||
{ value: 'dropped', label: 'Dropped' },
|
||||
{ value: 'plan_to_watch', label: 'Plan to Watch' },
|
||||
];
|
||||
|
||||
async function getSettings() {
|
||||
const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']);
|
||||
return {
|
||||
authToken: authToken || '',
|
||||
apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech',
|
||||
};
|
||||
}
|
||||
|
||||
async function apiFetch(path, init = {}) {
|
||||
const { authToken, apiBaseUrl } = await getSettings();
|
||||
const url = apiBaseUrl.replace(/\/+$/, '') + path;
|
||||
const headers = new Headers(init.headers || {});
|
||||
if (authToken) headers.set('Authorization', `Bearer ${authToken}`);
|
||||
const res = await fetch(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => '');
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function ensureContextMenu() {
|
||||
const { authToken } = await getSettings();
|
||||
await browser.contextMenus.removeAll();
|
||||
if (!authToken) return;
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: MENU_ROOT_ID,
|
||||
title: 'MyAnimeList',
|
||||
contexts: ['selection'],
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: MENU_WATCHLIST_ID,
|
||||
parentId: MENU_ROOT_ID,
|
||||
title: 'Add to Watchlist',
|
||||
contexts: ['selection'],
|
||||
});
|
||||
|
||||
for (const s of STATUSES) {
|
||||
browser.contextMenus.create({
|
||||
id: MENU_STATUS_PREFIX + s.value,
|
||||
parentId: MENU_WATCHLIST_ID,
|
||||
title: s.label,
|
||||
contexts: ['selection'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
ensureContextMenu();
|
||||
});
|
||||
|
||||
browser.runtime.onStartup.addListener(() => {
|
||||
ensureContextMenu();
|
||||
});
|
||||
|
||||
browser.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local') return;
|
||||
if (changes.authToken) ensureContextMenu();
|
||||
});
|
||||
|
||||
browser.contextMenus.onClicked.addListener(async info => {
|
||||
if (typeof info.menuItemId !== 'string') return;
|
||||
if (!info.menuItemId.startsWith(MENU_STATUS_PREFIX)) return;
|
||||
|
||||
const status = info.menuItemId.slice(MENU_STATUS_PREFIX.length);
|
||||
const text = (info.selectionText || '').trim().replace(/\s+/g, ' ').slice(0, 120);
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
const searchRes = await apiFetch(`/api/search-quick?q=${encodeURIComponent(text)}`);
|
||||
const items = await searchRes.json();
|
||||
const top = items && items[0];
|
||||
if (!top || !top.id) {
|
||||
await browser.notifications?.create?.({
|
||||
type: 'basic',
|
||||
title: 'MyAnimeList',
|
||||
message: `No matches for: ${text}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await apiFetch('/api/watchlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ animeId: top.id, status }),
|
||||
});
|
||||
} catch {
|
||||
// Silent failure by default; can be extended with notifications later.
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="35%" cy="35%" r="75%">
|
||||
<stop offset="0%" style="stop-color: var(--accent, #0466c8)" />
|
||||
<stop offset="100%" style="stop-color: var(--accent-dark, #1d4ed8)" />
|
||||
</radialGradient>
|
||||
<clipPath id="clip">
|
||||
<circle cx="50" cy="50" r="45" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Base -->
|
||||
<circle cx="50" cy="50" r="45" fill="url(#bg)" />
|
||||
|
||||
<!-- Crescent moon cutout -->
|
||||
<g clip-path="url(#clip)">
|
||||
<path
|
||||
d="M70 50a25 25 0 1 1 -25 -25 20 20 0 1 0 25 25z"
|
||||
fill="#FFF7ED"
|
||||
transform="translate(-2 -2)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 685 B |
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "MyAnimeList",
|
||||
"version": "0.1.0",
|
||||
"description": "Right-click selected anime titles and add them to your watchlist.",
|
||||
"permissions": ["contextMenus", "storage"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"action": {
|
||||
"default_title": "MAL Watchlist",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"icons": {
|
||||
"48": "icon.svg"
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b0f1a;
|
||||
--card: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--text: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.65);
|
||||
--accent: #6ea8fe;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #4ade80;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--card: rgba(0, 0, 0, 0.03);
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--text: rgba(0, 0, 0, 0.88);
|
||||
--muted: rgba(0, 0, 0, 0.6);
|
||||
--accent: #1f6feb;
|
||||
--danger: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font:
|
||||
14px/1.4 system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 0;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border: 1px solid var(--border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
background: rgba(110, 168, 254, 0.18);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: rgba(255, 107, 107, 0.18);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.body {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 44px;
|
||||
height: 62px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metaTitle {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.metaSub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mini {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: rgba(110, 168, 254, 0.18);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MAL Watchlist</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<section class="panel">
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img class="brandIcon" src="icon.svg" alt="" />
|
||||
<div class="title">MyAnimeList</div>
|
||||
</div>
|
||||
<button id="logoutBtn" class="link" hidden>Log out</button>
|
||||
</header>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="body">
|
||||
Select an anime title on any page, then right click to open the context menu. Under
|
||||
“MyAnimeList”, choose “Add to Watchlist” and pick a status to save it to your watchlist.
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div id="loggedIn" class="statusRow" hidden>
|
||||
<div class="statusDot"></div>
|
||||
<div class="statusText">Signed in — context menu enabled</div>
|
||||
</div>
|
||||
|
||||
<div id="login" class="login" hidden>
|
||||
<label class="label">
|
||||
Username
|
||||
<input id="username" class="input" autocomplete="username" />
|
||||
</label>
|
||||
<label class="label">
|
||||
Password
|
||||
<input id="password" class="input" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<button id="loginBtn" class="btn">Log in</button>
|
||||
<div id="loginErr" class="error" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
function qs(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']);
|
||||
return {
|
||||
authToken: authToken || '',
|
||||
apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech',
|
||||
};
|
||||
}
|
||||
|
||||
async function setSettings(patch) {
|
||||
await browser.storage.local.set(patch);
|
||||
}
|
||||
|
||||
function show(el, on) {
|
||||
el.hidden = !on;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const settings = await getSettings();
|
||||
document.body.dataset.state = settings.authToken ? 'in' : 'out';
|
||||
|
||||
const logoutBtn = qs('logoutBtn');
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
await setSettings({ authToken: '' });
|
||||
await render();
|
||||
});
|
||||
|
||||
const hasToken = !!settings.authToken;
|
||||
show(logoutBtn, hasToken);
|
||||
show(qs('login'), !hasToken);
|
||||
show(qs('loggedIn'), hasToken);
|
||||
|
||||
if (!hasToken) {
|
||||
setupLogin();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function setupLogin() {
|
||||
const loginErr = qs('loginErr');
|
||||
show(loginErr, false);
|
||||
|
||||
qs('loginBtn').onclick = async () => {
|
||||
show(loginErr, false);
|
||||
const username = qs('username').value.trim();
|
||||
const password = qs('password').value;
|
||||
if (!username || !password) {
|
||||
loginErr.textContent = 'Missing username or password';
|
||||
show(loginErr, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { apiBaseUrl } = await getSettings();
|
||||
const res = await fetch(apiBaseUrl.replace(/\/+$/, '') + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, name: 'Firefox extension' }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Invalid username or password');
|
||||
const data = await res.json();
|
||||
await setSettings({ authToken: data.token });
|
||||
await render();
|
||||
} catch (e) {
|
||||
loginErr.textContent = e.message || 'Login failed';
|
||||
show(loginErr, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render();
|
||||
489
integrations/animeschedule/animeschedule.go
Normal file
489
integrations/animeschedule/animeschedule.go
Normal file
@@ -0,0 +1,489 @@
|
||||
// Package animeschedule provides an integration with the animeschedule.net API.
|
||||
package animeschedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type AirType string
|
||||
|
||||
const (
|
||||
AirTypeJPN AirType = "JPN"
|
||||
AirTypeSUB AirType = "SUB"
|
||||
AirTypeDUB AirType = "DUB"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Title string
|
||||
AnimeURL string
|
||||
ImageURL string
|
||||
EpisodeText string
|
||||
AirType AirType
|
||||
AirsAt time.Time
|
||||
LocalTime string
|
||||
DateLabel string
|
||||
Weekday time.Weekday
|
||||
}
|
||||
|
||||
type WeekSchedule struct {
|
||||
Year int
|
||||
Week int
|
||||
Days map[time.Weekday][]Entry
|
||||
}
|
||||
|
||||
type HTTPStatusError struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
ContentType string
|
||||
BodyPreview string
|
||||
}
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
return fmt.Sprintf("unexpected status %d for %s", e.StatusCode, e.URL)
|
||||
}
|
||||
|
||||
var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`)
|
||||
var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`)
|
||||
|
||||
func scheduleLocation(timezone string) (*time.Location, error) {
|
||||
timezone = strings.TrimSpace(timezone)
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
location, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load schedule timezone %q: %w", timezone, err)
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int, timezone string) (WeekSchedule, error) {
|
||||
apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN"))
|
||||
|
||||
if apiToken != "" {
|
||||
return fetchWeekAPI(ctx, httpClient, apiToken, year, week, timezone)
|
||||
}
|
||||
|
||||
location, err := scheduleLocation(timezone)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, err
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://animeschedule.net/")
|
||||
q := u.Query()
|
||||
if year > 0 {
|
||||
q.Set("year", strconv.Itoa(year))
|
||||
}
|
||||
if week > 0 {
|
||||
q.Set("week", strconv.Itoa(week))
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
doc, finalURL, err := fetchDocument(ctx, httpClient, u.String())
|
||||
if err != nil {
|
||||
return WeekSchedule{}, err
|
||||
}
|
||||
|
||||
resolvedYear := year
|
||||
resolvedWeek := week
|
||||
if resolvedWeek == 0 {
|
||||
if match := reWeek.FindStringSubmatch(finalURL); len(match) == 2 {
|
||||
if v, err := strconv.Atoi(match[1]); err == nil {
|
||||
resolvedWeek = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if resolvedYear == 0 {
|
||||
if match := reYear.FindStringSubmatch(finalURL); len(match) == 2 {
|
||||
if v, err := strconv.Atoi(match[1]); err == nil {
|
||||
resolvedYear = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := WeekSchedule{
|
||||
Year: resolvedYear,
|
||||
Week: resolvedWeek,
|
||||
Days: map[time.Weekday][]Entry{},
|
||||
}
|
||||
|
||||
doc.Find(".timetable-column").Each(func(_ int, column *goquery.Selection) {
|
||||
h1 := column.Find("h1.timetable-column-date").First()
|
||||
rawHeader := strings.Join(strings.Fields(strings.TrimSpace(h1.Text())), " ")
|
||||
weekday := parseWeekdayFromHeader(rawHeader)
|
||||
if weekday == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dayEntries := make([]Entry, 0, 16)
|
||||
|
||||
column.Find(".timetable-column-show").Each(func(_ int, show *goquery.Selection) {
|
||||
if selectionHasClass(show, "filtered-out") {
|
||||
return
|
||||
}
|
||||
|
||||
a := show.Find("a.show-link").First()
|
||||
title := strings.TrimSpace(a.Find("h2").First().Text())
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(a.Text())
|
||||
}
|
||||
href, _ := a.Attr("href")
|
||||
animeURL := absolutizeURL("https://animeschedule.net", href)
|
||||
|
||||
imageURL := ""
|
||||
if img := a.Find("img").First(); img != nil && img.Length() == 1 {
|
||||
if src, ok := img.Attr("data-src"); ok {
|
||||
imageURL = strings.TrimSpace(src)
|
||||
}
|
||||
if imageURL == "" {
|
||||
if src, ok := img.Attr("src"); ok && !strings.HasPrefix(src, "data:") {
|
||||
imageURL = strings.TrimSpace(src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta := show.Find("h3.time-bar").First()
|
||||
metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
||||
|
||||
epText, _, airType := parseMeta(metaText)
|
||||
localTime, airsAt, _, _ := parseLocalTime(meta, location)
|
||||
if title == "" || animeURL == "" || localTime == "" || airType != AirTypeSUB {
|
||||
return
|
||||
}
|
||||
|
||||
dayEntries = append(dayEntries, Entry{
|
||||
Title: title,
|
||||
AnimeURL: animeURL,
|
||||
ImageURL: imageURL,
|
||||
EpisodeText: epText,
|
||||
AirType: airType,
|
||||
AirsAt: airsAt,
|
||||
LocalTime: localTime,
|
||||
DateLabel: rawHeader,
|
||||
Weekday: *weekday,
|
||||
})
|
||||
})
|
||||
|
||||
if len(dayEntries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
out.Days[*weekday] = append(out.Days[*weekday], preferredReleaseEntries(dayEntries)...)
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func selectionHasClass(selection *goquery.Selection, className string) bool {
|
||||
raw, ok := selection.Attr("class")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(strings.Fields(raw), className)
|
||||
}
|
||||
|
||||
func parseWeekdayFromHeader(header string) *time.Weekday {
|
||||
lower := strings.ToLower(header)
|
||||
candidates := []struct {
|
||||
key string
|
||||
val time.Weekday
|
||||
}{
|
||||
{"monday", time.Monday},
|
||||
{"tuesday", time.Tuesday},
|
||||
{"wednesday", time.Wednesday},
|
||||
{"thursday", time.Thursday},
|
||||
{"friday", time.Friday},
|
||||
{"saturday", time.Saturday},
|
||||
{"sunday", time.Sunday},
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if strings.Contains(lower, c.key) {
|
||||
v := c.val
|
||||
return &v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMeta(meta string) (episodeText string, localTime string, airType AirType) {
|
||||
// Example: "Ep 8 04:00 PM SUB"
|
||||
parts := strings.Fields(meta)
|
||||
if len(parts) < 4 {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// Find the time token(s)
|
||||
var timeIdx = -1
|
||||
for i := range parts {
|
||||
if strings.Contains(parts[i], ":") && len(parts[i]) >= 4 {
|
||||
timeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if timeIdx == -1 || timeIdx+2 >= len(parts) {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
localTime = strings.TrimSpace(parts[timeIdx] + " " + parts[timeIdx+1])
|
||||
typeRaw := strings.TrimSpace(parts[timeIdx+2])
|
||||
switch strings.ToUpper(typeRaw) {
|
||||
case "JPN":
|
||||
airType = AirTypeJPN
|
||||
case "SUB":
|
||||
airType = AirTypeSUB
|
||||
case "DUB":
|
||||
airType = AirTypeDUB
|
||||
default:
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
episodeText = strings.TrimSpace(strings.Join(parts[:timeIdx], " "))
|
||||
return episodeText, localTime, airType
|
||||
}
|
||||
|
||||
func preferredReleaseEntries(entries []Entry) []Entry {
|
||||
type keyedEntry struct {
|
||||
index int
|
||||
entry Entry
|
||||
}
|
||||
|
||||
selected := map[string]keyedEntry{}
|
||||
for i, entry := range entries {
|
||||
key := entry.AnimeURL + "\x00" + entry.EpisodeText
|
||||
current, ok := selected[key]
|
||||
if !ok || airTypePriority(entry.AirType) > airTypePriority(current.entry.AirType) {
|
||||
selected[key] = keyedEntry{index: i, entry: entry}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]keyedEntry, 0, len(selected))
|
||||
for _, entry := range selected {
|
||||
out = append(out, entry)
|
||||
}
|
||||
slices.SortFunc(out, func(a keyedEntry, b keyedEntry) int {
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
preferred := make([]Entry, 0, len(out))
|
||||
for _, entry := range out {
|
||||
preferred = append(preferred, entry.entry)
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
func airTypePriority(airType AirType) int {
|
||||
switch airType {
|
||||
case AirTypeSUB:
|
||||
return 3
|
||||
case AirTypeDUB:
|
||||
return 2
|
||||
case AirTypeJPN:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseLocalTime(meta *goquery.Selection, location *time.Location) (localTime string, airsAt time.Time, rawDatetime string, rawRenderedTime string) {
|
||||
// AnimeSchedule updates rendered time client-side based on the viewer's timezone.
|
||||
// The server-rendered HTML can show a different time string, so we prefer the `datetime`
|
||||
// attribute when available.
|
||||
t := meta.Find("time").First()
|
||||
if t.Length() == 1 {
|
||||
rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ")
|
||||
if raw, ok := t.Attr("datetime"); ok {
|
||||
rawDatetime = raw
|
||||
if parsed, err := parseScheduleDatetime(rawDatetime); err == nil {
|
||||
airsAt = parsed.In(location)
|
||||
localTime = airsAt.Format("15:04")
|
||||
return localTime, airsAt, rawDatetime, rawRenderedTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
||||
_, parsedTime, _ := parseMeta(fallback)
|
||||
return parsedTime, time.Time{}, "", ""
|
||||
}
|
||||
|
||||
func parseScheduleDatetime(value string) (time.Time, error) {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04Z07:00"} {
|
||||
parsed, err := time.Parse(layout, strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("parse schedule datetime %q", value)
|
||||
}
|
||||
|
||||
func absolutizeURL(base string, href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||
return href
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(href, "/")
|
||||
}
|
||||
|
||||
func addCommonHeaders(request *http.Request) {
|
||||
netutil.SetBrowserHTMLHeaders(request, "https://animeschedule.net/")
|
||||
}
|
||||
|
||||
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, string, error) {
|
||||
document, response, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error {
|
||||
return &HTTPStatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
URL: url,
|
||||
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
|
||||
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, url, err
|
||||
}
|
||||
|
||||
return document, response, nil
|
||||
}
|
||||
|
||||
type timetableAnimeAPI struct {
|
||||
Title string `json:"title"`
|
||||
English string `json:"english"`
|
||||
Route string `json:"route"`
|
||||
EpisodeDate time.Time `json:"episodeDate"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
SubtractedEpisodeNumber int `json:"subtractedEpisodeNumber"`
|
||||
AirType string `json:"airType"`
|
||||
ImageVersionRoute string `json:"imageVersionRoute"`
|
||||
}
|
||||
|
||||
func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int, timezone string) (WeekSchedule, error) {
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
location, err := scheduleLocation(timezone)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, err
|
||||
}
|
||||
|
||||
u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub")
|
||||
q := u.Query()
|
||||
if year > 0 && week > 0 {
|
||||
q.Set("year", strconv.Itoa(year))
|
||||
q.Set("week", strconv.Itoa(week))
|
||||
}
|
||||
q.Set("tz", location.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, fmt.Errorf("create api request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", netutil.Chrome135)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return WeekSchedule{}, fmt.Errorf("api request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(res.Body, netutil.Bytes512))
|
||||
return WeekSchedule{}, &HTTPStatusError{
|
||||
StatusCode: res.StatusCode,
|
||||
URL: u.String(),
|
||||
ContentType: strings.TrimSpace(res.Header.Get("Content-Type")),
|
||||
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
|
||||
}
|
||||
}
|
||||
|
||||
var payload []timetableAnimeAPI
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return WeekSchedule{}, fmt.Errorf("decode timetables api: %w", err)
|
||||
}
|
||||
|
||||
resolvedYear := year
|
||||
resolvedWeek := week
|
||||
if resolvedYear == 0 || resolvedWeek == 0 {
|
||||
resolvedYear, resolvedWeek = time.Now().In(time.Local).ISOWeek()
|
||||
}
|
||||
|
||||
out := WeekSchedule{
|
||||
Year: resolvedYear,
|
||||
Week: resolvedWeek,
|
||||
Days: map[time.Weekday][]Entry{},
|
||||
}
|
||||
|
||||
for _, item := range payload {
|
||||
title := strings.TrimSpace(item.English)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
episodeNumber := item.EpisodeNumber
|
||||
subtracted := item.SubtractedEpisodeNumber
|
||||
episodeText := ""
|
||||
switch {
|
||||
case subtracted > 0 && subtracted < episodeNumber:
|
||||
episodeText = fmt.Sprintf("Ep %d-%d", subtracted, episodeNumber)
|
||||
case episodeNumber > 0:
|
||||
episodeText = fmt.Sprintf("Ep %d", episodeNumber)
|
||||
default:
|
||||
episodeText = "Ep ?"
|
||||
}
|
||||
|
||||
airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType)))
|
||||
if airType != AirTypeSUB {
|
||||
continue
|
||||
}
|
||||
|
||||
episodeTime := item.EpisodeDate.In(location)
|
||||
weekday := episodeTime.Weekday()
|
||||
localTime := episodeTime.Format("15:04")
|
||||
|
||||
imageURL := ""
|
||||
if strings.TrimSpace(item.ImageVersionRoute) != "" {
|
||||
imageURL = "https://img.animeschedule.net/production/assets/public/img/" + strings.TrimLeft(strings.TrimSpace(item.ImageVersionRoute), "/")
|
||||
}
|
||||
|
||||
animeURL := ""
|
||||
if strings.TrimSpace(item.Route) != "" {
|
||||
animeURL = "https://animeschedule.net/anime/" + strings.TrimLeft(strings.TrimSpace(item.Route), "/")
|
||||
}
|
||||
|
||||
out.Days[weekday] = append(out.Days[weekday], Entry{
|
||||
Title: title,
|
||||
AnimeURL: animeURL,
|
||||
ImageURL: imageURL,
|
||||
EpisodeText: episodeText,
|
||||
AirType: airType,
|
||||
AirsAt: episodeTime,
|
||||
LocalTime: localTime,
|
||||
Weekday: weekday,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
101
integrations/animeschedule/animeschedule_test.go
Normal file
101
integrations/animeschedule/animeschedule_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package animeschedule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func TestParseLocalTimeUsesRequestedTimezone(t *testing.T) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
|
||||
<h3 class="time-bar">
|
||||
<span class="show-episode">Ep 9</span>
|
||||
<time datetime="2026-06-05T16:00+01:00" class="show-air-time">04:00 PM</time>
|
||||
<span>SUB</span>
|
||||
</h3>
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse document: %v", err)
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Europe/Copenhagen")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
|
||||
localTime, airsAt, _, rendered := parseLocalTime(doc.Find(".time-bar").First(), location)
|
||||
|
||||
if localTime != "17:00" {
|
||||
t.Fatalf("localTime = %q, want %q", localTime, "17:00")
|
||||
}
|
||||
if rendered != "04:00 PM" {
|
||||
t.Fatalf("rendered = %q, want %q", rendered, "04:00 PM")
|
||||
}
|
||||
if airsAt.Location().String() != "Europe/Copenhagen" {
|
||||
t.Fatalf("airsAt location = %q, want Europe/Copenhagen", airsAt.Location().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLocalTimeUsesExactAngelNextDoorSubRelease(t *testing.T) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
|
||||
<h3 class="time-bar">
|
||||
<span class="show-episode">Ep 10</span>
|
||||
<time datetime="2026-06-05T15:30+01:00" class="show-air-time">03:30 PM</time>
|
||||
<span>SUB</span>
|
||||
</h3>
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse document: %v", err)
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Europe/Copenhagen")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
|
||||
localTime, _, _, _ := parseLocalTime(doc.Find(".time-bar").First(), location)
|
||||
|
||||
if localTime != "16:30" {
|
||||
t.Fatalf("localTime = %q, want %q", localTime, "16:30")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseEntriesPrefersSubForSameEpisode(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{
|
||||
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||
EpisodeText: "Ep 9",
|
||||
AirType: AirTypeJPN,
|
||||
LocalTime: "16:00",
|
||||
},
|
||||
{
|
||||
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||
EpisodeText: "Ep 9",
|
||||
AirType: AirTypeSUB,
|
||||
LocalTime: "17:00",
|
||||
},
|
||||
{
|
||||
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||
EpisodeText: "Ep 6",
|
||||
AirType: AirTypeDUB,
|
||||
LocalTime: "17:00",
|
||||
},
|
||||
}
|
||||
|
||||
got := preferredReleaseEntries(entries)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].AirType != AirTypeSUB {
|
||||
t.Fatalf("first air type = %q, want %q", got[0].AirType, AirTypeSUB)
|
||||
}
|
||||
if got[1].AirType != AirTypeDUB {
|
||||
t.Fatalf("second air type = %q, want %q", got[1].AirType, AirTypeDUB)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,16 @@ func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]Recomme
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) WarmAnimeRecommendations(id int) {
|
||||
url := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, id)
|
||||
cacheKey := fmt.Sprintf("anime:recommendations:%d", id)
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
var resp RecommendationsResponse
|
||||
_ = c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp)
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -94,18 +104,7 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
}
|
||||
|
||||
func (c *Client) refreshAnimeByIDAsync(id int) {
|
||||
select {
|
||||
case c.refreshSem <- struct{}{}:
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { <-c.refreshSem }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.refreshAnimeByID(ctx, id)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,21 +5,24 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
@@ -29,6 +32,7 @@ type Client struct {
|
||||
lastReqTime time.Time // rate limiting: last request timestamp
|
||||
sf singleflight.Group
|
||||
refreshSem chan struct{}
|
||||
metrics *observability.Metrics
|
||||
|
||||
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
||||
randomPool []Anime
|
||||
@@ -38,7 +42,8 @@ type Client struct {
|
||||
|
||||
const jikanSlowLogThreshold = 750 * time.Millisecond
|
||||
|
||||
func NewClient(queries *db.Queries) *Client {
|
||||
func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client {
|
||||
traceEnabled = cfg.JikanTrace
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
@@ -51,6 +56,7 @@ func NewClient(queries *db.Queries) *Client {
|
||||
},
|
||||
baseURL: "https://api.jikan.moe/v4",
|
||||
db: queries,
|
||||
metrics: metrics,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
refreshSem: make(chan struct{}, 4),
|
||||
randomPool: make([]Anime, 0),
|
||||
@@ -140,30 +146,55 @@ func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
}
|
||||
|
||||
func jikanTraceEnabled() bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE")))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
return traceEnabled
|
||||
}
|
||||
|
||||
func shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
|
||||
if jikanTraceEnabled() || err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if source == "fresh" {
|
||||
return duration < 50*time.Millisecond
|
||||
}
|
||||
|
||||
if source == "refresh" {
|
||||
return duration < jikanSlowLogThreshold
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func jikanCacheLogLevel(source string, err error) observability.LogLevel {
|
||||
if err != nil {
|
||||
return observability.LogLevelError
|
||||
}
|
||||
|
||||
if source != "fresh" && source != "refresh" {
|
||||
// Stale reads are expected sometimes, but worth tracking in logs.
|
||||
return observability.LogLevelWarn
|
||||
}
|
||||
|
||||
return observability.LogLevelInfo
|
||||
}
|
||||
|
||||
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 {
|
||||
if shouldSkipJikanCacheLog(source, duration, err) {
|
||||
return
|
||||
}
|
||||
|
||||
errorValue := ""
|
||||
if err != nil {
|
||||
errorValue = err.Error()
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"jikan_cache key=%s source=%s duration_ms=%.2f error=%s",
|
||||
strconv.Quote(cacheKey),
|
||||
source,
|
||||
float64(duration.Microseconds())/1000,
|
||||
strconv.Quote(errorValue),
|
||||
observability.LogJSON(
|
||||
jikanCacheLogLevel(source, err),
|
||||
"jikan_cache",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"cache_key": cacheKey,
|
||||
"source": source,
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,18 +204,26 @@ func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt tim
|
||||
return
|
||||
}
|
||||
|
||||
errorValue := ""
|
||||
if err != nil {
|
||||
errorValue = err.Error()
|
||||
level := observability.LogLevelInfo
|
||||
if err != nil || statusCode >= http.StatusInternalServerError {
|
||||
level = observability.LogLevelError
|
||||
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
|
||||
level = observability.LogLevelWarn
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"jikan_upstream url=%s status=%d attempts=%d duration_ms=%.2f error=%s",
|
||||
strconv.Quote(urlStr),
|
||||
statusCode,
|
||||
attempts,
|
||||
float64(duration.Microseconds())/1000,
|
||||
strconv.Quote(errorValue),
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,11 +301,18 @@ func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
|
||||
|
||||
data, err := c.db.GetJikanCache(ctx, key)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
return err == nil
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
c.metrics.ObserveCache("jikan", "hit")
|
||||
return true
|
||||
}
|
||||
|
||||
// getStaleCache retrieves expired-but-available cache by key.
|
||||
@@ -276,11 +322,18 @@ func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) b
|
||||
|
||||
data, err := c.db.GetJikanCacheStale(ctx, key)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
return err == nil
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
c.metrics.ObserveCache("jikan_stale", "hit")
|
||||
return true
|
||||
}
|
||||
|
||||
// setCache stores data in cache with specified TTL.
|
||||
@@ -375,6 +428,12 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
|
||||
return
|
||||
}
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) runAsyncRefresh(refresh func(context.Context)) {
|
||||
select {
|
||||
case c.refreshSem <- struct{}{}:
|
||||
default:
|
||||
@@ -387,7 +446,7 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
|
||||
refresh(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -425,84 +484,154 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
|
||||
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 {
|
||||
if err := c.prepareRetryAttempt(ctx); err != nil {
|
||||
return logAndReturn(0, err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
resp, err := c.doRequest(ctx, urlStr)
|
||||
if err != nil {
|
||||
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
retry, requestErr := handleRequestRetry(ctx, err, attempt, maxRetries)
|
||||
if retry {
|
||||
continue
|
||||
}
|
||||
|
||||
return logAndReturn(0, fmt.Errorf("jikan api error: %w", err))
|
||||
return logAndReturn(0, requestErr)
|
||||
}
|
||||
|
||||
statusCode, retry, err := 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 fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
|
||||
return c.waitRateLimit(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 errors.Is(err, context.Canceled) {
|
||||
return false, fmt.Errorf("request canceled while retrying jikan request: %w", 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 {
|
||||
apiErr := &APIError{StatusCode: resp.StatusCode, URL: urlStr}
|
||||
retryable := isRetryableStatus(resp.StatusCode)
|
||||
return handleStatusRetry(ctx, resp, urlStr, out, attempt, maxRetries)
|
||||
}
|
||||
|
||||
err := json.NewDecoder(resp.Body).Decode(out)
|
||||
_ = resp.Body.Close()
|
||||
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, out any, 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 retryable && attempt < maxRetries-1 {
|
||||
if isRetryableStatus(statusCode) && attempt < maxRetries-1 {
|
||||
_ = resp.Body.Close()
|
||||
delay := max(retryAfter, retryDelay(attempt))
|
||||
|
||||
if retryErr := waitForRetry(ctx, delay); retryErr != nil {
|
||||
return logAndReturn(resp.StatusCode, retryErr)
|
||||
if retryErr := waitForRetry(ctx, max(retryAfter, retryDelay(attempt))); retryErr != nil {
|
||||
return statusCode, false, retryErr
|
||||
}
|
||||
|
||||
continue
|
||||
return statusCode, true, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
return statusCode, false, apiErr
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(out)
|
||||
_ = resp.Body.Close()
|
||||
if err == nil {
|
||||
return logAndReturn(resp.StatusCode, nil)
|
||||
func metricsEndpoint(urlStr string) string {
|
||||
trimmed := strings.TrimSpace(urlStr)
|
||||
if trimmed == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||
return logAndReturn(resp.StatusCode, retryErr)
|
||||
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
|
||||
}
|
||||
|
||||
return logAndReturn(resp.StatusCode, fmt.Errorf("failed to decode jikan response: %w", err))
|
||||
if _, err := strconv.Atoi(part); err == nil {
|
||||
out = append(out, "{id}")
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
|
||||
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
|
||||
if len(out) == 0 {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return "/" + strings.Join(out, "/")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,42 +23,13 @@ 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)
|
||||
}
|
||||
sqlDB := newTestCacheDB(t)
|
||||
defer sqlDB.Close()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
_, err = sqlDB.Exec(`
|
||||
CREATE TABLE jikan_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create cache table: %v", err)
|
||||
}
|
||||
|
||||
queries := db.New(sqlDB)
|
||||
client := NewClient(queries)
|
||||
client := NewClient(config.Config{}, queries, observability.NewMetrics())
|
||||
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
||||
staleBytes, err := json.Marshal(stale)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stale response: %v", err)
|
||||
}
|
||||
|
||||
_, 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)
|
||||
}
|
||||
insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour))
|
||||
|
||||
client.httpClient = &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
@@ -76,11 +49,63 @@ 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 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 {
|
||||
sqlDB.Close()
|
||||
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 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)
|
||||
@@ -88,6 +113,6 @@ 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)
|
||||
_ = sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).Scan(&rawData, &rawExpires)
|
||||
t.Fatalf("cache was not refreshed asynchronously; data=%s expires_at=%s", rawData, rawExpires)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package jikan provides a client for the Jikan v4 API.
|
||||
package jikan
|
||||
|
||||
import "time"
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
import "go.uber.org/fx"
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(NewClient),
|
||||
|
||||
@@ -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
|
||||
|
||||
139
integrations/jikan/producers.go
Normal file
139
integrations/jikan/producers.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProducerListEntry struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Titles []struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
} `json:"titles"`
|
||||
}
|
||||
|
||||
type ProducersResponse struct {
|
||||
Data []ProducerListEntry `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type ProducerListResult struct {
|
||||
Items []ProducerListEntry
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(query)
|
||||
if q == "" {
|
||||
return c.fetchProducersPage(ctx, "", page, limit)
|
||||
}
|
||||
|
||||
result, err := c.fetchProducersPage(ctx, q, page, limit)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return ProducerListResult{}, err
|
||||
}
|
||||
|
||||
return c.searchProducersFromPages(ctx, q, page, 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)
|
||||
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 {
|
||||
return ProducerListResult{}, err
|
||||
}
|
||||
|
||||
return ProducerListResult{
|
||||
Items: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchProducersFromPages(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||
const maxPagesToScan = 25
|
||||
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
startIndex := (page - 1) * limit
|
||||
endIndex := startIndex + limit
|
||||
|
||||
matches := make([]ProducerListEntry, 0, endIndex)
|
||||
scannedAll := false
|
||||
|
||||
for currentPage := 1; currentPage <= maxPagesToScan; currentPage++ {
|
||||
result, err := c.fetchProducersPage(ctx, "", currentPage, limit)
|
||||
if err != nil {
|
||||
return ProducerListResult{}, err
|
||||
}
|
||||
|
||||
for _, item := range result.Items {
|
||||
name := strings.ToLower(ProducerListEntryName(item))
|
||||
if strings.Contains(name, needle) {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) >= endIndex {
|
||||
return ProducerListResult{
|
||||
Items: matches[startIndex:endIndex],
|
||||
HasNextPage: len(matches) > endIndex || result.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !result.HasNextPage {
|
||||
scannedAll = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if startIndex >= len(matches) {
|
||||
return ProducerListResult{
|
||||
Items: []ProducerListEntry{},
|
||||
HasNextPage: !scannedAll,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if endIndex > len(matches) {
|
||||
endIndex = len(matches)
|
||||
}
|
||||
|
||||
return ProducerListResult{
|
||||
Items: matches[startIndex:endIndex],
|
||||
HasNextPage: !scannedAll,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ProducerListEntryName(entry ProducerListEntry) string {
|
||||
for _, t := range entry.Titles {
|
||||
if t.Title != "" {
|
||||
return t.Title
|
||||
}
|
||||
}
|
||||
if entry.MalID > 0 {
|
||||
return strconv.Itoa(entry.MalID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
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 setPositiveIntQueryValue(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")
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/observability"
|
||||
|
||||
"mal/integrations/watchorder"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -19,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))
|
||||
@@ -27,21 +45,104 @@ 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 isTVWatchOrderType(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), "tv")
|
||||
}
|
||||
|
||||
// isAllowedWatchOrderType returns true for the default uncluttered watch order types.
|
||||
func isAllowedWatchOrderType(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
return normalized == "tv" || normalized == "movie"
|
||||
}
|
||||
|
||||
func hasTVWatchOrderEntry(entries []watchorder.WatchOrderEntry) bool {
|
||||
for _, entry := range entries {
|
||||
if isTVWatchOrderType(entry.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
|
||||
if err != nil {
|
||||
var statusError *watchorder.HTTPStatusError
|
||||
if errors.As(err, &statusError) && statusError.StatusCode == http.StatusNotFound {
|
||||
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
|
||||
}
|
||||
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
|
||||
observability.Warn(
|
||||
"relations_watch_order_markup_missing",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
"url": watchOrderURL,
|
||||
},
|
||||
err,
|
||||
)
|
||||
} else if errors.As(err, &statusError) {
|
||||
observability.Warn(
|
||||
"relations_watch_order_http_error",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
"url": watchOrderURL,
|
||||
"status": statusError.StatusCode,
|
||||
"server": statusError.Server,
|
||||
"cf_ray": statusError.CFRay,
|
||||
"location": statusError.Location,
|
||||
"content_type": statusError.ContentType,
|
||||
"body_preview": statusError.BodyPreview,
|
||||
},
|
||||
err,
|
||||
)
|
||||
} else {
|
||||
observability.Warn(
|
||||
"relations_watch_order_fetch_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
"url": watchOrderURL,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
return watchorder.WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
c.setCache(ctx, cacheKey, result, watchOrderCacheTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshWatchOrderAsync(id int) {
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.refreshWatchOrder(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -51,37 +152,19 @@ func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrd
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
|
||||
if err != nil {
|
||||
var statusError *watchorder.HTTPStatusError
|
||||
if errors.As(err, &statusError) && statusError.StatusCode == 404 {
|
||||
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
|
||||
if c.getStaleCache(ctx, cacheKey, &cached) {
|
||||
c.refreshWatchOrderAsync(id)
|
||||
return cached, nil
|
||||
}
|
||||
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
|
||||
log.Printf("relations: watch-order markup missing for %d (%s): %v", id, watchOrderURL, err)
|
||||
} else if errors.As(err, &statusError) {
|
||||
log.Printf(
|
||||
"relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q",
|
||||
id,
|
||||
watchOrderURL,
|
||||
statusError.StatusCode,
|
||||
statusError.Server,
|
||||
statusError.CFRay,
|
||||
statusError.Location,
|
||||
statusError.ContentType,
|
||||
statusError.BodyPreview,
|
||||
)
|
||||
} else {
|
||||
log.Printf("relations: watch-order fetch failed for %d (%s): %v", id, watchOrderURL, err)
|
||||
|
||||
result, err := c.refreshWatchOrder(ctx, id)
|
||||
if err != nil {
|
||||
if c.getStaleCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
return watchorder.WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
c.setCache(ctx, cacheKey, result, watchOrderCacheTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -100,42 +183,54 @@ 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 {
|
||||
func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) ([]RelationEntry, error) {
|
||||
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
log.Printf("relations: using current-only fallback for %d: %v", id, err)
|
||||
|
||||
observability.Warn(
|
||||
"relations_watch_order_fallback_current_only",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
type fetchResult struct {
|
||||
index int
|
||||
anime Anime
|
||||
entry watchorder.WatchOrderEntry
|
||||
}
|
||||
|
||||
var allowedEntries []watchorder.WatchOrderEntry
|
||||
func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
|
||||
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
|
||||
seen := make(map[int]bool)
|
||||
shouldIncludeAllTypes := mode == WatchOrderModeComplete || !hasTVWatchOrderEntry(result.WatchOrder)
|
||||
|
||||
for _, entry := range result.WatchOrder {
|
||||
if len(allowedEntries) >= maxWatchOrderEntries {
|
||||
break
|
||||
}
|
||||
if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] {
|
||||
if seen[entry.ID] {
|
||||
continue
|
||||
}
|
||||
if !shouldIncludeAllTypes && !isAllowedWatchOrderType(entry.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[entry.ID] = true
|
||||
allowedEntries = append(allowedEntries, entry)
|
||||
}
|
||||
|
||||
return allowedEntries, seen
|
||||
}
|
||||
|
||||
func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []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 {
|
||||
@@ -145,10 +240,12 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case results <- fetchResult{index: i, anime: anime, entry: entry}:
|
||||
case <-gCtx.Done():
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -158,41 +255,69 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
close(results)
|
||||
}()
|
||||
|
||||
fetched := make([]fetchResult, 0, len(allowedEntries))
|
||||
fetched := make([]fetchResult, 0, len(entries))
|
||||
for res := range results {
|
||||
fetched = append(fetched, res)
|
||||
}
|
||||
|
||||
// Re-sort because they might have finished out of order
|
||||
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 buildRelationsFromResults(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),
|
||||
IsCurrent: res.entry.ID == id,
|
||||
IsExtra: false,
|
||||
})
|
||||
if res.entry.ID == id {
|
||||
relations[len(relations)-1].Relation = "Current"
|
||||
}
|
||||
}
|
||||
|
||||
if !seen[id] {
|
||||
return relations
|
||||
}
|
||||
|
||||
func (c *Client) ensureCurrentRelation(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
|
||||
}
|
||||
|
||||
relations = append([]RelationEntry{{
|
||||
return append([]RelationEntry{{
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
}}, relations...)
|
||||
}}, 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 := buildAllowedWatchOrderEntries(result, mode)
|
||||
fetched := c.fetchRelationResults(ctx, allowedEntries)
|
||||
relations := buildRelationsFromResults(fetched, id)
|
||||
relations, err = c.ensureCurrentRelation(ctx, id, seen, relations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(relations) == 0 {
|
||||
@@ -201,3 +326,9 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
|
||||
|
||||
return relations, nil
|
||||
}
|
||||
|
||||
func (c *Client) WarmFullRelations(id int) {
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.GetFullRelations(ctx, id, WatchOrderModeMain)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
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()
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := fn(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -16,9 +36,59 @@ func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
{name: "empty", input: "", want: false},
|
||||
}
|
||||
|
||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
||||
}
|
||||
|
||||
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 := isAllowedWatchOrderType(testCase.input)
|
||||
got := NormalizeWatchOrderMode(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %q, got %q", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasTVWatchOrderEntry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []watchorder.WatchOrderEntry
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "contains tv",
|
||||
entries: []watchorder.WatchOrderEntry{
|
||||
{ID: 1, Type: "Movie"},
|
||||
{ID: 2, Type: " TV "},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ona only",
|
||||
entries: []watchorder.WatchOrderEntry{
|
||||
{ID: 1, Type: "ONA"},
|
||||
{ID: 2, Type: "Special"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := hasTVWatchOrderEntry(testCase.entries)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
@@ -26,6 +96,81 @@ func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
},
|
||||
}
|
||||
|
||||
entries, seen := buildAllowedWatchOrderEntries(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 := buildAllowedWatchOrderEntries(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 := buildAllowedWatchOrderEntries(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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -34,6 +179,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"},
|
||||
}
|
||||
|
||||
@@ -58,12 +205,5 @@ func TestAllowedWatchOrderTypeFromDataset(t *testing.T) {
|
||||
{name: "label special", input: "Special", want: false},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := isAllowedWatchOrderType(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchAdvanced performs a filtered anime search with type, status, ordering, and genre filters.
|
||||
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (SearchResult, error) {
|
||||
func normalizeSearchPagination(page, limit int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -17,43 +16,47 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
||||
limit = 0
|
||||
}
|
||||
|
||||
genresParam := ""
|
||||
if len(genres) > 0 {
|
||||
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)
|
||||
}
|
||||
genresParam = strings.Join(ids, ",")
|
||||
|
||||
return strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, sfw, page, limit)
|
||||
func buildAdvancedSearchURL(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)
|
||||
setPositiveIntQueryValue(params, "producers", studioID)
|
||||
setQueryValue(params, "order_by", orderBy)
|
||||
setQueryValue(params, "sort", sort)
|
||||
setQueryValue(params, "genres", genres)
|
||||
setPositiveIntQueryValue(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 = normalizeSearchPagination(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 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 := buildAdvancedSearchURL(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
|
||||
@@ -73,7 +76,9 @@ func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, err
|
||||
cacheKey := fmt.Sprintf("top:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/top/anime?page=%d", c.baseURL, page)
|
||||
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
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,34 +17,24 @@ type ScheduleResult struct {
|
||||
|
||||
// GetSeasonsNow returns currently airing anime for the current season.
|
||||
func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("seasons_now:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/seasons/now?page=%d", c.baseURL, page)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||
if err != nil {
|
||||
return TopAnimeResult{}, err
|
||||
}
|
||||
|
||||
return TopAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
return c.getSeasonList(ctx, page, "now")
|
||||
}
|
||||
|
||||
// GetSeasonsUpcoming returns anime scheduled to air in upcoming seasons.
|
||||
func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
return c.getSeasonList(ctx, page, "upcoming")
|
||||
}
|
||||
|
||||
func (c *Client) getSeasonList(ctx context.Context, page int, season string) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("seasons_upcoming:%d", page)
|
||||
cacheKey := fmt.Sprintf("seasons_%s:%d", season, page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/seasons/upcoming?page=%d", c.baseURL, 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 {
|
||||
@@ -56,78 +48,121 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu
|
||||
}
|
||||
|
||||
// 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 nil
|
||||
return false
|
||||
}
|
||||
|
||||
c.poolInitialized = true
|
||||
c.poolMu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. Try to load all cached anime from the database
|
||||
func (c *Client) loadCachedRandomPool(ctx context.Context) {
|
||||
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
|
||||
if err == nil && len(cachedJSONs) > 0 {
|
||||
var loadedAnimes []Anime
|
||||
for _, dataStr := range cachedJSONs {
|
||||
var anime Anime
|
||||
if err := json.Unmarshal([]byte(dataStr), &anime); err == nil && anime.MalID > 0 {
|
||||
loadedAnimes = append(loadedAnimes, anime)
|
||||
}
|
||||
if err != nil || len(cachedJSONs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
loadedAnimes := decodeCachedAnime(cachedJSONs)
|
||||
if len(loadedAnimes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(loadedAnimes) > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// 2. Fetch Top Anime page 1 & 2 to ensure we have a robust baseline of high-quality popular anime
|
||||
go func() {
|
||||
loadedAnimes = append(loadedAnimes, anime)
|
||||
}
|
||||
|
||||
return loadedAnimes
|
||||
}
|
||||
|
||||
func (c *Client) seedRandomPoolBaseline() {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var fetchedAnimes []Anime
|
||||
|
||||
top, err := c.GetTopAnime(bgCtx, 1)
|
||||
if err == nil && len(top.Animes) > 0 {
|
||||
fetchedAnimes = append(fetchedAnimes, top.Animes...)
|
||||
}
|
||||
|
||||
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...)
|
||||
}
|
||||
|
||||
fetchedAnimes := c.fetchBaselineAnime(bgCtx)
|
||||
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()
|
||||
c.appendUniqueRandomPool(fetchedAnimes)
|
||||
}
|
||||
|
||||
// Start background refresher once seeding completes
|
||||
c.startPoolRefresher()
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Client) fetchBaselineAnime(ctx context.Context) []Anime {
|
||||
topPageOne := c.fetchTopAnimePage(ctx, 1)
|
||||
topPageTwo := c.fetchTopAnimePage(ctx, 2)
|
||||
currentSeason := c.fetchCurrentSeasonAnime(ctx)
|
||||
|
||||
fetchedAnimes := make([]Anime, 0, len(topPageOne)+len(topPageTwo)+len(currentSeason))
|
||||
fetchedAnimes = append(fetchedAnimes, topPageOne...)
|
||||
fetchedAnimes = append(fetchedAnimes, topPageTwo...)
|
||||
fetchedAnimes = append(fetchedAnimes, currentSeason...)
|
||||
return fetchedAnimes
|
||||
}
|
||||
|
||||
func (c *Client) fetchTopAnimePage(ctx context.Context, page int) []Anime {
|
||||
top, err := c.GetTopAnime(ctx, page)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return top.Animes
|
||||
}
|
||||
|
||||
func (c *Client) fetchCurrentSeasonAnime(ctx context.Context) []Anime {
|
||||
now, err := c.GetSeasonsNow(ctx, 1)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return now.Animes
|
||||
}
|
||||
|
||||
func (c *Client) appendUniqueRandomPool(animes []Anime) {
|
||||
c.poolMu.Lock()
|
||||
defer c.poolMu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
c.randomPool = append(c.randomPool, anime)
|
||||
seen[anime.MalID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// startPoolRefresher runs in the background to slowly mix in true random anime
|
||||
func (c *Client) startPoolRefresher() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
@@ -174,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,6 +1,9 @@
|
||||
package jikan
|
||||
|
||||
import ()
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ProducerResponse struct {
|
||||
Data struct {
|
||||
@@ -24,3 +27,18 @@ type ProducerResponse struct {
|
||||
} `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
|
||||
}
|
||||
|
||||
@@ -33,12 +33,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"`
|
||||
Titles []TitleEntry `json:"titles"`
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
@@ -230,35 +236,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
|
||||
for _, token := range strings.Fields(strings.ToLower(a.Duration)) {
|
||||
value, err := strconv.Atoi(token)
|
||||
if err == nil {
|
||||
currentValue = value
|
||||
hasValue = true
|
||||
continue
|
||||
}
|
||||
currentNum = ""
|
||||
} else if len(currentNum) > 0 && (c == 'h' || c == 'H') {
|
||||
isHours = true
|
||||
val, _ := strconv.Atoi(currentNum)
|
||||
hours = val
|
||||
currentNum = ""
|
||||
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
|
||||
}
|
||||
|
||||
@@ -455,13 +460,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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
102
integrations/playback/allanime/availability.go
Normal file
102
integrations/playback/allanime/availability.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/internal/domain"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AvailableEpisodes struct {
|
||||
Sub []string
|
||||
Dub []string
|
||||
Raw []string
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
|
||||
showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
|
||||
if err != nil {
|
||||
return domain.EpisodeAvailability{}, err
|
||||
}
|
||||
return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
|
||||
available, err := c.GetAvailableEpisodes(ctx, showID)
|
||||
if err != nil {
|
||||
return domain.EpisodeAvailability{}, err
|
||||
}
|
||||
|
||||
sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
|
||||
dub := parseEpisodeNumbers(available.Dub)
|
||||
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
|
||||
graphqlQuery := `query($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
availableEpisodesDetail
|
||||
lastEpisodeInfo
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
|
||||
if err != nil {
|
||||
return AvailableEpisodes{}, err
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
return AvailableEpisodes{}, 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")
|
||||
}
|
||||
|
||||
return AvailableEpisodes{
|
||||
Sub: stringSliceFromAny(detail["sub"]),
|
||||
Dub: stringSliceFromAny(detail["dub"]),
|
||||
Raw: stringSliceFromAny(detail["raw"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
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 stringSliceFromAny(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
|
||||
}
|
||||
@@ -3,54 +3,27 @@ package allanime
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/internal/domain"
|
||||
"mal/pkg/net/limits"
|
||||
"mal/pkg/net/useragent"
|
||||
"mal/pkg/net/utls"
|
||||
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 = useragent.Firefox121
|
||||
defaultUserAgent = netutil.Firefox121
|
||||
)
|
||||
|
||||
var (
|
||||
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
||||
)
|
||||
|
||||
var allAnimeUTLSClient = &http.Client{
|
||||
Transport: &utls.UtlsRoundTripper{},
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -59,6 +32,10 @@ func NewAllAnimeProvider() *AllAnimeProvider {
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: &netutil.UtlsRoundTripper{},
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
}
|
||||
@@ -67,124 +44,23 @@ func (c *AllAnimeProvider) Name() string {
|
||||
return "AllAnime"
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
|
||||
// 1. Search for the show to get its AllAnime ID
|
||||
graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) {
|
||||
shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) {
|
||||
edges {
|
||||
_id
|
||||
malId
|
||||
name
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]any{
|
||||
"search": map[string]any{
|
||||
"allowAdult": false,
|
||||
"allowUnknown": false,
|
||||
"query": query,
|
||||
},
|
||||
"limit": 40,
|
||||
"page": 1,
|
||||
"translationType": mode,
|
||||
"countryOrigin": "ALL",
|
||||
}
|
||||
|
||||
result, err := c.graphqlRequest(ctx, graphqlQuery, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid search response")
|
||||
}
|
||||
|
||||
shows, ok := data["shows"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid shows payload")
|
||||
}
|
||||
|
||||
edges, ok := shows["edges"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid search edges")
|
||||
}
|
||||
|
||||
out := make([]searchResult, 0, len(edges))
|
||||
for _, edge := range edges {
|
||||
item, ok := edge.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
id, _ := item["_id"].(string)
|
||||
malID, _ := item["malId"].(string)
|
||||
name, _ := item["name"].(string)
|
||||
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
|
||||
name = unquoted
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
|
||||
// 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
|
||||
}
|
||||
|
||||
showID := c.resolveShowIDWithFallback(ctx, animeID, titleCandidates, mode)
|
||||
if showID == "" {
|
||||
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 {
|
||||
@@ -197,59 +73,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) {
|
||||
return c.resolveShowIDStrict(ctx, animeID, titleCandidates, "sub")
|
||||
}
|
||||
|
||||
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 strict mal id match for %d", animeID)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -274,19 +97,13 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
||||
req.Header.Set("Referer", allAnimeReferer)
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute graphql request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read graphql response: %w", err)
|
||||
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
|
||||
@@ -301,516 +118,17 @@ 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)
|
||||
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, 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, err := allAnimeUTLSClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute GET request: %w", err)
|
||||
return 0, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
return 0, nil, fmt.Errorf("%s: %w", readErrPrefix, 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 := 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...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -20,167 +20,182 @@ func isLikelyMP4(data []byte) bool {
|
||||
return string(data[4:8]) == "ftyp"
|
||||
}
|
||||
|
||||
func TestDecodeSourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
type stringTransformTestCase struct {
|
||||
name string
|
||||
encoded string
|
||||
input 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
|
||||
}
|
||||
|
||||
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 := buildSourceReferences(tt.rawURLs)
|
||||
if len(got) != len(tt.wantRefs) {
|
||||
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
|
||||
return
|
||||
}
|
||||
|
||||
for i, want := range tt.wantRefs {
|
||||
if got[i].URL != want.URL {
|
||||
t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL)
|
||||
}
|
||||
if got[i].Name != want.Name {
|
||||
t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func 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",
|
||||
input: "https://example.com/video.m3u8",
|
||||
want: "m3u8",
|
||||
},
|
||||
{
|
||||
name: "master m3u8",
|
||||
url: "https://example.com/master.m3u8",
|
||||
wantType: "m3u8",
|
||||
input: "https://example.com/master.m3u8",
|
||||
want: "m3u8",
|
||||
},
|
||||
{
|
||||
name: "mp4 extension",
|
||||
url: "https://example.com/video.mp4",
|
||||
wantType: "mp4",
|
||||
input: "https://example.com/video.mp4",
|
||||
want: "mp4",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
url: "https://example.com/video.avi",
|
||||
wantType: "unknown",
|
||||
input: "https://example.com/video.avi",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "empty returns unknown",
|
||||
url: "",
|
||||
wantType: "unknown",
|
||||
input: "",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "case insensitive - M3U8",
|
||||
url: "https://example.com/MASTER.M3U8",
|
||||
wantType: "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",
|
||||
input: "https://streamwish.com/e/abc123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "streamsb",
|
||||
url: "https://streamsb.com/e/abc123",
|
||||
wantType: "embed",
|
||||
input: "https://streamsb.com/e/abc123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "mp4upload",
|
||||
url: "https://mp4upload.com/e/abc123",
|
||||
wantType: "embed",
|
||||
input: "https://mp4upload.com/e/abc123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "ok.ru",
|
||||
url: "https://ok.ru/video/123",
|
||||
wantType: "embed",
|
||||
input: "https://ok.ru/video/123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "gogoplay",
|
||||
url: "https://gogoplay.io/embed/123",
|
||||
wantType: "embed",
|
||||
input: "https://gogoplay.io/embed/123",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "streamlare",
|
||||
url: "https://streamlare.com/e/abc",
|
||||
wantType: "embed",
|
||||
input: "https://streamlare.com/e/abc",
|
||||
want: "embed",
|
||||
},
|
||||
{
|
||||
name: "unknown host",
|
||||
url: "https://unknown.com/video",
|
||||
wantType: "unknown",
|
||||
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 +219,21 @@ func TestBuildStreamSource(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, ok := resolveDirectSource(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 +285,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) {
|
||||
@@ -391,6 +394,27 @@ 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()
|
||||
|
||||
|
||||
235
integrations/playback/allanime/crypto.go
Normal file
235
integrations/playback/allanime/crypto.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
|
||||
)
|
||||
|
||||
func decryptTobeparsed(encoded string) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode failed: %w", err)
|
||||
}
|
||||
|
||||
if len(raw) < 29 {
|
||||
return nil, 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
|
||||
}
|
||||
|
||||
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 := firstNonEmptyString(
|
||||
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 := firstNonEmptySlice(
|
||||
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 hasEpisodeSourceURLs(data map[string]any) bool {
|
||||
return len(nestedSlice(data, "episode", "sourceUrls")) > 0
|
||||
}
|
||||
|
||||
func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]any, error) {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err)
|
||||
}
|
||||
|
||||
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
|
||||
return nil, fmt.Errorf("graphql error: %v", errs[0])
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmptySlice(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
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mal/pkg/net/limits"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -18,10 +20,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,
|
||||
}
|
||||
}
|
||||
@@ -54,7 +73,7 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.MiB2)) // 2MB limit
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) // 2MB limit
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read provider response: %w", err)
|
||||
}
|
||||
@@ -62,53 +81,148 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
||||
return e.parseProviderResponse(ctx, string(body)), nil
|
||||
}
|
||||
|
||||
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() { _ = 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 parseExternalEmbedResponse(rawURL, string(body), e.referer), 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
|
||||
|
||||
// extract per-source referer if present
|
||||
refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`)
|
||||
if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 {
|
||||
providerReferer = strings.ReplaceAll(match[1], `\/`, "/")
|
||||
}
|
||||
if providerReferer == "" {
|
||||
providerReferer = e.referer
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(response), &root); err != nil {
|
||||
return []StreamSource{}
|
||||
}
|
||||
|
||||
// extract direct link sources (mp4/embed)
|
||||
linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`)
|
||||
for _, match := range linkPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 3 {
|
||||
data := collectProviderResponseData(root, e.referer)
|
||||
sources := buildProviderLinkSources(data.links, data.referer)
|
||||
sources = append(sources, e.buildProviderHLSSources(ctx, data.hls, data.referer)...)
|
||||
|
||||
attachSubtitles(sources, data.subtitles)
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
func collectProviderResponseData(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:
|
||||
collectProviderMapData(x, &data)
|
||||
for _, child := range x {
|
||||
walk(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range x {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
if data.referer == "" {
|
||||
data.referer = fallbackReferer
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func collectProviderMapData(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, parseProviderSubtitles(subs)...)
|
||||
}
|
||||
}
|
||||
|
||||
func parseProviderSubtitles(items []any) []Subtitle {
|
||||
subtitles := make([]Subtitle, 0, len(items))
|
||||
for _, item := range items {
|
||||
node, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
link := strings.ReplaceAll(match[1], `\/`, "/")
|
||||
quality := strings.TrimSpace(match[2])
|
||||
sourceType := detectStreamType(link)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(link)
|
||||
lang, _ := node["lang"].(string)
|
||||
src, _ := node["src"].(string)
|
||||
lang = strings.TrimSpace(lang)
|
||||
src = strings.TrimSpace(src)
|
||||
if lang == "" || src == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
|
||||
}
|
||||
|
||||
return subtitles
|
||||
}
|
||||
|
||||
func buildProviderLinkSources(items []providerLinkItem, referer string) []StreamSource {
|
||||
sources := make([]StreamSource, 0, len(items))
|
||||
for _, item := range items {
|
||||
link := strings.TrimSpace(item.link)
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sources = append(sources, StreamSource{
|
||||
URL: link,
|
||||
Quality: quality,
|
||||
Quality: strings.TrimSpace(item.resolutionStr),
|
||||
Provider: "wixmp",
|
||||
Type: sourceType,
|
||||
Referer: providerReferer,
|
||||
Type: detectProviderSourceType(link),
|
||||
Referer: referer,
|
||||
})
|
||||
}
|
||||
|
||||
// extract HLS playlist sources
|
||||
hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`)
|
||||
for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 2 {
|
||||
return sources
|
||||
}
|
||||
|
||||
func detectProviderSourceType(link string) string {
|
||||
sourceType := detectStreamType(link)
|
||||
if sourceType != "unknown" {
|
||||
return sourceType
|
||||
}
|
||||
|
||||
return detectEmbedType(link)
|
||||
}
|
||||
|
||||
func (e *providerExtractor) buildProviderHLSSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
|
||||
sources := make([]StreamSource, 0, len(items))
|
||||
for _, item := range items {
|
||||
playlistURL, ok := providerPlaylistURL(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
playlistURL := strings.ReplaceAll(match[1], `\/`, "/")
|
||||
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...)
|
||||
}
|
||||
@@ -120,36 +234,32 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
Quality: "auto",
|
||||
Provider: "hls",
|
||||
Type: "m3u8",
|
||||
Referer: providerReferer,
|
||||
Referer: referer,
|
||||
})
|
||||
}
|
||||
|
||||
// extract subtitles and attach to all sources
|
||||
subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`)
|
||||
if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 {
|
||||
subtitles := make([]Subtitle, 0)
|
||||
subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`)
|
||||
for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) {
|
||||
if len(entry) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
subtitles = append(subtitles, Subtitle{
|
||||
Lang: strings.TrimSpace(entry[1]),
|
||||
URL: strings.ReplaceAll(entry[2], `\/`, "/"),
|
||||
})
|
||||
}
|
||||
|
||||
if len(subtitles) > 0 {
|
||||
for idx := range sources {
|
||||
sources[idx].Subtitles = subtitles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
func providerPlaylistURL(item providerHLSItem) (string, bool) {
|
||||
playlistURL := strings.TrimSpace(item.url)
|
||||
if playlistURL == "" || item.hardsubLang != "en-US" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
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.
|
||||
func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) {
|
||||
resp, err := doProxiedRequest(ctx, e.httpClient, masterURL, referer)
|
||||
@@ -158,65 +268,164 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)) // 512KB limit
|
||||
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
|
||||
}
|
||||
|
||||
func parseM3U8Sources(body string, masterURL string, referer string) []StreamSource {
|
||||
lines := strings.Split(body, "\n")
|
||||
baseURL := playlistBaseURL(masterURL)
|
||||
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
|
||||
currentBandwidth := 0
|
||||
sources := make([]StreamSource, 0)
|
||||
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
|
||||
|
||||
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 := parseStreamBandwidth(trimmed, bwPattern); ok {
|
||||
currentBandwidth = bandwidth
|
||||
continue
|
||||
}
|
||||
|
||||
// skip empty lines and non-stream lines
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
if shouldSkipM3U8Line(trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
streamURL := trimmed
|
||||
if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
|
||||
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,
|
||||
URL: resolvePlaylistURL(trimmed, baseURL),
|
||||
Quality: qualityFromBandwidth(currentBandwidth),
|
||||
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 parseStreamBandwidth(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 shouldSkipM3U8Line(line string) bool {
|
||||
return line == "" || strings.HasPrefix(line, "#")
|
||||
}
|
||||
|
||||
func resolvePlaylistURL(streamURL string, baseURL string) string {
|
||||
if strings.HasPrefix(streamURL, "http://") || strings.HasPrefix(streamURL, "https://") {
|
||||
return streamURL
|
||||
}
|
||||
|
||||
return baseURL + streamURL
|
||||
}
|
||||
|
||||
func qualityFromBandwidth(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"
|
||||
}
|
||||
}
|
||||
|
||||
func parseExternalEmbedResponse(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 parseMP4UploadSources(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 := decodeEscapedMediaURL(firstNonEmptyString(match[1], match[2]))
|
||||
if playlistURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []StreamSource{{
|
||||
URL: playlistURL,
|
||||
Quality: "auto",
|
||||
Provider: "ok",
|
||||
Type: "m3u8",
|
||||
Referer: referer,
|
||||
}}
|
||||
}
|
||||
|
||||
func parseMP4UploadSources(body string, referer string) []StreamSource {
|
||||
srcPattern := regexp.MustCompile(`(?m)src:\s*"([^"]+)"`)
|
||||
match := srcPattern.FindStringSubmatch(body)
|
||||
if len(match) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaURL := decodeEscapedMediaURL(match[1])
|
||||
if mediaURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []StreamSource{{
|
||||
URL: mediaURL,
|
||||
Provider: "mp4upload",
|
||||
Type: detectProviderSourceType(mediaURL),
|
||||
Referer: referer,
|
||||
}}
|
||||
}
|
||||
|
||||
func decodeEscapedMediaURL(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))
|
||||
}
|
||||
|
||||
156
integrations/playback/allanime/search.go
Normal file
156
integrations/playback/allanime/search.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const searchQuery = `query(
|
||||
$search: SearchInput
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$limit: Int = 40
|
||||
$page: Int = 1
|
||||
$countryOrigin: VaildCountryOriginEnumType = ALL
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
edges {
|
||||
_id
|
||||
malId
|
||||
name
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
type searchResult struct {
|
||||
ID string
|
||||
MalID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
|
||||
type searchData struct {
|
||||
Shows struct {
|
||||
Edges []struct {
|
||||
ID string `json:"_id"`
|
||||
MalID string `json:"malId"`
|
||||
Name string `json:"name"`
|
||||
} `json:"edges"`
|
||||
} `json:"shows"`
|
||||
}
|
||||
|
||||
type searchInput struct {
|
||||
AllowAdult bool `json:"allowAdult"`
|
||||
AllowUnknown bool `json:"allowUnknown"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type searchVariables struct {
|
||||
Search searchInput `json:"search"`
|
||||
TranslationType string `json:"translationType"`
|
||||
}
|
||||
|
||||
vars := searchVariables{
|
||||
Search: searchInput{
|
||||
AllowAdult: false,
|
||||
AllowUnknown: false,
|
||||
Query: query,
|
||||
},
|
||||
TranslationType: mode,
|
||||
}
|
||||
|
||||
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
|
||||
Headers: map[string]string{
|
||||
"Referer": allAnimeReferer,
|
||||
"User-Agent": defaultUserAgent,
|
||||
},
|
||||
BodyMax: netutil.MiB2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]searchResult, 0, len(data.Shows.Edges))
|
||||
for _, edge := range data.Shows.Edges {
|
||||
id := edge.ID
|
||||
malID := edge.MalID
|
||||
name := edge.Name
|
||||
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
|
||||
name = unquoted
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) resolveShowIDWithFallback(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
|
||||
targetMalIDStr := strconv.Itoa(animeID)
|
||||
firstAvailableShowID := ""
|
||||
|
||||
for _, title := range titleCandidates {
|
||||
searchResults, err := c.Search(ctx, title, mode)
|
||||
if err != nil || len(searchResults) == 0 {
|
||||
continue
|
||||
}
|
||||
if showID := exactMatchShowID(searchResults, targetMalIDStr); showID != "" {
|
||||
return showID
|
||||
}
|
||||
if firstAvailableShowID == "" {
|
||||
firstAvailableShowID = searchResults[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
return firstAvailableShowID
|
||||
}
|
||||
|
||||
func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
|
||||
for _, res := range searchResults {
|
||||
if res.MalID == targetMalID {
|
||||
return res.ID
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
|
||||
for _, mode := range []string{"sub", "dub"} {
|
||||
showID, err := c.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) 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)
|
||||
}
|
||||
316
integrations/playback/allanime/sources.go
Normal file
316
integrations/playback/allanime/sources.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
|
||||
|
||||
type sourceReference struct {
|
||||
URL string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
|
||||
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
|
||||
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
|
||||
sourceUrls
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
|
||||
if err == nil {
|
||||
sources := c.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 {
|
||||
if source, ok := resolveDirectSource(ref); ok {
|
||||
out = append(out, source)
|
||||
return out
|
||||
}
|
||||
|
||||
extracted := c.resolveExtractedSources(ctx, ref)
|
||||
if len(extracted) > 0 {
|
||||
out = append(out, extracted...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveDirectSource(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) resolveExtractedSources(ctx context.Context, ref sourceReference) []StreamSource {
|
||||
rawURL := strings.TrimSpace(ref.URL)
|
||||
decoded := decodeSourceURL(rawURL)
|
||||
if decoded == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isHTTPURL(decoded) {
|
||||
extracted, err := c.extractor.ExtractEmbedVideoLinks(ctx, decoded)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return extracted
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(decoded, "/") {
|
||||
decoded = "/" + decoded
|
||||
}
|
||||
|
||||
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return extracted
|
||||
}
|
||||
|
||||
func detectSourceType(sourceURL string) string {
|
||||
sourceType := detectStreamType(sourceURL)
|
||||
if sourceType != "unknown" {
|
||||
return sourceType
|
||||
}
|
||||
|
||||
return detectEmbedType(sourceURL)
|
||||
}
|
||||
|
||||
func isHTTPURL(value string) bool {
|
||||
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
|
||||
}
|
||||
|
||||
func buildStreamSource(url, sourceType, provider string) StreamSource {
|
||||
return StreamSource{
|
||||
URL: url,
|
||||
Provider: provider,
|
||||
Type: sourceType,
|
||||
Referer: allAnimeReferer,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
|
||||
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 (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
|
||||
req, err := newEpisodeHashRequest(ctx, showID, episode, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create GET request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
req.Header.Set("Referer", allAnimeReferer)
|
||||
req.Header.Set("Origin", allAnimeOrigin)
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
statusCode, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET status %d: %s", statusCode, string(respBody))
|
||||
}
|
||||
|
||||
parsed, err := parseGraphQLResponse(respBody, "decode response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no data in response")
|
||||
}
|
||||
|
||||
decrypted, err := responseFromTobeparsed(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decrypted != nil {
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
if hasEpisodeSourceURLs(data) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no usable data in response")
|
||||
}
|
||||
|
||||
func newEpisodeHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
|
||||
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode)
|
||||
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("variables", varsJSON)
|
||||
params.Set("extensions", extJSON)
|
||||
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api?%s", allAnimeBaseURL, params.Encode()), nil)
|
||||
}
|
||||
|
||||
func detectStreamType(sourceURL string) string {
|
||||
lower := strings.ToLower(sourceURL)
|
||||
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
|
||||
return "m3u8"
|
||||
}
|
||||
|
||||
if strings.Contains(lower, ".mp4") {
|
||||
return "mp4"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func detectEmbedType(rawURL string) string {
|
||||
lower := strings.ToLower(rawURL)
|
||||
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
|
||||
for _, host := range embedHosts {
|
||||
if strings.Contains(lower, host) {
|
||||
return "embed"
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package watchorder provides anime watch order data from various sources.
|
||||
package watchorder
|
||||
|
||||
import (
|
||||
@@ -5,8 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/pkg/net/limits"
|
||||
"mal/pkg/net/useragent"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -82,36 +82,12 @@ func parseRootID(url string) (int, error) {
|
||||
}
|
||||
|
||||
func addCommonHeaders(request *http.Request) {
|
||||
request.Header.Set("User-Agent", useragent.Chrome135)
|
||||
request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
request.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
request.Header.Set("Referer", "https://chiaki.site/")
|
||||
request.Header.Set("Cache-Control", "no-cache")
|
||||
netutil.SetBrowserHTMLHeaders(request, "https://chiaki.site/")
|
||||
}
|
||||
|
||||
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
addCommonHeaders(request)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
// limit body read for error context; avoid reading large error pages
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, limits.Bytes512))
|
||||
return nil, &HTTPStatusError{
|
||||
document, _, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error {
|
||||
return &HTTPStatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
URL: url,
|
||||
Server: strings.TrimSpace(response.Header.Get("Server")),
|
||||
@@ -120,14 +96,8 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
|
||||
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
|
||||
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
|
||||
}
|
||||
}
|
||||
|
||||
document, err := goquery.NewDocumentFromReader(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse html: %w", err)
|
||||
}
|
||||
|
||||
return document, nil
|
||||
})
|
||||
return document, err
|
||||
}
|
||||
|
||||
func extractTypeLabelsByID(doc *goquery.Document) map[int]string {
|
||||
@@ -241,7 +211,7 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
|
||||
return "", fmt.Errorf("proxy status %d", response.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, limits.MiB2))
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read proxy response: %w", err)
|
||||
}
|
||||
|
||||
@@ -141,10 +141,10 @@ Jujutsu Kaisen 0
|
||||
testClient := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case request.URL.Host == "chiaki.site":
|
||||
switch request.URL.Host {
|
||||
case "chiaki.site":
|
||||
return mockResponse(http.StatusForbidden, map[string]string{"Content-Type": "text/html; charset=utf-8"}, "blocked"), nil
|
||||
case request.URL.Host == "r.jina.ai":
|
||||
case "r.jina.ai":
|
||||
// Proxy response is plain text/markdown.
|
||||
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/plain; charset=utf-8"}, proxyPayload), nil
|
||||
default:
|
||||
|
||||
391
internal/anime/browse_handler.go
Normal file
391
internal/anime/browse_handler.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"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"))
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid page")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid limit")
|
||||
}
|
||||
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 || id < 0 {
|
||||
return browseQuery{}, fmt.Errorf("invalid studio 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")
|
||||
}
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid page")
|
||||
}
|
||||
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 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) {
|
||||
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, _ := h.svc.GetGenres(c.Request.Context())
|
||||
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.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,
|
||||
})
|
||||
}
|
||||
123
internal/anime/catalog_handler.go
Normal file
123
internal/anime/catalog_handler.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "search.gohtml", gin.H{
|
||||
"User": server.CurrentUser(c),
|
||||
"CurrentPath": "/search",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Airing")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Popular")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Continue")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"top_pick_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = "TopPickForYou"
|
||||
data.Fragment = "top_pick_for_you_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleTopPicks(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"top_picks_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
c.HTML(http.StatusOK, "top_picks.gohtml", gin.H{
|
||||
"CurrentPath": "/top-picks",
|
||||
"User": user,
|
||||
"Animes": data.Animes,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
event,
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
204
internal/anime/command_palette.go
Normal file
204
internal/anime/command_palette.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const commandPaletteAnimeLimit = 24
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type commandPaletteResponse struct {
|
||||
Items []commandPaletteItem `json:"items"`
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
NextPage int `json:"nextPage,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"))
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]commandPaletteItem, 0, commandPaletteAnimeLimit)
|
||||
|
||||
if query != "" {
|
||||
hasNextPage := false
|
||||
if len(query) >= 2 {
|
||||
var animeItems []commandPaletteItem
|
||||
animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page)
|
||||
items = append(items, animeItems...)
|
||||
}
|
||||
|
||||
if page == 1 {
|
||||
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, commandPaletteResponse{
|
||||
Items: items,
|
||||
HasNextPage: hasNextPage,
|
||||
NextPage: page + 1,
|
||||
})
|
||||
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, commandPaletteResponse{Items: items})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
|
||||
all := []commandPaletteItem{
|
||||
{ID: "nav:home", Type: "navigation", Label: "Go to Home", Subtitle: "Navigation", Href: "/", Icon: "home"},
|
||||
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
|
||||
{ID: "nav:top-picks", Type: "navigation", Label: "Open Top Picks", Subtitle: "Navigation", Href: "/top-picks", Icon: "sparkles"},
|
||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=asc", Icon: "trending"},
|
||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=asc", 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, page int) ([]commandPaletteItem, bool) {
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, commandPaletteAnimeLimit)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
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, res.HasNextPage
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
220
internal/anime/details_handler.go
Normal file
220
internal/anime/details_handler.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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) 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 {
|
||||
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 "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
|
||||
}
|
||||
|
||||
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,
|
||||
"Mode": string(mode),
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
73
internal/anime/handler.go
Normal file
73
internal/anime/handler.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/internal/domain"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AnimeHandler struct {
|
||||
svc Service
|
||||
watchlistSvc domain.WatchlistService
|
||||
episodeSvc domain.EpisodeService
|
||||
scheduleCache map[string]cachedWeekSchedule
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
domain.AnimeCatalogService
|
||||
domain.AnimeSearchService
|
||||
domain.AnimeDetailsService
|
||||
WarmDetailSections(id int)
|
||||
}
|
||||
|
||||
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
|
||||
return &AnimeHandler{
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
episodeSvc: episodeSvc,
|
||||
scheduleCache: make(map[string]cachedWeekSchedule),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool {
|
||||
animeIDs := make([]int64, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
if anime.MalID > 0 {
|
||||
animeIDs = append(animeIDs, int64(anime.MalID))
|
||||
}
|
||||
}
|
||||
return h.watchlistMapForIDs(ctx, userID, animeIDs)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool {
|
||||
if userID == "" || len(animeIDs) == 0 {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
|
||||
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
|
||||
if err != nil {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
return watchlistMap
|
||||
}
|
||||
|
||||
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("/search", h.HandleSearch)
|
||||
r.GET("/top-picks", h.HandleTopPicks)
|
||||
r.GET("/browse", h.HandleBrowse)
|
||||
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/jikan/random/anime", h.HandleRandomAnime)
|
||||
r.GET("/api/jikan/producers", h.HandleProducers)
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AnimeHandler struct {
|
||||
svc domain.AnimeService
|
||||
watchlistSvc domain.WatchlistService
|
||||
}
|
||||
|
||||
func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
|
||||
return &AnimeHandler{
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool {
|
||||
animeIDs := make([]int64, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
if anime.MalID > 0 {
|
||||
animeIDs = append(animeIDs, int64(anime.MalID))
|
||||
}
|
||||
}
|
||||
return h.watchlistMapForIDs(ctx, userID, animeIDs)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool {
|
||||
if userID == "" || len(animeIDs) == 0 {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
|
||||
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
|
||||
if err != nil {
|
||||
return map[int64]bool{}
|
||||
}
|
||||
return watchlistMap
|
||||
}
|
||||
|
||||
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("/discover", h.HandleDiscover)
|
||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||
r.GET("/browse", h.HandleBrowse)
|
||||
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/jikan/random/anime", h.HandleRandomAnime)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user, _ := c.Get("User")
|
||||
|
||||
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) renderCatalogSection(c *gin.Context, section string) {
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
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, _ := c.Get("User")
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
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) 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"
|
||||
|
||||
var genres []int
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, _ := strconv.Atoi(g)
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24)
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "anime_card_scroll",
|
||||
"Animes": res.Animes,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"SFW": sfw,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "browse_content",
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"SFW": sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": res.Animes,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"NextPage": page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"SFW": sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": res.Animes,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"NextPage": page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
section := c.Query("section")
|
||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
|
||||
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 {
|
||||
log.Printf("failed to fetch section %s: %v", section, err)
|
||||
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
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
status := ""
|
||||
var watchlistIDs []int64
|
||||
ep := 1
|
||||
var cwSeconds float64
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
|
||||
if err == nil {
|
||||
status = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
}
|
||||
|
||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), u.ID, int64(id))
|
||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Query("animeId"))
|
||||
if id <= 0 {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
|
||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
relations, err := h.svc.GetRelations(relationsCtx, id)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch relations for anime %d: %v", id, err)
|
||||
c.Status(http.StatusNoContent)
|
||||
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, true, 1, 5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.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(res.Animes))
|
||||
for i, anime := range res.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)
|
||||
}
|
||||
|
||||
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, _ := c.Get("User")
|
||||
u, ok := user.(*domain.User)
|
||||
if !ok {
|
||||
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, u.ID, query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, u.ID, query)...)
|
||||
c.JSON(http.StatusOK, items)
|
||||
return
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteContinueItems(c, u.ID, query)...)
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, u.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, true, 1, 5)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]commandPaletteItem, 0, len(res.Animes))
|
||||
for _, anime := range res.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 {
|
||||
if row.TitleEnglish.Valid && row.TitleEnglish.String != "" {
|
||||
return row.TitleEnglish.String
|
||||
}
|
||||
return row.TitleOriginal
|
||||
}
|
||||
|
||||
func watchlistTitle(row domain.UserWatchListRow) string {
|
||||
if row.TitleEnglish.Valid && row.TitleEnglish.String != "" {
|
||||
return row.TitleEnglish.String
|
||||
}
|
||||
return row.TitleOriginal
|
||||
}
|
||||
|
||||
func watchlistStatusLabel(status string) string {
|
||||
switch status {
|
||||
case "watching":
|
||||
return "Watching"
|
||||
case "plan_to_watch":
|
||||
return "Plan to Watch"
|
||||
default:
|
||||
return "Watchlist"
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"})
|
||||
return
|
||||
}
|
||||
if anime.MalID == 0 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Random anime unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
inWatchlist := false
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), u.ID, []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, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := c.Get("User")
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
124
internal/anime/handler_test.go
Normal file
124
internal/anime/handler_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubEpisodeService struct {
|
||||
episodes domain.CanonicalEpisodeList
|
||||
err error
|
||||
forced bool
|
||||
}
|
||||
|
||||
func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
|
||||
s.forced = forceRefresh
|
||||
if s.err != nil {
|
||||
return domain.CanonicalEpisodeList{}, s.err
|
||||
}
|
||||
return s.episodes, nil
|
||||
}
|
||||
|
||||
func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
episodes []domain.CanonicalEpisode
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "dub availability",
|
||||
episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, HasSub: true, HasDub: true},
|
||||
},
|
||||
want: "Dub available",
|
||||
},
|
||||
{
|
||||
name: "subtitled availability",
|
||||
episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, HasSub: true, SubOnly: true},
|
||||
},
|
||||
want: "Subtitled only",
|
||||
},
|
||||
{
|
||||
name: "unknown availability",
|
||||
episodes: []domain.CanonicalEpisode{{Number: 1}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no episodes",
|
||||
episodes: []domain.CanonicalEpisode{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := animeAudioAvailabilityLabel(tt.episodes)
|
||||
if got != tt.want {
|
||||
t.Fatalf("animeAudioAvailabilityLabel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "allanime source",
|
||||
source: "AllAnime",
|
||||
want: "Dub available",
|
||||
},
|
||||
{
|
||||
name: "jikan fallback source",
|
||||
source: "jikan_fallback",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "legacy source",
|
||||
source: "legacy_disabled",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "provider error",
|
||||
err: errors.New("provider unavailable"),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Source: tt.source,
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, HasSub: true, HasDub: true},
|
||||
},
|
||||
},
|
||||
err: tt.err,
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
||||
|
||||
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
|
||||
Anime: jikan.Anime{MalID: 52991},
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want)
|
||||
}
|
||||
if !episodeSvc.forced {
|
||||
t.Fatal("animeAudioAvailability() did not force provider refresh")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/internal/anime/handler"
|
||||
"mal/internal/anime/repository"
|
||||
"mal/internal/anime/service"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -11,12 +9,19 @@ import (
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(
|
||||
repository.NewAnimeRepository,
|
||||
service.NewAnimeService,
|
||||
handler.NewAnimeHandler,
|
||||
NewAnimeRepository,
|
||||
fx.Annotate(
|
||||
NewAnimeService,
|
||||
fx.As(new(Service)),
|
||||
fx.As(new(domain.AnimeCatalogService)),
|
||||
fx.As(new(domain.AnimeSearchService)),
|
||||
fx.As(new(domain.AnimeDetailsService)),
|
||||
fx.As(new(domain.AnimePlaybackService)),
|
||||
),
|
||||
NewAnimeHandler,
|
||||
),
|
||||
fx.Provide(
|
||||
server.AsRouteRegister(func(h *handler.AnimeHandler) server.RouteRegister {
|
||||
server.AsRouteRegister(func(h *AnimeHandler) server.RouteRegister {
|
||||
return h
|
||||
}),
|
||||
),
|
||||
|
||||
831
internal/anime/recommendations.go
Normal file
831
internal/anime/recommendations.go
Normal file
@@ -0,0 +1,831 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
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 recommendationSeed struct {
|
||||
animeID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type weightedEntity struct {
|
||||
id int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type profileSearchQuery struct {
|
||||
genreIDs []int
|
||||
studioID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type recommendationCandidate struct {
|
||||
anime jikan.Anime
|
||||
score float64
|
||||
genreMatches int
|
||||
themeMatches int
|
||||
studioMatches int
|
||||
demographicMatches int
|
||||
}
|
||||
|
||||
type userTasteProfile struct {
|
||||
genres map[int]float64
|
||||
themes map[int]float64
|
||||
studios map[int]float64
|
||||
demographics map[int]float64
|
||||
prefersAiring bool
|
||||
prefersRecent bool
|
||||
}
|
||||
|
||||
func buildRecommendationSeeds(
|
||||
now time.Time,
|
||||
watchlist []db.GetUserWatchListRow,
|
||||
) []recommendationSeed {
|
||||
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
|
||||
|
||||
for _, entry := range watchlist {
|
||||
weight := recommendationEntryWeight(now, entry)
|
||||
if weight <= 0 || entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seeds = append(seeds, recommendationSeed{
|
||||
animeID: int(entry.AnimeID),
|
||||
weight: weight,
|
||||
})
|
||||
if len(seeds) >= forYouMaxSeeds {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
|
||||
status := strings.TrimSpace(entry.Status)
|
||||
|
||||
var statusWeight float64
|
||||
switch status {
|
||||
case "completed":
|
||||
statusWeight = 1.0
|
||||
case "watching":
|
||||
statusWeight = 0.9
|
||||
case "plan_to_watch":
|
||||
statusWeight = 0.35
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
recencyWeight := 1.0
|
||||
if !entry.UpdatedAt.IsZero() {
|
||||
age := now.Sub(entry.UpdatedAt)
|
||||
if age > 0 {
|
||||
recencyWeight = math.Max(0.35, 1-(age.Hours()/forYouSeedRecencyWindow.Hours()))
|
||||
}
|
||||
}
|
||||
|
||||
progressWeight := 0.6
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
|
||||
}
|
||||
|
||||
return statusWeight * recencyWeight * progressWeight
|
||||
}
|
||||
|
||||
func buildTasteProfile(
|
||||
now time.Time,
|
||||
seeds []recommendationSeed,
|
||||
seedAnimes []jikan.Anime,
|
||||
) userTasteProfile {
|
||||
profile := userTasteProfile{
|
||||
genres: make(map[int]float64),
|
||||
themes: make(map[int]float64),
|
||||
studios: make(map[int]float64),
|
||||
demographics: make(map[int]float64),
|
||||
}
|
||||
|
||||
var totalWeight float64
|
||||
var airingWeight float64
|
||||
var recentWeight float64
|
||||
|
||||
for i, anime := range seedAnimes {
|
||||
seedWeight := 1.0
|
||||
if i < len(seeds) && seeds[i].weight > 0 {
|
||||
seedWeight = seeds[i].weight
|
||||
}
|
||||
|
||||
addEntityWeights(profile.genres, anime.Genres, seedWeight)
|
||||
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
|
||||
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
|
||||
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
|
||||
|
||||
if anime.Airing {
|
||||
airingWeight += seedWeight
|
||||
}
|
||||
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
||||
recentWeight += seedWeight
|
||||
}
|
||||
totalWeight += seedWeight
|
||||
}
|
||||
|
||||
if totalWeight > 0 {
|
||||
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
||||
profile.prefersRecent = recentWeight/totalWeight >= 0.5
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
|
||||
for _, entity := range entities {
|
||||
if entity.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
target[entity.MalID] += weight
|
||||
}
|
||||
}
|
||||
|
||||
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
||||
queries := make([]profileSearchQuery, 0, 6)
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.genres, forYouProfileGenreSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.themes, forYouProfileThemeSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
studioID: entity.id,
|
||||
weight: entity.weight * 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
|
||||
if limit <= 0 || len(weights) == 0 {
|
||||
return []weightedEntity{}
|
||||
}
|
||||
|
||||
items := make([]weightedEntity, 0, len(weights))
|
||||
for id, weight := range weights {
|
||||
if id <= 0 || weight <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, weightedEntity{id: id, weight: weight})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].weight == items[j].weight {
|
||||
return items[i].id < items[j].id
|
||||
}
|
||||
return items[i].weight > items[j].weight
|
||||
})
|
||||
|
||||
if len(items) > limit {
|
||||
return items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func profileSearchRankWeight(rank int) float64 {
|
||||
return math.Max(0.35, 1-(float64(rank)*0.08))
|
||||
}
|
||||
|
||||
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
|
||||
return (math.Log1p(collaborativeScore) * forYouCollaborativeWeight) +
|
||||
(profileSearchScore * forYouProfileSearchWeight)
|
||||
}
|
||||
|
||||
func hasTasteMetadata(anime jikan.Anime) bool {
|
||||
return len(anime.Genres) > 0 ||
|
||||
len(anime.Themes) > 0 ||
|
||||
len(anime.Studios) > 0 ||
|
||||
len(anime.Demographics) > 0
|
||||
}
|
||||
|
||||
func scoreRecommendationCandidate(
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
candidate jikan.Anime,
|
||||
collaborativeScore float64,
|
||||
profileSearchScore float64,
|
||||
) recommendationCandidate {
|
||||
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
||||
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
||||
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
||||
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
||||
|
||||
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
||||
score += genreScore * forYouGenreMatchWeight
|
||||
score += themeScore * forYouThemeMatchWeight
|
||||
score += studioScore * forYouStudioMatchWeight
|
||||
score += demographicScore * forYouDemographicMatchWeight
|
||||
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genreMatches,
|
||||
themeMatches: themeMatches,
|
||||
studioMatches: studioMatches,
|
||||
demographicMatches: demographicMatches,
|
||||
}
|
||||
}
|
||||
|
||||
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) <= forYouFreshReleaseWindow
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
|
||||
type candidateStore struct {
|
||||
watchlistAnimeIDs map[int]struct{}
|
||||
byID map[int]rankedCandidate
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
|
||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
return &candidateStore{
|
||||
watchlistAnimeIDs: watchlistAnimeIDs,
|
||||
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
|
||||
}
|
||||
|
||||
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) 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 := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return seedAnimes, nil
|
||||
}
|
||||
|
||||
func (s *animeService) 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 := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= forYouMaxRecommendations {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) 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 := s.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
forYouProfileSearchLimit,
|
||||
)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) scoreRankedCandidates(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
ranked []rankedCandidate,
|
||||
) ([]recommendationCandidate, error) {
|
||||
limit := min(len(ranked), forYouCandidateFetchLimit)
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
item := ranked[i]
|
||||
g.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, err := s.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, 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 (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
|
||||
}
|
||||
|
||||
seedAnimes, err := s.fetchSeedAnimes(ctx, seedPool)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
store := newCandidateStore(watchlist)
|
||||
|
||||
if err := s.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
if err := s.collectProfileSearchCandidates(ctx, profile, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
ranked := store.ranked()
|
||||
if len(ranked) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
candidates, err := s.scoreRankedCandidates(ctx, now, profile, ranked)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
}
|
||||
226
internal/anime/recommendations_test.go
Normal file
226
internal/anime/recommendations_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRecommendationEntryWeightPrioritizesCommittedRecentHistory(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
completed := recommendationEntryWeight(now, db.GetUserWatchListRow{
|
||||
Status: "completed",
|
||||
UpdatedAt: now.Add(-24 * time.Hour),
|
||||
CurrentEpisode: sql.NullInt64{Int64: 12, Valid: true},
|
||||
})
|
||||
planned := recommendationEntryWeight(now, db.GetUserWatchListRow{
|
||||
Status: "plan_to_watch",
|
||||
UpdatedAt: now.Add(-24 * time.Hour),
|
||||
})
|
||||
|
||||
if completed <= planned {
|
||||
t.Fatalf("expected completed history to outrank planned history, got completed=%f planned=%f", completed, planned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRecommendationSeedsFiltersUnsupportedStatuses(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
seeds := buildRecommendationSeeds(now, []db.GetUserWatchListRow{
|
||||
{AnimeID: 1, Status: "dropped", UpdatedAt: now},
|
||||
{AnimeID: 2, Status: "watching", UpdatedAt: now},
|
||||
{AnimeID: 3, Status: "completed", UpdatedAt: now},
|
||||
})
|
||||
|
||||
if len(seeds) != 2 {
|
||||
t.Fatalf("expected 2 valid seeds, got %d", len(seeds))
|
||||
}
|
||||
if seeds[0].animeID != 2 || seeds[1].animeID != 3 {
|
||||
t.Fatalf("unexpected seed ordering: %+v", seeds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreRecommendationCandidateRewardsProfileOverlap(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{},
|
||||
}
|
||||
|
||||
matching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||
MalID: 10,
|
||||
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||
Popularity: 100,
|
||||
Score: 8.0,
|
||||
}, 5.0, 0)
|
||||
nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||
MalID: 11,
|
||||
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
|
||||
Popularity: 100,
|
||||
Score: 8.0,
|
||||
}, 5.0, 0)
|
||||
|
||||
if matching.score <= nonMatching.score {
|
||||
t.Fatalf("expected matching candidate to score higher, got matching=%f nonMatching=%f", matching.score, nonMatching.score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
profile := buildTasteProfile(
|
||||
now,
|
||||
[]recommendationSeed{
|
||||
{animeID: 1, weight: 2.0},
|
||||
{animeID: 2, weight: 0.5},
|
||||
},
|
||||
[]jikan.Anime{
|
||||
{
|
||||
MalID: 1,
|
||||
Airing: true,
|
||||
Year: 2026,
|
||||
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||
Themes: []jikan.NamedEntity{{MalID: 10, Name: "Team Sports"}},
|
||||
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
|
||||
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
|
||||
},
|
||||
{
|
||||
MalID: 2,
|
||||
Year: 2001,
|
||||
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
|
||||
Themes: []jikan.NamedEntity{{MalID: 11, Name: "School"}},
|
||||
Studios: []jikan.NamedEntity{{MalID: 21, Name: "Madhouse"}},
|
||||
Demographics: []jikan.NamedEntity{{MalID: 31, Name: "Seinen"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if profile.genres[1] <= profile.genres[2] {
|
||||
t.Fatalf("expected stronger seed genre to carry more weight, got profile=%+v", profile.genres)
|
||||
}
|
||||
if !profile.prefersAiring {
|
||||
t.Fatal("expected weighted profile to prefer airing anime")
|
||||
}
|
||||
if !profile.prefersRecent {
|
||||
t.Fatal("expected weighted profile to prefer recent anime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileSearchQueriesIncludesTasteSignals(t *testing.T) {
|
||||
profile := userTasteProfile{
|
||||
genres: map[int]float64{
|
||||
1: 2.0,
|
||||
2: 1.5,
|
||||
3: 0.2,
|
||||
},
|
||||
themes: map[int]float64{
|
||||
10: 1.4,
|
||||
},
|
||||
studios: map[int]float64{
|
||||
20: 1.0,
|
||||
},
|
||||
demographics: map[int]float64{
|
||||
30: 1.2,
|
||||
},
|
||||
}
|
||||
|
||||
queries := buildProfileSearchQueries(profile)
|
||||
|
||||
if !hasGenreSearchQuery(queries, 1) {
|
||||
t.Fatalf("expected strongest genre query, got %+v", queries)
|
||||
}
|
||||
if !hasGenreSearchQuery(queries, 10) {
|
||||
t.Fatalf("expected theme query, got %+v", queries)
|
||||
}
|
||||
if !hasGenreSearchQuery(queries, 30) {
|
||||
t.Fatalf("expected demographic query, got %+v", queries)
|
||||
}
|
||||
if !hasStudioSearchQuery(queries, 20) {
|
||||
t.Fatalf("expected studio query, got %+v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) {
|
||||
const sportsGenreID = 30
|
||||
|
||||
candidates := []recommendationCandidate{
|
||||
{anime: testRecommendationAnime(1, sportsGenreID), score: 10},
|
||||
{anime: testRecommendationAnime(2, sportsGenreID), score: 9.9},
|
||||
{anime: testRecommendationAnime(3, sportsGenreID), score: 9.8},
|
||||
{anime: testRecommendationAnime(4, sportsGenreID), score: 9.7},
|
||||
{anime: testRecommendationAnime(5, sportsGenreID), score: 9.6},
|
||||
{anime: testRecommendationAnime(6, 1), score: 9.5},
|
||||
{anime: testRecommendationAnime(7, 2), score: 9.4},
|
||||
{anime: testRecommendationAnime(8, 3), score: 9.3},
|
||||
}
|
||||
|
||||
reranked := rerankRecommendationCandidates(candidates, 8)
|
||||
if len(reranked) < 5 {
|
||||
t.Fatalf("expected enough reranked candidates, got %d", len(reranked))
|
||||
}
|
||||
|
||||
for i := 0; i <= len(reranked)-5; i++ {
|
||||
if allHaveGenre(reranked[i:i+5], sportsGenreID) {
|
||||
t.Fatalf("expected reranker to avoid five sports anime in a row, got %+v", animeIDs(reranked))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testRecommendationAnime(id int, genreID int) jikan.Anime {
|
||||
return jikan.Anime{
|
||||
MalID: id,
|
||||
Genres: []jikan.NamedEntity{{MalID: genreID, Name: "Genre"}},
|
||||
}
|
||||
}
|
||||
|
||||
func allHaveGenre(animes []domain.Anime, genreID int) bool {
|
||||
for _, anime := range animes {
|
||||
hasGenre := false
|
||||
for _, genre := range anime.Genres {
|
||||
if genre.MalID == genreID {
|
||||
hasGenre = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGenre {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func animeIDs(animes []domain.Anime) []int {
|
||||
ids := make([]int, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
ids = append(ids, anime.MalID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
|
||||
for _, query := range queries {
|
||||
for _, id := range query.genreIDs {
|
||||
if id == genreID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasStudioSearchQuery(queries []profileSearchQuery, studioID int) bool {
|
||||
for _, query := range queries {
|
||||
if query.studioID == studioID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package repository
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
76
internal/anime/reviews_handler.go
Normal file
76
internal/anime/reviews_handler.go
Normal file
@@ -0,0 +1,76 @@
|
||||
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) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid anime id")
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid page")
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
121
internal/anime/schedule.go
Normal file
121
internal/anime/schedule.go
Normal file
@@ -0,0 +1,121 @@
|
||||
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.Lock()
|
||||
cached, ok := h.scheduleCache[cacheKey]
|
||||
h.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.Lock()
|
||||
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
||||
h.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()
|
||||
}
|
||||
320
internal/anime/service.go
Normal file
320
internal/anime/service.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Package anime provides anime catalog, search, and details services.
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type animeService struct {
|
||||
jikan *jikan.Client
|
||||
repo domain.AnimeRepository
|
||||
}
|
||||
|
||||
func wrapAnimes(in []jikan.Anime) []domain.Anime {
|
||||
out := make([]domain.Anime, 0, len(in))
|
||||
for _, a := range in {
|
||||
out = append(out, domain.Anime{Anime: a})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
|
||||
return &animeService{jikan: jikan, repo: repo}
|
||||
}
|
||||
|
||||
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
|
||||
var (
|
||||
res jikan.TopAnimeResult
|
||||
cw []db.GetContinueWatchingEntriesRow
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Airing":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Popular":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if userID != "" && section == "Continue" {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
if len(animes) > 6 {
|
||||
animes = animes[:6]
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: animes,
|
||||
ContinueWatching: cw,
|
||||
}, 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{Anime: anime}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) {
|
||||
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, studioID, sfw, page, limit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
|
||||
res, err := s.jikan.GetProducerByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, t := range res.Data.Titles {
|
||||
if t.Title != "" {
|
||||
return t.Title, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error) {
|
||||
return s.jikan.GetProducers(ctx, query, page, limit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
genres, err := s.jikan.GetAnimeGenres(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.Genre, 0, len(genres))
|
||||
for _, g := range genres {
|
||||
if g.MalID <= 0 || strings.TrimSpace(g.Name) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.Genre{MalID: g.MalID, Name: g.Name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
out := make([]domain.CharacterEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
var mapped domain.CharacterEntry
|
||||
mapped.Character.MalID = it.Character.MalID
|
||||
mapped.Character.URL = it.Character.URL
|
||||
mapped.Character.Name = it.Character.Name
|
||||
mapped.Character.Images.Jpg.ImageURL = it.Character.Images.Jpg.ImageURL
|
||||
mapped.Character.Images.Webp.ImageURL = it.Character.Images.Webp.ImageURL
|
||||
mapped.Character.Images.Webp.SmallImageURL = it.Character.Images.Webp.SmallImageURL
|
||||
mapped.Role = it.Role
|
||||
|
||||
if len(it.VoiceActors) > 0 {
|
||||
mapped.VoiceActors = make([]domain.CharacterVoiceActor, 0, len(it.VoiceActors))
|
||||
for _, va := range it.VoiceActors {
|
||||
var mappedVA domain.CharacterVoiceActor
|
||||
mappedVA.Language = va.Language
|
||||
mappedVA.Person.MalID = va.Person.MalID
|
||||
mappedVA.Person.URL = va.Person.URL
|
||||
mappedVA.Person.Name = va.Person.Name
|
||||
mappedVA.Person.Images.Jpg.ImageURL = va.Person.Images.Jpg.ImageURL
|
||||
mapped.VoiceActors = append(mapped.VoiceActors, mappedVA)
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, mapped)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
out := make([]domain.RecommendationEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
var mapped domain.RecommendationEntry
|
||||
mapped.Entry.MalID = it.Entry.MalID
|
||||
mapped.Entry.URL = it.Entry.URL
|
||||
mapped.Entry.Title = it.Entry.Title
|
||||
mapped.Entry.Images.Webp.LargeImageURL = it.Entry.Images.Webp.LargeImageURL
|
||||
mapped.URL = it.URL
|
||||
mapped.Votes = it.Votes
|
||||
out = append(out, mapped)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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) {
|
||||
s.jikan.WarmAnimeRecommendations(id)
|
||||
s.jikan.WarmFullRelations(id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
|
||||
return s.jikan.GetEpisodes(ctx, id, page)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
out := make([]domain.StaffEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
var mapped domain.StaffEntry
|
||||
mapped.Person.MalID = it.Person.MalID
|
||||
mapped.Person.URL = it.Person.URL
|
||||
mapped.Person.Name = it.Person.Name
|
||||
mapped.Person.Images.Jpg.ImageURL = it.Person.Images.Jpg.ImageURL
|
||||
mapped.Positions = append([]string(nil), it.Positions...)
|
||||
out = append(out, mapped)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
out := domain.Statistics{
|
||||
Watching: stats.Watching,
|
||||
Completed: stats.Completed,
|
||||
OnHold: stats.OnHold,
|
||||
Dropped: stats.Dropped,
|
||||
PlanToWatch: stats.PlanToWatch,
|
||||
Total: stats.Total,
|
||||
}
|
||||
if len(stats.Scores) > 0 {
|
||||
out.Scores = make([]domain.StatisticsScore, 0, len(stats.Scores))
|
||||
for _, s := range stats.Scores {
|
||||
out.Scores = append(out.Scores, domain.StatisticsScore{Score: s.Score, Votes: s.Votes, Percentage: s.Percentage})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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{
|
||||
Openings: append([]string(nil), themes.Openings...),
|
||||
Endings: append([]string(nil), themes.Endings...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
out := make([]domain.ReviewEntry, 0, len(data))
|
||||
for _, it := range data {
|
||||
mapped := domain.ReviewEntry{
|
||||
MalID: it.MalID,
|
||||
URL: it.URL,
|
||||
Type: it.Type,
|
||||
Date: it.Date,
|
||||
Review: it.Review,
|
||||
Score: it.Score,
|
||||
Tags: append([]string(nil), it.Tags...),
|
||||
IsSpoiler: it.IsSpoiler,
|
||||
IsPreliminary: it.IsPreliminary,
|
||||
EpisodesSeen: it.EpisodesSeen,
|
||||
Reactions: domain.ReviewReactions{
|
||||
Overall: it.Reactions.Overall,
|
||||
Nice: it.Reactions.Nice,
|
||||
LoveIt: it.Reactions.LoveIt,
|
||||
Funny: it.Reactions.Funny,
|
||||
Confusing: it.Reactions.Confusing,
|
||||
Informative: it.Reactions.Informative,
|
||||
WellWritten: it.Reactions.WellWritten,
|
||||
Creative: it.Reactions.Creative,
|
||||
},
|
||||
}
|
||||
mapped.User.URL = it.User.URL
|
||||
mapped.User.Username = it.User.Username
|
||||
mapped.User.Images.Jpg.ImageURL = it.User.Images.Jpg.ImageURL
|
||||
mapped.User.Images.Webp.ImageURL = it.User.Images.Webp.ImageURL
|
||||
out = append(out, mapped)
|
||||
}
|
||||
|
||||
return out, pag.HasNextPage, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
|
||||
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := s.jikan.GetRandomAnime(randomCtx)
|
||||
if err == nil {
|
||||
return domain.Anime{Anime: anime}, nil
|
||||
}
|
||||
|
||||
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
|
||||
s.jikan.GetSeasonsNow,
|
||||
s.jikan.GetTopAnime,
|
||||
s.jikan.GetSeasonsUpcoming,
|
||||
} {
|
||||
res, fallbackErr := fallback(ctx, 1)
|
||||
if fallbackErr != nil || len(res.Animes) == 0 {
|
||||
continue
|
||||
}
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
|
||||
}
|
||||
|
||||
return domain.Anime{}, 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
|
||||
}
|
||||
result := make([]domain.EpisodeData, len(episodes))
|
||||
for i, ep := range episodes {
|
||||
result[i] = domain.EpisodeData{
|
||||
MalID: ep.MalID,
|
||||
Title: ep.Title,
|
||||
IsFiller: ep.Filler,
|
||||
IsRecap: ep.Recap,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type animeService struct {
|
||||
jikan *jikan.Client
|
||||
repo domain.AnimeRepository
|
||||
}
|
||||
|
||||
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
|
||||
return &animeService{jikan: jikan, repo: repo}
|
||||
}
|
||||
|
||||
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
|
||||
var (
|
||||
res jikan.TopAnimeResult
|
||||
cw []db.GetContinueWatchingEntriesRow
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Airing":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Popular":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if userID != "" && section == "Continue" {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
animes := res.Animes
|
||||
if len(animes) > 6 {
|
||||
animes = animes[:6]
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: animes,
|
||||
ContinueWatching: cw,
|
||||
}, 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 := res.Animes
|
||||
if len(animes) > 8 {
|
||||
animes = animes[:8]
|
||||
}
|
||||
|
||||
return domain.DiscoverSectionData{
|
||||
Animes: animes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||
return s.jikan.GetAnimeByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) {
|
||||
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
return s.jikan.GetAnimeGenres(ctx)
|
||||
}
|
||||
|
||||
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Character, error) {
|
||||
return s.jikan.GetAnimeCharacters(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.Recommendation, error) {
|
||||
return s.jikan.GetAnimeRecommendations(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
|
||||
return s.jikan.GetEpisodes(ctx, id, page)
|
||||
}
|
||||
|
||||
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
|
||||
return s.jikan.GetAnimeStaff(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
|
||||
return s.jikan.GetAnimeStatistics(ctx, id)
|
||||
}
|
||||
|
||||
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
|
||||
return s.jikan.GetAnimeThemes(ctx, id)
|
||||
}
|
||||
|
||||
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 data, pag.HasNextPage, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
|
||||
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := s.jikan.GetRandomAnime(randomCtx)
|
||||
if err == nil {
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
|
||||
s.jikan.GetSeasonsNow,
|
||||
s.jikan.GetTopAnime,
|
||||
s.jikan.GetSeasonsUpcoming,
|
||||
} {
|
||||
res, fallbackErr := fallback(ctx, 1)
|
||||
if fallbackErr != nil || len(res.Animes) == 0 {
|
||||
continue
|
||||
}
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
return res.Animes[r.Intn(len(res.Animes))], nil
|
||||
}
|
||||
|
||||
return domain.Anime{}, 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
|
||||
}
|
||||
result := make([]domain.EpisodeData, len(episodes))
|
||||
for i, ep := range episodes {
|
||||
result[i] = domain.EpisodeData{
|
||||
MalID: ep.MalID,
|
||||
Title: ep.Title,
|
||||
IsFiller: ep.Filler,
|
||||
IsRecap: ep.Recap,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
// Package app bootstraps and wires the application dependencies.
|
||||
package app
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/integrations/playback/allanime"
|
||||
"mal/internal/anime"
|
||||
"mal/internal/audit"
|
||||
"mal/internal/auth"
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/episodes"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/playback"
|
||||
"mal/internal/server"
|
||||
"mal/internal/templates"
|
||||
"mal/internal/watchlist"
|
||||
"mal/templates"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/render"
|
||||
@@ -19,7 +23,10 @@ import (
|
||||
|
||||
func NewApp() *fx.App {
|
||||
return fx.New(
|
||||
fx.WithLogger(observability.NewFxLogger),
|
||||
config.Module,
|
||||
database.Module,
|
||||
audit.Module,
|
||||
jikan.Module,
|
||||
allanime.Module,
|
||||
episodes.Module,
|
||||
|
||||
35
internal/audit/context.go
Normal file
35
internal/audit/context.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package audit
|
||||
|
||||
import "context"
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
ctxKeyIP ctxKey = "audit_ip"
|
||||
ctxKeyUserAgent ctxKey = "audit_user_agent"
|
||||
)
|
||||
|
||||
func WithRequestInfo(ctx context.Context, ip string, userAgent string) context.Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
next := context.WithValue(ctx, ctxKeyIP, ip)
|
||||
return context.WithValue(next, ctxKeyUserAgent, userAgent)
|
||||
}
|
||||
|
||||
func RequestInfoFromContext(ctx context.Context) (ip string, userAgent string) {
|
||||
if ctx == nil {
|
||||
return "", ""
|
||||
}
|
||||
if v := ctx.Value(ctxKeyIP); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
ip = s
|
||||
}
|
||||
}
|
||||
if v := ctx.Value(ctxKeyUserAgent); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
userAgent = s
|
||||
}
|
||||
}
|
||||
return ip, userAgent
|
||||
}
|
||||
29
internal/audit/middleware.go
Normal file
29
internal/audit/middleware.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ContextMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := clientIP(c.ClientIP())
|
||||
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
c.Request = c.Request.WithContext(WithRequestInfo(c.Request.Context(), ip, userAgent))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(ip string) string {
|
||||
trimmed := strings.TrimSpace(ip)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parsed := net.ParseIP(trimmed)
|
||||
if parsed == nil {
|
||||
return trimmed
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
9
internal/audit/module.go
Normal file
9
internal/audit/module.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(NewAuditService),
|
||||
)
|
||||
73
internal/audit/service.go
Normal file
73
internal/audit/service.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package audit provides audit logging for user actions.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type auditService struct {
|
||||
queries *db.Queries
|
||||
}
|
||||
|
||||
func NewAuditService(queries *db.Queries) domain.AuditService {
|
||||
return &auditService{queries: queries}
|
||||
}
|
||||
|
||||
func (s *auditService) Record(ctx context.Context, event domain.AuditEvent) error {
|
||||
if s == nil || s.queries == nil {
|
||||
return errors.New("audit service not configured")
|
||||
}
|
||||
action := strings.TrimSpace(event.Action)
|
||||
if action == "" {
|
||||
return errors.New("audit action missing")
|
||||
}
|
||||
|
||||
ip, userAgent := RequestInfoFromContext(ctx)
|
||||
if strings.TrimSpace(event.IP) != "" {
|
||||
ip = event.IP
|
||||
}
|
||||
if strings.TrimSpace(event.UserAgent) != "" {
|
||||
userAgent = event.UserAgent
|
||||
}
|
||||
|
||||
metadataJSON := event.MetadataJSON
|
||||
if len(metadataJSON) == 0 {
|
||||
metadataJSON = json.RawMessage("null")
|
||||
}
|
||||
|
||||
_, err := s.queries.CreateAuditLog(ctx, db.CreateAuditLogParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: sql.NullString{String: strings.TrimSpace(event.UserID), Valid: strings.TrimSpace(event.UserID) != ""},
|
||||
Action: action,
|
||||
ResourceType: sql.NullString{String: strings.TrimSpace(event.ResourceType), Valid: strings.TrimSpace(event.ResourceType) != ""},
|
||||
ResourceID: sql.NullString{String: strings.TrimSpace(event.ResourceID), Valid: strings.TrimSpace(event.ResourceID) != ""},
|
||||
Ip: sql.NullString{String: strings.TrimSpace(ip), Valid: strings.TrimSpace(ip) != ""},
|
||||
UserAgent: sql.NullString{String: strings.TrimSpace(userAgent), Valid: strings.TrimSpace(userAgent) != ""},
|
||||
MetadataJson: sql.NullString{String: string(metadataJSON), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
observability.Info(
|
||||
"audit",
|
||||
"audit",
|
||||
action,
|
||||
map[string]any{
|
||||
"user_id": event.UserID,
|
||||
"resource_type": event.ResourceType,
|
||||
"resource_id": event.ResourceID,
|
||||
},
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
116
internal/audit/service_test.go
Normal file
116
internal/audit/service_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"mal/internal/audit"
|
||||
"mal/internal/database"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
func TestRecordInsertsAuditLog(t *testing.T) {
|
||||
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 {
|
||||
Foo string `json:"foo"`
|
||||
}{Foo: "bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Record(ctx, domain.AuditEvent{
|
||||
UserID: "user-1",
|
||||
Action: "test_action",
|
||||
ResourceType: "thing",
|
||||
ResourceID: "123",
|
||||
MetadataJSON: metadata,
|
||||
}); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
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() }()
|
||||
|
||||
if !rows.Next() {
|
||||
t.Fatalf("expected audit row")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 row.ip != "127.0.0.1" || row.userAgent != "unit-test" {
|
||||
t.Fatalf("unexpected request info ip=%q userAgent=%q", row.ip, row.userAgent)
|
||||
}
|
||||
if row.metadataJSON == "" || row.metadataJSON == "null" {
|
||||
t.Fatalf("expected metadata_json, got %q", row.metadataJSON)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package handler
|
||||
// Package auth provides authentication and session management.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
121
internal/auth/middleware.go
Normal file
121
internal/auth/middleware.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type publicRoute struct {
|
||||
method string
|
||||
path string
|
||||
prefix bool
|
||||
}
|
||||
|
||||
var publicRoutes = []publicRoute{
|
||||
// Pages.
|
||||
{method: http.MethodGet, path: "/login"},
|
||||
{method: http.MethodPost, path: "/login"},
|
||||
{method: http.MethodGet, path: "/logout"},
|
||||
|
||||
// Static assets.
|
||||
{path: "/static", prefix: true},
|
||||
{path: "/dist", prefix: true},
|
||||
|
||||
// Observability endpoints.
|
||||
{method: http.MethodGet, path: "/metrics"},
|
||||
|
||||
// Auth API.
|
||||
{method: http.MethodPost, path: "/api/auth/login"},
|
||||
}
|
||||
|
||||
func isPublicRequest(method string, path string) bool {
|
||||
for _, r := range publicRoutes {
|
||||
if r.method != "" && r.method != method {
|
||||
continue
|
||||
}
|
||||
if r.prefix {
|
||||
if strings.HasPrefix(path, r.path) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if path == r.path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func authenticateAPIRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, bool, error) {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
user, err := svc.ValidateAPIToken(c.Request.Context(), token)
|
||||
return user, "", false, err
|
||||
}
|
||||
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
|
||||
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
return user, sessionID, true, err
|
||||
}
|
||||
|
||||
func authenticatePageRequest(c *gin.Context, svc domain.AuthService) (*domain.User, string, error) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
return user, sessionID, err
|
||||
}
|
||||
|
||||
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
if isPublicRequest(c.Request.Method, path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
var user *domain.User
|
||||
var err error
|
||||
var sessionID string
|
||||
var usesCookieSession bool
|
||||
|
||||
// API routes can authenticate via Bearer token OR cookie session.
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
user, sessionID, usesCookieSession, err = authenticateAPIRequest(c, svc)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Non-API routes only use cookie sessions and redirect to /login.
|
||||
user, sessionID, err = authenticatePageRequest(c, svc)
|
||||
usesCookieSession = true
|
||||
if err != nil || user == nil {
|
||||
c.Redirect(http.StatusSeeOther, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if usesCookieSession {
|
||||
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
|
||||
c.SetCookie("session_id", sessionID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
|
||||
}
|
||||
}
|
||||
|
||||
c.Set("User", user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Allow access to login, logout and static assets without authentication
|
||||
if path == "/login" || path == "/logout" ||
|
||||
strings.HasPrefix(path, "/static") ||
|
||||
strings.HasPrefix(path, "/dist") ||
|
||||
path == "/api/auth/login" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
var user *domain.User
|
||||
var err error
|
||||
var sessionID string
|
||||
var usesCookieSession bool
|
||||
|
||||
// API routes can authenticate via Bearer token OR cookie session.
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
user, err = svc.ValidateAPIToken(c.Request.Context(), token)
|
||||
} else if cookieSessionID, cookieErr := c.Cookie("session_id"); cookieErr == nil {
|
||||
sessionID = cookieSessionID
|
||||
usesCookieSession = true
|
||||
user, err = svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
} else {
|
||||
err = cookieErr
|
||||
}
|
||||
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Non-API routes only use cookie sessions and redirect to /login.
|
||||
cookieSessionID, cookieErr := c.Cookie("session_id")
|
||||
if cookieErr != nil {
|
||||
c.Redirect(http.StatusSeeOther, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
sessionID = cookieSessionID
|
||||
usesCookieSession = true
|
||||
user, err = svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
if err != nil || user == nil {
|
||||
c.Redirect(http.StatusSeeOther, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if usesCookieSession {
|
||||
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
|
||||
c.SetCookie("session_id", sessionID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
|
||||
}
|
||||
}
|
||||
|
||||
c.Set("User", user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"mal/internal/auth/handler"
|
||||
"mal/internal/auth/middleware"
|
||||
"mal/internal/auth/repository"
|
||||
"mal/internal/auth/service"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(
|
||||
repository.NewAuthRepository,
|
||||
service.NewAuthService,
|
||||
handler.NewAuthHandler,
|
||||
func(svc domain.AuthService) gin.HandlerFunc {
|
||||
return middleware.AuthMiddleware(svc)
|
||||
},
|
||||
NewAuthRepository,
|
||||
NewAuthService,
|
||||
NewAuthHandler,
|
||||
AuthMiddleware,
|
||||
),
|
||||
fx.Provide(
|
||||
server.AsRouteRegister(func(h *handler.AuthHandler) server.RouteRegister {
|
||||
server.AsRouteRegister(func(h *AuthHandler) server.RouteRegister {
|
||||
return h
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package repository
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -27,7 +27,7 @@ func (r *authRepository) GetUserByUsername(ctx context.Context, username string)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
return &domain.User{User: u}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
@@ -38,7 +38,7 @@ func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.Us
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
return &domain.User{User: u}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) CreateSession(ctx context.Context, userID string, sessionID string) (*domain.Session, error) {
|
||||
@@ -50,7 +50,7 @@ func (r *authRepository) CreateSession(ctx context.Context, userID string, sessi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
return &domain.Session{Session: s}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) {
|
||||
@@ -61,7 +61,7 @@ func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*dom
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
return &domain.Session{Session: s}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) RefreshSession(ctx context.Context, sessionID string, expiresAt time.Time) error {
|
||||
@@ -85,7 +85,7 @@ func (r *authRepository) CreateAPIToken(ctx context.Context, userID, tokenHash,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
return &domain.APIToken{ApiToken: t}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string) (*domain.APIToken, error) {
|
||||
@@ -96,7 +96,7 @@ func (r *authRepository) GetAPITokenByHash(ctx context.Context, tokenHash string
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
return &domain.APIToken{ApiToken: t}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) TouchAPITokenLastUsedAt(ctx context.Context, tokenID string) error {
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/internal/domain"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,10 +19,11 @@ import (
|
||||
|
||||
type authService struct {
|
||||
repo domain.AuthRepository
|
||||
auditSvc domain.AuditService
|
||||
}
|
||||
|
||||
func NewAuthService(repo domain.AuthRepository) domain.AuthService {
|
||||
return &authService{repo: repo}
|
||||
func NewAuthService(repo domain.AuthRepository, auditSvc domain.AuditService) domain.AuthService {
|
||||
return &authService{repo: repo, auditSvc: auditSvc}
|
||||
}
|
||||
|
||||
func (s *authService) Login(ctx context.Context, username, password string) (*domain.Session, error) {
|
||||
@@ -58,11 +61,32 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
|
||||
trimmedName = "Firefox extension"
|
||||
}
|
||||
|
||||
rawToken, tokenHash := newOpaqueToken()
|
||||
rawToken, tokenHash, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := s.repo.CreateAPIToken(ctx, user.ID, tokenHash, trimmedName); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
metadataBytes, err := json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
}{Name: trimmedName})
|
||||
if err == nil {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: user.ID,
|
||||
Action: "api_token_created",
|
||||
ResourceType: "api_token",
|
||||
MetadataJSON: metadataBytes,
|
||||
})
|
||||
} else {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: user.ID,
|
||||
Action: "api_token_created",
|
||||
ResourceType: "api_token",
|
||||
})
|
||||
}
|
||||
|
||||
return rawToken, user, nil
|
||||
}
|
||||
|
||||
@@ -120,15 +144,25 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return errors.New("user id missing")
|
||||
}
|
||||
return s.repo.RevokeAllAPITokensForUser(ctx, userID)
|
||||
if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "api_token_revoked_all",
|
||||
ResourceType: "api_token",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func newOpaqueToken() (token string, tokenHash string) {
|
||||
func newOpaqueToken() (token string, tokenHash string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
_, _ = rand.Read(buf)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", "", fmt.Errorf("generate token bytes: %w", err)
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
tokenHash = hex.EncodeToString(sum[:])
|
||||
return token, tokenHash
|
||||
return token, tokenHash, nil
|
||||
}
|
||||
12
internal/avatar.go
Normal file
12
internal/avatar.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func DefaultAvatarURL(username string) string {
|
||||
params := url.Values{}
|
||||
params.Set("seed", strings.TrimSpace(username))
|
||||
return "https://api.dicebear.com/9.x/dylan/svg?" + params.Encode()
|
||||
}
|
||||
85
internal/config/config.go
Normal file
85
internal/config/config.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Package config provides application configuration loading and access.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EpisodeAvailabilityMode string
|
||||
|
||||
const (
|
||||
EpisodeAvailabilityModeAuto EpisodeAvailabilityMode = "auto"
|
||||
EpisodeAvailabilityModeLegacy EpisodeAvailabilityMode = "legacy"
|
||||
EpisodeAvailabilityModeJikan EpisodeAvailabilityMode = "jikan"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
|
||||
// GinMode maps to gin.SetMode. When empty, the server uses release mode by default.
|
||||
GinMode string
|
||||
|
||||
DatabaseFile string
|
||||
|
||||
// Allow any Origin for CORS. Intended for local dev / reverse proxy setups only.
|
||||
CORSAllowAll bool
|
||||
|
||||
EpisodeAvailabilityMode EpisodeAvailabilityMode
|
||||
|
||||
// Optional. When empty, proxy token signing is disabled.
|
||||
PlaybackProxySecret string
|
||||
|
||||
// Optional debug toggle for Jikan client tracing.
|
||||
JikanTrace bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Port: firstNonEmpty(strings.TrimSpace(os.Getenv("PORT")), "3000"),
|
||||
GinMode: strings.TrimSpace(os.Getenv("GIN_MODE")),
|
||||
DatabaseFile: firstNonEmpty(strings.TrimSpace(os.Getenv("DATABASE_FILE")), "mal.db"),
|
||||
CORSAllowAll: strings.TrimSpace(os.Getenv("MAL_CORS_ALLOW_ALL")) == "1",
|
||||
PlaybackProxySecret: strings.TrimSpace(os.Getenv("PLAYBACK_PROXY_SECRET")),
|
||||
JikanTrace: truthy(strings.TrimSpace(os.Getenv("MAL_JIKAN_TRACE"))),
|
||||
EpisodeAvailabilityMode: EpisodeAvailabilityModeAuto,
|
||||
}
|
||||
|
||||
if raw := strings.ToLower(strings.TrimSpace(os.Getenv("EPISODE_AVAILABILITY_MODE"))); raw != "" {
|
||||
switch EpisodeAvailabilityMode(raw) {
|
||||
case EpisodeAvailabilityModeAuto, EpisodeAvailabilityModeLegacy, EpisodeAvailabilityModeJikan:
|
||||
cfg.EpisodeAvailabilityMode = EpisodeAvailabilityMode(raw)
|
||||
default:
|
||||
return Config{}, fmt.Errorf("invalid EPISODE_AVAILABILITY_MODE: %q (expected auto|legacy|jikan)", raw)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.Port) == "" {
|
||||
return Config{}, errors.New("PORT must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.DatabaseFile) == "" {
|
||||
return Config{}, errors.New("DATABASE_FILE must not be empty")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truthy(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
7
internal/config/module.go
Normal file
7
internal/config/module.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
import "go.uber.org/fx"
|
||||
|
||||
var Module = fx.Options(
|
||||
fx.Provide(Load),
|
||||
)
|
||||
@@ -1,11 +1,13 @@
|
||||
// Package database manages database schema migrations and fixes.
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.uber.org/fx"
|
||||
@@ -19,12 +21,11 @@ var Module = fx.Options(
|
||||
ProvideSQLDB,
|
||||
ProvideQueries,
|
||||
),
|
||||
fx.Invoke(RunMigrations),
|
||||
fx.Invoke(RunMigrationsAndFixes),
|
||||
)
|
||||
|
||||
func ProvideSQLDB() (*sql.DB, error) {
|
||||
dbPath := db.GetDBFile()
|
||||
dbConn, err := db.Open(dbPath)
|
||||
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
|
||||
dbConn, err := db.Open(cfg.DatabaseFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
@@ -37,15 +38,29 @@ func ProvideQueries(sqlDB *sql.DB) *db.Queries {
|
||||
|
||||
func RunMigrations(sqlDB *sql.DB) error {
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return fmt.Errorf("failed to set goose dialect: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Running database migrations...")
|
||||
observability.Info("db_migrations_start", "database", "", nil)
|
||||
if err := goose.Up(sqlDB, "migrations"); err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
version, err := goose.GetDBVersion(sqlDB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get database migration version: %w", err)
|
||||
}
|
||||
|
||||
observability.Info("db_migrations_complete", "database", "", map[string]any{"version": version})
|
||||
|
||||
return nil
|
||||
}
|
||||
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
|
||||
if err := RunMigrations(sqlDB); err != nil {
|
||||
return err
|
||||
}
|
||||
return RunDataFixes(sqlDB)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
@@ -12,7 +13,7 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := RunMigrations(sqlDB); err != nil {
|
||||
@@ -28,7 +29,7 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
||||
} {
|
||||
t.Run(indexName, func(t *testing.T) {
|
||||
var count int
|
||||
err := sqlDB.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName).Scan(&count)
|
||||
err := sqlDB.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query index: %v", err)
|
||||
}
|
||||
|
||||
97
internal/database/fixes.go
Normal file
97
internal/database/fixes.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
dbfixes "mal/internal/database/fixes"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
func RunDataFixes(sqlDB *sql.DB) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
fixes := dbfixes.All()
|
||||
|
||||
if len(fixes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ensureDataFixTable(ctx, sqlDB); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applied, err := loadAppliedFixes(ctx, sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, fix := range fixes {
|
||||
if applied[fix.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
observability.Info(
|
||||
"db_data_fix_start",
|
||||
"database",
|
||||
"",
|
||||
map[string]any{
|
||||
"id": fix.ID,
|
||||
},
|
||||
)
|
||||
if err := fix.Apply(ctx, sqlDB); err != nil {
|
||||
return fmt.Errorf("data fix %s failed: %w", fix.ID, err)
|
||||
}
|
||||
if err := markFixApplied(ctx, sqlDB, fix.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDataFixTable(ctx context.Context, sqlDB *sql.DB) error {
|
||||
// Safety for cases where migrations weren't run (or in tests). This is intentionally tiny and idempotent.
|
||||
_, err := sqlDB.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS data_fixes (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure data_fixes table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAppliedFixes(ctx context.Context, sqlDB *sql.DB) (map[string]bool, error) {
|
||||
rows, err := sqlDB.QueryContext(ctx, `SELECT id FROM data_fixes`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load applied data fixes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan data fix id: %w", err)
|
||||
}
|
||||
applied[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate data fixes: %w", err)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func markFixApplied(ctx context.Context, sqlDB *sql.DB, id string) error {
|
||||
_, err := sqlDB.ExecContext(ctx, `INSERT OR IGNORE INTO data_fixes (id) VALUES (?)`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark data fix applied id=%s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260526_episode_availability_backfill_next_refresh_at",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
// Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata),
|
||||
// which can result in "never refresh again" behavior on the server.
|
||||
_, err := sqlDB.ExecContext(ctx, `
|
||||
UPDATE episode_availability_cache
|
||||
SET next_refresh_at = datetime(CURRENT_TIMESTAMP, '+6 hours'),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE next_refresh_at IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfill episode_availability_cache.next_refresh_at: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
46
internal/database/fixes/20260528_backfill_avatar_url.go
Normal file
46
internal/database/fixes/20260528_backfill_avatar_url.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"mal/internal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260528_backfill_avatar_url",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
rows, err := sqlDB.QueryContext(ctx, `SELECT id, username FROM user WHERE avatar_url = ''`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
type userRow struct {
|
||||
id string
|
||||
username string
|
||||
}
|
||||
toUpdate := make([]userRow, 0, 64)
|
||||
for rows.Next() {
|
||||
var r userRow
|
||||
if err := rows.Scan(&r.id, &r.username); err != nil {
|
||||
return err
|
||||
}
|
||||
toUpdate = append(toUpdate, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, u := range toUpdate {
|
||||
avatarURL := internal.DefaultAvatarURL(u.username)
|
||||
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET avatar_url = ? WHERE id = ?`, avatarURL, u.id); err != nil {
|
||||
return fmt.Errorf("update avatar_url for user %s: %w", u.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
type animeDurationRow struct {
|
||||
id int64
|
||||
titleOriginal string
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260608_backfill_anime_duration_seconds",
|
||||
Apply: applyAnimeDurationSecondsBackfill,
|
||||
})
|
||||
}
|
||||
|
||||
func applyAnimeDurationSecondsBackfill(ctx context.Context, sqlDB *sql.DB) error {
|
||||
toUpdate, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
|
||||
for _, row := range toUpdate {
|
||||
anime, err := client.GetAnimeByID(ctx, int(row.id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch anime %d for duration backfill: %w", row.id, err)
|
||||
}
|
||||
|
||||
durationSeconds := anime.DurationSeconds()
|
||||
if durationSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := sqlDB.ExecContext(
|
||||
ctx,
|
||||
`UPDATE anime SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL`,
|
||||
durationSeconds,
|
||||
row.id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update anime %d duration_seconds: %w", row.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listAnimeMissingDurationSeconds(ctx context.Context, sqlDB *sql.DB) ([]animeDurationRow, error) {
|
||||
rows, err := sqlDB.QueryContext(ctx, `
|
||||
SELECT id, title_original, title_english, title_japanese, image_url, airing
|
||||
FROM anime
|
||||
WHERE duration_seconds IS NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var toUpdate []animeDurationRow
|
||||
for rows.Next() {
|
||||
var row animeDurationRow
|
||||
var titleEnglish sql.NullString
|
||||
var titleJapanese sql.NullString
|
||||
var imageURL string
|
||||
var airing sql.NullBool
|
||||
if err := rows.Scan(
|
||||
&row.id,
|
||||
&row.titleOriginal,
|
||||
&titleEnglish,
|
||||
&titleJapanese,
|
||||
&imageURL,
|
||||
&airing,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan anime row missing duration_seconds: %w", err)
|
||||
}
|
||||
toUpdate = append(toUpdate, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
|
||||
return toUpdate, nil
|
||||
}
|
||||
25
internal/database/fixes/registry.go
Normal file
25
internal/database/fixes/registry.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Package fixes implements one-off database migration fixes.
|
||||
package fixes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type Fix struct {
|
||||
ID string
|
||||
Apply func(ctx context.Context, sqlDB *sql.DB) error
|
||||
}
|
||||
|
||||
var registered []Fix
|
||||
|
||||
func Register(fix Fix) {
|
||||
registered = append(registered, fix)
|
||||
}
|
||||
|
||||
func All() []Fix {
|
||||
out := append([]Fix(nil), registered...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose NO TRANSACTION
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE user_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -16,6 +19,8 @@ DROP TABLE user;
|
||||
|
||||
ALTER TABLE user_new RENAME TO user;
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = '';
|
||||
-- +goose Down
|
||||
|
||||
8
internal/database/migrations/022_add_data_fixes.sql
Normal file
8
internal/database/migrations/022_add_data_fixes.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS data_fixes (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS data_fixes;
|
||||
18
internal/database/migrations/023_add_audit_log.sql
Normal file
18
internal/database/migrations/023_add_audit_log.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id TEXT REFERENCES user(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
metadata_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id_occurred_at ON audit_log(user_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action_occurred_at ON audit_log(action, occurred_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS recommendation_event (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
anime_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
source TEXT,
|
||||
metadata_json TEXT,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(anime_id) REFERENCES anime(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_event_user_occurred_at
|
||||
ON recommendation_event(user_id, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_event_user_event_type_occurred_at
|
||||
ON recommendation_event(user_id, event_type, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_event_anime_occurred_at
|
||||
ON recommendation_event(anime_id, occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommendation_impression (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
anime_id INTEGER NOT NULL,
|
||||
rail TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
request_id TEXT,
|
||||
metadata_json TEXT,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(anime_id) REFERENCES anime(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_impression_user_occurred_at
|
||||
ON recommendation_impression(user_id, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendation_impression_request_id
|
||||
ON recommendation_impression(request_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommendation_profile_snapshot (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
profile_json TEXT NOT NULL,
|
||||
source_window_start DATETIME,
|
||||
source_window_end DATETIME,
|
||||
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS recommendation_profile_snapshot;
|
||||
DROP INDEX IF EXISTS idx_recommendation_impression_request_id;
|
||||
DROP INDEX IF EXISTS idx_recommendation_impression_user_occurred_at;
|
||||
DROP TABLE IF EXISTS recommendation_impression;
|
||||
DROP INDEX IF EXISTS idx_recommendation_event_anime_occurred_at;
|
||||
DROP INDEX IF EXISTS idx_recommendation_event_user_event_type_occurred_at;
|
||||
DROP INDEX IF EXISTS idx_recommendation_event_user_occurred_at;
|
||||
DROP TABLE IF EXISTS recommendation_event;
|
||||
@@ -9,9 +9,7 @@ func (q *Queries) GetCommandPaletteContinueWatching(ctx context.Context, userID
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
limit = commandPaletteLimit(limit)
|
||||
|
||||
needle, pattern := commandPalettePattern(query)
|
||||
rows, err := q.db.QueryContext(ctx, `
|
||||
@@ -44,12 +42,26 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
item, err := scanContinueWatchingEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanContinueWatchingEntry(rows scanner) (GetContinueWatchingEntriesRow, error) {
|
||||
var item GetContinueWatchingEntriesRow
|
||||
if err := rows.Scan(
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
@@ -63,25 +75,15 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.AnimeDurationSeconds,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (q *Queries) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error) {
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
limit = commandPaletteLimit(limit)
|
||||
|
||||
needle, pattern := commandPalettePattern(query)
|
||||
rows, err := q.db.QueryContext(ctx, `
|
||||
@@ -122,12 +124,26 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]GetUserWatchListRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
item, err := scanWatchListEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanWatchListEntry(rows scanner) (GetUserWatchListRow, error) {
|
||||
var item GetUserWatchListRow
|
||||
if err := rows.Scan(
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
@@ -142,19 +158,23 @@ LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.Airing,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func commandPalettePattern(query string) (string, string) {
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
return needle, "%" + needle + "%"
|
||||
}
|
||||
|
||||
func commandPaletteLimit(limit int64) int64 {
|
||||
if limit <= 0 {
|
||||
return 5
|
||||
}
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func openCommandPaletteTestDB(t *testing.T) *sql.DB {
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
_, err = sqlDB.Exec(`
|
||||
_, err = sqlDB.ExecContext(context.Background(), `
|
||||
CREATE TABLE anime (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title_original TEXT NOT NULL,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package db provides database access via sqlc-generated queries and helper functions.
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
@@ -10,11 +11,21 @@ func NullStringOr(n sql.NullString, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DisplayTitle returns the English title, falling back to Japanese then original
|
||||
// DisplayTitle returns the English title, falling back to original then Japanese.
|
||||
func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal string) string {
|
||||
return NullStringOr(titleEnglish, NullStringOr(titleJapanese, titleOriginal))
|
||||
if titleEnglish.Valid && titleEnglish.String != "" {
|
||||
return titleEnglish.String
|
||||
}
|
||||
if titleOriginal != "" {
|
||||
return titleOriginal
|
||||
}
|
||||
return NullStringOr(titleJapanese, titleOriginal)
|
||||
}
|
||||
|
||||
func (r GetUserWatchListRow) DisplayTitle() string {
|
||||
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
|
||||
}
|
||||
|
||||
func (r GetContinueWatchingEntriesRow) DisplayTitle() string {
|
||||
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
|
||||
}
|
||||
|
||||
30
internal/db/helpers_test.go
Normal file
30
internal/db/helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisplayTitlePrefersOriginalBeforeJapanese(t *testing.T) {
|
||||
got := DisplayTitle(
|
||||
sql.NullString{},
|
||||
sql.NullString{String: "サイバーパンク エッジランナーズ", Valid: true},
|
||||
"Cyberpunk: Edgerunners",
|
||||
)
|
||||
|
||||
if got != "Cyberpunk: Edgerunners" {
|
||||
t.Fatalf("DisplayTitle() = %q, want original title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayTitlePrefersEnglish(t *testing.T) {
|
||||
got := DisplayTitle(
|
||||
sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true},
|
||||
sql.NullString{String: "葬送のフリーレン", Valid: true},
|
||||
"Sousou no Frieren",
|
||||
)
|
||||
|
||||
if got != "Frieren: Beyond Journey's End" {
|
||||
t.Fatalf("DisplayTitle() = %q, want English title", got)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
@@ -47,6 +47,18 @@ type ApiToken struct {
|
||||
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
UserID sql.NullString `json:"user_id"`
|
||||
Action string `json:"action"`
|
||||
ResourceType sql.NullString `json:"resource_type"`
|
||||
ResourceID sql.NullString `json:"resource_id"`
|
||||
Ip sql.NullString `json:"ip"`
|
||||
UserAgent sql.NullString `json:"user_agent"`
|
||||
MetadataJson sql.NullString `json:"metadata_json"`
|
||||
}
|
||||
|
||||
type ContinueWatchingEntry struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
@@ -58,6 +70,11 @@ type ContinueWatchingEntry struct {
|
||||
DurationSeconds sql.NullFloat64 `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
type DataFix struct {
|
||||
ID string `json:"id"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
}
|
||||
|
||||
type EpisodeAvailabilityCache struct {
|
||||
AnimeID int64 `json:"anime_id"`
|
||||
Data string `json:"data"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type Querier interface {
|
||||
CountPendingAnimeFetchRetries(ctx context.Context) (int64, error)
|
||||
CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (ApiToken, error)
|
||||
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error)
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
|
||||
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
|
||||
@@ -22,8 +23,11 @@ type Querier interface {
|
||||
GetAllCachedAnime(ctx context.Context) ([]string, error)
|
||||
GetAnime(ctx context.Context, id int64) (Anime, error)
|
||||
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
|
||||
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error)
|
||||
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error)
|
||||
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
||||
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
|
||||
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
|
||||
@@ -35,14 +39,18 @@ type Querier interface {
|
||||
GetUser(ctx context.Context, id string) (User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
|
||||
GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error)
|
||||
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
||||
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
||||
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
||||
MarkEpisodeAvailabilityRefreshFailed(ctx context.Context, arg MarkEpisodeAvailabilityRefreshFailedParams) error
|
||||
MarkRelationsSynced(ctx context.Context, id int64) error
|
||||
RefreshSession(ctx context.Context, arg RefreshSessionParams) error
|
||||
RevokeAllAPITokensForUser(ctx context.Context, userID string) error
|
||||
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
||||
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
|
||||
HasSkipSegmentOverrideTable(ctx context.Context) (bool, error)
|
||||
ListSkipSegmentOverrides(ctx context.Context, userID string, animeID int64, episode int64) ([]SkipSegmentOverrideRow, error)
|
||||
TouchAPITokenLastUsedAt(ctx context.Context, id string) error
|
||||
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
|
||||
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
|
||||
@@ -50,6 +58,7 @@ type Querier interface {
|
||||
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||
UpsertEpisodeAvailabilityCache(ctx context.Context, arg UpsertEpisodeAvailabilityCacheParams) error
|
||||
UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEpisodeProviderMappingParams) error
|
||||
UpsertSkipSegmentOverride(ctx context.Context, r SkipSegmentOverrideRow) error
|
||||
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
-- name: GetUser :one
|
||||
SELECT * FROM user WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: CreateAuditLog :one
|
||||
INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAuditLogsForUser :many
|
||||
SELECT *
|
||||
FROM audit_log
|
||||
WHERE user_id = ?
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT ?;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM user WHERE username = ? LIMIT 1;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
// source: queries.sql
|
||||
|
||||
package db
|
||||
@@ -57,6 +57,49 @@ func (q *Queries) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createAuditLog = `-- name: CreateAuditLog :one
|
||||
INSERT INTO audit_log (id, user_id, action, resource_type, resource_id, ip, user_agent, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json
|
||||
`
|
||||
|
||||
type CreateAuditLogParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID sql.NullString `json:"user_id"`
|
||||
Action string `json:"action"`
|
||||
ResourceType sql.NullString `json:"resource_type"`
|
||||
ResourceID sql.NullString `json:"resource_id"`
|
||||
Ip sql.NullString `json:"ip"`
|
||||
UserAgent sql.NullString `json:"user_agent"`
|
||||
MetadataJson sql.NullString `json:"metadata_json"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (AuditLog, error) {
|
||||
row := q.db.QueryRowContext(ctx, createAuditLog,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Action,
|
||||
arg.ResourceType,
|
||||
arg.ResourceID,
|
||||
arg.Ip,
|
||||
arg.UserAgent,
|
||||
arg.MetadataJson,
|
||||
)
|
||||
var i AuditLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OccurredAt,
|
||||
&i.UserID,
|
||||
&i.Action,
|
||||
&i.ResourceType,
|
||||
&i.ResourceID,
|
||||
&i.Ip,
|
||||
&i.UserAgent,
|
||||
&i.MetadataJson,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createSession = `-- name: CreateSession :one
|
||||
INSERT INTO session (id, user_id, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
@@ -124,22 +167,6 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const refreshSession = `-- name: RefreshSession :exec
|
||||
UPDATE session
|
||||
SET expires_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RefreshSessionParams struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec
|
||||
DELETE FROM watch_list_entry
|
||||
WHERE user_id = ? AND anime_id = ?
|
||||
@@ -299,6 +326,52 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAuditLogsForUser = `-- name: GetAuditLogsForUser :many
|
||||
SELECT id, occurred_at, user_id, "action", resource_type, resource_id, ip, user_agent, metadata_json
|
||||
FROM audit_log
|
||||
WHERE user_id = ?
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
type GetAuditLogsForUserParams struct {
|
||||
UserID sql.NullString `json:"user_id"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAuditLogsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AuditLog
|
||||
for rows.Next() {
|
||||
var i AuditLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.OccurredAt,
|
||||
&i.UserID,
|
||||
&i.Action,
|
||||
&i.ResourceType,
|
||||
&i.ResourceID,
|
||||
&i.Ip,
|
||||
&i.UserAgent,
|
||||
&i.MetadataJson,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
|
||||
SELECT
|
||||
c.id,
|
||||
@@ -918,6 +991,22 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const refreshSession = `-- name: RefreshSession :exec
|
||||
UPDATE session
|
||||
SET expires_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RefreshSessionParams struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const revokeAllAPITokensForUser = `-- name: RevokeAllAPITokensForUser :exec
|
||||
UPDATE api_token
|
||||
SET revoked_at = CURRENT_TIMESTAMP
|
||||
|
||||
@@ -3,12 +3,10 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Note: we intentionally avoid naming this struct SkipSegmentOverride because
|
||||
// some environments may have an sqlc-generated SkipSegmentOverride model,
|
||||
// which would cause a redeclare build error.
|
||||
type SkipSegmentOverrideRow struct {
|
||||
ID string
|
||||
UserID string
|
||||
@@ -67,6 +65,9 @@ func (q *Queries) HasSkipSegmentOverrideTable(ctx context.Context) (bool, error)
|
||||
const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='skip_segment_override' LIMIT 1;`
|
||||
var name sql.NullString
|
||||
if err := q.db.QueryRowContext(ctx, query).Scan(&name); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("check skip segment override table: %w", err)
|
||||
}
|
||||
return name.Valid && name.String != "", nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user