mirror of
https://github.com/AnimeThemes/animethemes-server.git
synced 2026-07-11 01:24:46 +02:00
Compare commits
658 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fec8c51dda | |||
| 5dcad93fab | |||
| b19185da8f | |||
| f036cd2d1b | |||
| 19b340bf75 | |||
| 73c2400a0f | |||
| 5476445ccc | |||
| 6a81ddd1ee | |||
| 624b076f03 | |||
| 25389ce3d2 | |||
| af2bc6c2dc | |||
| 7182fa39bd | |||
| fa27b2dd80 | |||
| 519fc09ef4 | |||
| 65a723f9b5 | |||
| e0ea789eeb | |||
| 654f8a7f70 | |||
| d399933cf0 | |||
| fd2ef1611b | |||
| a3f1a8c7e3 | |||
| f5e098875f | |||
| 333a519611 | |||
| effdcecd79 | |||
| ddceceda58 | |||
| 44d1145f4a | |||
| 0e41771f4c | |||
| 71c4f56a9d | |||
| 74d3ee200a | |||
| 9613657962 | |||
| d813ca3b66 | |||
| c30ad3e28f | |||
| 94bcdf1275 | |||
| 14b55970fe | |||
| 7fff5465c9 | |||
| 0404625001 | |||
| 72b24f2eb6 | |||
| 00c60c58c4 | |||
| 5be6f661a4 | |||
| 4e1d4236fc | |||
| 75f391cc65 | |||
| 80d59f7e45 | |||
| b1d7e9ed30 | |||
| a1341d0157 | |||
| cac463383e | |||
| a433a7484b | |||
| 8cae74c677 | |||
| eeb5fd006a | |||
| b9b95acb0b | |||
| 582ca2b7dd | |||
| 884f6921a0 | |||
| 3ae5898927 | |||
| 37e21989bb | |||
| 756d57bfeb | |||
| 042decc60c | |||
| 2f1a55a433 | |||
| cd11c5fa3b | |||
| 93da5aafe4 | |||
| 07933577c1 | |||
| 00560e9ab4 | |||
| 6c4378b29a | |||
| a5483871d4 | |||
| 549f5b6461 | |||
| 544155052c | |||
| e522eaf1f9 | |||
| b6d42e21b4 | |||
| 4f61a86638 | |||
| fcaa13891b | |||
| 8d350a89f4 | |||
| 3ec851fc23 | |||
| 7d949e8947 | |||
| c42771a411 | |||
| fa8d223b4b | |||
| 0f6b009e37 | |||
| f370caf6cf | |||
| 23df360b60 | |||
| efcc70c332 | |||
| 0e1235cf5a | |||
| 5b9a8e3976 | |||
| d2055c3726 | |||
| 37076c44cd | |||
| aff2250f48 | |||
| fc3f146bb4 | |||
| 722aaf9c14 | |||
| d8f4db64a7 | |||
| b73d98fc2d | |||
| 6a0ddcad96 | |||
| a230b803b7 | |||
| 5839dfc4c2 | |||
| cd2039d6e1 | |||
| b05a6022ac | |||
| ea21ce28ee | |||
| 2ae33b0fed | |||
| 5d22f66eba | |||
| 8323620f83 | |||
| 6b1f130843 | |||
| e84df9059e | |||
| 8386535251 | |||
| 93a48173e5 | |||
| 8995240844 | |||
| 26f9885968 | |||
| be08cf0d11 | |||
| 24ec43bdab | |||
| a80f8d4d35 | |||
| fb05c1770e | |||
| e4e28b74b5 | |||
| 74cfefb20e | |||
| c1bf8be3a1 | |||
| b66c5cb3da | |||
| e4179cf312 | |||
| 73ea075f54 | |||
| 92c54ccdf5 | |||
| d2ced8db70 | |||
| fcf64b6406 | |||
| dff1634480 | |||
| ef56c3e130 | |||
| 5061fc126d | |||
| 1f5e48da37 | |||
| a2d159a90d | |||
| 5e7b03b167 | |||
| 747d1d8fc3 | |||
| e99e692694 | |||
| c7e83e0ae9 | |||
| 3815716a9f | |||
| b685c656df | |||
| 3356167e3b | |||
| 7d676d3010 | |||
| f8786cb6bf | |||
| da54035060 | |||
| 5817636003 | |||
| 2257d4b6ed | |||
| cdfcc901ab | |||
| af2946155e | |||
| 742dc20106 | |||
| 03103389b2 | |||
| 44b9354495 | |||
| 6c86a0b9de | |||
| b09ceebe4b | |||
| 58cc05a07e | |||
| 7c5075b188 | |||
| 60c5351b04 | |||
| c6f43a395e | |||
| fa5468d1a5 | |||
| 7eac12d788 | |||
| a71ca35efb | |||
| 05b6bb0a03 | |||
| a4975c8813 | |||
| 45ea839c4e | |||
| 1f815a6082 | |||
| fdb6b69687 | |||
| 01411c6f77 | |||
| 1f8b9daa1f | |||
| 9fb9c5e11b | |||
| d8dc07010a | |||
| dfd958ef69 | |||
| 33650905d9 | |||
| 871b9fe86f | |||
| 117970261b | |||
| 1ee1067594 | |||
| 4120a73228 | |||
| 4f7ab1ca5a | |||
| bcc9dec704 | |||
| 6d9d549d52 | |||
| b7d10d81e8 | |||
| 10ae7b865f | |||
| d74ba4c8e5 | |||
| 84cdc19fb0 | |||
| 22918e3adb | |||
| c2d6b81158 | |||
| 743b319340 | |||
| 67d759a129 | |||
| d88376a289 | |||
| ece37dcc73 | |||
| 1aa38b391d | |||
| 6ac84415c8 | |||
| bd3419715c | |||
| 3f1bd8a327 | |||
| 110950e0f2 | |||
| 1c71ed0571 | |||
| f90c3d3732 | |||
| 60406d89fa | |||
| 90b7a2820e | |||
| 48208ce42d | |||
| 3d1d2dcfbc | |||
| d73ff841bd | |||
| 869677e49f | |||
| c91de1acf9 | |||
| 03dd572300 | |||
| 8077b37b9d | |||
| 245aadf381 | |||
| 144994676e | |||
| aad47fdc90 | |||
| fefff9b8ab | |||
| 7b786cf911 | |||
| 696a9882a9 | |||
| 8258bc05af | |||
| 1745f1f32f | |||
| ac5df24981 | |||
| 037a460add | |||
| 3306e8502e | |||
| 01eb8c175d | |||
| ee7549f9c6 | |||
| f95cac61a2 | |||
| 3698939f88 | |||
| 75696b7a15 | |||
| 2eb9d2e75d | |||
| a4c2d3a0de | |||
| 44c412fc32 | |||
| 4a6739b8cd | |||
| 0f290b1677 | |||
| f0437bf764 | |||
| d225bd60fc | |||
| 1f8827996c | |||
| 59e770bef0 | |||
| f75765f27e | |||
| 7a9dc62022 | |||
| af88a1098e | |||
| 7b69fbadd8 | |||
| 07cdddcbcb | |||
| 8dd25de1a4 | |||
| c0ffa7083d | |||
| 56737563b2 | |||
| e5c3273755 | |||
| 848c6efa69 | |||
| 5fd00ec028 | |||
| 5120342f37 | |||
| 12d53fd99a | |||
| e0011d8e98 | |||
| 270ca8ed7b | |||
| 3de70d1199 | |||
| 3f7cbdf24a | |||
| efef58eb24 | |||
| da93812c12 | |||
| 4a7b960d34 | |||
| 6721443646 | |||
| 520578e97a | |||
| 1aa4d020a8 | |||
| e9246d0e0d | |||
| 506c7e115f | |||
| f074b82d80 | |||
| ba7fb60383 | |||
| 269cf22996 | |||
| ad899b7271 | |||
| ac9b4be61b | |||
| 0ed5ad68d8 | |||
| b262fdf08c | |||
| 414836a58f | |||
| 63cb73fe8d | |||
| ff6aa20807 | |||
| ec29509163 | |||
| ed8a6b9212 | |||
| bc33705162 | |||
| 4241c6ed5e | |||
| 164a9f9573 | |||
| 4dc8bb9238 | |||
| 128a2bf9cf | |||
| 8c62a8e892 | |||
| 0a05a6c73b | |||
| 213727c2a2 | |||
| a97e1d5abd | |||
| 08dfb5b099 | |||
| a4ed8f257e | |||
| db4495436a | |||
| a4d3191294 | |||
| 69c748dd9c | |||
| a4ef61b134 | |||
| ab572e951c | |||
| b220154cd3 | |||
| 3173c2818a | |||
| ad7c23b788 | |||
| 7c22f3acc1 | |||
| 9963515a31 | |||
| bdceea0a7c | |||
| ed8322d62a | |||
| 7e2712386d | |||
| 7cdf7c90dc | |||
| a15a9d141c | |||
| 01827680b6 | |||
| 40c334ccb3 | |||
| 7382ed4a37 | |||
| 5614c0d4c6 | |||
| 4eb60cd6f3 | |||
| 13074d716a | |||
| 9b5b69fb01 | |||
| da39dc0b74 | |||
| 1348e16648 | |||
| 4b3ecbe793 | |||
| eb265c839b | |||
| 9b08e233e3 | |||
| dc522d1a5f | |||
| 444884921c | |||
| c540c7bb7f | |||
| 8bfa2bb44c | |||
| 7513cf90fe | |||
| b4542a50c8 | |||
| 5ad3ab5c0f | |||
| 5ac1e9f3fe | |||
| 7fffee1ebb | |||
| 87adc2ff32 | |||
| 6a4b3c37c7 | |||
| 5501526a1d | |||
| 1b5f42e601 | |||
| 6fe8385054 | |||
| e1029cb4ab | |||
| 0484fd73cb | |||
| 3a79dc85c8 | |||
| 8f2787553c | |||
| 7c146e465f | |||
| 6ae0cf12b1 | |||
| 0a9f393c8c | |||
| 3860f3ef73 | |||
| 51bcd875d4 | |||
| f6d75ea29e | |||
| a90befebfe | |||
| e58518f51a | |||
| a2ed59cc01 | |||
| 5c5a9fda71 | |||
| 1b72050cda | |||
| 2400f663e1 | |||
| 9070068308 | |||
| aed66607be | |||
| 239664851b | |||
| 799d258857 | |||
| a0788eeeea | |||
| 8065d567f3 | |||
| c8266936e5 | |||
| a5c3914478 | |||
| 173407348d | |||
| 25cf1a3a08 | |||
| ae26b4135c | |||
| 904b8a8b65 | |||
| 2cba594edf | |||
| 1a07ac1d00 | |||
| 5c5e96f2e6 | |||
| 076eea744a | |||
| 1c23cc3298 | |||
| 7fd7141a38 | |||
| 37662dd4c4 | |||
| 7bb4c221ce | |||
| f8b9aca0fc | |||
| 0fb8c9b372 | |||
| 413498e153 | |||
| 7afa555e2a | |||
| b24dfebbf3 | |||
| f84a4b33a5 | |||
| 1dd9a8a606 | |||
| 9ad19f260a | |||
| 6bbfa1e86a | |||
| 7b75be439c | |||
| b0dee5998f | |||
| 286796bc0f | |||
| 1eaa7fff9b | |||
| 77869066f2 | |||
| e525cd7367 | |||
| ecf7f2d7da | |||
| 59f8ce12dc | |||
| 6fc78d3709 | |||
| f9c932fd30 | |||
| 3417eb63a1 | |||
| e7b2e540c2 | |||
| a32fa7f6d3 | |||
| c4385cf6d9 | |||
| 682e02895c | |||
| 63395481f7 | |||
| abbadf9fcd | |||
| c7ba086947 | |||
| 69cc755077 | |||
| 6f8bcd9ba0 | |||
| 002358af1b | |||
| 38f0e2fa76 | |||
| 453cc1e72a | |||
| c0db39c697 | |||
| b426620534 | |||
| 7d5156e937 | |||
| 5962a7c3c2 | |||
| efbdcc1e6a | |||
| d468811adc | |||
| 6c3837aca2 | |||
| 88aeaecafa | |||
| bc9df20162 | |||
| 9e46ba9a2c | |||
| 5fb12ae228 | |||
| 936aafcc4f | |||
| 7a2fb3fbfd | |||
| b181a0b254 | |||
| 7178470f50 | |||
| d49085432c | |||
| ad79ed1117 | |||
| fdceea037c | |||
| d4b39d0c8e | |||
| fc7ec8d3d1 | |||
| 7bfa1ec0dd | |||
| 89ce3e05be | |||
| aad7f54c92 | |||
| c406d73270 | |||
| d45545e7e1 | |||
| d609fbd68a | |||
| e10bcfc0df | |||
| 33f62dae1a | |||
| 912a9c737b | |||
| af1d86f8d8 | |||
| d34309467a | |||
| a38c023caa | |||
| 310e300bbd | |||
| 2a856c3eee | |||
| 7be14cb615 | |||
| c67af74c5f | |||
| 3ff694fba2 | |||
| ed6662c11f | |||
| e374b7d1d2 | |||
| 55e37e1f3c | |||
| 7c23f5fb4b | |||
| 6c4d9a11c0 | |||
| 3ce9bb09b7 | |||
| 0e6a401154 | |||
| fe9a24167c | |||
| 08459d85ad | |||
| 2fd17ac2f7 | |||
| 4acd5e39df | |||
| ef0bfb8298 | |||
| 61a8d53fde | |||
| 997391f440 | |||
| 326f8283a6 | |||
| 84a0281169 | |||
| 3acac99104 | |||
| eeff29b0fc | |||
| 2e0e411dd5 | |||
| 3ce94d9c40 | |||
| 4013c91a63 | |||
| a27a46305a | |||
| 8d4f27f8ce | |||
| e4c924fdcb | |||
| 6fd693189c | |||
| 745865394f | |||
| db0fbe9d32 | |||
| 8a6a2c1420 | |||
| f782d17eb2 | |||
| cc2e3e16cf | |||
| d8a673bb74 | |||
| cab98d822c | |||
| 70d325374b | |||
| 13f6b23490 | |||
| c04b155767 | |||
| 6b1b6124ad | |||
| 8890171698 | |||
| 14c378bd80 | |||
| fdd767f49c | |||
| 5ccbd0455f | |||
| ae8747da56 | |||
| 89fe315c08 | |||
| c02840a580 | |||
| e1b25f46d4 | |||
| 7c0e2b1087 | |||
| 115e5b32f6 | |||
| 85878dc7c1 | |||
| 36d7c3ea34 | |||
| 50e0bab1fd | |||
| 9238fd3d57 | |||
| 294ab4fb75 | |||
| acd95fe2ff | |||
| f68e87aa25 | |||
| 9a22e2a5b2 | |||
| 483bec0231 | |||
| ea5683d080 | |||
| 847189aad4 | |||
| 41e4a90347 | |||
| 0416985efc | |||
| d53f49654d | |||
| 6d18ac287c | |||
| 67f4cd83ce | |||
| 376e3a96a3 | |||
| 63a86e708a | |||
| 6a76dbf85d | |||
| 68999dc4f6 | |||
| c27059b7d2 | |||
| 354c6b1bc7 | |||
| 09f91ca426 | |||
| 9f0a6a3ae2 | |||
| d4dea635b0 | |||
| 80e48e543c | |||
| f0d1e0687e | |||
| f2e4dce092 | |||
| 80ed802c97 | |||
| cbd7fe75d3 | |||
| a7f314d357 | |||
| 7f00114415 | |||
| 97b49dbb5e | |||
| f33db28908 | |||
| 229b89c46f | |||
| 52dd8c2e94 | |||
| 74f8395ec4 | |||
| 92dc3a5138 | |||
| 1744b5457a | |||
| 811e8672e3 | |||
| 2da92a9cf0 | |||
| b265ad0bbc | |||
| e472a1d3bd | |||
| 99153cbf5f | |||
| 5100f13dd8 | |||
| 928de7c96c | |||
| 991491f746 | |||
| c2cdb15287 | |||
| 1f2b81e05c | |||
| 55a471369a | |||
| e4d068f428 | |||
| 856b4b53d7 | |||
| de0d09a68e | |||
| 4f6c7aeed8 | |||
| c7cbed03bf | |||
| ee7e2c1412 | |||
| f50dd32daa | |||
| 559db075b1 | |||
| 4c8eded119 | |||
| 17ec9613bd | |||
| 229d064b69 | |||
| a46ed2bcf9 | |||
| 9b18c6ac3d | |||
| be69acd47a | |||
| 46fcecd5ab | |||
| 68bd228931 | |||
| a609bf0be7 | |||
| 6fefbf372e | |||
| 67828f430b | |||
| 342a409bed | |||
| 01952f3ff4 | |||
| 03778f8339 | |||
| 46dca69a82 | |||
| 5a9fa858b3 | |||
| c7a9f6af4d | |||
| 0e7def3024 | |||
| 3c33fd5d66 | |||
| 1d1509b35b | |||
| 55301d3c91 | |||
| 6a2b0f3a51 | |||
| f8ed4da5a2 | |||
| 1e8e816abc | |||
| 6aa43bbdf9 | |||
| 683f9ff41b | |||
| 8279465803 | |||
| 708e6e41a0 | |||
| 6fe645d783 | |||
| 3aa49ba7ed | |||
| b39c321683 | |||
| 8c789924ae | |||
| 9b81a5476d | |||
| 5bfee4aafb | |||
| 9456a47de3 | |||
| 26c2fb5d38 | |||
| a77f3c5fa4 | |||
| 5302b2b820 | |||
| 1d205a8041 | |||
| 89d4dd3d31 | |||
| e3baa82f75 | |||
| 016593b3ba | |||
| 5f5d015b79 | |||
| d376477705 | |||
| ce794f40ff | |||
| 4c33114f38 | |||
| 6015dcbbbf | |||
| fd2cebe3cf | |||
| d857d922d6 | |||
| 54943c9b2c | |||
| 5a0e3404ea | |||
| 9334d05b13 | |||
| dd011b2886 | |||
| 02fef3aed2 | |||
| 7ba0416c20 | |||
| e2d0fe3cd6 | |||
| d168afd4e7 | |||
| 088ea287c5 | |||
| 2e930b17ff | |||
| 1e586c94a0 | |||
| 295a997354 | |||
| ae9952ad68 | |||
| 22d57c1fb4 | |||
| a3cf74bb2d | |||
| c356db1ed7 | |||
| cadaa73f18 | |||
| 638549b329 | |||
| 9c3413789f | |||
| 37f6ab1bc1 | |||
| 864c70198b | |||
| 3ab6a476be | |||
| 8faf07d56f | |||
| b6aca7b877 | |||
| 54af771c4f | |||
| 69a7f80efb | |||
| 315de28398 | |||
| 4dfd46be9a | |||
| 5cf9301710 | |||
| fbff1356a9 | |||
| a7b74acaa0 | |||
| 3d633be59d | |||
| 62cba9bd34 | |||
| 78f02c0064 | |||
| 17f2df3e48 | |||
| a721faa645 | |||
| 3dcd4e27fc | |||
| de084e4eda | |||
| ca7a2b8960 | |||
| da02b741e1 | |||
| 411eb51fbc | |||
| 8ac62b267b | |||
| 2328acff83 | |||
| 5a29b6b8ca | |||
| ef7f2f8048 | |||
| 1e7648b4b4 | |||
| a0d560cb32 | |||
| bff3c8601d | |||
| f9c6990171 | |||
| 688381c7b0 | |||
| f87046724b | |||
| ecf9b83997 | |||
| 3a9d78f122 | |||
| 47074aee36 | |||
| 779a6a602c | |||
| 808d0cd56d | |||
| f326a1623d | |||
| d289dcf9df | |||
| 1bd30c8bd5 | |||
| 454a1c02c3 | |||
| c414809f40 | |||
| bbed2f9bba | |||
| 71c96a93be | |||
| 27778857b1 | |||
| 09aec12920 | |||
| 90bbe971ed | |||
| 0fc9fd832c | |||
| 128b90dae2 | |||
| e0f6dc7d97 | |||
| 909e19d709 | |||
| a63addd6fb | |||
| a97a1663d4 | |||
| 3d239a624a | |||
| a8c462fd7f | |||
| 12871c3252 | |||
| 9d51617cd6 | |||
| c2e3aecf05 | |||
| d0d0fafe3e | |||
| 7bb45de3d0 | |||
| 642815b3ff | |||
| e0ac3ddee2 | |||
| afb432f72f | |||
| 65392944c3 | |||
| c98058a61d | |||
| 4017d69a1f | |||
| cc3ff63177 | |||
| c4220e55d0 | |||
| eb7f07fe34 | |||
| 327f998cb9 | |||
| e914fd7e3a | |||
| 76c0734dca | |||
| d6d59ca4f8 | |||
| 52bd044cf2 | |||
| 963a291d42 | |||
| 517ba0ebf8 | |||
| 1f7505ac60 | |||
| 6d66a3b6d1 | |||
| 4c4977ca8c |
+2
-2
@@ -3,9 +3,9 @@ root = true
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
|
||||
+90
-64
@@ -1,27 +1,28 @@
|
||||
# api
|
||||
API_PATH=/api
|
||||
API_URL=
|
||||
API_PATH=
|
||||
API_URL=http://api.animethemes.test
|
||||
|
||||
# app
|
||||
APP_NAME=AnimeThemes
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
DEBUGBAR_ENABLED=true
|
||||
DEBUGBAR_EDITOR=
|
||||
AUDITING_ENABLED=true
|
||||
APP_URL=http://animethemes.test
|
||||
ASSET_URL=null
|
||||
APP_KEY=
|
||||
APP_LOCAL_IPS=
|
||||
|
||||
# audio
|
||||
AUDIO_DISK_DEFAULT=audios_nyc
|
||||
AUDIO_DISKS=audios_nyc
|
||||
AUDIO_DISK_DEFAULT=audios_local
|
||||
AUDIO_DISKS=audios_local
|
||||
AUDIO_DISK_ROOT=
|
||||
AUDIO_PATH=/audio
|
||||
AUDIO_URL=
|
||||
AUDIO_STREAMING_METHOD=
|
||||
AUDIO_STREAMING_METHOD=response
|
||||
AUDIO_NGINX_REDIRECT=
|
||||
|
||||
# audit
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
# broadcasting
|
||||
BROADCAST_DRIVER=log
|
||||
PUSHER_APP_KEY=
|
||||
@@ -51,18 +52,26 @@ DYNAMODB_ENDPOINT=
|
||||
CACHE_PREFIX=animethemes
|
||||
|
||||
# database
|
||||
DB_CONNECTION=mysql
|
||||
DB_CONNECTION=mysql_prod
|
||||
DATABASE_URL=
|
||||
DB_HOST_READ=127.0.0.1
|
||||
DB_HOST_WRITE=127.0.0.1
|
||||
DB_STICKY=false
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=laravel
|
||||
DB_DATABASE=animethemes
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
DB_SOCKET=
|
||||
MYSQL_ATTR_SSL_CA=null
|
||||
|
||||
DATABASE_BETA_URL=
|
||||
DB_BETA_HOST=
|
||||
DB_BETA_PORT=3306
|
||||
DB_BETA_DATABASE=animethemes-beta
|
||||
DB_BETA_USERNAME=root
|
||||
DB_BETA_PASSWORD=
|
||||
DB_BETA_SOCKET=
|
||||
|
||||
REDIS_CLIENT=predis
|
||||
REDIS_CLUSTER=redis
|
||||
REDIS_URL=null
|
||||
@@ -74,25 +83,22 @@ REDIS_DB=0
|
||||
REDIS_CACHE_DB=1
|
||||
|
||||
# dump
|
||||
DUMP_DISK=dumps
|
||||
DUMP_DISK=dumps_local
|
||||
DUMP_DISK_ROOT=
|
||||
DUMP_URL=http://localhost
|
||||
DUMP_PATH=
|
||||
DUMP_URL="${APP_URL}"
|
||||
DUMP_PATH=/dump
|
||||
|
||||
# elastic client
|
||||
ELASTIC_CONNECTION=default
|
||||
ELASTIC_HOST=http://localhost:9200
|
||||
|
||||
# elastic driver
|
||||
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
|
||||
|
||||
# elastic migrations
|
||||
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
|
||||
# external profile
|
||||
USER_MAX_PROFILES=5
|
||||
|
||||
# ffmpeg
|
||||
FFMPEG_BINARIES=
|
||||
FFPROBE_BINARIES=
|
||||
|
||||
# filament
|
||||
FILAMENT_URL=http://admin.animethemes.test
|
||||
FILAMENT_PATH=
|
||||
|
||||
# filesystems
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
@@ -160,33 +166,33 @@ SCRIPT_STREAM_READS=
|
||||
SCRIPT_DISABLE_ASSERTS=
|
||||
SCRIPT_VISIBILITY=
|
||||
|
||||
# flags
|
||||
ALLOW_VIDEO_STREAMS=false
|
||||
ALLOW_AUDIO_STREAMS=false
|
||||
ALLOW_DISCORD_NOTIFICATIONS=false
|
||||
ALLOW_VIEW_RECORDING=false
|
||||
ALLOW_DUMP_DOWNLOADING=false
|
||||
ALLOW_SCRIPT_DOWNLOADING=false
|
||||
|
||||
# fortify
|
||||
FORTIFY_HOME="${APP_URL}"
|
||||
FORTIFY_PATH=
|
||||
FORTIFY_URL=http://localhost
|
||||
FORTIFY_URL="${APP_URL}"
|
||||
|
||||
# graphql
|
||||
GRAPHIQL_URL="${GRAPHQL_URL}/graphiql"
|
||||
GRAPHIQL_GRAPHQL_ENDPOINT="${GRAPHQL_URL}"
|
||||
GRAPHQL_URL=http://graphql.animethemes.test
|
||||
GRAPHQL_PATH=/graphql
|
||||
GRAPHIQL_ENABLED=true
|
||||
|
||||
# hashids
|
||||
HASHIDS_SALT_MAIN=
|
||||
HASHIDS_SALT_PLAYLISTS=
|
||||
|
||||
# hashing
|
||||
BCRYPT_ROUNDS=10
|
||||
|
||||
# horizon
|
||||
HORIZON_DOMAIN=
|
||||
HORIZON_URL=
|
||||
HORIZON_PATH=horizon
|
||||
|
||||
# image
|
||||
IMAGE_DISK=images
|
||||
IMAGE_DISK=images_local
|
||||
IMAGE_DISK_ROOT=
|
||||
|
||||
# jetstream
|
||||
JETSTREAM_PATH=
|
||||
JETSTREAM_URL=http://localhost
|
||||
|
||||
# logging
|
||||
LOG_CHANNEL=daily
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
@@ -204,26 +210,40 @@ MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"
|
||||
MAIL_LOG_CHANNEL=
|
||||
|
||||
# nova
|
||||
NOVA_DOMAIN_NAME=null
|
||||
NOVA_GUARD=null
|
||||
NOVA_LICENSE_KEY=
|
||||
NOVA_PASSWORDS=null
|
||||
NOVA_PATH=/admin
|
||||
# pennant
|
||||
PENNANT_STORE=database
|
||||
|
||||
# playlist
|
||||
PLAYLIST_MAX_TRACKS=1000
|
||||
USER_MAX_PLAYLISTS=1000
|
||||
|
||||
# queue
|
||||
QUEUE_CONNECTION=sync
|
||||
REDIS_QUEUE=default
|
||||
QUEUE_FAILED_DRIVER=database-uuids
|
||||
|
||||
# sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=
|
||||
|
||||
# scout
|
||||
SCOUT_DRIVER=null
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=false
|
||||
SCOUT_IDENTIFY=false
|
||||
|
||||
ALGOLIA_APP_ID=
|
||||
ALGOLIA_SECRET=
|
||||
|
||||
ELASTIC_CONNECTION=default
|
||||
ELASTIC_HOST=http://localhost:9200
|
||||
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
|
||||
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
|
||||
|
||||
TYPESENSE_HOST=localhost
|
||||
TYPESENSE_PORT=8108
|
||||
TYPESENSE_PROTOCOL=http
|
||||
TYPESENSE_API_KEY=
|
||||
|
||||
# secure headers
|
||||
ENABLE_CLEAR_SITE_DATA=false
|
||||
ENABLE_HSTS=false
|
||||
@@ -237,12 +257,20 @@ MAILGUN_SECRET=
|
||||
MAILGUN_ENDPOINT=api.mailgun.net
|
||||
POSTMARK_TOKEN=
|
||||
DISCORD_BOT_API_TOKEN=
|
||||
DISCORD_BOT_API_URL=
|
||||
DISCORD_BOT_API_KEY=
|
||||
DB_UPDATES_DISCORD_CHANNEL=
|
||||
ADMIN_DISCORD_CHANNEL=
|
||||
OPENAI_BEARER_TOKEN=null
|
||||
MAL_CLIENT_ID=null
|
||||
DO_BEARER_TOKEN=null
|
||||
MAL_CLIENT_SECRET=null
|
||||
MAL_REDIRECT_URI=null
|
||||
ANILIST_CLIENT_ID=null
|
||||
ANILIST_CLIENT_SECRET=null
|
||||
ANILIST_REDIRECT_URI=null
|
||||
|
||||
# session
|
||||
SESSION_COOKIE=animethemes_session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_CONNECTION=null
|
||||
@@ -250,34 +278,32 @@ SESSION_STORE=null
|
||||
SESSION_DOMAIN=null
|
||||
SESSION_SECURE_COOKIE=
|
||||
|
||||
# telescope
|
||||
TELESCOPE_DOMAIN=null
|
||||
TELESCOPE_PATH=telescope
|
||||
TELESCOPE_DRIVER=database
|
||||
TELESCOPE_ENABLED=true
|
||||
# submission
|
||||
USER_MAX_SUBMISSIONS=50
|
||||
|
||||
# validation
|
||||
MODERATION_SERVICE=none
|
||||
|
||||
# video
|
||||
VIDEO_DISK_DEFAULT=videos_nyc
|
||||
VIDEO_DISKS=videos_nyc
|
||||
VIDEO_DISK_DEFAULT=videos_local
|
||||
VIDEO_DISKS=videos_local
|
||||
VIDEO_DISK_ROOT=
|
||||
VIDEO_PATH=/video
|
||||
VIDEO_URL=
|
||||
VIDEO_STREAMING_METHOD=
|
||||
VIDEO_URL="${APP_URL}"
|
||||
VIDEO_RATE_LIMITER=90
|
||||
VIDEO_STREAMING_METHOD=response
|
||||
VIDEO_NGINX_REDIRECT=
|
||||
VIDEO_ENCODER_VERSION=
|
||||
VIDEO_UPLOAD_DISKS=
|
||||
|
||||
SCRIPT_DISK=scripts
|
||||
SCRIPT_DISK=scripts_local
|
||||
SCRIPT_DISK_ROOT=
|
||||
SCRIPT_URL=http://localhost
|
||||
SCRIPT_PATH=
|
||||
SCRIPT_URL="${APP_URL}"
|
||||
SCRIPT_PATH=/videoscript
|
||||
|
||||
# web
|
||||
WEB_URL=http://localhost
|
||||
WEB_URL="${APP_URL}:3000"
|
||||
WEB_PATH=
|
||||
|
||||
# wiki
|
||||
WIKI_FEATURED_ENTRY=
|
||||
WIKI_FEATURED_VIDEO=
|
||||
WIKI_DONATE_URL=#
|
||||
WIKI_FAQ_URL=#
|
||||
WIKI_LOGIN="h${APP_URL}:3000/login"
|
||||
WIKI_RESET_PASSWORD="${APP_URL}:3000/reset-password"
|
||||
WIKI_EXTERNAL_PROFILE="${APP_URL}:3000/external"
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
# api
|
||||
API_PATH=
|
||||
API_URL=http://api.animethemes.test
|
||||
|
||||
# app
|
||||
APP_NAME=AnimeThemes
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
DEBUGBAR_ENABLED=true
|
||||
DEBUGBAR_EDITOR=
|
||||
AUDITING_ENABLED=true
|
||||
APP_URL=http://animethemes.test
|
||||
ASSET_URL=null
|
||||
APP_KEY=
|
||||
APP_LOCAL_IPS=
|
||||
|
||||
# audio
|
||||
AUDIO_DISK_DEFAULT=audios_local
|
||||
AUDIO_DISKS=audios_local
|
||||
AUDIO_DISK_ROOT=
|
||||
AUDIO_PATH=/audio
|
||||
AUDIO_URL=
|
||||
AUDIO_STREAMING_METHOD=response
|
||||
AUDIO_NGINX_REDIRECT=
|
||||
|
||||
# broadcasting
|
||||
BROADCAST_DRIVER=log
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_HOST=
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
ABLY_KEY=
|
||||
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
# cache
|
||||
CACHE_DRIVER=file
|
||||
MEMCACHED_PERSISTENT_ID=
|
||||
MEMCACHED_USERNAME=
|
||||
MEMCACHED_PASSWORD=
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
MEMCACHED_PORT=11211
|
||||
DYNAMODB_CACHE_TABLE=cache
|
||||
DYNAMODB_ENDPOINT=
|
||||
CACHE_PREFIX=animethemes
|
||||
|
||||
# database
|
||||
DB_CONNECTION=mysql_prod
|
||||
DATABASE_URL=
|
||||
DB_HOST=mysql
|
||||
DB_HOST_READ=mysql
|
||||
DB_HOST_WRITE=mysql
|
||||
DB_STICKY=false
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=animethemes
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
DB_SOCKET=
|
||||
MYSQL_ATTR_SSL_CA=null
|
||||
|
||||
DATABASE_BETA_URL=
|
||||
DB_BETA_HOST=
|
||||
DB_BETA_PORT=3306
|
||||
DB_BETA_DATABASE=animethemes-beta
|
||||
DB_BETA_USERNAME=root
|
||||
DB_BETA_PASSWORD=
|
||||
DB_BETA_SOCKET=
|
||||
|
||||
REDIS_CLIENT=predis
|
||||
REDIS_CLUSTER=redis
|
||||
REDIS_URL=null
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
REDIS_SCHEME=tcp
|
||||
REDIS_DB=0
|
||||
REDIS_CACHE_DB=1
|
||||
|
||||
# dump
|
||||
DUMP_DISK=dumps
|
||||
DUMP_DISK_ROOT=
|
||||
DUMP_URL="${APP_URL}"
|
||||
DUMP_PATH=/dump
|
||||
|
||||
# ffmpeg
|
||||
FFMPEG_BINARIES=
|
||||
FFPROBE_BINARIES=
|
||||
|
||||
# filament
|
||||
FILAMENT_URL=http://admin.animethemes.test
|
||||
FILAMENT_PATH=
|
||||
|
||||
# filesystems
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
IMAGE_ACCESS_KEY_ID=
|
||||
IMAGE_SECRET_ACCESS_KEY=
|
||||
IMAGE_DEFAULT_REGION=
|
||||
IMAGE_ENDPOINT=
|
||||
IMAGE_BUCKET=
|
||||
IMAGE_STREAM_READS=
|
||||
IMAGE_DISABLE_ASSERTS=
|
||||
IMAGE_VISIBILITY=
|
||||
IMAGE_URL=
|
||||
|
||||
VIDEO_NYC_ACCESS_KEY_ID=
|
||||
VIDEO_NYC_SECRET_ACCESS_KEY=
|
||||
VIDEO_NYC_DEFAULT_REGION=
|
||||
VIDEO_NYC_ENDPOINT=
|
||||
VIDEO_NYC_BUCKET=
|
||||
VIDEO_NYC_STREAM_READS=
|
||||
VIDEO_NYC_DISABLE_ASSERTS=
|
||||
VIDEO_NYC_VISIBILITY=
|
||||
|
||||
VIDEO_FRA_ACCESS_KEY_ID=
|
||||
VIDEO_FRA_SECRET_ACCESS_KEY=
|
||||
VIDEO_FRA_DEFAULT_REGION=
|
||||
VIDEO_FRA_ENDPOINT=
|
||||
VIDEO_FRA_BUCKET=
|
||||
VIDEO_FRA_STREAM_READS=
|
||||
VIDEO_FRA_DISABLE_ASSERTS=
|
||||
VIDEO_FRA_VISIBILITY=
|
||||
|
||||
AUDIO_NYC_ACCESS_KEY_ID=
|
||||
AUDIO_NYC_SECRET_ACCESS_KEY=
|
||||
AUDIO_NYC_DEFAULT_REGION=
|
||||
AUDIO_NYC_ENDPOINT=
|
||||
AUDIO_NYC_BUCKET=
|
||||
AUDIO_NYC_STREAM_READS=
|
||||
AUDIO_NYC_DISABLE_ASSERTS=
|
||||
AUDIO_NYC_VISIBILITY=
|
||||
|
||||
AUDIO_FRA_ACCESS_KEY_ID=
|
||||
AUDIO_FRA_SECRET_ACCESS_KEY=
|
||||
AUDIO_FRA_DEFAULT_REGION=
|
||||
AUDIO_FRA_ENDPOINT=
|
||||
AUDIO_FRA_BUCKET=
|
||||
AUDIO_FRA_STREAM_READS=
|
||||
AUDIO_FRA_DISABLE_ASSERTS=
|
||||
AUDIO_FRA_VISIBILITY=
|
||||
|
||||
DUMP_ACCESS_KEY_ID=
|
||||
DUMP_SECRET_ACCESS_KEY=
|
||||
DUMP_DEFAULT_REGION=
|
||||
DUMP_ENDPOINT=
|
||||
DUMP_BUCKET=
|
||||
DUMP_STREAM_READS=
|
||||
DUMP_DISABLE_ASSERTS=
|
||||
DUMP_VISIBILITY=
|
||||
|
||||
SCRIPT_ACCESS_KEY_ID=
|
||||
SCRIPT_SECRET_ACCESS_KEY=
|
||||
SCRIPT_DEFAULT_REGION=
|
||||
SCRIPT_ENDPOINT=
|
||||
SCRIPT_BUCKET=
|
||||
SCRIPT_STREAM_READS=
|
||||
SCRIPT_DISABLE_ASSERTS=
|
||||
SCRIPT_VISIBILITY=
|
||||
|
||||
# fortify
|
||||
FORTIFY_HOME="${APP_URL}":3000
|
||||
FORTIFY_PATH=
|
||||
FORTIFY_URL="${APP_URL}"
|
||||
|
||||
# graphql
|
||||
GRAPHIQL_URL="${GRAPHQL_URL}/graphiql"
|
||||
GRAPHIQL_GRAPHQL_ENDPOINT="${GRAPHQL_URL}"
|
||||
GRAPHQL_URL=http://graphql.animethemes.test
|
||||
GRAPHQL_PATH=/graphql
|
||||
GRAPHIQL_ENABLED=true
|
||||
|
||||
# hashids
|
||||
HASHIDS_SALT_MAIN=
|
||||
HASHIDS_SALT_PLAYLISTS=
|
||||
|
||||
# hashing
|
||||
BCRYPT_ROUNDS=10
|
||||
|
||||
# horizon
|
||||
HORIZON_URL=
|
||||
HORIZON_PATH=horizon
|
||||
|
||||
# image
|
||||
IMAGE_DISK=images_local
|
||||
IMAGE_DISK_ROOT=
|
||||
|
||||
# logging
|
||||
LOG_CHANNEL=daily
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# mail
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS=admin@animethemes.moe
|
||||
MAIL_FROM_NAME=AnimeThemes
|
||||
MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"
|
||||
MAIL_LOG_CHANNEL=
|
||||
|
||||
# pennant
|
||||
PENNANT_STORE=database
|
||||
|
||||
# playlist
|
||||
PLAYLIST_MAX_TRACKS=1000
|
||||
USER_MAX_PLAYLISTS=1000
|
||||
|
||||
# queue
|
||||
QUEUE_CONNECTION=sync
|
||||
REDIS_QUEUE=default
|
||||
QUEUE_FAILED_DRIVER=database-uuids
|
||||
|
||||
# sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=*
|
||||
|
||||
# scout
|
||||
SCOUT_DRIVER=typesense
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=false
|
||||
SCOUT_IDENTIFY=false
|
||||
|
||||
ALGOLIA_APP_ID=
|
||||
ALGOLIA_SECRET=
|
||||
|
||||
ELASTIC_CONNECTION=default
|
||||
ELASTIC_HOST=http://elasticsearch:9200
|
||||
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
|
||||
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
|
||||
|
||||
TYPESENSE_HOST=typesense
|
||||
TYPESENSE_PORT=8108
|
||||
TYPESENSE_PROTOCOL=http
|
||||
TYPESENSE_API_KEY=
|
||||
|
||||
# secure headers
|
||||
ENABLE_CLEAR_SITE_DATA=false
|
||||
ENABLE_HSTS=false
|
||||
ENABLE_EXPECT_CT=false
|
||||
ENABLE_PERMISSIONS_POLICY=false
|
||||
ENABLE_CSP=false
|
||||
|
||||
# services
|
||||
MAILGUN_DOMAIN=
|
||||
MAILGUN_SECRET=
|
||||
MAILGUN_ENDPOINT=api.mailgun.net
|
||||
POSTMARK_TOKEN=
|
||||
DISCORD_BOT_API_TOKEN=
|
||||
DISCORD_BOT_API_URL=
|
||||
DISCORD_BOT_API_KEY=
|
||||
DB_UPDATES_DISCORD_CHANNEL=
|
||||
ADMIN_DISCORD_CHANNEL=
|
||||
OPENAI_BEARER_TOKEN=null
|
||||
MAL_CLIENT_ID=null
|
||||
MAL_CLIENT_SECRET=null
|
||||
MAL_REDIRECT_URI=null
|
||||
ANILIST_CLIENT_ID=null
|
||||
ANILIST_CLIENT_SECRET=null
|
||||
ANILIST_REDIRECT_URI=null
|
||||
|
||||
# session
|
||||
SESSION_COOKIE=animethemes_session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_CONNECTION=null
|
||||
SESSION_STORE=null
|
||||
SESSION_DOMAIN=null
|
||||
SESSION_SECURE_COOKIE=
|
||||
|
||||
# submission
|
||||
USER_MAX_SUBMISSIONS=50
|
||||
|
||||
# validation
|
||||
MODERATION_SERVICE=none
|
||||
|
||||
# video
|
||||
VIDEO_DISK_DEFAULT=videos_local
|
||||
VIDEO_DISKS=videos_local
|
||||
VIDEO_DISK_ROOT=
|
||||
VIDEO_PATH=/video
|
||||
VIDEO_URL="${APP_URL}"
|
||||
VIDEO_RATE_LIMITER=90
|
||||
VIDEO_STREAMING_METHOD=response
|
||||
VIDEO_NGINX_REDIRECT=
|
||||
|
||||
SCRIPT_DISK=scripts_local
|
||||
SCRIPT_DISK_ROOT=
|
||||
SCRIPT_URL="${APP_URL}"
|
||||
SCRIPT_PATH=/videoscript
|
||||
|
||||
# web
|
||||
WEB_URL="${APP_URL}:3000"
|
||||
WEB_PATH=
|
||||
|
||||
# wiki
|
||||
WIKI_LOGIN="${APP_URL}:3000/login"
|
||||
WIKI_RESET_PASSWORD="${APP_URL}:3000/reset-password"
|
||||
WIKI_EXTERNAL_PROFILE="${APP_URL}:3000/external"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
* text=auto
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
|
||||
+49
-53
@@ -1,66 +1,65 @@
|
||||
- [Assignment](#assignment)
|
||||
- [Development Environment](#development-environment)
|
||||
- [Feature Branch Workflow](#feature-branch-workflow)
|
||||
- [Cleanup Before Pull Request](#cleanup-before-pull-request)
|
||||
- [Pull Request](#pull-request)
|
||||
|
||||
## Assignment
|
||||
|
||||
Unless otherwise specified, any issue that does not have an assignee can be claimed by any volunteer.
|
||||
|
||||
Please leave a comment in the issue indicating interest so that you can be designated as the assignee by a member. This is a requirement in Github to allow asssignment of contributors outside of the organization by members.
|
||||
Leave a comment in the issue indicating interest so that the assignee can be designated by a member. This is a requirement in Github to allow assignment of contributors outside of the organization by members.
|
||||
|
||||
If further discussion is needed before work can be started, please make use of the programming channels on the discord.
|
||||
If further discussion is needed before work can be started, please make use of the `#api` channel on the discord.
|
||||
|
||||
## Development Environment
|
||||
|
||||
Ensure that you have a functional local instance of AnimeThemes forked from this repository by following the [Installation Guide](https://github.com/AnimeThemes/animethemes-server/wiki/Installation).
|
||||
Follow the [Installation Guide](/AnimeThemes/animethemes-server/wiki/Installation) to ensure a functional local instance of AnimeThemes forked from this repository.
|
||||
|
||||
Enable your repository in [StyleCI](https://styleci.io/) so that code styling guidelines are followed. At the time of writing, analysis is run on push and merge.
|
||||
Enable the forked repository in [StyleCI](https://styleci.io/) so that code styling guidelines are followed. At the time of writing, analysis is run on push and merge.
|
||||
|
||||
Set this repository as upstream to your fork.
|
||||
```sh
|
||||
# Set this repository as upstream to the forked repository.
|
||||
git remote add upstream git@github.com:AnimeThemes/animethemes-server.git
|
||||
|
||||
`git remote add upstream https://github.com/AnimeThemes/animethemes-server.git`
|
||||
# Pull upstream changes so that the local main branch is even with the upstream main branch.
|
||||
git pull upstream main
|
||||
|
||||
Checkout release branch.
|
||||
|
||||
`git checkout release-branch`
|
||||
|
||||
Pull upstream changes so that your local branch is even with upstream.
|
||||
|
||||
`git pull upstream release-branch`
|
||||
|
||||
Push upstream changes to your remote if needed.
|
||||
|
||||
`git push`
|
||||
# Push upstream changes to the forked remote main branch if needed.
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Feature Branch Workflow
|
||||
|
||||
Once your release branch is even with upstream, create feature branch from release branch.
|
||||
```sh
|
||||
# Once the release branch is even with upstream, create a new feature branch from the release branch. The new feature branch name should be descriptive.
|
||||
git checkout -b new-feature-branch
|
||||
|
||||
`git branch new-feature-branch`
|
||||
# Make changes in the new feature branch. Once completed, run tests.
|
||||
php artisan config:clear && php artisan test --parallel
|
||||
|
||||
Switch to feature branch.
|
||||
# Execute static code analysis.
|
||||
composer test:types
|
||||
|
||||
`git checkout new-feature-branch`
|
||||
# Execute pint.
|
||||
composer lint
|
||||
```
|
||||
|
||||
Make changes in feature branch. Once completed, run tests.
|
||||
Evaluate code changes against [conventions & standards](/AnimeThemes/animethemes-server/wiki/Coding-Conventions-&-Standards).
|
||||
|
||||
`php artisan config:clear && php artisan test --parallel`
|
||||
|
||||
Execute static code analysis.
|
||||
|
||||
`./vendor/bin/phpstan analyse --memory-limit=-1`
|
||||
|
||||
Stage changes. Commit changes. Please include ticket ID's and use [Semantic Commit Messages](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716).
|
||||
Stage changes. Commit changes. Please include issue ID's and use [Semantic Commit Messages](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716).
|
||||
|
||||
## Cleanup Before Pull Request
|
||||
|
||||
Fetch upstream master and merge with your repo's master branch
|
||||
```
|
||||
git fetch upstream release-branch
|
||||
git checkout release-branch
|
||||
git merge upstream/release-branch
|
||||
```
|
||||
```sh
|
||||
# Fetch the upstream release branch and merge with the fork repo's release branch.
|
||||
git fetch upstream main
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
|
||||
If there were any new commits, rebase your development branch
|
||||
```
|
||||
# If there were any new commits, rebase the new feature branch.
|
||||
git checkout new-feature-branch
|
||||
git rebase release-branch
|
||||
git rebase main
|
||||
```
|
||||
|
||||
## Pull Request
|
||||
@@ -69,27 +68,24 @@ Push changes to remote.
|
||||
|
||||
`git push --set-upstream origin new-feature-branch`
|
||||
|
||||
Confirm StyleCI passing status on push.
|
||||
|
||||
Create Pull Request from your new feature branch to upstream release branch.
|
||||
Create Pull Request from the new feature branch to the upstream release branch.
|
||||
|
||||
## Pruning
|
||||
|
||||
Once the Pull Request is merged, delete the feature branch locally.
|
||||
```sh
|
||||
# Once the Pull Request is merged, check out the release branch and delete the feature branch locally.
|
||||
git checkout main
|
||||
git branch -D new-feature-branch
|
||||
|
||||
`git branch --delete new-feature-branch`
|
||||
# Delete the remote feature branch.
|
||||
git push origin -D new-feature-branch
|
||||
|
||||
Delete the remote feature branch.
|
||||
# Pull upstream changes so that the fork release branch is even with upstream.
|
||||
git pull upstream main
|
||||
|
||||
`git push origin --delete new-feature-branch`
|
||||
|
||||
Pull upstream changes so that your local branch is even with upstream.
|
||||
|
||||
`git pull upstream release-branch`
|
||||
|
||||
Push upstream changes to your remote if needed.
|
||||
|
||||
`git push`
|
||||
# Push upstream changes to the fork's remote if needed.
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: AnimeThemes
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: animethemes
|
||||
ko_fi: # Replace with a Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,4 @@
|
||||
contact_links:
|
||||
- name: API question
|
||||
url: https://discord.gg/m9zbVyQ
|
||||
about: 'For API questions, go to the #api channel on the Discord server'
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement, php
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,25 @@
|
||||
name: GraphQL Schema
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
graphql:
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.5'
|
||||
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo
|
||||
- uses: actions/checkout@v3
|
||||
- name: Copy .env
|
||||
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
- name: Directory Permissions
|
||||
run: chmod -R 777 storage bootstrap/cache
|
||||
- name: Validate GraphQL Schema via Lighthouse
|
||||
run: |
|
||||
php artisan lighthouse:validate-schema
|
||||
@@ -10,27 +10,16 @@ jobs:
|
||||
steps:
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
php-version: '8.5'
|
||||
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, phpunit
|
||||
- uses: actions/checkout@v3
|
||||
- name: Copy .env
|
||||
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_PASSWORD: ${{ secrets.NOVA_PASSWORD }}
|
||||
ENLIGHTN_USERNAME: ${{ secrets.ENLIGHTN_USERNAME }}
|
||||
ENLIGHTN_PASSWORD: ${{ secrets.ENLIGHTN_PASSWORD }}
|
||||
run: |
|
||||
composer config "http-basic.nova.laravel.com" "$NOVA_USERNAME" "$NOVA_PASSWORD"
|
||||
composer config "http-basic.satis.laravel-enlightn.com" "$ENLIGHTN_USERNAME" "$ENLIGHTN_PASSWORD"
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
- name: Generate key
|
||||
run: php artisan key:generate
|
||||
- name: Install NPM dependencies
|
||||
run: npm install
|
||||
- name: Compile assets
|
||||
run: npm run build
|
||||
- name: Directory Permissions
|
||||
run: chmod -R 777 storage bootstrap/cache
|
||||
- name: Create Database
|
||||
|
||||
@@ -10,30 +10,29 @@ jobs:
|
||||
steps:
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
php-version: '8.5'
|
||||
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, phpunit
|
||||
- uses: actions/checkout@v3
|
||||
- name: Copy .env
|
||||
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_PASSWORD: ${{ secrets.NOVA_PASSWORD }}
|
||||
run: |
|
||||
composer config "http-basic.nova.laravel.com" "$NOVA_USERNAME" "$NOVA_PASSWORD"
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
- name: Generate key
|
||||
run: php artisan key:generate
|
||||
- name: Install NPM dependencies
|
||||
run: npm install
|
||||
- name: Compile assets
|
||||
run: npm run build
|
||||
- name: Directory Permissions
|
||||
run: chmod -R 777 storage bootstrap/cache
|
||||
- name: Create Database
|
||||
run: |
|
||||
mkdir -p database
|
||||
touch database/database.sqlite
|
||||
- name: Install static FFmpeg
|
||||
run: |
|
||||
curl -L -o ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
tar -xf ffmpeg.tar.xz
|
||||
sudo mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/
|
||||
sudo mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/
|
||||
ffmpeg -encoders | grep av1 || true
|
||||
- name: Execute tests (Unit and Feature tests) via PHPUnit
|
||||
env:
|
||||
DB_CONNECTION: sqlite
|
||||
|
||||
+10
@@ -1,20 +1,30 @@
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/build
|
||||
/public/audios
|
||||
/public/images
|
||||
/public/videos
|
||||
/public/dumps
|
||||
/public/scripts
|
||||
/storage/*.key
|
||||
/vendor
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/.fleet
|
||||
.vscode
|
||||
.idea
|
||||
auth.json
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.sql
|
||||
public/css/app.css
|
||||
public/js/app.js
|
||||
test.php
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
version: 8.1
|
||||
version: 8.5
|
||||
preset: laravel
|
||||
finder:
|
||||
not-name:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/test.yml"><img src="https://github.com/AnimeThemes/animethemes-server/workflows/Tests/badge.svg?branch=main" alt="tests"></a>
|
||||
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/static-analysis.yml"><img src="https://github.com/AnimeThemes/animethemes-server/workflows/Static%20Analysis/badge.svg?branch=main" alt="static-analysis"></a>
|
||||
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/test.yml"><img src="https://github.com/AnimeThemes/animethemes-server/actions/workflows/test.yml/badge.svg?branch=main" alt="tests"></a>
|
||||
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/static-analysis.yml"><img src="https://github.com/AnimeThemes/animethemes-server/actions/workflows/static-analysis.yml/badge.svg?branch=main" alt="static-analysis"></a>
|
||||
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/graphql.yml"><img src="https://github.com/AnimeThemes/animethemes-server/actions/workflows/graphql.yml/badge.svg?branch=main" alt="graphql"></a>
|
||||
<a href="https://github.styleci.io/repos/111264405?branch=main"><img src="https://github.styleci.io/repos/111264405/shield?branch=main" alt="StyleCI"></a>
|
||||
<a href="https://discordapp.com/invite/m9zbVyQ"><img src="https://img.shields.io/discord/354388306580078594.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2"></a>
|
||||
<a href="https://github.com/AnimeThemes/animethemes-server/blob/main/LICENSE"><img src="https://img.shields.io/github/license/AnimeThemes/animethemes-server"></a>
|
||||
<a href="https://reddit.com/r/AnimeThemes"><img src="https://img.shields.io/reddit/subreddit-subscribers/AnimeThemes?style=social"></a>
|
||||
<a href="https://twitter.com/AnimeThemesMoe"><img src="https://img.shields.io/twitter/follow/AnimeThemesMoe?style=social"></a>
|
||||
<a href="https://x.com/AnimeThemesMoe"><img src="https://img.shields.io/twitter/follow/AnimeThemesMoe?style=social"></a>
|
||||
</p>
|
||||
|
||||
[**AnimeThemes**](https://animethemes.moe/) is a simple and consistent repository of anime opening and ending themes. We provide direct links to high quality WebMs of your favorite OPs and EDs for your listening and discussion needs.
|
||||
@@ -14,14 +15,208 @@ This is the repository for the server application that is responsible for AnimeT
|
||||
|
||||
This project is powered by [**Laravel**](https://laravel.com/), a PHP framework for web artisans.
|
||||
|
||||
## Installation
|
||||
# Installation
|
||||
|
||||
Please review the [**Installation Guide**](https://github.com/AnimeThemes/animethemes-server/wiki/Installation) in the wiki for detailed instructions.
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Pre Setup](#pre-setup)
|
||||
- [PHP](#php)
|
||||
- [Setup](#setup)
|
||||
- [Laravel Herd](#laravel-herd)
|
||||
- [Docker](#docker)
|
||||
- [Running](#running)
|
||||
- [Extra Configuration](#extra-configuration)
|
||||
- [Feature Flags](#feature-flags)
|
||||
- [Users](#users)
|
||||
- [Search](#search)
|
||||
- [Local Storage](#local-storage)
|
||||
- [Contributing](#contributing)
|
||||
- [Resources](#resources)
|
||||
|
||||
## Contributing
|
||||
## Prerequisites
|
||||
|
||||
* [Laravel Herd](https://herd.laravel.com/) or [Docker](https://www.docker.com/)
|
||||
* MySQL 8+ or Docker
|
||||
* [composer](https://getcomposer.org/download/) or Docker
|
||||
|
||||
Docker will setup PHP, MySQL and Typesense for you. If you are on Windows, use the [WSL](https://learn.microsoft.com/windows/wsl/install) terminal.
|
||||
|
||||
Laravel Herd will setup PHP 8.5 for you. You should download and setup MySQL and Typesense manually.
|
||||
|
||||
## Pre Setup
|
||||
|
||||
### PHP
|
||||
|
||||
You can skip this if you are using Docker.
|
||||
|
||||
In order to accept video uploads, we should ensure that php will accept requests of adequate sizes.
|
||||
|
||||
Set `post_max_size` to `200M`.
|
||||
|
||||
Set `upload_max_filesize` to `200M`.
|
||||
|
||||
## Setup
|
||||
|
||||
### Laravel Herd
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone git@github.com:AnimeThemes/animethemes-server.git
|
||||
cd animethemes-server
|
||||
|
||||
# Create the database
|
||||
mysql -h localhost -u root -p -e "CREATE DATABASE IF NOT EXISTS ``animethemes`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
|
||||
# Install dependencies, import dumps, migrate the database and run seeders
|
||||
composer setup
|
||||
|
||||
# Generate an application key
|
||||
php artisan key:generate
|
||||
|
||||
# Run the following in the project directory:
|
||||
herd link admin.animethemes.test
|
||||
herd link animethemes.test
|
||||
herd link api.animethemes.test
|
||||
herd link graphql.animethemes.test
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone git@github.com:AnimeThemes/animethemes-server.git
|
||||
cd animethemes-server
|
||||
|
||||
cp .env.example-sail .env
|
||||
|
||||
# Install Composer dependencies using a throwaway container
|
||||
docker run --rm \
|
||||
-u "$(id -u):$(id -g)" \
|
||||
-v "$(pwd):/var/www/html" \
|
||||
-w /var/www/html \
|
||||
laravelsail/php82-composer:latest \
|
||||
composer install --ignore-platform-reqs
|
||||
|
||||
# Start sail
|
||||
./vendor/bin/sail up -d
|
||||
|
||||
# Generate an application key
|
||||
./vendor/bin/sail artisan key:generate
|
||||
|
||||
# Import dumps, migrate the database and run seeders
|
||||
./vendor/bin/sail artisan db:sync --drop
|
||||
```
|
||||
|
||||
You can optionally configure a shell alias following the [official Sail guide](https://laravel.com/docs/13.x/sail#configuring-a-shell-alias).
|
||||
The following instruction assume you have configured a shell alias. If not you need to replace `sail` with `./vendor/bin/sail`.
|
||||
|
||||
Open the following file and paste the contents there:
|
||||
|
||||
For Windows/WSL: `C:\Windows\System32\drivers\etc\hosts`
|
||||
|
||||
For Linux: `/etc/hosts`
|
||||
|
||||
```
|
||||
127.0.0.1 admin.animethemes.test
|
||||
127.0.0.1 animethemes.test
|
||||
127.0.0.1 api.animethemes.test
|
||||
127.0.0.1 graphql.animethemes.test
|
||||
```
|
||||
|
||||
Restart the container:
|
||||
|
||||
```bash
|
||||
sail restart
|
||||
sail artisan optimize
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
If all went well, AnimeThemes should be live at `http://animethemes.test`.
|
||||
|
||||
## Extra Configuration
|
||||
|
||||
### Feature Flags
|
||||
|
||||
Features that require external services are disabled by default. Here we will review the configuration options for enabling additional features.
|
||||
|
||||
For example, if we want to enable video streams, we need to set the `App\Features\AllowVideoStreams` value on DB to `true`. We recommend setting up a local archive for the `videos_local` disk.
|
||||
|
||||
### Users
|
||||
|
||||
```sh
|
||||
# Open the terminal of tinker
|
||||
php artisan tinker
|
||||
# or through Docker
|
||||
sail artisan tinker
|
||||
|
||||
# Create the user
|
||||
$user = User::factory()->create(['name' => 'User Name', 'email' => 'example@example.com', 'password' => 'password', 'email_verified_at' => now()]);
|
||||
|
||||
# It is useful to create a user with the Admin role with permissions to all actions
|
||||
# Assign the Admin role to the user
|
||||
$user->assignRole('Admin');
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
Change the `SCOUT_DRIVER` variable in `.env` to "typesense". Add additional configuration like host and port.
|
||||
|
||||
Import models into our indices using:
|
||||
|
||||
```sh
|
||||
# Import Models using a custom artisan command
|
||||
php artisan scout:import-all
|
||||
# or through Docker
|
||||
sail artisan scout:import-all
|
||||
```
|
||||
|
||||
### Local Storage
|
||||
|
||||
We are not required to set up s3 buckets in order to interact with media. We have the option to configure local filesystems that we can stream audio/video from and download scripts/dumps from.
|
||||
|
||||
Configure local filesystem disks in `.env`
|
||||
|
||||
```sh
|
||||
AUDIO_DISK_DEFAULT=audios_local
|
||||
AUDIO_DISKS=audios_local
|
||||
...
|
||||
DUMP_DISK=dumps_local
|
||||
...
|
||||
IMAGE_DISK=images_local
|
||||
...
|
||||
VIDEO_DISK_DEFAULT=videos_local
|
||||
VIDEO_DISKS=videos_local
|
||||
...
|
||||
SCRIPT_DISK=scripts_local
|
||||
|
||||
```
|
||||
|
||||
By default, app storage directories will be used to store media. External directories can be specified as the root if media is stored elsewhere.
|
||||
|
||||
Remark: It is recommended to include a `.gitignore` at the root directory of the filesystem so that media files are not indexed by git.
|
||||
|
||||
```sh
|
||||
AUDIO_DISK_ROOT="E:\\animethemes-audios\\"
|
||||
...
|
||||
DUMP_DISK_ROOT="E:\\animethemes-db-dumps\\"
|
||||
...
|
||||
IMAGE_DISK_ROOT="E:\\animethemes-images\\"
|
||||
...
|
||||
SCRIPT_DISK_ROOT="E:\\animethemes-scripts\\"
|
||||
...
|
||||
VIDEO_DISK_ROOT="E:\\animethemes-videos\\"
|
||||
```
|
||||
|
||||
Create symbolic links to target storage directories.
|
||||
|
||||
```php
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
# Contributing
|
||||
|
||||
Please review the [**Contributing Guide**](https://github.com/AnimeThemes/animethemes-server/wiki/Contributing) in the wiki for detailed instructions.
|
||||
|
||||
## Resources
|
||||
# Resources
|
||||
|
||||
Please make use of the #development channel in the [**Discord Server**](https://discordapp.com/invite/m9zbVyQ) for questions pertaining to the AnimeThemes database or API.
|
||||
Please make use of the #api channel in the [**Discord Server**](https://discordapp.com/invite/m9zbVyQ) for questions pertaining to the AnimeThemes database or API.
|
||||
|
||||
@@ -8,56 +8,25 @@ use App\Enums\Actions\ActionStatus;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class ActionResult.
|
||||
*/
|
||||
class ActionResult
|
||||
{
|
||||
/**
|
||||
* Create a new action result instance.
|
||||
*
|
||||
* @param ActionStatus $status
|
||||
* @param string|null $message
|
||||
*/
|
||||
public function __construct(protected readonly ActionStatus $status, protected readonly ?string $message = null)
|
||||
{
|
||||
}
|
||||
public function __construct(protected readonly ActionStatus $status, protected readonly ?string $message = null) {}
|
||||
|
||||
/**
|
||||
* Get the action result status.
|
||||
*
|
||||
* @return ActionStatus
|
||||
*/
|
||||
public function getStatus(): ActionStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action result message.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the action failed?
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasFailed(): bool
|
||||
{
|
||||
return ActionStatus::FAILED()->is($this->status);
|
||||
return $this->status === ActionStatus::FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write results to log.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function toLog(): void
|
||||
{
|
||||
$this->hasFailed()
|
||||
@@ -65,12 +34,6 @@ class ActionResult
|
||||
: Log::info($this->getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Write results to console output.
|
||||
*
|
||||
* @param Command $command
|
||||
* @return void
|
||||
*/
|
||||
public function toConsole(Command $command): void
|
||||
{
|
||||
$this->hasFailed()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Discord;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Actions\Models\Wiki\Video\DiscordNotificationType;
|
||||
use App\Models\Wiki\Video;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class DiscordVideoNotificationAction
|
||||
{
|
||||
/**
|
||||
* Get the HTTP client for Discord API.
|
||||
*/
|
||||
public static function getHttp(): PendingRequest
|
||||
{
|
||||
return Http::withHeaders(['x-api-key' => Config::get('services.discord.api_key')])
|
||||
->baseUrl(Config::get('services.discord.api_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Video> $videos
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
public function handle(Collection $videos, array $fields): ActionResult
|
||||
{
|
||||
$type = Arr::get($fields, DiscordNotificationType::getFieldKey());
|
||||
|
||||
static::getHttp()
|
||||
->post('/notification', [
|
||||
'type' => $type->value,
|
||||
'videos' => $videos->map(fn (Video $video): array => ['videoId' => $video->getKey()])->toArray(),
|
||||
])
|
||||
->throw();
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\Auth\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Rules\ModerationRule;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
@@ -13,34 +13,33 @@ use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
/**
|
||||
* Class CreateNewUser.
|
||||
*/
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array $input
|
||||
* @return Model
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): Model
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::TABLE)],
|
||||
'password' => Password::required(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['required'] : '',
|
||||
User::ATTRIBUTE_NAME => ['required', 'string', 'max:35', 'alpha_dash', Rule::unique(User::class), new ModerationRule()],
|
||||
User::ATTRIBUTE_EMAIL => ['required', 'string', 'email', 'max:255', 'indisposable', Rule::unique(User::class)],
|
||||
User::ATTRIBUTE_PASSWORD => Password::required(),
|
||||
'terms' => ['required'],
|
||||
])->validate();
|
||||
|
||||
return User::query()->create([
|
||||
User::ATTRIBUTE_NAME => Arr::get($input, 'name'),
|
||||
User::ATTRIBUTE_EMAIL => Arr::get($input, 'email'),
|
||||
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, 'password')),
|
||||
$user = new User([
|
||||
User::ATTRIBUTE_NAME => Arr::get($input, User::ATTRIBUTE_NAME),
|
||||
User::ATTRIBUTE_EMAIL => Arr::get($input, User::ATTRIBUTE_EMAIL),
|
||||
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, User::ATTRIBUTE_PASSWORD)),
|
||||
]);
|
||||
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,28 +12,23 @@ use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
/**
|
||||
* Class ResetUserPassword.
|
||||
*/
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param mixed $user
|
||||
* @param array $input
|
||||
* @return void
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function reset($user, array $input): void
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => Password::required(),
|
||||
User::ATTRIBUTE_PASSWORD => Password::required(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, 'password')),
|
||||
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, User::ATTRIBUTE_PASSWORD)),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,26 +13,21 @@ use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Validation\Validator as IlluminateValidator;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
/**
|
||||
* Class UpdateUserPassword.
|
||||
*/
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param mixed $user
|
||||
* @param array $input
|
||||
* @return void
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update($user, array $input): void
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string'],
|
||||
'password' => Password::required(),
|
||||
])->after(function (IlluminateValidator $validator) use ($user, $input) {
|
||||
User::ATTRIBUTE_PASSWORD => Password::required(),
|
||||
])->after(function (IlluminateValidator $validator) use ($user, $input): void {
|
||||
if (! isset($input['current_password']) || ! Hash::check($input['current_password'], $user->password)) {
|
||||
$validator->errors()
|
||||
->add('current_password', __('The provided password does not match your current password.'));
|
||||
@@ -40,7 +35,7 @@ class UpdateUserPassword implements UpdatesUserPasswords
|
||||
})->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, 'password')),
|
||||
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, User::ATTRIBUTE_PASSWORD)),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,64 +5,39 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\Auth\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Rules\ModerationRule;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
/**
|
||||
* Class UpdateUserProfileInformation.
|
||||
*/
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param mixed $user
|
||||
* @param array $input
|
||||
* @return void
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(mixed $user, array $input): void
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique(User::TABLE)->ignore($user->id)],
|
||||
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
/** @var array{name: string, email: string} $validated */
|
||||
$validated = Validator::make($input, [
|
||||
User::ATTRIBUTE_NAME => ['required_without:'.User::ATTRIBUTE_EMAIL, 'string', 'max:35', 'alpha_dash', Rule::unique(User::class)->ignore($user->id), new ModerationRule()],
|
||||
User::ATTRIBUTE_EMAIL => ['required_without:'.User::ATTRIBUTE_NAME, 'string', 'email', 'max:255', 'indisposable', Rule::unique(User::class)->ignore($user->id)],
|
||||
])->validate();
|
||||
|
||||
if (isset($input['photo'])) {
|
||||
$user->updateProfilePhoto($input['photo']);
|
||||
}
|
||||
$email = Arr::get($validated, User::ATTRIBUTE_EMAIL);
|
||||
if (Arr::has($validated, User::ATTRIBUTE_EMAIL) && $email !== $user->email) {
|
||||
$validated += [User::ATTRIBUTE_EMAIL_VERIFIED_AT => null];
|
||||
|
||||
if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
$user->update($validated);
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
} else {
|
||||
$user->forceFill([
|
||||
User::ATTRIBUTE_NAME => Arr::get($input, 'name'),
|
||||
User::ATTRIBUTE_EMAIL => Arr::get($input, 'email'),
|
||||
])->save();
|
||||
$user->update($validated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param mixed $user
|
||||
* @param array $input
|
||||
* @return void
|
||||
*/
|
||||
protected function updateVerifiedUser(mixed $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
User::ATTRIBUTE_NAME => Arr::get($input, 'name'),
|
||||
User::ATTRIBUTE_EMAIL => Arr::get($input, 'email'),
|
||||
User::ATTRIBUTE_EMAIL_VERIFIED_AT => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Admin\Dump;
|
||||
|
||||
use App\Actions\Http\DownloadAction;
|
||||
use App\Constants\Config\DumpConstants;
|
||||
use App\Models\Admin\Dump;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* @extends DownloadAction<Dump>
|
||||
*/
|
||||
class DumpDownloadAction extends DownloadAction
|
||||
{
|
||||
/**
|
||||
* Get the path of the resource in storage.
|
||||
*/
|
||||
protected function path(): string
|
||||
{
|
||||
return $this->model->path;
|
||||
}
|
||||
|
||||
public function disk(): string
|
||||
{
|
||||
return Config::get(DumpConstants::DISK_QUALIFIED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api;
|
||||
|
||||
use App\Contracts\Models\Nameable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @template TModel of \Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
class DestroyAction
|
||||
{
|
||||
/**
|
||||
* @param TModel $model
|
||||
* @return TModel
|
||||
*/
|
||||
public function destroy(Model $model): Model
|
||||
{
|
||||
$model->delete();
|
||||
|
||||
// Scout will load relations to refresh related search indices.
|
||||
$model->unsetRelations();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force delete model that doesn't apply soft deletes.
|
||||
*
|
||||
* @param TModel&Nameable $model
|
||||
*/
|
||||
public function forceDelete(Model&Nameable $model): string
|
||||
{
|
||||
$message = Str::of(Str::headline(class_basename($model)))
|
||||
->append(' \'')
|
||||
->append($model->getName())
|
||||
->append('\' was deleted.')
|
||||
->__toString();
|
||||
|
||||
$model->delete();
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ForceDeleteAction
|
||||
{
|
||||
public function forceDelete(BaseModel $model): string
|
||||
{
|
||||
$message = Str::of(Str::headline(class_basename($model)))
|
||||
->append(' \'')
|
||||
->append($model->getName())
|
||||
->append('\' was deleted.')
|
||||
->__toString();
|
||||
|
||||
$model->forceDelete();
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api;
|
||||
|
||||
use App\Concerns\Actions\Http\Api\ConstrainsEagerLoads;
|
||||
use App\Enums\Http\Api\Paging\PaginationStrategy;
|
||||
use App\Http\Api\Criteria\Paging\Criteria;
|
||||
use App\Http\Api\Filter\HasFilter;
|
||||
use App\Http\Api\Query\Query;
|
||||
use App\Http\Api\Schema\EloquentSchema;
|
||||
use App\Http\Api\Schema\Schema;
|
||||
use App\Http\Api\Scope\ScopeParser;
|
||||
use App\Scout\Search;
|
||||
use Illuminate\Contracts\Pagination\Paginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use RuntimeException;
|
||||
|
||||
class IndexAction
|
||||
{
|
||||
use ConstrainsEagerLoads;
|
||||
|
||||
/**
|
||||
* Show paginated listing of models.
|
||||
*/
|
||||
public function index(Builder $builder, Query $query, Schema $schema): Collection|Paginator
|
||||
{
|
||||
$builder->with($this->constrainEagerLoads($query, $schema));
|
||||
|
||||
$this->select($builder, $query, $schema);
|
||||
|
||||
$this->withAggregates($builder, $query, $schema);
|
||||
|
||||
$scope = ScopeParser::parse($schema->type());
|
||||
$this->filter($builder, $query, $schema, $scope);
|
||||
|
||||
// special case: only apply HasFilter to top-level models
|
||||
if (filled($schema->allowedIncludes())) {
|
||||
$hasFilter = new HasFilter($schema->allowedIncludes());
|
||||
foreach ($query->getFilterCriteria() as $criteria) {
|
||||
if ($criteria->shouldFilter($hasFilter, $scope)) {
|
||||
$criteria->filter($builder, $hasFilter, $query, $schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->sort($builder, $query, $schema, $scope);
|
||||
|
||||
// paginate
|
||||
$paginationCriteria = $query->getPagingCriteria(PaginationStrategy::OFFSET);
|
||||
|
||||
return $paginationCriteria instanceof Criteria
|
||||
? $paginationCriteria->paginate($builder)
|
||||
: $builder->get();
|
||||
}
|
||||
|
||||
public function search(
|
||||
Query $query,
|
||||
Schema $schema,
|
||||
PaginationStrategy $paginationStrategy = PaginationStrategy::OFFSET
|
||||
): Collection|Paginator {
|
||||
if ($query->hasSearchCriteria() && $schema instanceof EloquentSchema) {
|
||||
$search = Search::getSearch($schema->model(), $query->getSearchCriteria());
|
||||
|
||||
if ($search->shouldSearch()) {
|
||||
return $search->searchViaJSONAPI($query, $schema, $paginationStrategy);
|
||||
}
|
||||
}
|
||||
|
||||
// Let developer know why search can't be performed
|
||||
$driver = Config::get('scout.driver');
|
||||
$term = $query->getSearchCriteria()?->getTerm();
|
||||
throw new RuntimeException("Can't search for term '$term' with driver '$driver' and type '{$schema->type()}'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api\List\Playlist\Track;
|
||||
|
||||
use App\Actions\Http\Api\DestroyAction;
|
||||
use App\Actions\Models\List\Playlist\RemoveTrackAction;
|
||||
use App\Concerns\Actions\List\LocksPlaylist;
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DestroyTrackAction
|
||||
{
|
||||
use LocksPlaylist;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Playlist $playlist, PlaylistTrack $track): string
|
||||
{
|
||||
return $this->withPlaylistLock($playlist, function () use ($playlist, $track) {
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$removeAction = new RemoveTrackAction();
|
||||
|
||||
$removeAction->remove($playlist, $track);
|
||||
|
||||
$message = Str::of(Str::headline(class_basename($track)))
|
||||
->append(' \'')
|
||||
->append($track->getName())
|
||||
->append('\' was deleted.')
|
||||
->__toString();
|
||||
|
||||
$destroyAction = new DestroyAction();
|
||||
|
||||
$destroyAction->forceDelete($track);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $message;
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api\List\Playlist\Track;
|
||||
|
||||
use App\Actions\Http\Api\StoreAction;
|
||||
use App\Actions\Models\List\Playlist\InsertTrackAction;
|
||||
use App\Actions\Models\List\Playlist\InsertTrackAfterAction;
|
||||
use App\Actions\Models\List\Playlist\InsertTrackBeforeAction;
|
||||
use App\Concerns\Actions\List\LocksPlaylist;
|
||||
use App\Contracts\Models\HasHashids;
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class StoreTrackAction
|
||||
{
|
||||
use LocksPlaylist;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function store(Playlist $playlist, Builder $builder, array $parameters): PlaylistTrack
|
||||
{
|
||||
return $this->withPlaylistLock($playlist, function () use ($playlist, $builder, $parameters) {
|
||||
$trackParameters = $parameters;
|
||||
|
||||
$previousHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_PREVIOUS);
|
||||
$nextHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_NEXT);
|
||||
|
||||
$trackParameters += [PlaylistTrack::ATTRIBUTE_PLAYLIST => $playlist->getKey()];
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
/** @var StoreAction<PlaylistTrack> $storeAction */
|
||||
$storeAction = new StoreAction();
|
||||
|
||||
$track = $storeAction->store($builder, $trackParameters);
|
||||
|
||||
if (filled($nextHashid) && blank($previousHashid)) {
|
||||
/** @var PlaylistTrack $next */
|
||||
$next = PlaylistTrack::query()
|
||||
->with(PlaylistTrack::RELATION_PREVIOUS)
|
||||
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
|
||||
->where(HasHashids::ATTRIBUTE_HASHID, $nextHashid)
|
||||
->firstOrFail();
|
||||
|
||||
$insertAction = new InsertTrackBeforeAction();
|
||||
|
||||
$insertAction->insertBefore($playlist, $track, $next);
|
||||
}
|
||||
|
||||
if (filled($previousHashid) && blank($nextHashid)) {
|
||||
/** @var PlaylistTrack $previous */
|
||||
$previous = PlaylistTrack::query()
|
||||
->with(PlaylistTrack::RELATION_NEXT)
|
||||
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
|
||||
->where(HasHashids::ATTRIBUTE_HASHID, $previousHashid)
|
||||
->firstOrFail();
|
||||
|
||||
$insertAction = new InsertTrackAfterAction();
|
||||
|
||||
$insertAction->insertAfter($playlist, $track, $previous);
|
||||
}
|
||||
|
||||
if (blank($nextHashid) && blank($previousHashid)) {
|
||||
$insertAction = new InsertTrackAction();
|
||||
|
||||
$insertAction->insert($playlist, $track);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $storeAction->cleanup($track);
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api\List\Playlist\Track;
|
||||
|
||||
use App\Actions\Http\Api\UpdateAction;
|
||||
use App\Actions\Models\List\Playlist\InsertTrackAfterAction;
|
||||
use App\Actions\Models\List\Playlist\InsertTrackBeforeAction;
|
||||
use App\Actions\Models\List\Playlist\RemoveTrackAction;
|
||||
use App\Concerns\Actions\List\LocksPlaylist;
|
||||
use App\Contracts\Models\HasHashids;
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdateTrackAction
|
||||
{
|
||||
use LocksPlaylist;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(Playlist $playlist, PlaylistTrack $track, array $parameters): PlaylistTrack
|
||||
{
|
||||
return $this->withPlaylistLock($playlist, function () use ($playlist, $track, $parameters) {
|
||||
$trackParameters = $parameters;
|
||||
|
||||
$previousHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_PREVIOUS);
|
||||
$nextHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_NEXT);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
/** @var UpdateAction<PlaylistTrack> $updateAction */
|
||||
$updateAction = new UpdateAction();
|
||||
|
||||
$updateAction->update($track, $trackParameters);
|
||||
|
||||
if (filled($nextHashid) || filled($previousHashid)) {
|
||||
$removeAction = new RemoveTrackAction();
|
||||
|
||||
$removeAction->remove($playlist, $track);
|
||||
}
|
||||
|
||||
if (filled($nextHashid) && blank($previousHashid)) {
|
||||
$next = PlaylistTrack::query()
|
||||
->with(PlaylistTrack::RELATION_PREVIOUS)
|
||||
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
|
||||
->where(HasHashids::ATTRIBUTE_HASHID, $nextHashid)
|
||||
->firstOrFail();
|
||||
|
||||
$insertAction = new InsertTrackBeforeAction();
|
||||
|
||||
$insertAction->insertBefore($playlist, $track, $next);
|
||||
}
|
||||
|
||||
if (filled($previousHashid) && blank($nextHashid)) {
|
||||
$previous = PlaylistTrack::query()
|
||||
->with(PlaylistTrack::RELATION_NEXT)
|
||||
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
|
||||
->where(HasHashids::ATTRIBUTE_HASHID, $previousHashid)
|
||||
->firstOrFail();
|
||||
|
||||
$insertAction = new InsertTrackAfterAction();
|
||||
|
||||
$insertAction->insertAfter($playlist, $track, $previous);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $updateAction->cleanup($track);
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api;
|
||||
|
||||
use App\Contracts\Models\SoftDeletable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RestoreAction
|
||||
{
|
||||
public function restore(Model&SoftDeletable $model): Model&SoftDeletable
|
||||
{
|
||||
$model->restore();
|
||||
|
||||
return $this->cleanup($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform model cleanup for presentation.
|
||||
*/
|
||||
public function cleanup(Model&SoftDeletable $model): Model&SoftDeletable
|
||||
{
|
||||
// Scout will load relations to refresh related search indices.
|
||||
$model->unsetRelations();
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api;
|
||||
|
||||
use App\Concerns\Actions\Http\Api\ConstrainsEagerLoads;
|
||||
use App\Http\Api\Query\Query;
|
||||
use App\Http\Api\Schema\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ShowAction
|
||||
{
|
||||
use ConstrainsEagerLoads;
|
||||
|
||||
public function show(Model $model, Query $query, Schema $schema): Model
|
||||
{
|
||||
// eager load relations with constraints
|
||||
$model->load($this->constrainEagerLoads($query, $schema));
|
||||
|
||||
// Load aggregate relation values
|
||||
$this->loadAggregates($model, $query, $schema);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @template TModel of \Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
class StoreAction
|
||||
{
|
||||
/**
|
||||
* @param Builder<TModel> $builder
|
||||
* @return TModel
|
||||
*/
|
||||
public function store(Builder $builder, array $parameters): Model
|
||||
{
|
||||
$model = $builder->create($parameters);
|
||||
|
||||
return $this->cleanup($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform model cleanup for presentation.
|
||||
*
|
||||
* @param TModel $model
|
||||
* @return TModel
|
||||
*/
|
||||
public function cleanup(Model $model): Model
|
||||
{
|
||||
// Scout will load relations to refresh related search indices.
|
||||
$model->unsetRelations();
|
||||
|
||||
// Columns with default values may be unset if not provided in the query string.
|
||||
$model->refresh();
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @template TModel of \Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
class UpdateAction
|
||||
{
|
||||
/**
|
||||
* @param TModel $model
|
||||
* @return TModel
|
||||
*/
|
||||
public function update(Model $model, array $parameters): Model
|
||||
{
|
||||
$model->update($parameters);
|
||||
|
||||
return $this->cleanup($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform model cleanup for presentation.
|
||||
*
|
||||
* @param TModel $model
|
||||
* @return TModel
|
||||
*/
|
||||
public function cleanup(Model $model): Model
|
||||
{
|
||||
// Scout will load relations to refresh related search indices.
|
||||
$model->unsetRelations();
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Api\Wiki\Image;
|
||||
|
||||
use App\Actions\Http\Api\StoreAction;
|
||||
use App\Http\Api\Field\Wiki\Image\ImageFileField;
|
||||
use App\Models\Wiki\Image;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* @extends StoreAction<Image>
|
||||
*/
|
||||
class StoreImageAction extends StoreAction
|
||||
{
|
||||
/**
|
||||
* @param Builder<Image> $builder
|
||||
*/
|
||||
public function store(Builder $builder, array $parameters): Image
|
||||
{
|
||||
$imageParameters = Arr::except($parameters, ImageFileField::ATTRIBUTE_FILE);
|
||||
|
||||
$file = Arr::get($parameters, ImageFileField::ATTRIBUTE_FILE);
|
||||
if ($file instanceof UploadedFile) {
|
||||
/** @var FilesystemAdapter $fs */
|
||||
$fs = Storage::disk(Config::get('image.disk'));
|
||||
|
||||
$fsFile = $fs->putFile('', $file);
|
||||
|
||||
Arr::set($imageParameters, Image::ATTRIBUTE_PATH, $fsFile);
|
||||
}
|
||||
|
||||
return parent::store($builder, $imageParameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http;
|
||||
|
||||
use App\Contracts\Storage\InteractsWithDisk;
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* @template TModel of \App\Models\BaseModel
|
||||
*/
|
||||
abstract class DownloadAction implements InteractsWithDisk
|
||||
{
|
||||
/**
|
||||
* @param TModel $model
|
||||
*/
|
||||
public function __construct(protected readonly BaseModel $model) {}
|
||||
|
||||
public function download(): StreamedResponse
|
||||
{
|
||||
/** @var FilesystemAdapter $fs */
|
||||
$fs = Storage::disk($this->disk());
|
||||
|
||||
return $fs->download($this->path());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path of the resource in storage.
|
||||
*/
|
||||
abstract protected function path(): string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http;
|
||||
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
abstract class NginxStreamAction extends StreamAction
|
||||
{
|
||||
public function stream(string $disposition = 'inline'): Response
|
||||
{
|
||||
/** @var FilesystemAdapter $fs */
|
||||
$fs = Storage::disk($this->disk());
|
||||
|
||||
$temporaryURL = $fs->temporaryUrl($this->streamable->path(), now()->addMinutes(5));
|
||||
|
||||
// Get the url information
|
||||
$url_scheme = parse_url($temporaryURL, PHP_URL_SCHEME);
|
||||
$url_host = parse_url($temporaryURL, PHP_URL_HOST);
|
||||
$url_path_query = parse_url($temporaryURL, PHP_URL_PATH).'?'.parse_url($temporaryURL, PHP_URL_QUERY);
|
||||
|
||||
$link = "{$this->nginxRedirect()}$url_scheme/$url_host$url_path_query";
|
||||
|
||||
$response = new Response();
|
||||
|
||||
$disposition = $response->headers->makeDisposition($disposition, $this->streamable->basename());
|
||||
|
||||
return $response->withHeaders([
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Content-Type' => $this->streamable->mimetype(),
|
||||
'Content-Length' => $this->streamable->size(),
|
||||
'Content-Disposition' => $disposition,
|
||||
'X-Accel-Redirect' => $link,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the location of the nginx internal redirect.
|
||||
*/
|
||||
abstract protected function nginxRedirect(): string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
abstract class ResponseStreamAction extends StreamAction
|
||||
{
|
||||
public function stream(string $disposition = 'inline'): StreamedResponse
|
||||
{
|
||||
$response = new StreamedResponse();
|
||||
|
||||
$disposition = $response->headers->makeDisposition($disposition, $this->streamable->basename());
|
||||
|
||||
$response->headers->replace([
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Content-Type' => $this->streamable->mimetype(),
|
||||
'Content-Length' => $this->streamable->size(),
|
||||
'Content-Disposition' => $disposition,
|
||||
]);
|
||||
|
||||
$fs = Storage::disk($this->disk());
|
||||
|
||||
$response->setCallback(function () use ($fs): void {
|
||||
$stream = $fs->readStream($this->streamable->path());
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
});
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http;
|
||||
|
||||
use App\Contracts\Models\Streamable;
|
||||
use App\Contracts\Storage\InteractsWithDisk;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
abstract class StreamAction implements InteractsWithDisk
|
||||
{
|
||||
public function __construct(protected readonly Streamable $streamable) {}
|
||||
|
||||
abstract public function stream(string $disposition = 'inline'): Response;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Wiki\Audio;
|
||||
|
||||
use App\Actions\Http\NginxStreamAction;
|
||||
use App\Constants\Config\AudioConstants;
|
||||
use App\Models\Wiki\Audio;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class AudioNginxStreamAction extends NginxStreamAction
|
||||
{
|
||||
public function __construct(Audio $audio)
|
||||
{
|
||||
parent::__construct($audio);
|
||||
}
|
||||
|
||||
public function disk(): string
|
||||
{
|
||||
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the location of the nginx internal redirect.
|
||||
*/
|
||||
protected function nginxRedirect(): string
|
||||
{
|
||||
return Config::get(AudioConstants::NGINX_REDIRECT_QUALIFIED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Wiki\Audio;
|
||||
|
||||
use App\Actions\Http\ResponseStreamAction;
|
||||
use App\Constants\Config\AudioConstants;
|
||||
use App\Models\Wiki\Audio;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class AudioResponseStreamAction extends ResponseStreamAction
|
||||
{
|
||||
public function __construct(Audio $audio)
|
||||
{
|
||||
parent::__construct($audio);
|
||||
}
|
||||
|
||||
public function disk(): string
|
||||
{
|
||||
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Wiki\Video\Script;
|
||||
|
||||
use App\Actions\Http\DownloadAction;
|
||||
use App\Constants\Config\VideoConstants;
|
||||
use App\Models\Wiki\Video\VideoScript;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* @extends DownloadAction<VideoScript>
|
||||
*/
|
||||
class ScriptDownloadAction extends DownloadAction
|
||||
{
|
||||
/**
|
||||
* Get the path of the resource in storage.
|
||||
*/
|
||||
protected function path(): string
|
||||
{
|
||||
return $this->model->path;
|
||||
}
|
||||
|
||||
public function disk(): string
|
||||
{
|
||||
return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Wiki\Video;
|
||||
|
||||
use App\Actions\Http\NginxStreamAction;
|
||||
use App\Constants\Config\VideoConstants;
|
||||
use App\Models\Wiki\Video;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class VideoNginxStreamAction extends NginxStreamAction
|
||||
{
|
||||
public function __construct(Video $video)
|
||||
{
|
||||
parent::__construct($video);
|
||||
}
|
||||
|
||||
public function disk(): string
|
||||
{
|
||||
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the location of the nginx internal redirect.
|
||||
*/
|
||||
protected function nginxRedirect(): string
|
||||
{
|
||||
return Config::get(VideoConstants::NGINX_REDIRECT_QUALIFIED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Http\Wiki\Video;
|
||||
|
||||
use App\Actions\Http\ResponseStreamAction;
|
||||
use App\Constants\Config\VideoConstants;
|
||||
use App\Models\Wiki\Video;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class VideoResponseStreamAction extends ResponseStreamAction
|
||||
{
|
||||
public function __construct(Video $video)
|
||||
{
|
||||
parent::__construct($video);
|
||||
}
|
||||
|
||||
public function disk(): string
|
||||
{
|
||||
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
||||
|
||||
/**
|
||||
* Class DeleteUser.
|
||||
*/
|
||||
class DeleteUser implements DeletesUsers
|
||||
{
|
||||
/**
|
||||
* Delete the given user.
|
||||
*
|
||||
* @param mixed $user
|
||||
* @return void
|
||||
*/
|
||||
public function delete($user): void
|
||||
{
|
||||
DB::transaction(function () use ($user) {
|
||||
$user->tokens->each->delete();
|
||||
$user->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Aggregate;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SyncLikeAggregatesAction
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
try {
|
||||
DB::statement('
|
||||
INSERT INTO like_aggregates (likeable_id, likeable_type, value)
|
||||
SELECT likeable_id, likeable_type, COUNT(*) as value
|
||||
FROM likes
|
||||
GROUP BY likeable_type, likeable_id
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value);
|
||||
');
|
||||
} catch (Exception $e) {
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED,
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return new ActionResult(
|
||||
ActionStatus::PASSED,
|
||||
'Like aggregates synced successfully',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models;
|
||||
|
||||
use App\Contracts\Models\HasHashids;
|
||||
use App\Models\BaseModel;
|
||||
use Vinkla\Hashids\Facades\Hashids;
|
||||
|
||||
class AssignHashidsAction
|
||||
{
|
||||
public function assign(HasHashids&BaseModel $model, ?string $connection = null): void
|
||||
{
|
||||
$hashids = Hashids::connection($connection);
|
||||
|
||||
$model->setAttribute(HasHashids::ATTRIBUTE_HASHID, $hashids->encode($model->hashids()));
|
||||
|
||||
$model->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @template TModel of \App\Models\BaseModel
|
||||
*/
|
||||
abstract class BackfillAction
|
||||
{
|
||||
/**
|
||||
* @param TModel $model
|
||||
*/
|
||||
public function __construct(protected BaseModel $model) {}
|
||||
|
||||
abstract public function handle(): ActionResult;
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*
|
||||
* @return TModel
|
||||
*/
|
||||
abstract protected function getModel(): BaseModel;
|
||||
|
||||
/**
|
||||
* Get the relation to resources.
|
||||
*/
|
||||
abstract protected function relation(): Relation;
|
||||
|
||||
/**
|
||||
* Get the human-friendly label for the underlying model.
|
||||
*/
|
||||
protected function label(): string
|
||||
{
|
||||
return Str::headline(class_basename($this->getModel()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\Wiki\ExternalApiAction;
|
||||
use App\Actions\Storage\Wiki\Image\UploadImageAction;
|
||||
use App\Concerns\Models\CanCreateExternalResource;
|
||||
use App\Contracts\Models\HasImages;
|
||||
use App\Contracts\Models\HasResources;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\BaseModel;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Models\Wiki\Image;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Uri;
|
||||
|
||||
abstract class BackfillWikiAction
|
||||
{
|
||||
use CanCreateExternalResource;
|
||||
|
||||
final public const RESOURCES = 'resources';
|
||||
final public const IMAGES = 'images';
|
||||
|
||||
public function __construct(protected BaseModel $model, protected array $toBackfill) {}
|
||||
|
||||
abstract public function handle(): ActionResult;
|
||||
|
||||
/**
|
||||
* Get the external API actions available for the backfill action.
|
||||
*
|
||||
* @return ExternalApiAction[]
|
||||
*/
|
||||
abstract protected function getExternalApiActions(): array;
|
||||
|
||||
/**
|
||||
* Create the resources given the response.
|
||||
*/
|
||||
protected function forResources(ExternalApiAction $api): void
|
||||
{
|
||||
$toBackfill = $this->toBackfill[self::RESOURCES];
|
||||
|
||||
foreach ($api->getResources() as $site => $url) {
|
||||
$site = ResourceSite::from($site);
|
||||
|
||||
if (! in_array($site, $toBackfill)) {
|
||||
Log::info("Resource {$site->localize()} should not be backfilled for {$this->label()} {$this->getModel()->getName()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->getModel()->resources()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $site->value)->exists()) {
|
||||
Log::info("Resource {$site->localize()} already exists for {$this->label()} {$this->getModel()->getName()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createResource(Uri::of($url), $site, $this->getModel());
|
||||
|
||||
$this->backfilled($site, self::RESOURCES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the images given the response.
|
||||
*/
|
||||
protected function forImages(ExternalApiAction $api): void
|
||||
{
|
||||
$toBackfill = $this->toBackfill[self::IMAGES];
|
||||
|
||||
foreach ($api->getImages() as $facet => $url) {
|
||||
$facet = ImageFacet::from($facet);
|
||||
|
||||
if (! in_array($facet, $toBackfill)) {
|
||||
Log::info("Skipping {$facet->localize()} for {$this->label()} {$this->getModel()->getName()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->getModel()->images()->getQuery()->where(Image::ATTRIBUTE_FACET, $facet->value)->exists()) {
|
||||
Log::info("Image {$facet->localize()} already exists for {$this->label()} {$this->getModel()->getName()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
UploadImageAction::createImageFromUrl($url, $facet, $this->getModel());
|
||||
|
||||
$this->backfilled($facet, self::IMAGES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove element already backfilled.
|
||||
*/
|
||||
protected function backfilled(mixed $enum, string $scope): void
|
||||
{
|
||||
$index = array_search($enum, $this->toBackfill[$scope]);
|
||||
|
||||
if ($index !== false) {
|
||||
unset($this->toBackfill[$scope][$index]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model for the action.
|
||||
*
|
||||
* @return BaseModel&HasResources&HasImages
|
||||
*/
|
||||
abstract protected function getModel(): BaseModel;
|
||||
|
||||
/**
|
||||
* Get the human-friendly label for the underlying model.
|
||||
*/
|
||||
protected function label(): string
|
||||
{
|
||||
return Str::headline(class_basename($this->getModel()));
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Class BaseAction.
|
||||
*
|
||||
* @template TModel of \App\Models\BaseModel
|
||||
*/
|
||||
abstract class BaseAction
|
||||
{
|
||||
/**
|
||||
* Create a new action instance.
|
||||
*
|
||||
* @param TModel $model
|
||||
*/
|
||||
public function __construct(protected BaseModel $model)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action.
|
||||
*
|
||||
* @return ActionResult
|
||||
*/
|
||||
abstract public function handle(): ActionResult;
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*
|
||||
* @return TModel
|
||||
*/
|
||||
abstract protected function getModel(): BaseModel;
|
||||
|
||||
/**
|
||||
* Get the relation to resources.
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
abstract protected function relation(): Relation;
|
||||
|
||||
/**
|
||||
* Get the human-friendly label for the underlying model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function label(): string
|
||||
{
|
||||
return Str::headline(class_basename($this->getModel()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Document;
|
||||
|
||||
use App\Models\Document\Page;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdatePageRelations
|
||||
{
|
||||
public function handle(Page $page): void
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$page->previous?->next()?->associate($page)?->saveQuietly();
|
||||
$page->next?->previous()?->associate($page)?->saveQuietly();
|
||||
|
||||
$oldPreviousId = $page->getOriginal(Page::ATTRIBUTE_PREVIOUS);
|
||||
if ($oldPreviousId && $oldPreviousId !== $page->previous?->getKey()) {
|
||||
Page::query()->find($oldPreviousId)->next()->disassociate()->saveQuietly();
|
||||
}
|
||||
|
||||
$oldNextId = $page->getOriginal(Page::ATTRIBUTE_NEXT);
|
||||
if ($oldNextId && $oldNextId !== $page->next?->getKey()) {
|
||||
Page::query()->find($oldNextId)->previous()->disassociate()->saveQuietly();
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Entry;
|
||||
|
||||
use App\Models\List\External\ExternalToken;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* This action will create the entries through the authentication method.
|
||||
*/
|
||||
abstract class BaseExternalEntryClaimedAction
|
||||
{
|
||||
/**
|
||||
* The JSON response of the external API.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
protected ?array $data = null;
|
||||
|
||||
/**
|
||||
* The id of the external user.
|
||||
*/
|
||||
protected ?int $userId = null;
|
||||
|
||||
public function __construct(protected ExternalToken $token) {}
|
||||
|
||||
public function getUserId(): ?int
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
protected function getToken(): string
|
||||
{
|
||||
return Crypt::decrypt($this->token->access_token);
|
||||
}
|
||||
|
||||
abstract public function getUsername(): ?string;
|
||||
|
||||
/**
|
||||
* Get the entries of the response.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
abstract public function getEntries(): array;
|
||||
|
||||
/**
|
||||
* @throws RequestException
|
||||
*/
|
||||
abstract protected function makeRequest(): void;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Entry;
|
||||
|
||||
use App\Models\List\ExternalProfile;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
/**
|
||||
* This action will create the entries through the username method.
|
||||
*/
|
||||
abstract class BaseExternalEntryUnclaimedAction
|
||||
{
|
||||
/**
|
||||
* The JSON response of the external API.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
protected ?array $data = null;
|
||||
|
||||
public function __construct(protected ExternalProfile|array $profile) {}
|
||||
|
||||
public function getUsername(): string
|
||||
{
|
||||
if ($this->profile instanceof ExternalProfile) {
|
||||
return $this->profile->name;
|
||||
}
|
||||
|
||||
return Arr::get($this->profile, ExternalProfile::ATTRIBUTE_NAME);
|
||||
}
|
||||
|
||||
abstract public function getUserId(): ?int;
|
||||
|
||||
/**
|
||||
* Get the entries of the response.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
abstract public function getEntries(): array;
|
||||
|
||||
/**
|
||||
* @throws RequestException
|
||||
*/
|
||||
abstract protected function makeRequest(): void;
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Entry\Claimed;
|
||||
|
||||
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
|
||||
use App\Enums\Models\List\ExternalEntryStatus;
|
||||
use App\Models\List\External\ExternalEntry;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AnilistExternalEntryClaimedAction extends BaseExternalEntryClaimedAction
|
||||
{
|
||||
/**
|
||||
* Get the entries of the response.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getEntries(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if ($this->data === null) {
|
||||
$this->makeRequest();
|
||||
}
|
||||
|
||||
if ($data = $this->data) {
|
||||
$lists = Arr::where(Arr::get($data, 'MediaListCollection.lists'), fn ($value): bool => $value['isCustomList'] === false);
|
||||
|
||||
foreach ($lists as $list) {
|
||||
foreach (Arr::get($list, 'entries') as $entry) {
|
||||
$entryId = intval(Arr::get($entry, 'media.id'));
|
||||
$entries[] = [
|
||||
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $entryId,
|
||||
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, 'score'),
|
||||
ExternalEntry::ATTRIBUTE_STATUS => ExternalEntryStatus::getAnilistMapping(Arr::get($entry, 'status'))->value,
|
||||
ExternalEntry::ATTRIBUTE_IS_FAVORITE => Arr::get($entry, 'media.isFavourite'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
if ($this->data === null) {
|
||||
$this->makeRequest();
|
||||
}
|
||||
|
||||
return Arr::get($this->data, 'Viewer.name');
|
||||
}
|
||||
|
||||
public function getUserId(): ?int
|
||||
{
|
||||
if ($this->userId !== null) {
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
[, $payload] = explode('.', $this->getToken());
|
||||
|
||||
$decodedArray = json_decode(base64_decode($payload), true);
|
||||
|
||||
$this->userId = intval(Arr::get($decodedArray, 'sub'));
|
||||
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function makeRequest(): void
|
||||
{
|
||||
try {
|
||||
$query = '
|
||||
query($userId: Int) {
|
||||
Viewer {
|
||||
name
|
||||
}
|
||||
MediaListCollection(userId: $userId, type: ANIME) {
|
||||
lists {
|
||||
name
|
||||
status
|
||||
isCustomList
|
||||
entries {
|
||||
status
|
||||
score
|
||||
media {
|
||||
id
|
||||
isFavourite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'userId' => $this->getUserId(),
|
||||
];
|
||||
|
||||
$this->data = Http::withToken($this->getToken())
|
||||
->post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json('data');
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Entry\Claimed;
|
||||
|
||||
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
|
||||
use App\Enums\Models\List\ExternalEntryStatus;
|
||||
use App\Enums\Models\List\ExternalProfileSite;
|
||||
use App\Models\List\External\ExternalEntry;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class MalExternalEntryClaimedAction extends BaseExternalEntryClaimedAction
|
||||
{
|
||||
/**
|
||||
* The JSON response of the user endpoint in external API.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
protected ?array $userData = null;
|
||||
|
||||
/**
|
||||
* Get the entries of the response.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getEntries(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if ($this->data === null) {
|
||||
$this->makeRequest();
|
||||
}
|
||||
|
||||
if ($data = $this->data) {
|
||||
foreach ($data as $info) {
|
||||
$animeInfo = Arr::get($info, 'node');
|
||||
$listStatus = Arr::get($info, 'list_status');
|
||||
|
||||
$status = Arr::get($listStatus, 'is_rewatching')
|
||||
? 'rewatching'
|
||||
: Arr::get($listStatus, 'status');
|
||||
|
||||
$entries[] = [
|
||||
ExternalResource::ATTRIBUTE_EXTERNAL_ID => Arr::get($animeInfo, 'id'),
|
||||
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($listStatus, 'score'),
|
||||
ExternalEntry::ATTRIBUTE_STATUS => ExternalEntryStatus::getMalMapping($status)->value,
|
||||
ExternalEntry::ATTRIBUTE_IS_FAVORITE => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
if ($this->userData === null) {
|
||||
$this->makeUserRequest();
|
||||
}
|
||||
|
||||
return Arr::get($this->userData, 'name');
|
||||
}
|
||||
|
||||
public function getUserId(): ?int
|
||||
{
|
||||
if ($this->data === null) {
|
||||
$this->makeUserRequest();
|
||||
}
|
||||
|
||||
return Arr::get($this->userData, 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function makeUserRequest(): void
|
||||
{
|
||||
try {
|
||||
$this->userData = Http::withToken($this->getToken())
|
||||
->get('https://api.myanimelist.net/v2/users/@me')
|
||||
->throw()
|
||||
->json();
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function makeRequest(): void
|
||||
{
|
||||
$next = null;
|
||||
|
||||
try {
|
||||
do {
|
||||
$response = Http::withToken($this->getToken())
|
||||
->get($next ?? 'https://api.myanimelist.net/v2/users/@me/animelist?fields=list_status&limit=1000')
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$this->data = array_merge($this->data ?? [], Arr::get($response, 'data'));
|
||||
|
||||
$next = Arr::get($response, 'paging.next');
|
||||
|
||||
// TODO: test
|
||||
RateLimiter::hit(ExternalProfileSite::MAL->name);
|
||||
} while (filled($next));
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Entry\Unclaimed;
|
||||
|
||||
use App\Actions\Models\List\External\Entry\BaseExternalEntryUnclaimedAction;
|
||||
use App\Enums\Models\List\ExternalEntryStatus;
|
||||
use App\Models\List\External\ExternalEntry;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AnilistExternalEntryUnclaimedAction extends BaseExternalEntryUnclaimedAction
|
||||
{
|
||||
/**
|
||||
* Get the entries of the response.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getEntries(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if ($this->data === null) {
|
||||
$this->makeRequest();
|
||||
}
|
||||
|
||||
$favorites = Arr::map(Arr::get($this->data, 'User.favourites.anime.nodes'), fn ($value) => $value['id']);
|
||||
$lists = Arr::where(Arr::get($this->data, 'MediaListCollection.lists'), fn ($value): bool => $value['isCustomList'] === false);
|
||||
|
||||
foreach ($lists as $list) {
|
||||
foreach (Arr::get($list, 'entries') as $entry) {
|
||||
$entryId = intval(Arr::get($entry, 'media.id'));
|
||||
$entries[] = [
|
||||
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $entryId,
|
||||
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, 'score'),
|
||||
ExternalEntry::ATTRIBUTE_STATUS => ExternalEntryStatus::getAnilistMapping(Arr::get($entry, 'status'))->value,
|
||||
ExternalEntry::ATTRIBUTE_IS_FAVORITE => in_array($entryId, $favorites),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function getUserId(): ?int
|
||||
{
|
||||
if ($this->data === null) {
|
||||
$this->makeRequest();
|
||||
}
|
||||
|
||||
return Arr::get($this->data, 'User.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function makeRequest(): void
|
||||
{
|
||||
try {
|
||||
$query = '
|
||||
query($userName: String) {
|
||||
User(name: $userName) {
|
||||
id
|
||||
favourites {
|
||||
anime {
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MediaListCollection(userName: $userName, type: ANIME) {
|
||||
lists {
|
||||
name
|
||||
status
|
||||
isCustomList
|
||||
entries {
|
||||
status
|
||||
score
|
||||
media {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'userName' => $this->getUsername(),
|
||||
];
|
||||
|
||||
$this->data = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json('data');
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External;
|
||||
|
||||
use App\Actions\Http\Api\StoreAction;
|
||||
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
|
||||
use App\Actions\Models\List\External\Entry\Claimed\AnilistExternalEntryClaimedAction;
|
||||
use App\Actions\Models\List\External\Entry\Claimed\MalExternalEntryClaimedAction;
|
||||
use App\Enums\Models\List\ExternalProfileSite;
|
||||
use App\Enums\Models\List\ExternalProfileVisibility;
|
||||
use App\Models\List\External\ExternalToken;
|
||||
use App\Models\List\ExternalProfile;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class StoreExternalProfileClaimedAction
|
||||
{
|
||||
protected Collection $resources;
|
||||
|
||||
/**
|
||||
* Get the first record or store external profile given determined external token.
|
||||
*
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function firstOrCreate(ExternalToken $token, array $parameters): ExternalProfile
|
||||
{
|
||||
try {
|
||||
$site = ExternalProfileSite::fromLocalizedName(Arr::get($parameters, ExternalProfile::ATTRIBUTE_SITE));
|
||||
|
||||
$action = static::getActionClass($site, $token);
|
||||
|
||||
$userId = $action->getUserId();
|
||||
|
||||
return $this->firstForUserIdOrCreate($userId, $site, $action, $parameters);
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first record or create the profile for a userId and site.
|
||||
*/
|
||||
protected function firstForUserIdOrCreate(int $userId, ExternalProfileSite $site, BaseExternalEntryClaimedAction $action, array $parameters): ExternalProfile
|
||||
{
|
||||
$claimedProfile = ExternalProfile::query()
|
||||
->where(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID, $userId)
|
||||
->where(ExternalProfile::ATTRIBUTE_SITE, $site->value)
|
||||
->whereHas(ExternalProfile::RELATION_USER)
|
||||
->first();
|
||||
|
||||
if ($claimedProfile instanceof ExternalProfile) {
|
||||
return $claimedProfile;
|
||||
}
|
||||
|
||||
$unclaimedProfile = ExternalProfile::query()
|
||||
->where(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID, $userId)
|
||||
->where(ExternalProfile::ATTRIBUTE_SITE, $site->value)
|
||||
->whereDoesntHave(ExternalProfile::RELATION_USER)
|
||||
->first();
|
||||
|
||||
if ($unclaimedProfile instanceof ExternalProfile) {
|
||||
return tap(
|
||||
$unclaimedProfile,
|
||||
fn ($unclaimedProfile) => $unclaimedProfile->update([
|
||||
ExternalProfile::ATTRIBUTE_USER => Arr::integer($parameters, ExternalProfile::ATTRIBUTE_USER),
|
||||
ExternalProfile::ATTRIBUTE_NAME => $action->getUsername(),
|
||||
ExternalProfile::ATTRIBUTE_VISIBILITY => ExternalProfileVisibility::PRIVATE->value,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** @var StoreAction<ExternalProfile> $storeAction */
|
||||
$storeAction = new StoreAction();
|
||||
|
||||
return $storeAction->store(ExternalProfile::query(), [
|
||||
ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID => $userId,
|
||||
ExternalProfile::ATTRIBUTE_USER => Arr::integer($parameters, ExternalProfile::ATTRIBUTE_USER),
|
||||
ExternalProfile::ATTRIBUTE_NAME => $action->getUsername(),
|
||||
ExternalProfile::ATTRIBUTE_SITE => $site->value,
|
||||
ExternalProfile::ATTRIBUTE_VISIBILITY => ExternalProfileVisibility::PRIVATE->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the entries token class.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public static function getActionClass(ExternalProfileSite $site, ExternalToken $token): BaseExternalEntryClaimedAction
|
||||
{
|
||||
return match ($site) {
|
||||
ExternalProfileSite::ANILIST => new AnilistExternalEntryClaimedAction($token),
|
||||
ExternalProfileSite::MAL => new MalExternalEntryClaimedAction($token),
|
||||
default => throw new RuntimeException("External entry token action not configured for site {$site->localize()}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External;
|
||||
|
||||
use App\Actions\Http\Api\StoreAction;
|
||||
use App\Actions\Models\List\External\Entry\BaseExternalEntryUnclaimedAction;
|
||||
use App\Actions\Models\List\External\Entry\Unclaimed\AnilistExternalEntryUnclaimedAction;
|
||||
use App\Enums\Models\List\ExternalProfileSite;
|
||||
use App\Enums\Models\List\ExternalProfileVisibility;
|
||||
use App\Models\List\ExternalProfile;
|
||||
use Exception;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class StoreExternalProfileUnclaimedAction
|
||||
{
|
||||
/**
|
||||
* Get the first record or store an external profile and its entries given determined username.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function firstOrCreate(Builder $builder, array $profileParameters): ExternalProfile
|
||||
{
|
||||
$name = Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_NAME);
|
||||
$site = ExternalProfileSite::fromLocalizedName(Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_SITE));
|
||||
$visibility = ExternalProfileVisibility::fromLocalizedName(Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_VISIBILITY));
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$findProfile = ExternalProfile::query()
|
||||
->where(ExternalProfile::ATTRIBUTE_NAME, $name)
|
||||
->where(ExternalProfile::ATTRIBUTE_SITE, $site->value)
|
||||
->first();
|
||||
|
||||
if ($findProfile instanceof ExternalProfile) {
|
||||
throw_if($findProfile->isClaimed(), AuthorizationException::class, "The external profile '{$findProfile->getName()}' is already claimed.");
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
return $findProfile;
|
||||
}
|
||||
|
||||
$action = static::getActionClass($site, $profileParameters);
|
||||
|
||||
/** @var StoreAction<ExternalProfile> $storeAction */
|
||||
$storeAction = new StoreAction();
|
||||
|
||||
$profile = $storeAction->store($builder, [
|
||||
ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID => $action->getUserId(),
|
||||
ExternalProfile::ATTRIBUTE_NAME => $name,
|
||||
ExternalProfile::ATTRIBUTE_SITE => $site->value,
|
||||
ExternalProfile::ATTRIBUTE_VISIBILITY => $visibility->value,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $profile;
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the entries class.
|
||||
*
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public static function getActionClass(ExternalProfileSite $site, ExternalProfile|array $profile): BaseExternalEntryUnclaimedAction
|
||||
{
|
||||
return match ($site) {
|
||||
ExternalProfileSite::ANILIST => new AnilistExternalEntryUnclaimedAction($profile),
|
||||
default => throw new RuntimeException("External entry action not configured for site {$site->localize()}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External;
|
||||
|
||||
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
|
||||
use App\Actions\Models\List\External\Entry\BaseExternalEntryUnclaimedAction;
|
||||
use App\Enums\Models\List\ExternalProfileSite;
|
||||
use App\Events\List\ExternalProfile\ExternalProfileSynced;
|
||||
use App\Models\List\External\ExternalEntry;
|
||||
use App\Models\List\ExternalProfile;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SyncExternalProfileAction
|
||||
{
|
||||
protected Collection $resources;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(ExternalProfile $profile): ExternalProfile
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// A claimed profile will be synced using its external token.
|
||||
$action = $profile->isClaimed()
|
||||
? $this->getClaimedActionClass($profile)
|
||||
: $this->getUnclaimedActionClass($profile);
|
||||
|
||||
$entries = $action->getEntries();
|
||||
|
||||
$this->cacheResources($profile->site);
|
||||
|
||||
$externalEntries = [];
|
||||
foreach ($entries as $entry) {
|
||||
$externalId = Arr::get($entry, 'external_id');
|
||||
|
||||
foreach ($this->getAnimesByExternalId($externalId) as $anime) {
|
||||
$externalEntries[] = [
|
||||
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, ExternalEntry::ATTRIBUTE_SCORE),
|
||||
ExternalEntry::ATTRIBUTE_IS_FAVORITE => Arr::get($entry, ExternalEntry::ATTRIBUTE_IS_FAVORITE),
|
||||
ExternalEntry::ATTRIBUTE_STATUS => Arr::get($entry, ExternalEntry::ATTRIBUTE_STATUS),
|
||||
ExternalEntry::ATTRIBUTE_ANIME => $anime,
|
||||
ExternalEntry::ATTRIBUTE_PROFILE => $profile->getKey(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the external entries or update its status filtering by anime id.
|
||||
ExternalEntry::query()
|
||||
->upsert(
|
||||
$externalEntries,
|
||||
[ExternalEntry::ATTRIBUTE_ANIME, ExternalEntry::ATTRIBUTE_PROFILE],
|
||||
ExternalEntry::fieldsForUpdate(),
|
||||
);
|
||||
|
||||
// An external entry that is not in the external list anymore should be removed.
|
||||
ExternalEntry::query()
|
||||
->where(ExternalEntry::ATTRIBUTE_PROFILE, $profile->getKey())
|
||||
->whereNotIn(ExternalEntry::ATTRIBUTE_ANIME, Arr::map($externalEntries, fn ($value) => $value[ExternalEntry::ATTRIBUTE_ANIME]))
|
||||
->delete();
|
||||
|
||||
$profile->update([ExternalProfile::ATTRIBUTE_SYNCED_AT => now()]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
ExternalProfileSynced::dispatch($profile);
|
||||
|
||||
return $profile;
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the entries token class.
|
||||
*/
|
||||
protected function getClaimedActionClass(ExternalProfile $profile): BaseExternalEntryClaimedAction
|
||||
{
|
||||
return StoreExternalProfileClaimedAction::getActionClass($profile->site, $profile->externaltoken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the entries class.
|
||||
*/
|
||||
protected function getUnclaimedActionClass(ExternalProfile $profile): BaseExternalEntryUnclaimedAction
|
||||
{
|
||||
return StoreExternalProfileUnclaimedAction::getActionClass($profile->site, $profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the resources for performance proposals.
|
||||
*/
|
||||
protected function cacheResources(ExternalProfileSite $profileSite): void
|
||||
{
|
||||
// External resources are only added by mods so it doesn't change too often.
|
||||
$this->resources = Cache::flexible("resources_{$profileSite->name}", [60, 300], fn () => ExternalResource::query()
|
||||
->where(ExternalResource::ATTRIBUTE_SITE, $profileSite->getResourceSite()->value)
|
||||
->with([ExternalResource::RELATION_ANIME => fn ($query) => $query->select([Anime::TABLE.'.'.Anime::ATTRIBUTE_ID])])
|
||||
->whereHas(ExternalResource::RELATION_ANIME)
|
||||
->get()
|
||||
->mapWithKeys(fn (ExternalResource $resource): array => [$resource->external_id => $resource->anime->map(fn (Anime $anime) => $anime->getKey())]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
protected function getAnimesByExternalId(int $externalId): Collection
|
||||
{
|
||||
return $this->resources->get($externalId) ?? collect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Token;
|
||||
|
||||
use App\Models\List\External\ExternalToken;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
|
||||
abstract class BaseExternalTokenAction
|
||||
{
|
||||
/**
|
||||
* Use the authorization code to get the tokens and store them.
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
abstract public function store(array $parameters): ExternalToken;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Token\Site;
|
||||
|
||||
use App\Actions\Models\List\External\Token\BaseExternalTokenAction;
|
||||
use App\Constants\Config\ServiceConstants;
|
||||
use App\Models\List\External\ExternalToken;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AnilistExternalTokenAction extends BaseExternalTokenAction
|
||||
{
|
||||
/**
|
||||
* Use the authorization code to get the tokens and store them.
|
||||
*
|
||||
* @param array{code: string} $parameters
|
||||
*/
|
||||
public function store(array $parameters): ExternalToken
|
||||
{
|
||||
$code = Arr::string($parameters, 'code');
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->acceptJson()
|
||||
->post('https://anilist.co/api/v2/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => Config::get(ServiceConstants::ANILIST_CLIENT_ID),
|
||||
'client_secret' => Config::get(ServiceConstants::ANILIST_CLIENT_SECRET),
|
||||
'redirect_uri' => Config::get(ServiceConstants::ANILIST_REDIRECT_URI),
|
||||
'code' => $code,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$token = Arr::string($response, 'access_token');
|
||||
|
||||
return ExternalToken::query()->create([
|
||||
ExternalToken::ATTRIBUTE_ACCESS_TOKEN => Crypt::encrypt($token),
|
||||
]);
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\External\Token\Site;
|
||||
|
||||
use App\Actions\Models\List\External\Token\BaseExternalTokenAction;
|
||||
use App\Constants\Config\ServiceConstants;
|
||||
use App\Models\List\External\ExternalToken;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MalExternalTokenAction extends BaseExternalTokenAction
|
||||
{
|
||||
/**
|
||||
* Use the authorization code to get the tokens and store them.
|
||||
*
|
||||
* @param array{code: string, state: string} $parameters
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function store(array $parameters): ExternalToken
|
||||
{
|
||||
$code = Arr::string($parameters, 'code');
|
||||
$state = Arr::string($parameters, 'state');
|
||||
|
||||
$codeVerifier = Cache::get("mal-external-token-request-{$state}");
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->acceptJson()
|
||||
->post('https://myanimelist.net/v1/oauth2/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => Config::string(ServiceConstants::MAL_CLIENT_ID),
|
||||
'client_secret' => Config::string(ServiceConstants::MAL_CLIENT_SECRET),
|
||||
'redirect_uri' => Config::string(ServiceConstants::MAL_REDIRECT_URI),
|
||||
'code' => $code,
|
||||
'code_verifier' => $codeVerifier,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$token = Arr::string($response, 'access_token');
|
||||
$refreshToken = Arr::string($response, 'refresh_token');
|
||||
|
||||
return ExternalToken::query()->create([
|
||||
ExternalToken::ATTRIBUTE_ACCESS_TOKEN => Crypt::encrypt($token),
|
||||
ExternalToken::ATTRIBUTE_REFRESH_TOKEN => Crypt::encrypt($refreshToken),
|
||||
]);
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
Cache::forget("mal-external-token-request-{$state}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List;
|
||||
|
||||
use App\Actions\Models\List\External\StoreExternalProfileClaimedAction;
|
||||
use App\Actions\Models\List\External\Token\BaseExternalTokenAction;
|
||||
use App\Actions\Models\List\External\Token\Site\AnilistExternalTokenAction;
|
||||
use App\Actions\Models\List\External\Token\Site\MalExternalTokenAction;
|
||||
use App\Enums\Models\List\ExternalProfileSite;
|
||||
use App\Models\List\External\ExternalToken;
|
||||
use App\Models\List\ExternalProfile;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class ExternalTokenCallbackAction
|
||||
{
|
||||
/**
|
||||
* We should store the token and the profile.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function store(array $parameters): ExternalProfile
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$site = ExternalProfileSite::fromLocalizedName(Arr::get($parameters, ExternalProfile::ATTRIBUTE_SITE));
|
||||
|
||||
$externalToken = ExternalToken::query()
|
||||
->whereRelation(ExternalToken::RELATION_PROFILE, ExternalProfile::ATTRIBUTE_USER, Arr::integer($parameters, ExternalProfile::ATTRIBUTE_USER))
|
||||
->whereRelation(ExternalToken::RELATION_PROFILE, ExternalProfile::ATTRIBUTE_SITE, $site->value)
|
||||
->first();
|
||||
|
||||
if (! $externalToken instanceof ExternalToken) {
|
||||
$externalToken = $this->getActionClass($site)->store($parameters);
|
||||
}
|
||||
|
||||
$profileAction = new StoreExternalProfileClaimedAction();
|
||||
|
||||
$profile = $profileAction->firstOrCreate($externalToken, $parameters);
|
||||
|
||||
$profile->externaltoken()->save($externalToken);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $profile;
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the token class.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
protected function getActionClass(ExternalProfileSite $site): BaseExternalTokenAction
|
||||
{
|
||||
return match ($site) {
|
||||
ExternalProfileSite::ANILIST => new AnilistExternalTokenAction(),
|
||||
ExternalProfileSite::MAL => new MalExternalTokenAction(),
|
||||
default => throw new RuntimeException("External token action not configured for site {$site->localize()}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\Playlist;
|
||||
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FixPlaylistAction
|
||||
{
|
||||
public function handle(Playlist $playlist, mixed $context = null): int
|
||||
{
|
||||
// Fetch all tracks in the playlist and index them by track_id
|
||||
$this->sendMessage("Fetching tracks for playlist ID: {$playlist->getKey()}...", $context, 'info');
|
||||
|
||||
/** @var Collection<int, PlaylistTrack> $tracks */
|
||||
$tracks = PlaylistTrack::query()->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
|
||||
->orderBy(PlaylistTrack::ATTRIBUTE_ID)
|
||||
->get()
|
||||
->keyBy(PlaylistTrack::ATTRIBUTE_ID);
|
||||
|
||||
// Find the playlist and get the first_id
|
||||
$this->sendMessage('Fetching playlist details...', $context, 'info');
|
||||
|
||||
$first_id = $playlist->first_id;
|
||||
|
||||
// Initialize arrays for ordered tracks and visited tracks
|
||||
$orderedTracks = [];
|
||||
$visitedTracks = [];
|
||||
|
||||
// Start at the first_id and follow the next_id chain
|
||||
$this->sendMessage('Reconstructing the playlist order...', $context, 'info');
|
||||
|
||||
$current_id = $first_id;
|
||||
|
||||
while ($current_id !== null) {
|
||||
if (isset($visitedTracks[$current_id])) {
|
||||
// If we encounter a previously visited track, we've detected a cycle
|
||||
$this->sendMessage("Cycle detected at track ID: $current_id, stopping...", $context, 'warn');
|
||||
break;
|
||||
}
|
||||
|
||||
// Mark the current track as visited and add to ordered tracks
|
||||
$visitedTracks[$current_id] = true;
|
||||
$orderedTracks[] = $current_id;
|
||||
|
||||
// Get the next track in sequence
|
||||
$currentTrack = $tracks[$current_id];
|
||||
$current_id = $currentTrack->next_id;
|
||||
}
|
||||
|
||||
// Check if we have traversed all tracks; if not, some tracks are broken
|
||||
if (count($orderedTracks) !== count($tracks)) {
|
||||
$this->sendMessage('Detected broken tracks, attempting to add them to the end of the list...', $context, 'warn');
|
||||
|
||||
// Find all tracks that were not visited (potentially broken)
|
||||
$remainingTracks = $tracks->reject(fn ($track): bool => isset($visitedTracks[$track->track_id]));
|
||||
|
||||
// Add remaining tracks to the end, preserving the existing order as much as possible
|
||||
foreach ($remainingTracks as $remainingTrack) {
|
||||
$orderedTracks[] = $remainingTrack->track_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the previous_id and next_id for all tracks in the correct order
|
||||
$this->sendMessage('Updating track links (previous_id and next_id)...', $context, 'info');
|
||||
DB::transaction(function () use ($orderedTracks, $tracks, $playlist, $context): void {
|
||||
$previous_id = null;
|
||||
|
||||
foreach ($orderedTracks as $index => $track_id) {
|
||||
$track = $tracks[$track_id];
|
||||
|
||||
// Get the next track in the sequence or set null if it's the last one
|
||||
$next_id = $orderedTracks[$index + 1] ?? null;
|
||||
|
||||
// Update the previous_id and next_id for the track
|
||||
$track->previous_id = $previous_id;
|
||||
$track->next_id = $next_id;
|
||||
$track->save();
|
||||
|
||||
// Move the previous_id pointer forward
|
||||
$previous_id = $track_id;
|
||||
}
|
||||
|
||||
// Update the playlist's first_id and last_id
|
||||
$playlist->first_id = $orderedTracks[0];
|
||||
$playlist->last_id = $orderedTracks[count($orderedTracks) - 1];
|
||||
$playlist->save();
|
||||
|
||||
$this->sendMessage('Playlist first_id and last_id updated successfully.', $context, 'info');
|
||||
});
|
||||
|
||||
$this->sendMessage('Playlist fixed successfully.', $context, 'info');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the message given the context.
|
||||
*/
|
||||
protected function sendMessage(string $message, mixed $context, string $type): void
|
||||
{
|
||||
if (is_null($context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$translatedType = match (true) {
|
||||
$context === 'log' => match ($type) {
|
||||
'warn' => 'warning',
|
||||
default => $type,
|
||||
},
|
||||
default => $type,
|
||||
};
|
||||
|
||||
match (true) {
|
||||
$context instanceof Command => $context->$translatedType($message),
|
||||
$context === 'log' => Log::$translatedType($message),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\Playlist;
|
||||
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InsertTrackAction
|
||||
{
|
||||
/**
|
||||
* Append track to playlist.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function insert(Playlist $playlist, PlaylistTrack $track): void
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
if ($playlist->first()->doesntExist()) {
|
||||
$playlist->first()->associate($track)->save();
|
||||
}
|
||||
|
||||
$last = $playlist->last;
|
||||
|
||||
$playlist->last()->associate($track)->save();
|
||||
|
||||
$last?->next()?->associate($track)?->save();
|
||||
$track->previous()->associate($last);
|
||||
|
||||
$track->next()->disassociate()->save();
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\Playlist;
|
||||
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InsertTrackAfterAction
|
||||
{
|
||||
/**
|
||||
* Insert track after previous track.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function insertAfter(Playlist $playlist, PlaylistTrack $track, PlaylistTrack $previous): void
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
if ($playlist->last()->is($previous)) {
|
||||
$playlist->last()->associate($track)->save();
|
||||
}
|
||||
|
||||
$next = $previous->next;
|
||||
$next?->previous()?->associate($track)?->save();
|
||||
$track->next()->associate($next);
|
||||
|
||||
$previous->next()->associate($track)->save();
|
||||
$track->previous()->associate($previous)->save();
|
||||
|
||||
$track->moveAfter($previous);
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\Playlist;
|
||||
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InsertTrackBeforeAction
|
||||
{
|
||||
/**
|
||||
* Insert track before next track.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function insertBefore(Playlist $playlist, PlaylistTrack $track, PlaylistTrack $next): void
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
if ($playlist->first()->is($next)) {
|
||||
$playlist->first()->associate($track)->save();
|
||||
}
|
||||
|
||||
$previous = $next->previous;
|
||||
$previous?->next()?->associate($track)?->save();
|
||||
$track->previous()->associate($previous);
|
||||
|
||||
$next->previous()->associate($track)->save();
|
||||
$track->next()->associate($next)->save();
|
||||
|
||||
$track->moveBefore($next);
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\List\Playlist;
|
||||
|
||||
use App\Models\List\Playlist;
|
||||
use App\Models\List\Playlist\PlaylistTrack;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RemoveTrackAction
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function remove(Playlist $playlist, PlaylistTrack $track): void
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$previous = $track->previous;
|
||||
$next = $track->next;
|
||||
|
||||
if ($playlist->first()->is($track)) {
|
||||
$playlist->first()->associate($next);
|
||||
}
|
||||
if ($playlist->last()->is($track)) {
|
||||
$playlist->last()->associate($previous);
|
||||
}
|
||||
$playlist->save();
|
||||
|
||||
$previous?->next()?->associate($next)?->save();
|
||||
$next?->previous()?->associate($previous)?->save();
|
||||
|
||||
$track->previous()->disassociate();
|
||||
$track->next()->disassociate()->save();
|
||||
|
||||
PlaylistTrack::query()
|
||||
->whereBelongsTo($playlist)
|
||||
->where(PlaylistTrack::ATTRIBUTE_POSITION, '>', $track->position)
|
||||
->decrement(PlaylistTrack::ATTRIBUTE_POSITION);
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class AnimeDateAction
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Anime> $anime
|
||||
*/
|
||||
public function handle(Collection $anime): ActionResult
|
||||
{
|
||||
$anilistIds = $anime
|
||||
->map(fn (Anime $anime) => $anime->resources->where(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value)->first()?->external_id)
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$query = <<<'GRAPHQL'
|
||||
query AnimeDates($ids: [Int]) {
|
||||
Page(perPage: 20) {
|
||||
media(id_in: $ids, type: ANIME) {
|
||||
id
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GRAPHQL;
|
||||
|
||||
$response = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'ids' => $anilistIds,
|
||||
],
|
||||
])
|
||||
->throw();
|
||||
|
||||
if (! $response->ok() || $response->json('errors')) {
|
||||
return new ActionResult(ActionStatus::FAILED, $response->json('errors.0.message'));
|
||||
}
|
||||
|
||||
foreach ($response->json('data.Page.media') as $media) {
|
||||
$animeToUpdate = $anime->first(
|
||||
fn (Anime $anime): bool => $anime->resources->where(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value)->first()?->external_id === Arr::integer($media, 'id')
|
||||
);
|
||||
|
||||
$animeToUpdate?->update([
|
||||
'start_date' => Arr::get($media, 'startDate'),
|
||||
'end_date' => Arr::get($media, 'endDate'),
|
||||
]);
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime;
|
||||
|
||||
use App\Actions\Models\Wiki\BackfillImageAction;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\Image;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillAnimeImageAction.
|
||||
*
|
||||
* @extends BackfillImageAction<Anime>
|
||||
*/
|
||||
abstract class BackfillAnimeImageAction extends BackfillImageAction
|
||||
{
|
||||
/**
|
||||
* Create a new action instance.
|
||||
*
|
||||
* @param Anime $anime
|
||||
*/
|
||||
public function __construct(Anime $anime)
|
||||
{
|
||||
parent::__construct($anime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*
|
||||
* @return Anime
|
||||
*/
|
||||
protected function getModel(): Anime
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation to images.
|
||||
*
|
||||
* @return BelongsToMany
|
||||
*/
|
||||
protected function relation(): BelongsToMany
|
||||
{
|
||||
return $this->getModel()->images();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach Image to Anime.
|
||||
*
|
||||
* @param Image $image
|
||||
* @return void
|
||||
*/
|
||||
protected function attachImage(Image $image): void
|
||||
{
|
||||
Log::info("Attaching Image '{$image->getName()}' to {$this->label()} '{$this->getModel()->getName()}'");
|
||||
$this->relation()->attach($image);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime;
|
||||
|
||||
use App\Actions\Models\Wiki\BackfillResourceAction;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Pivots\AnimeResource;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillAnimeResourceAction.
|
||||
*
|
||||
* @extends BackfillResourceAction<Anime>
|
||||
*/
|
||||
abstract class BackfillAnimeResourceAction extends BackfillResourceAction
|
||||
{
|
||||
/**
|
||||
* Create a new action instance.
|
||||
*
|
||||
* @param Anime $anime
|
||||
*/
|
||||
public function __construct(Anime $anime)
|
||||
{
|
||||
parent::__construct($anime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*
|
||||
* @return Anime
|
||||
*/
|
||||
protected function getModel(): Anime
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation to images.
|
||||
*
|
||||
* @return BelongsToMany
|
||||
*/
|
||||
protected function relation(): BelongsToMany
|
||||
{
|
||||
return $this->getModel()->resources();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or Create Resource from response.
|
||||
*
|
||||
* @param int $id
|
||||
* @param string|null $slug
|
||||
* @return ExternalResource
|
||||
*/
|
||||
protected function getOrCreateResource(int $id, string $slug = null): ExternalResource
|
||||
{
|
||||
$resource = ExternalResource::query()
|
||||
->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)
|
||||
->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id)
|
||||
->where(ExternalResource::ATTRIBUTE_LINK, ResourceSite::formatAnimeResourceLink($this->getSite(), $id, $slug))
|
||||
->first();
|
||||
|
||||
if ($resource === null) {
|
||||
Log::info("Creating {$this->getSite()->description} Resource '$id'");
|
||||
|
||||
$resource = ExternalResource::query()->create([
|
||||
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $id,
|
||||
ExternalResource::ATTRIBUTE_LINK => ResourceSite::formatAnimeResourceLink($this->getSite(), $id, $slug),
|
||||
ExternalResource::ATTRIBUTE_SITE => $this->getSite()->value,
|
||||
]);
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach Resource to Anime.
|
||||
*
|
||||
* @param ExternalResource $resource
|
||||
* @return void
|
||||
*/
|
||||
protected function attachResource(ExternalResource $resource): void
|
||||
{
|
||||
if (AnimeResource::query()
|
||||
->where($this->getModel()->getKeyName(), $this->getModel()->getKey())
|
||||
->where($resource->getKeyName(), $resource->getKey())
|
||||
->doesntExist()
|
||||
) {
|
||||
Log::info("Attaching Resource '{$resource->getName()}' to {$this->label()} '{$this->getModel()->getName()}'");
|
||||
$this->relation()->attach($resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
|
||||
|
||||
use App\Actions\Models\Wiki\ExternalApiAction;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillResources;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AniZipAnimeExternalApiAction extends ExternalApiAction implements BackfillResources
|
||||
{
|
||||
public function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::ANILIST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the response after the request.
|
||||
*
|
||||
* @param BelongsToMany<ExternalResource, Anime> $resources
|
||||
*/
|
||||
public function handle(BelongsToMany $resources): static
|
||||
{
|
||||
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value);
|
||||
|
||||
if ($resource instanceof ExternalResource) {
|
||||
$id = $resource->external_id;
|
||||
|
||||
try {
|
||||
$this->response = Http::get("https://api.ani.zip/v1/mappings?anilist_id=$id")
|
||||
->throw()
|
||||
->json();
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapped resources.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getResources(): array
|
||||
{
|
||||
$resources = [];
|
||||
|
||||
if ($links = $this->response) {
|
||||
foreach ($this->getResourcesMapping() as $site => $key) {
|
||||
$id = Arr::get($links, $key);
|
||||
|
||||
if ($id !== null) {
|
||||
$resources[$site] = ResourceSite::from($site)
|
||||
->formatResourceLink(
|
||||
Anime::class,
|
||||
is_numeric($id) ? (int) $id : null,
|
||||
is_string($id) ? $id : null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available sites to backfill.
|
||||
*/
|
||||
public function getResourcesMapping(): array
|
||||
{
|
||||
return [
|
||||
ResourceSite::ANIME_PLANET->value => 'animeplanet_id',
|
||||
ResourceSite::ANIDB->value => 'anidb_id',
|
||||
ResourceSite::ANILIST->value => 'anilist_id',
|
||||
ResourceSite::KITSU->value => 'kitsu_id',
|
||||
ResourceSite::LIVECHART->value => 'livechart_id',
|
||||
ResourceSite::MAL->value => 'mal_id',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
|
||||
|
||||
use App\Actions\Models\Wiki\ExternalApiAction;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillImages;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillResources;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillSynonyms;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Enums\Models\Wiki\SynonymType;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AnilistAnimeExternalApiAction extends ExternalApiAction implements BackfillImages, BackfillResources, BackfillSynonyms
|
||||
{
|
||||
public function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::ANILIST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the response after the request.
|
||||
*
|
||||
* @param BelongsToMany<ExternalResource, Anime> $resources
|
||||
*/
|
||||
public function handle(BelongsToMany $resources): static
|
||||
{
|
||||
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value);
|
||||
|
||||
if ($resource instanceof ExternalResource) {
|
||||
$query = '
|
||||
query ($id: Int) {
|
||||
Media (id: $id, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
extraLarge
|
||||
medium
|
||||
}
|
||||
externalLinks {
|
||||
url
|
||||
site
|
||||
language
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $resource->external_id,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->response = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapped resources.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getResources(): array
|
||||
{
|
||||
$resources = [];
|
||||
|
||||
if ($response = $this->response) {
|
||||
foreach (Arr::get($response, 'data.Media.externalLinks') as $link) {
|
||||
$url = Arr::get($link, 'url');
|
||||
$siteAnilist = Arr::get($link, 'site');
|
||||
$language = Arr::get($link, 'language');
|
||||
|
||||
foreach ($this->getResourcesMapping() as $site => $key) {
|
||||
if ($siteAnilist === $key) {
|
||||
if (in_array($siteAnilist, ['Official Site', 'Twitter']) && ! in_array($language, ['Japanese', null])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resources[$site] = $url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available sites to backfill.
|
||||
*/
|
||||
public function getResourcesMapping(): array
|
||||
{
|
||||
return [
|
||||
ResourceSite::X->value => 'Twitter',
|
||||
ResourceSite::OFFICIAL_SITE->value => 'Official Site',
|
||||
ResourceSite::NETFLIX->value => 'Netflix',
|
||||
ResourceSite::CRUNCHYROLL->value => 'Crunchyroll',
|
||||
ResourceSite::HIDIVE->value => 'HIDIVE',
|
||||
ResourceSite::AMAZON_PRIME_VIDEO->value => 'Amazon Prime Video',
|
||||
ResourceSite::HULU->value => 'Hulu',
|
||||
ResourceSite::DISNEY_PLUS->value => 'Disney Plus',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the images.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getImagesMapping(): array
|
||||
{
|
||||
return [
|
||||
ImageFacet::SMALL_COVER->value => 'data.Media.coverImage.medium',
|
||||
ImageFacet::LARGE_COVER->value => 'data.Media.coverImage.extraLarge',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the synonyms.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getSynonymsMapping(): array
|
||||
{
|
||||
return [
|
||||
SynonymType::ENGLISH->value => 'data.Media.title.english',
|
||||
SynonymType::NATIVE->value => 'data.Media.title.native',
|
||||
SynonymType::OTHER->value => 'data.Media.title.romaji',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
|
||||
|
||||
use App\Actions\Models\Wiki\ExternalApiAction;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillResources;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JikanAnimeExternalApiAction extends ExternalApiAction implements BackfillResources
|
||||
{
|
||||
public function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::MAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the response after the request.
|
||||
*
|
||||
* @param BelongsToMany<ExternalResource, Anime> $resources
|
||||
*/
|
||||
public function handle(BelongsToMany $resources): static
|
||||
{
|
||||
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL->value);
|
||||
|
||||
if ($resource instanceof ExternalResource) {
|
||||
$id = $resource->external_id;
|
||||
|
||||
try {
|
||||
$this->response = Http::get("https://api.jikan.moe/v4/anime/$id/external")
|
||||
->throw()
|
||||
->json();
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapped resources.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getResources(): array
|
||||
{
|
||||
$resources = [];
|
||||
|
||||
$reversed = array_flip($this->getResourcesMapping());
|
||||
|
||||
if ($response = $this->response) {
|
||||
$links = Arr::get($response, 'data');
|
||||
|
||||
foreach ($links as $link) {
|
||||
$siteMal = Arr::get($link, 'name');
|
||||
$url = Arr::get($link, 'url');
|
||||
|
||||
foreach ($this->getResourcesMapping() as $site => $key) {
|
||||
if ($siteMal === $key) {
|
||||
$resourceSite = ResourceSite::from(Arr::get($reversed, $siteMal));
|
||||
$resources[$site] = $resourceSite->formatResourceLink(Anime::class, intval(ResourceSite::parseIdFromLink($url)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available sites to backfill.
|
||||
*/
|
||||
public function getResourcesMapping(): array
|
||||
{
|
||||
return [
|
||||
ResourceSite::ANIDB->value => 'AniDB',
|
||||
ResourceSite::ANN->value => 'ANN',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
|
||||
|
||||
use App\Actions\Models\Wiki\ExternalApiAction;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillResources;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LivechartAnimeExternalApiAction extends ExternalApiAction implements BackfillResources
|
||||
{
|
||||
public function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::LIVECHART;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the response after the request.
|
||||
*
|
||||
* @param BelongsToMany<ExternalResource, Anime> $resources
|
||||
*/
|
||||
public function handle(BelongsToMany $resources): static
|
||||
{
|
||||
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::LIVECHART->value);
|
||||
|
||||
if ($resource instanceof ExternalResource) {
|
||||
$id = $resource->external_id;
|
||||
|
||||
try {
|
||||
$this->response = Http::get("https://www.livechart.me/api/v1/anime/$id")
|
||||
->throw()
|
||||
->json();
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the resources.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getResourcesMapping(): array
|
||||
{
|
||||
return [
|
||||
ResourceSite::ANIDB->value => 'anidb_url',
|
||||
ResourceSite::ANILIST->value => 'anilist_url',
|
||||
ResourceSite::ANIME_PLANET->value => 'anime_planet_url',
|
||||
ResourceSite::ANN->value => 'ann_url',
|
||||
ResourceSite::KITSU->value => 'kitsu_url',
|
||||
ResourceSite::MAL->value => 'mal_url',
|
||||
ResourceSite::OFFICIAL_SITE->value => 'website_url',
|
||||
ResourceSite::X->value => 'twitter_url',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
|
||||
|
||||
use App\Actions\Models\Wiki\ExternalApiAction;
|
||||
use App\Constants\Config\ServiceConstants;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillStudios;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MalAnimeExternalApiAction extends ExternalApiAction implements BackfillStudios
|
||||
{
|
||||
public function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::MAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the response after the request.
|
||||
*
|
||||
* @param BelongsToMany<ExternalResource, Anime> $resources
|
||||
*/
|
||||
public function handle(BelongsToMany $resources): static
|
||||
{
|
||||
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL->value);
|
||||
|
||||
if ($resource instanceof ExternalResource) {
|
||||
try {
|
||||
$this->response = Http::withHeaders(['X-MAL-CLIENT-ID' => Config::get(ServiceConstants::MAL_CLIENT_ID)])
|
||||
->get("https://api.myanimelist.net/v2/anime/$resource->external_id", [
|
||||
'fields' => 'studios',
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
} catch (RequestException $e) {
|
||||
Log::error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapped studios.
|
||||
*/
|
||||
public function getStudios(): array
|
||||
{
|
||||
return Arr::get($this->response, 'studios', []);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Image;
|
||||
|
||||
use App\Actions\Models\Wiki\Anime\BackfillAnimeImageAction;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Models\Wiki\Image;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Class BackfillLargeCoverImageAction.
|
||||
*/
|
||||
class BackfillLargeCoverImageAction extends BackfillAnimeImageAction
|
||||
{
|
||||
/**
|
||||
* Get the facet to backfill.
|
||||
*
|
||||
* @return ImageFacet
|
||||
*/
|
||||
protected function getFacet(): ImageFacet
|
||||
{
|
||||
return ImageFacet::COVER_LARGE();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Image.
|
||||
*
|
||||
* @return Image|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getImage(): ?Image
|
||||
{
|
||||
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
|
||||
if ($anilistResource instanceof ExternalResource) {
|
||||
return $this->getAnilistImage($anilistResource);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Anilist API for large cover image.
|
||||
*
|
||||
* @param ExternalResource $anilistResource
|
||||
* @return Image|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getAnilistImage(ExternalResource $anilistResource): ?Image
|
||||
{
|
||||
$query = '
|
||||
query ($id: Int) {
|
||||
Media (id: $id, type: ANIME) {
|
||||
coverImage {
|
||||
extraLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $anilistResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$anilistCoverLarge = Arr::get($response, 'data.Media.coverImage.extraLarge');
|
||||
if ($anilistCoverLarge !== null) {
|
||||
return $this->createImage($anilistCoverLarge);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Image;
|
||||
|
||||
use App\Actions\Models\Wiki\Anime\BackfillAnimeImageAction;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Models\Wiki\Image;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Class BackfillSmallCoverImageAction.
|
||||
*/
|
||||
class BackfillSmallCoverImageAction extends BackfillAnimeImageAction
|
||||
{
|
||||
/**
|
||||
* Get the facet to backfill.
|
||||
*
|
||||
* @return ImageFacet
|
||||
*/
|
||||
protected function getFacet(): ImageFacet
|
||||
{
|
||||
return ImageFacet::COVER_SMALL();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Image.
|
||||
*
|
||||
* @return Image|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getImage(): ?Image
|
||||
{
|
||||
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
|
||||
if ($anilistResource instanceof ExternalResource) {
|
||||
return $this->getAnilistImage($anilistResource);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Anilist API for large cover image.
|
||||
*
|
||||
* @param ExternalResource $anilistResource
|
||||
* @return Image|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getAnilistImage(ExternalResource $anilistResource): ?Image
|
||||
{
|
||||
$query = '
|
||||
query ($id: Int) {
|
||||
Media (id: $id, type: ANIME) {
|
||||
coverImage {
|
||||
medium
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $anilistResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$anilistCoverSmall = Arr::get($response, 'data.Media.coverImage.medium');
|
||||
if ($anilistCoverSmall !== null) {
|
||||
return $this->createImage($anilistCoverSmall);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Resource;
|
||||
|
||||
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Class BackfillAnidbResourceAction.
|
||||
*/
|
||||
class BackfillAnidbResourceAction extends BackfillAnimeResourceAction
|
||||
{
|
||||
/**
|
||||
* Get the site to backfill.
|
||||
*
|
||||
* @return ResourceSite
|
||||
*/
|
||||
protected function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::ANIDB();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Resource mapping.
|
||||
*
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getResource(): ?ExternalResource
|
||||
{
|
||||
// Allow fall-throughs in case AniDB Resource is not mapped to every external site.
|
||||
|
||||
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
|
||||
if ($malResource instanceof ExternalResource) {
|
||||
$anidbResource = $this->getAnidbMapping($malResource, 'myanimelist');
|
||||
if ($anidbResource !== null) {
|
||||
return $anidbResource;
|
||||
}
|
||||
|
||||
// failed mapping, sleep before re-attempting
|
||||
sleep(rand(1, 3));
|
||||
}
|
||||
|
||||
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
|
||||
if ($anilistResource instanceof ExternalResource) {
|
||||
$anidbResource = $this->getAnidbMapping($anilistResource, 'anilist');
|
||||
if ($anidbResource !== null) {
|
||||
return $anidbResource;
|
||||
}
|
||||
|
||||
// failed mapping, sleep before re-attempting
|
||||
sleep(rand(1, 3));
|
||||
}
|
||||
|
||||
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
|
||||
if ($kitsuResource instanceof ExternalResource) {
|
||||
return $this->getAnidbMapping($kitsuResource, 'kitsu');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Yuna API for AniDB mapping.
|
||||
*
|
||||
* @param ExternalResource $resource
|
||||
* @param string $source
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getAnidbMapping(ExternalResource $resource, string $source): ?ExternalResource
|
||||
{
|
||||
$response = Http::get('https://relations.yuna.moe/api/ids', [
|
||||
'source' => $source,
|
||||
'id' => $resource->external_id,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$anidbId = Arr::get($response, 'anidb');
|
||||
|
||||
// Only proceed if we have a match
|
||||
if ($anidbId !== null) {
|
||||
return $this->getOrCreateResource($anidbId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Resource;
|
||||
|
||||
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillAnilistResourceAction.
|
||||
*/
|
||||
class BackfillAnilistResourceAction extends BackfillAnimeResourceAction
|
||||
{
|
||||
/**
|
||||
* Get the site to backfill.
|
||||
*
|
||||
* @return ResourceSite
|
||||
*/
|
||||
protected function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::ANILIST();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Resource mapping.
|
||||
*
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getResource(): ?ExternalResource
|
||||
{
|
||||
// Allow fall-throughs in case Anilist Resource is not mapped to every external site.
|
||||
|
||||
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
|
||||
if ($malResource instanceof ExternalResource) {
|
||||
$anilistResource = $this->getMalAnilistMapping($malResource);
|
||||
if ($anilistResource !== null) {
|
||||
return $anilistResource;
|
||||
}
|
||||
}
|
||||
|
||||
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
|
||||
if ($kitsuResource instanceof ExternalResource) {
|
||||
$anilistResource = $this->getKitsuAnilistMapping($kitsuResource);
|
||||
if ($anilistResource !== null) {
|
||||
return $anilistResource;
|
||||
}
|
||||
}
|
||||
|
||||
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
|
||||
if ($anidbResource instanceof ExternalResource) {
|
||||
return $this->getAnidbAnilistMapping($anidbResource);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Anilist API for MAL mapping.
|
||||
*
|
||||
* @param ExternalResource $malResource
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getMalAnilistMapping(ExternalResource $malResource): ?ExternalResource
|
||||
{
|
||||
$query = '
|
||||
query ($id: Int) {
|
||||
Media (idMal: $id, type: ANIME) {
|
||||
id
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $malResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$anilistId = Arr::get($response, 'data.Media.id');
|
||||
if ($anilistId === null) {
|
||||
Log::info("Skipping null Anilist mapping for MAL id '$malResource->external_id'");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getOrCreateResource($anilistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Kitsu API for Anilist mapping.
|
||||
*
|
||||
* @param ExternalResource $kitsuResource
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getKitsuAnilistMapping(ExternalResource $kitsuResource): ?ExternalResource
|
||||
{
|
||||
$query = '
|
||||
query ($id: ID!) {
|
||||
findAnimeById (id: $id) {
|
||||
mappings(first:20) {
|
||||
nodes {
|
||||
externalId
|
||||
externalSite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $kitsuResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://kitsu.io/api/graphql', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$kitsuMappings = Arr::get($response, 'data.findAnimeById.mappings.nodes', []);
|
||||
foreach ($kitsuMappings as $kitsuMapping) {
|
||||
$externalId = Arr::get($kitsuMapping, 'externalId');
|
||||
$externalSite = Arr::get($kitsuMapping, 'externalSite');
|
||||
if ($externalSite !== 'ANILIST_ANIME' || empty($externalId)) {
|
||||
Log::info("Skipping mapping of externalId '$externalId' and externalSite '$externalSite'");
|
||||
continue;
|
||||
}
|
||||
|
||||
return $this->getOrCreateResource(intval($externalId));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Yuna API for Anilist mapping.
|
||||
*
|
||||
* @param ExternalResource $anidbResource
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getAnidbAnilistMapping(ExternalResource $anidbResource): ?ExternalResource
|
||||
{
|
||||
$response = Http::get('https://relations.yuna.moe/api/ids', [
|
||||
'source' => 'anidb',
|
||||
'id' => $anidbResource->external_id,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$anilistId = Arr::get($response, 'anilist');
|
||||
|
||||
if ($anilistId !== null) {
|
||||
return $this->getOrCreateResource($anilistId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Resource;
|
||||
|
||||
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillAnnResourceAction.
|
||||
*/
|
||||
class BackfillAnnResourceAction extends BackfillAnimeResourceAction
|
||||
{
|
||||
/**
|
||||
* Get the site to backfill.
|
||||
*
|
||||
* @return ResourceSite
|
||||
*/
|
||||
protected function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::ANN();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Resource mapping.
|
||||
*
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getResource(): ?ExternalResource
|
||||
{
|
||||
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
|
||||
if ($kitsuResource instanceof ExternalResource) {
|
||||
return $this->getKitsuAnnMapping($kitsuResource);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Kitsu API for ANN mapping.
|
||||
*
|
||||
* @param ExternalResource $kitsuResource
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getKitsuAnnMapping(ExternalResource $kitsuResource): ?ExternalResource
|
||||
{
|
||||
$query = '
|
||||
query ($id: ID!) {
|
||||
findAnimeById (id: $id) {
|
||||
mappings(first:20) {
|
||||
nodes {
|
||||
externalId
|
||||
externalSite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $kitsuResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://kitsu.io/api/graphql', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$kitsuMappings = Arr::get($response, 'data.findAnimeById.mappings.nodes', []);
|
||||
foreach ($kitsuMappings as $kitsuMapping) {
|
||||
$externalId = Arr::get($kitsuMapping, 'externalId');
|
||||
$externalSite = Arr::get($kitsuMapping, 'externalSite');
|
||||
if ($externalSite !== 'ANIMENEWSNETWORK' || empty($externalId)) {
|
||||
Log::info("Skipping mapping of externalId '$externalId' and externalSite '$externalSite'");
|
||||
continue;
|
||||
}
|
||||
|
||||
return $this->getOrCreateResource(intval($externalId));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Resource;
|
||||
|
||||
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillKitsuResourceAction.
|
||||
*/
|
||||
class BackfillKitsuResourceAction extends BackfillAnimeResourceAction
|
||||
{
|
||||
/**
|
||||
* Get the site to backfill.
|
||||
*
|
||||
* @return ResourceSite
|
||||
*/
|
||||
protected function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::KITSU();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Resource mapping.
|
||||
*
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getResource(): ?ExternalResource
|
||||
{
|
||||
// Allow fall-throughs in case Kitsu Resource is not mapped to every external site.
|
||||
|
||||
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
|
||||
if ($malResource instanceof ExternalResource) {
|
||||
$kitsuResource = $this->getKitsuMapping($malResource, 'MYANIMELIST_ANIME');
|
||||
if ($kitsuResource !== null) {
|
||||
return $kitsuResource;
|
||||
}
|
||||
|
||||
// failed mapping, sleep before re-attempting
|
||||
sleep(rand(1, 3));
|
||||
}
|
||||
|
||||
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
|
||||
if ($anilistResource instanceof ExternalResource) {
|
||||
$kitsuResource = $this->getKitsuMapping($anilistResource, 'ANILIST_ANIME');
|
||||
if ($kitsuResource !== null) {
|
||||
return $kitsuResource;
|
||||
}
|
||||
|
||||
// failed mapping, sleep before re-attempting
|
||||
sleep(rand(1, 3));
|
||||
}
|
||||
|
||||
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
|
||||
if ($anidbResource instanceof ExternalResource) {
|
||||
$kitsuResource = $this->getKitsuMapping($anidbResource, 'ANIDB');
|
||||
if ($kitsuResource !== null) {
|
||||
return $kitsuResource;
|
||||
}
|
||||
|
||||
// failed mapping, sleep before re-attempting
|
||||
sleep(rand(1, 3));
|
||||
}
|
||||
|
||||
$annResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN);
|
||||
if ($annResource instanceof ExternalResource) {
|
||||
return $this->getKitsuMapping($annResource, 'ANIMENEWSNETWORK');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Kitsu API for MAL mapping.
|
||||
*
|
||||
* @param ExternalResource $resource
|
||||
* @param string $externalSite
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getKitsuMapping(ExternalResource $resource, string $externalSite): ?ExternalResource
|
||||
{
|
||||
$query = '
|
||||
query ($externalId: ID!, $externalSite: MappingExternalSiteEnum!) {
|
||||
lookupMapping(externalId: $externalId, externalSite: $externalSite) {
|
||||
... on Anime {
|
||||
id
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'externalId' => $resource->external_id,
|
||||
'externalSite' => $externalSite,
|
||||
];
|
||||
|
||||
$response = Http::post('https://kitsu.io/api/graphql', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$kitsuMapping = Arr::get($response, 'data.lookupMapping');
|
||||
if ($kitsuMapping !== null) {
|
||||
$id = Arr::get($kitsuMapping, 'id');
|
||||
$slug = Arr::get($kitsuMapping, 'slug');
|
||||
if (empty($id) || empty($slug)) {
|
||||
Log::info("Skipping mapping of id '$id' and slug '$slug'");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getOrCreateResource(intval($id), $slug);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Resource;
|
||||
|
||||
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillMalResourceAction.
|
||||
*/
|
||||
class BackfillMalResourceAction extends BackfillAnimeResourceAction
|
||||
{
|
||||
/**
|
||||
* Get the site to backfill.
|
||||
*
|
||||
* @return ResourceSite
|
||||
*/
|
||||
protected function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::MAL();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Resource mapping.
|
||||
*
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getResource(): ?ExternalResource
|
||||
{
|
||||
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
|
||||
if ($kitsuResource instanceof ExternalResource) {
|
||||
$malResource = $this->getKitsuMalMapping($kitsuResource);
|
||||
if ($malResource !== null) {
|
||||
return $malResource;
|
||||
}
|
||||
}
|
||||
|
||||
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
|
||||
if ($anilistResource instanceof ExternalResource) {
|
||||
$malResource = $this->getAnilistMalMapping($anilistResource);
|
||||
if ($malResource !== null) {
|
||||
return $malResource;
|
||||
}
|
||||
}
|
||||
|
||||
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
|
||||
if ($anidbResource instanceof ExternalResource) {
|
||||
return $this->getAnidbMalMapping($anidbResource);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Kitsu API for MAL mapping.
|
||||
*
|
||||
* @param ExternalResource $kitsuResource
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getKitsuMalMapping(ExternalResource $kitsuResource): ?ExternalResource
|
||||
{
|
||||
$query = '
|
||||
query ($id: ID!) {
|
||||
findAnimeById (id: $id) {
|
||||
mappings(first:20) {
|
||||
nodes {
|
||||
externalId
|
||||
externalSite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $kitsuResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://kitsu.io/api/graphql', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$kitsuMappings = Arr::get($response, 'data.findAnimeById.mappings.nodes', []);
|
||||
foreach ($kitsuMappings as $kitsuMapping) {
|
||||
$externalId = Arr::get($kitsuMapping, 'externalId');
|
||||
$externalSite = Arr::get($kitsuMapping, 'externalSite');
|
||||
if ($externalSite !== 'MYANIMELIST_ANIME' || empty($externalId)) {
|
||||
Log::info("Skipping mapping of externalId '$externalId' and externalSite '$externalSite'");
|
||||
continue;
|
||||
}
|
||||
|
||||
return $this->getOrCreateResource(intval($externalId));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Anilist API for MAL mapping.
|
||||
*
|
||||
* @param ExternalResource $anilistResource
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getAnilistMalMapping(ExternalResource $anilistResource): ?ExternalResource
|
||||
{
|
||||
$query = '
|
||||
query ($id: Int) {
|
||||
Media (id: $id, type: ANIME) {
|
||||
idMal
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $anilistResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$malId = Arr::get($response, 'data.Media.idMal');
|
||||
if ($malId === null) {
|
||||
Log::info("Skipping null MAL mapping for Anilist id '$anilistResource->external_id'");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getOrCreateResource($malId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Yuna API for Mal mapping.
|
||||
*
|
||||
* @param ExternalResource $anidbResource
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getAnidbMalMapping(ExternalResource $anidbResource): ?ExternalResource
|
||||
{
|
||||
$response = Http::get('https://relations.yuna.moe/api/ids', [
|
||||
'source' => 'anidb',
|
||||
'id' => $anidbResource->external_id,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$malId = Arr::get($response, 'myanimelist');
|
||||
|
||||
if ($malId !== null) {
|
||||
return $this->getOrCreateResource($malId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Anime\Studio;
|
||||
|
||||
use App\Actions\Models\Wiki\BackfillStudiosAction;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Models\Wiki\Studio;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillAnimeStudiosAction.
|
||||
*
|
||||
* @extends BackfillStudiosAction<Anime>
|
||||
*/
|
||||
class BackfillAnimeStudiosAction extends BackfillStudiosAction
|
||||
{
|
||||
/**
|
||||
* Create a new action instance.
|
||||
*
|
||||
* @param Anime $anime
|
||||
*/
|
||||
public function __construct(Anime $anime)
|
||||
{
|
||||
parent::__construct($anime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*
|
||||
* @return Anime
|
||||
*/
|
||||
protected function getModel(): Anime
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation to studios.
|
||||
*
|
||||
* @return BelongsToMany
|
||||
*/
|
||||
protected function relation(): BelongsToMany
|
||||
{
|
||||
return $this->getModel()->studios();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party API for Anime Studios.
|
||||
*
|
||||
* @return Studio[]
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getStudios(): array
|
||||
{
|
||||
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
|
||||
if ($malResource instanceof ExternalResource) {
|
||||
$studios = $this->getMalAnimeStudios($malResource);
|
||||
if (! empty($studios)) {
|
||||
return $studios;
|
||||
}
|
||||
}
|
||||
|
||||
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
|
||||
if ($anilistResource instanceof ExternalResource) {
|
||||
$studios = $this->getAnilistAnimeStudios($anilistResource);
|
||||
if (! empty($studios)) {
|
||||
return $studios;
|
||||
}
|
||||
}
|
||||
|
||||
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
|
||||
if ($kitsuResource instanceof ExternalResource) {
|
||||
return $this->getKitsuAnimeStudios($kitsuResource);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query MAL API for Anime Studios.
|
||||
*
|
||||
* @param ExternalResource $malResource
|
||||
* @return Studio[]
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getMalAnimeStudios(ExternalResource $malResource): array
|
||||
{
|
||||
$studios = [];
|
||||
|
||||
$response = Http::withHeaders(['X-MAL-CLIENT-ID' => Config::get('services.mal.client')])
|
||||
->get("https://api.myanimelist.net/v2/anime/$malResource->external_id", [
|
||||
'fields' => 'studios',
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$malStudios = Arr::get($response, 'studios', []);
|
||||
|
||||
foreach ($malStudios as $malStudio) {
|
||||
$name = Arr::get($malStudio, 'name');
|
||||
$id = Arr::get($malStudio, 'id');
|
||||
if (empty($name) || empty($id)) {
|
||||
Log::info("Skipping empty studio of name '$name' and id '$id' for MAL Resource '{$malResource->getName()}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
$studio = $this->getOrCreateStudio($name);
|
||||
|
||||
$studios[] = $studio;
|
||||
|
||||
$this->ensureStudioHasResource($studio, ResourceSite::MAL(), $id);
|
||||
}
|
||||
|
||||
return $studios;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Anilist API for Anime Studios.
|
||||
*
|
||||
* @param ExternalResource $anilistResource
|
||||
* @return Studio[]
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getAnilistAnimeStudios(ExternalResource $anilistResource): array
|
||||
{
|
||||
$studios = [];
|
||||
|
||||
$query = '
|
||||
query ($id: Int) {
|
||||
Media (id: $id, type: ANIME) {
|
||||
studios (isMain: true) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $anilistResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://graphql.anilist.co', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$anilistStudios = Arr::get($response, 'data.Media.studios.nodes', []);
|
||||
|
||||
foreach ($anilistStudios as $anilistStudio) {
|
||||
$name = Arr::get($anilistStudio, 'name');
|
||||
$id = Arr::get($anilistStudio, 'id');
|
||||
if (empty($name) || empty($id)) {
|
||||
Log::info("Skipping empty studio of name '$name' and id '$id' for Anilist Resource '{$anilistResource->getName()}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
$studio = $this->getOrCreateStudio($name);
|
||||
|
||||
$studios[] = $studio;
|
||||
|
||||
$this->ensureStudioHasResource($studio, ResourceSite::ANILIST(), $id);
|
||||
}
|
||||
|
||||
return $studios;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Kitsu API for Anime Studios.
|
||||
*
|
||||
* @param ExternalResource $kitsuResource
|
||||
* @return Studio[]
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getKitsuAnimeStudios(ExternalResource $kitsuResource): array
|
||||
{
|
||||
$studios = [];
|
||||
|
||||
$query = '
|
||||
query ($id: ID!) {
|
||||
findAnimeById(id: $id) {
|
||||
productions(first:20) {
|
||||
nodes {
|
||||
role
|
||||
company {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'id' => $kitsuResource->external_id,
|
||||
];
|
||||
|
||||
$response = Http::post('https://kitsu.io/api/graphql', [
|
||||
'query' => $query,
|
||||
'variables' => $variables,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$kitsuStudios = Arr::get($response, 'data.findAnimeById.productions.nodes', []);
|
||||
|
||||
foreach ($kitsuStudios as $kitsuStudio) {
|
||||
$role = Arr::get($kitsuStudio, 'role');
|
||||
$name = Arr::get($kitsuStudio, 'company.name');
|
||||
if ($role !== 'STUDIO' || empty($name)) {
|
||||
Log::info("Skipping production company of name '$name' and role '$role' for Anilist Resource '{$kitsuResource->getName()}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
$studio = $this->getOrCreateStudio($name);
|
||||
|
||||
$studios[] = $studio;
|
||||
}
|
||||
|
||||
return $studios;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach Studios.
|
||||
*
|
||||
* @param Studio[] $studios
|
||||
* @return void
|
||||
*/
|
||||
protected function attachStudios(array $studios): void
|
||||
{
|
||||
Log::info("Attaching studios to {$this->label()} '{$this->getModel()->getName()}'");
|
||||
$this->relation()->attach(Arr::pluck($studios, Studio::ATTRIBUTE_ID));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Storage\Wiki\Image\UploadImageAction;
|
||||
use App\Contracts\Models\HasImages;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AttachImageAction
|
||||
{
|
||||
/**
|
||||
* @param ImageFacet[] $facets
|
||||
*/
|
||||
public function handle(BaseModel&HasImages $model, array $fields, array $facets): ActionResult
|
||||
{
|
||||
foreach ($facets as $facet) {
|
||||
$image = Arr::get($fields, $facet->name);
|
||||
|
||||
if (blank($image)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
UploadImageAction::createImageFromFile($image, $facet, $model);
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Concerns\Models\CanCreateExternalResource;
|
||||
use App\Contracts\Models\HasResources;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AttachResourceAction
|
||||
{
|
||||
use CanCreateExternalResource;
|
||||
|
||||
/**
|
||||
* @param ResourceSite[] $sites
|
||||
*/
|
||||
public function handle(BaseModel&HasResources $model, array $fields, array $sites): ActionResult
|
||||
{
|
||||
foreach ($sites as $resourceSite) {
|
||||
$url = Arr::get($fields, $resourceSite->name);
|
||||
|
||||
if (blank($url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createResource($url, $resourceSite, $model);
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\BackfillWikiAction;
|
||||
use App\Actions\Models\Wiki\Anime\ExternalApi\AnilistAnimeExternalApiAction;
|
||||
use App\Actions\Models\Wiki\Anime\ExternalApi\AniZipAnimeExternalApiAction;
|
||||
use App\Actions\Models\Wiki\Anime\ExternalApi\JikanAnimeExternalApiAction;
|
||||
use App\Actions\Models\Wiki\Anime\ExternalApi\LivechartAnimeExternalApiAction;
|
||||
use App\Actions\Models\Wiki\Anime\ExternalApi\MalAnimeExternalApiAction;
|
||||
use App\Concerns\Models\CanCreateStudio;
|
||||
use App\Concerns\Models\HasLabel;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillImages;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillResources;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillStudios;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillSynonyms;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\SynonymType;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\Synonym;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BackfillAnimeAction extends BackfillWikiAction
|
||||
{
|
||||
use CanCreateStudio;
|
||||
use HasLabel;
|
||||
|
||||
final public const string STUDIOS = 'studios';
|
||||
final public const string SYNONYMS = 'synonyms';
|
||||
|
||||
public function __construct(protected Anime $anime, protected array $toBackfill)
|
||||
{
|
||||
parent::__construct($anime, $toBackfill);
|
||||
}
|
||||
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
try {
|
||||
foreach ($this->getExternalApiActions() as $api) {
|
||||
DB::beginTransaction();
|
||||
|
||||
if (
|
||||
count($this->toBackfill[self::RESOURCES]) === 0
|
||||
&& count($this->toBackfill[self::IMAGES]) === 0
|
||||
&& ! $this->toBackfill[self::STUDIOS]
|
||||
&& ! $this->toBackfill[self::SYNONYMS]
|
||||
) {
|
||||
// Don't make other requests if everything is backfilled
|
||||
Log::info("Backfill action finished for Anime {$this->getModel()->getName()}");
|
||||
DB::rollBack();
|
||||
break;
|
||||
}
|
||||
|
||||
$response = $api->handle($this->getModel()->resources());
|
||||
|
||||
if ($response instanceof BackfillResources) {
|
||||
$this->forResources($response);
|
||||
}
|
||||
|
||||
if ($response instanceof BackfillImages) {
|
||||
$this->forImages($response);
|
||||
}
|
||||
|
||||
if ($response instanceof BackfillStudios) {
|
||||
$this->forStudios($response);
|
||||
}
|
||||
|
||||
if ($response instanceof BackfillSynonyms) {
|
||||
$this->forSynonyms($response);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the external API actions available for the backfill action.
|
||||
*
|
||||
* @return ExternalApiAction[]
|
||||
*/
|
||||
protected function getExternalApiActions(): array
|
||||
{
|
||||
return [
|
||||
new AniZipAnimeExternalApiAction(),
|
||||
new LivechartAnimeExternalApiAction(),
|
||||
new AnilistAnimeExternalApiAction(),
|
||||
new JikanAnimeExternalApiAction(),
|
||||
new MalAnimeExternalApiAction(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the studios given the response.
|
||||
*/
|
||||
protected function forStudios(ExternalApiAction&BackfillStudios $response): void
|
||||
{
|
||||
$studios = $response->getStudios();
|
||||
|
||||
if (! $this->toBackfill[self::STUDIOS]) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($studios as $studio) {
|
||||
$id = Arr::get($studio, 'id');
|
||||
$name = Arr::get($studio, 'name');
|
||||
|
||||
if (blank($name) || blank($id)) {
|
||||
Log::info("Skipping empty studio of name '$name' and id '$id''");
|
||||
continue;
|
||||
}
|
||||
|
||||
$studio = $this->getOrCreateStudio($name);
|
||||
|
||||
Log::info("Attaching Studio of name '$name' to Anime {$this->getModel()->getName()}");
|
||||
$this->getModel()->studios()->attach($studio);
|
||||
|
||||
$this->ensureStudioHasResource($studio, $response->getSite(), $id);
|
||||
}
|
||||
|
||||
if ($this->getModel()->studios()->exists()) {
|
||||
$this->toBackfill[self::STUDIOS] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the synonyms given the response.
|
||||
*/
|
||||
protected function forSynonyms(ExternalApiAction&BackfillSynonyms $api): void
|
||||
{
|
||||
if (! $this->toBackfill[self::SYNONYMS]) {
|
||||
return;
|
||||
}
|
||||
|
||||
$synonyms = $api->getSynonyms()
|
||||
->reject(
|
||||
fn (string $text, int $type): bool => $type === SynonymType::OTHER->value && Str::is($text, $this->getModel()->getName(), true)
|
||||
)
|
||||
->all();
|
||||
|
||||
foreach ($synonyms as $type => $text) {
|
||||
$this->getModel()->synonyms()->create([
|
||||
Synonym::ATTRIBUTE_TEXT => $text,
|
||||
Synonym::ATTRIBUTE_TYPE => $type,
|
||||
]);
|
||||
|
||||
$this->toBackfill[self::SYNONYMS] = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getModel(): Anime
|
||||
{
|
||||
return $this->anime;
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\BaseAction;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Models\Wiki\Image;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Testing\File;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Class BackfillImageAction.
|
||||
*
|
||||
* @template TModel of \App\Models\BaseModel
|
||||
* @extends BaseAction<TModel>
|
||||
*/
|
||||
abstract class BackfillImageAction extends BaseAction
|
||||
{
|
||||
/**
|
||||
* Handle action.
|
||||
*
|
||||
* @return ActionResult
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
if ($this->relation()->getQuery()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->exists()) {
|
||||
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Image of Facet '{$this->getFacet()->value}'.");
|
||||
|
||||
return new ActionResult(ActionStatus::SKIPPED());
|
||||
}
|
||||
|
||||
$image = $this->getImage();
|
||||
|
||||
if ($image !== null) {
|
||||
$this->attachImage($image);
|
||||
}
|
||||
|
||||
if ($this->relation()->getQuery()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->doesntExist()) {
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED(),
|
||||
"{$this->label()} '{$this->getModel()->getName()}' has no {$this->getFacet()->description} Image after backfilling. Please review."
|
||||
);
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Image from response.
|
||||
*
|
||||
* @param string $url
|
||||
* @return Image
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function createImage(string $url): Image
|
||||
{
|
||||
$imageResponse = Http::get($url)->throw();
|
||||
|
||||
$image = $imageResponse->body();
|
||||
|
||||
$file = File::createWithContent(basename($url), $image);
|
||||
|
||||
$fs = Storage::disk(Config::get('image.disk'));
|
||||
|
||||
$fsFile = $fs->putFile($this->path(), $file);
|
||||
|
||||
/** @var Image $image */
|
||||
$image = Image::query()->create([
|
||||
Image::ATTRIBUTE_FACET => $this->getFacet()->value,
|
||||
Image::ATTRIBUTE_PATH => $fsFile,
|
||||
]);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to storage image in filesystem.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function path(): string
|
||||
{
|
||||
return Str::of(Str::kebab(class_basename($this->getModel())))
|
||||
->append(DIRECTORY_SEPARATOR)
|
||||
->append(Str::kebab($this->getFacet()->description))
|
||||
->__toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach Image to model.
|
||||
*
|
||||
* @param Image $image
|
||||
* @return void
|
||||
*/
|
||||
abstract protected function attachImage(Image $image): void;
|
||||
|
||||
/**
|
||||
* Get the facet to backfill.
|
||||
*
|
||||
* @return ImageFacet
|
||||
*/
|
||||
abstract protected function getFacet(): ImageFacet;
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Image.
|
||||
*
|
||||
* @return Image|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
abstract protected function getImage(): ?Image;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\BaseAction;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillResourceAction.
|
||||
*
|
||||
* @template TModel of \App\Models\BaseModel
|
||||
* @extends BaseAction<TModel>
|
||||
*/
|
||||
abstract class BackfillResourceAction extends BaseAction
|
||||
{
|
||||
/**
|
||||
* Handle action.
|
||||
*
|
||||
* @return ActionResult
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
if ($this->relation()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->exists()) {
|
||||
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Resource of Site '{$this->getSite()->value}'.");
|
||||
|
||||
return new ActionResult(ActionStatus::SKIPPED());
|
||||
}
|
||||
|
||||
$resource = $this->getResource();
|
||||
|
||||
if ($resource !== null) {
|
||||
$this->attachResource($resource);
|
||||
}
|
||||
|
||||
if ($this->relation()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->doesntExist()) {
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED(),
|
||||
"{$this->label()} '{$this->getModel()->getName()}' has no {$this->getSite()->description} Resource after backfilling. Please review."
|
||||
);
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or Create Resource from response.
|
||||
*
|
||||
* @param int $id
|
||||
* @param string|null $slug
|
||||
* @return ExternalResource
|
||||
*/
|
||||
abstract protected function getOrCreateResource(int $id, string $slug = null): ExternalResource;
|
||||
|
||||
/**
|
||||
* Attach External Resource to model.
|
||||
*
|
||||
* @param ExternalResource $resource
|
||||
* @return void
|
||||
*/
|
||||
abstract protected function attachResource(ExternalResource $resource): void;
|
||||
|
||||
/**
|
||||
* Get the site to backfill.
|
||||
*
|
||||
* @return ResourceSite
|
||||
*/
|
||||
abstract protected function getSite(): ResourceSite;
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Resource mapping.
|
||||
*
|
||||
* @return ExternalResource|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
abstract protected function getResource(): ?ExternalResource;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\BackfillAction;
|
||||
use App\Concerns\Models\CanCreateExternalResource;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\Song;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Uri;
|
||||
|
||||
/**
|
||||
* @extends BackfillAction<Song>
|
||||
*/
|
||||
class BackfillSongAction extends BackfillAction
|
||||
{
|
||||
use CanCreateExternalResource;
|
||||
|
||||
public function __construct(
|
||||
Song $song,
|
||||
protected readonly string $lnkto,
|
||||
) {
|
||||
parent::__construct($song);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Request to lnk.to site
|
||||
$response = Http::get($this->lnkto)
|
||||
->throw();
|
||||
|
||||
$pattern = '/<a[^>]*class="[^"]*music-service-list__link[^"]*js-redirect[^"]*"[^>]*href="([^"]+)"[^>]*data-label="([^"]*)"[^>]*>/i';
|
||||
|
||||
preg_match_all($pattern, (string) $response->body(), $matches);
|
||||
|
||||
foreach ($matches[1] as $key => $link) {
|
||||
$label = $matches[2][$key];
|
||||
$resourceSite = $this->getMappingFromExternalSite($label);
|
||||
|
||||
if (! $resourceSite instanceof ResourceSite) {
|
||||
Log::info("Skipping {$label} for Song {$this->getModel()->getName()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createResource(Uri::of($link), $resourceSite, $this->getModel());
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resource site according the external label.
|
||||
*/
|
||||
protected function getMappingFromExternalSite(string $label): ?ResourceSite
|
||||
{
|
||||
return match ($label) {
|
||||
'applemusic' => ResourceSite::APPLE_MUSIC,
|
||||
'spotify' => ResourceSite::SPOTIFY,
|
||||
'youtubemusic' => ResourceSite::YOUTUBE_MUSIC,
|
||||
'amazonmp3' => ResourceSite::AMAZON_MUSIC,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*/
|
||||
protected function getModel(): Song
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation to resources.
|
||||
*/
|
||||
protected function relation(): BelongsToMany
|
||||
{
|
||||
return $this->getModel()->resources();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\BackfillWikiAction;
|
||||
use App\Actions\Models\Wiki\Studio\ExternalApi\MalStudioExternalApiAction;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillImages;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Models\Wiki\Studio;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BackfillStudioAction extends BackfillWikiAction
|
||||
{
|
||||
public function __construct(protected Studio $studio, protected array $toBackfill)
|
||||
{
|
||||
parent::__construct($studio, $toBackfill);
|
||||
}
|
||||
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
foreach ($this->getExternalApiActions() as $api) {
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
if (count($this->toBackfill[self::IMAGES]) === 0) {
|
||||
// Don't make other requests if everything is backfilled
|
||||
Log::info("Backfill action finished for Studio {$this->getModel()->getName()}");
|
||||
DB::rollBack();
|
||||
|
||||
return new ActionResult(ActionStatus::SKIPPED);
|
||||
}
|
||||
|
||||
$response = $api->handle($this->getModel()->resources());
|
||||
|
||||
if ($api instanceof BackfillImages) {
|
||||
$this->forImages($response);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the external API actions available for the backfill action.
|
||||
*
|
||||
* @return ExternalApiAction[]
|
||||
*/
|
||||
protected function getExternalApiActions(): array
|
||||
{
|
||||
return [
|
||||
new MalStudioExternalApiAction(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getModel(): Studio
|
||||
{
|
||||
return $this->studio;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\BaseAction;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Models\Wiki\Studio;
|
||||
use App\Pivots\StudioResource;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Class BackfillStudiosAction.
|
||||
*
|
||||
* @template TModel of \App\Models\BaseModel
|
||||
* @extends BaseAction<TModel>
|
||||
*/
|
||||
abstract class BackfillStudiosAction extends BaseAction
|
||||
{
|
||||
/**
|
||||
* Handle action.
|
||||
*
|
||||
* @return ActionResult
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
if ($this->relation()->getQuery()->exists()) {
|
||||
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Studios.");
|
||||
|
||||
return new ActionResult(ActionStatus::SKIPPED());
|
||||
}
|
||||
|
||||
$studios = $this->getStudios();
|
||||
|
||||
if (! empty($studios)) {
|
||||
$this->attachStudios($studios);
|
||||
}
|
||||
|
||||
if ($this->relation()->getQuery()->doesntExist()) {
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED(),
|
||||
"{$this->label()} '{$this->getModel()->getName()}' has no Studios after backfilling. Please review."
|
||||
);
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Studio from name (case-insensitive).
|
||||
*
|
||||
* @param string $name
|
||||
* @return Studio
|
||||
*/
|
||||
protected function getOrCreateStudio(string $name): Studio
|
||||
{
|
||||
$column = Studio::ATTRIBUTE_NAME;
|
||||
$studio = Studio::query()
|
||||
->where(DB::raw("lower($column)"), Str::lower($name))
|
||||
->first();
|
||||
|
||||
if (! $studio instanceof Studio) {
|
||||
Log::info("Creating studio '$name'");
|
||||
|
||||
$studio = Studio::query()->create([
|
||||
Studio::ATTRIBUTE_NAME => $name,
|
||||
Studio::ATTRIBUTE_SLUG => Str::slug($name, '_'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $studio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Studio has Resource.
|
||||
*
|
||||
* @param Studio $studio
|
||||
* @param ResourceSite $site
|
||||
* @param int $id
|
||||
* @return void
|
||||
*/
|
||||
protected function ensureStudioHasResource(Studio $studio, ResourceSite $site, int $id): void
|
||||
{
|
||||
$studioResource = ExternalResource::query()
|
||||
->where(ExternalResource::ATTRIBUTE_SITE, $site->value)
|
||||
->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id)
|
||||
->where(ExternalResource::ATTRIBUTE_LINK, ResourceSite::formatStudioResourceLink($site, $id))
|
||||
->first();
|
||||
|
||||
if (! $studioResource instanceof ExternalResource) {
|
||||
Log::info("Creating studio resource with site '$site->value' and id '$id'");
|
||||
|
||||
$studioResource = ExternalResource::query()->create([
|
||||
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $id,
|
||||
ExternalResource::ATTRIBUTE_LINK => ResourceSite::formatStudioResourceLink($site, $id),
|
||||
ExternalResource::ATTRIBUTE_SITE => $site->value,
|
||||
]);
|
||||
}
|
||||
|
||||
if (StudioResource::query()
|
||||
->where($studio->getKeyName(), $studio->getKey())
|
||||
->where($studioResource->getKeyName(), $studioResource->getKey())
|
||||
->doesntExist()
|
||||
) {
|
||||
Log::info("Attaching resource '$studioResource->link' to studio '{$studio->getName()}'");
|
||||
$studioResource->studios()->attach($studio);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach Studios.
|
||||
*
|
||||
* @param Studio[] $studios
|
||||
* @return void
|
||||
*/
|
||||
abstract protected function attachStudios(array $studios): void;
|
||||
|
||||
/**
|
||||
* Query third-party API for Studios.
|
||||
*
|
||||
* @return Studio[]
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
abstract protected function getStudios(): array;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki;
|
||||
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillImages;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillResources;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillSynonyms;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Enums\Models\Wiki\SynonymType;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
abstract class ExternalApiAction
|
||||
{
|
||||
public ?array $response = null;
|
||||
|
||||
abstract public function getSite(): ResourceSite;
|
||||
|
||||
/**
|
||||
* Set the response after the request.
|
||||
*/
|
||||
abstract public function handle(BelongsToMany $resources): static;
|
||||
|
||||
/**
|
||||
* Get the mapped resources.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getResources(): array
|
||||
{
|
||||
$resources = [];
|
||||
|
||||
if ($this instanceof BackfillResources && $this->response) {
|
||||
foreach ($this->getResourcesMapping() as $site => $key) {
|
||||
if (Arr::get($this->response, $key) !== null) {
|
||||
$resources[$site] = Arr::get($this->response, $key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapped images.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getImages(): array
|
||||
{
|
||||
$images = [];
|
||||
|
||||
if ($this instanceof BackfillImages && $this->response) {
|
||||
foreach ($this->getImagesMapping() as $facet => $key) {
|
||||
if (Arr::get($this->response, $key) !== null) {
|
||||
$images[$facet] = Arr::get($this->response, $key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapped synonyms.
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public function getSynonyms(): Collection
|
||||
{
|
||||
$synonyms = collect();
|
||||
|
||||
if ($this instanceof BackfillSynonyms && $this->response) {
|
||||
foreach ($this->getSynonymsMapping() as $type => $key) {
|
||||
$synonyms->put($type, Arr::get($this->response, $key));
|
||||
}
|
||||
}
|
||||
|
||||
return $synonyms
|
||||
->filter(fn (?string $text): bool => filled($text))
|
||||
->reject(function (string $text, int $type) use ($synonyms) {
|
||||
if ($type !== SynonymType::OTHER->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $synonyms
|
||||
->except([SynonymType::OTHER->value])
|
||||
->containsStrict($text);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Image;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Constants\Config\ImageConstants;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Models\Wiki\Image;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OptimizeImageAction
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Image $image,
|
||||
protected readonly ?string $extension = null,
|
||||
protected readonly ?int $width = null,
|
||||
protected readonly ?int $height = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
if ($this->extension === null && $this->width === null && $this->height === null) {
|
||||
return new ActionResult(
|
||||
ActionStatus::SKIPPED,
|
||||
'No optimization parameters provided, nothing to process.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$directory = File::dirname($this->image->path);
|
||||
|
||||
$optimizedImage = $this->handleFFmpeg();
|
||||
|
||||
if ($optimizedImage === null) {
|
||||
DB::rollBack();
|
||||
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED,
|
||||
"Failed to optimize image '{$this->image->path}'.",
|
||||
);
|
||||
}
|
||||
|
||||
$path = $this->uploadImage(
|
||||
Storage::disk('local')->path($optimizedImage),
|
||||
$directory
|
||||
);
|
||||
|
||||
// Delete the old image from the bucket and local.
|
||||
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->delete($this->image->path);
|
||||
Storage::disk('local')->delete($optimizedImage);
|
||||
|
||||
$this->image->update([
|
||||
Image::ATTRIBUTE_PATH => $path,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
|
||||
protected function handleFFmpeg(): ?string
|
||||
{
|
||||
$filesToDelete = [];
|
||||
$imagePath = null;
|
||||
|
||||
try {
|
||||
Storage::disk('local')->put(
|
||||
$this->image->path,
|
||||
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->get($this->image->path),
|
||||
);
|
||||
|
||||
$imagePath = $this->image->path;
|
||||
|
||||
$filesToDelete[] = $imagePath;
|
||||
|
||||
if ($this->extension !== null) {
|
||||
$imagePath = $this->convertImage($imagePath);
|
||||
$filesToDelete[] = $imagePath;
|
||||
}
|
||||
|
||||
if ($this->width !== null && $this->height !== null) {
|
||||
$imagePath = $this->downscaleImage($imagePath);
|
||||
$filesToDelete[] = $imagePath;
|
||||
}
|
||||
|
||||
return $imagePath;
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
} finally {
|
||||
Storage::disk('local')->delete(Arr::where($filesToDelete, fn (string $file): bool => $file !== $imagePath));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the image to given extension.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function convertImage(string $path): string
|
||||
{
|
||||
[$command, $imagePath] = match ($this->extension) {
|
||||
'avif' => static::getAvifCommand($path),
|
||||
default => throw new Exception("Unsupported image extension: {$this->extension}"),
|
||||
};
|
||||
|
||||
Process::run($command)->throw();
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale the image to given width and height.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function downscaleImage(string $path): string
|
||||
{
|
||||
[$command, $imagePath] = static::getDownscaleCommand($path, $this->width, $this->height);
|
||||
|
||||
Process::run($command)->throw();
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the image to the bucket.
|
||||
*/
|
||||
protected function uploadImage(string $image, string $directory): string
|
||||
{
|
||||
/** @var \Illuminate\Filesystem\FilesystemAdapter $fs */
|
||||
$fs = Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED));
|
||||
|
||||
$fsFile = $fs->putFile($directory, $image);
|
||||
|
||||
Log::info("Uploading optimized Image {$fsFile}");
|
||||
|
||||
return $fsFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:array<int, string>, 1:string}
|
||||
*/
|
||||
public static function getAvifCommand(string $path): array
|
||||
{
|
||||
$imagePath = Storage::disk('local')->path(
|
||||
$newPath = Str::replaceLast(File::extension($path), 'avif', $path)
|
||||
);
|
||||
|
||||
return [
|
||||
[
|
||||
'ffmpeg',
|
||||
'-i',
|
||||
Storage::disk('local')->path($path),
|
||||
'-c:v',
|
||||
'libaom-av1',
|
||||
'-crf',
|
||||
'30',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
$imagePath,
|
||||
],
|
||||
$newPath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:array<int, string>, 1:string}
|
||||
*/
|
||||
public static function getDownscaleCommand(string $path, int $width = 100, int $height = 150): array
|
||||
{
|
||||
$tempPath = Storage::disk('local')->path(
|
||||
$newPath = Str::replaceLast('.', '_scaled.', $path)
|
||||
);
|
||||
|
||||
return [
|
||||
[
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-i',
|
||||
Storage::disk('local')->path($path),
|
||||
'-vf',
|
||||
"scale={$width}:{$height}",
|
||||
$tempPath,
|
||||
],
|
||||
$newPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Song;
|
||||
|
||||
use App\Models\Wiki\Song;
|
||||
use App\Models\Wiki\Song\Performance;
|
||||
use App\Pivots\Wiki\ArtistMember;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ManageSongPerformances
|
||||
{
|
||||
protected Song $song;
|
||||
|
||||
public function __construct(
|
||||
Song|int $song,
|
||||
/** @var Collection<int, non-empty-array<string, mixed>> */
|
||||
protected Collection $performances = new Collection(),
|
||||
/** @var Collection<int, array<string, mixed>> */
|
||||
protected Collection $members = new Collection(),
|
||||
) {
|
||||
$this->song = $song instanceof Song ? $song : Song::query()->find($song);
|
||||
}
|
||||
|
||||
public function addArtist(
|
||||
int $artist,
|
||||
?int $member = null,
|
||||
?string $alias = null,
|
||||
?string $as = null,
|
||||
?string $memberAlias = null,
|
||||
?string $memberAs = null
|
||||
): static {
|
||||
$this->performances->push([
|
||||
Performance::ATTRIBUTE_SONG => $this->song->getKey(),
|
||||
Performance::ATTRIBUTE_ARTIST => $artist,
|
||||
Performance::ATTRIBUTE_MEMBER => $member,
|
||||
Performance::ATTRIBUTE_ALIAS => filled($alias) ? trim($alias) : null,
|
||||
Performance::ATTRIBUTE_AS => filled($as) ? trim($as) : null,
|
||||
Performance::ATTRIBUTE_MEMBER_ALIAS => filled($memberAlias) ? trim($memberAlias) : null,
|
||||
Performance::ATTRIBUTE_MEMBER_AS => filled($memberAs) ? trim($memberAs) : null,
|
||||
]);
|
||||
|
||||
if ($member !== null) {
|
||||
$this->members->put($member, [
|
||||
ArtistMember::ATTRIBUTE_ARTIST => $artist,
|
||||
ArtistMember::ATTRIBUTE_MEMBER => $member,
|
||||
ArtistMember::ATTRIBUTE_ALIAS => filled($memberAlias) ? trim($memberAlias) : null,
|
||||
ArtistMember::ATTRIBUTE_AS => filled($memberAs) ? trim($memberAs) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function commit(): static
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$new = collect($this->performances)
|
||||
->keyBy(fn (array $p): string => $p[Performance::ATTRIBUTE_ARTIST].':'.($p[Performance::ATTRIBUTE_MEMBER] ?? ''));
|
||||
|
||||
$existing = Performance::query()
|
||||
->whereBelongsTo($this->song)
|
||||
->get()
|
||||
->keyBy(fn (Performance $p): string => $p->artist_id.':'.($p->member_id ?? ''));
|
||||
|
||||
$models = $new->map(
|
||||
fn (array $performance) => Performance::query()->updateOrCreate(
|
||||
Arr::only($performance, [Performance::ATTRIBUTE_SONG, Performance::ATTRIBUTE_ARTIST, Performance::ATTRIBUTE_MEMBER]),
|
||||
Arr::only($performance, [Performance::ATTRIBUTE_ALIAS, Performance::ATTRIBUTE_AS, Performance::ATTRIBUTE_MEMBER_ALIAS, Performance::ATTRIBUTE_MEMBER_AS])
|
||||
)
|
||||
);
|
||||
|
||||
$existing->diffKeys($new)->each->delete();
|
||||
|
||||
Performance::setNewOrder($models->pluck(Performance::ATTRIBUTE_ID)->all());
|
||||
|
||||
// Update artist_member table to match member performances
|
||||
ArtistMember::query()->upsert(
|
||||
$this->members->all(),
|
||||
[ArtistMember::ATTRIBUTE_ARTIST, ArtistMember::ATTRIBUTE_MEMBER],
|
||||
[ArtistMember::ATTRIBUTE_ALIAS, ArtistMember::ATTRIBUTE_AS],
|
||||
);
|
||||
|
||||
static::setArtistMemberRelevance();
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complex query to set relevance for artist members based on created_at order.
|
||||
*/
|
||||
public static function setArtistMemberRelevance(): void
|
||||
{
|
||||
DB::statement('
|
||||
WITH base AS (
|
||||
SELECT
|
||||
artist_id,
|
||||
COALESCE(MAX(relevance), 0) AS max_rel
|
||||
FROM artist_member
|
||||
GROUP BY artist_id
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
artist_id,
|
||||
member_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY artist_id
|
||||
ORDER BY created_at
|
||||
) AS rn
|
||||
FROM artist_member
|
||||
WHERE relevance IS NULL
|
||||
)
|
||||
UPDATE artist_member am
|
||||
JOIN ranked r
|
||||
ON am.artist_id = r.artist_id
|
||||
AND am.member_id = r.member_id
|
||||
JOIN base b
|
||||
ON b.artist_id = r.artist_id
|
||||
SET am.relevance = r.rn + b.max_rel;
|
||||
');
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Studio;
|
||||
|
||||
use App\Actions\Models\Wiki\BackfillImageAction;
|
||||
use App\Models\Wiki\Image;
|
||||
use App\Models\Wiki\Studio;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class BackfillStudioImageAction.
|
||||
*
|
||||
* @extends BackfillImageAction<Studio>
|
||||
*/
|
||||
abstract class BackfillStudioImageAction extends BackfillImageAction
|
||||
{
|
||||
/**
|
||||
* Create a new action instance.
|
||||
*
|
||||
* @param Studio $studio
|
||||
*/
|
||||
public function __construct(Studio $studio)
|
||||
{
|
||||
parent::__construct($studio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*
|
||||
* @return Studio
|
||||
*/
|
||||
protected function getModel(): Studio
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation to images.
|
||||
*
|
||||
* @return BelongsToMany
|
||||
*/
|
||||
protected function relation(): BelongsToMany
|
||||
{
|
||||
return $this->getModel()->images();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach Image to Studio.
|
||||
*
|
||||
* @param Image $image
|
||||
* @return void
|
||||
*/
|
||||
protected function attachImage(Image $image): void
|
||||
{
|
||||
Log::info("Attaching Image '{$image->getName()}' to {$this->label()} '{$this->getModel()->getName()}'");
|
||||
$this->relation()->attach($image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Studio\ExternalApi;
|
||||
|
||||
use App\Actions\Models\Wiki\ExternalApiAction;
|
||||
use App\Contracts\Actions\Models\Wiki\BackfillImages;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Models\Wiki\Studio;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class MalStudioExternalApiAction extends ExternalApiAction implements BackfillImages
|
||||
{
|
||||
public function getSite(): ResourceSite
|
||||
{
|
||||
return ResourceSite::MAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the response after the request.
|
||||
*
|
||||
* @param BelongsToMany<ExternalResource, Studio> $resources
|
||||
*/
|
||||
public function handle(BelongsToMany $resources): static
|
||||
{
|
||||
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL->value);
|
||||
|
||||
if ($resource instanceof ExternalResource) {
|
||||
$this->response = [
|
||||
'images' => [
|
||||
'large' => "https://cdn.myanimelist.net/images/company/$resource->external_id.png",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapping for the images.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getImagesMapping(): array
|
||||
{
|
||||
return [
|
||||
ImageFacet::LARGE_COVER->value => 'images.large',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Models\Wiki\Studio\Image;
|
||||
|
||||
use App\Actions\Models\Wiki\Studio\BackfillStudioImageAction;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Enums\Models\Wiki\ResourceSite;
|
||||
use App\Models\Wiki\ExternalResource;
|
||||
use App\Models\Wiki\Image;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
|
||||
/**
|
||||
* Class BackfillLargeCoverImageAction.
|
||||
*/
|
||||
class BackfillLargeCoverImageAction extends BackfillStudioImageAction
|
||||
{
|
||||
/**
|
||||
* Get the facet to backfill.
|
||||
*
|
||||
* @return ImageFacet
|
||||
*/
|
||||
protected function getFacet(): ImageFacet
|
||||
{
|
||||
return ImageFacet::COVER_LARGE();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query third-party APIs to find Image.
|
||||
*
|
||||
* @return Image|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getImage(): ?Image
|
||||
{
|
||||
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
|
||||
if ($malResource instanceof ExternalResource) {
|
||||
return $this->getMalImage($malResource);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query MAL API for large cover image.
|
||||
*
|
||||
* @param ExternalResource $malResource
|
||||
* @return Image|null
|
||||
*
|
||||
* @throws RequestException
|
||||
*/
|
||||
protected function getMalImage(ExternalResource $malResource): ?Image
|
||||
{
|
||||
return $this->createImage("https://cdn.myanimelist.net/img/common/companies/$malResource->external_id.png");
|
||||
}
|
||||
}
|
||||
@@ -5,92 +5,91 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Models\Wiki\Video\Audio;
|
||||
|
||||
use App\Actions\ActionResult;
|
||||
use App\Actions\Models\BaseAction;
|
||||
use App\Actions\Repositories\ReconcileResults;
|
||||
use App\Actions\Repositories\Wiki\Audio\ReconcileAudioRepositoriesAction;
|
||||
use App\Constants\Config\AudioConstants;
|
||||
use App\Actions\Models\BackfillAction;
|
||||
use App\Actions\Storage\Wiki\Audio\MoveAudioAction;
|
||||
use App\Actions\Storage\Wiki\Audio\UploadAudioAction;
|
||||
use App\Constants\Config\VideoConstants;
|
||||
use App\Contracts\Repositories\RepositoryInterface;
|
||||
use App\Enums\Actions\ActionStatus;
|
||||
use App\Enums\Actions\Models\Wiki\Video\DeriveSourceVideo;
|
||||
use App\Enums\Actions\Models\Wiki\Video\OverwriteAudio;
|
||||
use App\Enums\Actions\Models\Wiki\Video\ReplaceRelatedAudio;
|
||||
use App\Models\Wiki\Anime;
|
||||
use App\Models\Wiki\Anime\AnimeTheme;
|
||||
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
|
||||
use App\Models\Wiki\Audio;
|
||||
use App\Models\Wiki\Video;
|
||||
use App\Repositories\Eloquent\Wiki\AudioRepository as AudioDestinationRepository;
|
||||
use App\Repositories\Storage\Wiki\AudioRepository as AudioSourceRepository;
|
||||
use Exception;
|
||||
use FFMpeg\Coordinate\TimeCode;
|
||||
use FFMpeg\Filters\Audio\AddMetadataFilter;
|
||||
use FFMpeg\Filters\Audio\AudioClipFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use ProtoneMedia\LaravelFFMpeg\Support\FFMpeg;
|
||||
|
||||
/**
|
||||
* Class BackfillVideoAudioAction.
|
||||
*
|
||||
* @extends BaseAction<Video>
|
||||
* @extends BackfillAction<Video>
|
||||
*/
|
||||
class BackfillAudioAction extends BaseAction
|
||||
class BackfillAudioAction extends BackfillAction
|
||||
{
|
||||
/**
|
||||
* Create a new action instance.
|
||||
*
|
||||
* @param Video $video
|
||||
* @param DeriveSourceVideo $deriveSourceVideo
|
||||
* @param OverwriteAudio $overwriteAudio
|
||||
*/
|
||||
public function __construct(
|
||||
Video $video,
|
||||
protected readonly DeriveSourceVideo $deriveSourceVideo = new DeriveSourceVideo(DeriveSourceVideo::YES),
|
||||
protected readonly OverwriteAudio $overwriteAudio = new OverwriteAudio(OverwriteAudio::NO)
|
||||
protected readonly DeriveSourceVideo $deriveSourceVideo = DeriveSourceVideo::YES,
|
||||
protected readonly OverwriteAudio $overwriteAudio = OverwriteAudio::NO,
|
||||
protected readonly ReplaceRelatedAudio $replaceRelatedAudio = ReplaceRelatedAudio::NO,
|
||||
) {
|
||||
parent::__construct($video);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action.
|
||||
*
|
||||
* @return ActionResult
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
if ($this->relation()->getQuery()->exists() && ! $this->overwriteAudio()) {
|
||||
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Audio'.");
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
return new ActionResult(ActionStatus::SKIPPED());
|
||||
if ($this->relation()->getQuery()->exists() && ! $this->overwriteAudio()) {
|
||||
DB::rollBack();
|
||||
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Audio'.");
|
||||
|
||||
return new ActionResult(ActionStatus::SKIPPED);
|
||||
}
|
||||
|
||||
$audio = $this->getAudio();
|
||||
|
||||
if ($audio instanceof Audio) {
|
||||
$this->attachAudio($audio);
|
||||
}
|
||||
|
||||
if ($this->relation()->getQuery()->doesntExist()) {
|
||||
DB::rollBack();
|
||||
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED,
|
||||
"{$this->label()} '{$this->getModel()->getName()}' has no Audio after backfilling. Please review."
|
||||
);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
|
||||
DB::rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$audio = $this->getAudio();
|
||||
|
||||
if ($audio !== null) {
|
||||
$this->attachAudio($audio);
|
||||
}
|
||||
|
||||
if ($this->relation()->getQuery()->doesntExist()) {
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED(),
|
||||
"{$this->label()} '{$this->getModel()->getName()}' has no Audio after backfilling. Please review."
|
||||
);
|
||||
}
|
||||
|
||||
return new ActionResult(ActionStatus::PASSED());
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model the action is handling.
|
||||
*
|
||||
* @return Video
|
||||
*/
|
||||
protected function getModel(): Video
|
||||
{
|
||||
@@ -99,54 +98,63 @@ class BackfillAudioAction extends BaseAction
|
||||
|
||||
/**
|
||||
* Get the relation to audio.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
protected function relation(): BelongsTo
|
||||
{
|
||||
return $this->getModel()->audio();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the source video should be derived.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function deriveSourceVideo(): bool
|
||||
{
|
||||
return DeriveSourceVideo::YES()->is($this->deriveSourceVideo);
|
||||
return $this->deriveSourceVideo === DeriveSourceVideo::YES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if audio should be overwritten.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function overwriteAudio(): bool
|
||||
{
|
||||
return OverwriteAudio::YES()->is($this->overwriteAudio);
|
||||
return $this->overwriteAudio === OverwriteAudio::YES;
|
||||
}
|
||||
|
||||
protected function replaceRelatedAudio(): bool
|
||||
{
|
||||
return $this->replaceRelatedAudio === ReplaceRelatedAudio::YES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or Create Audio.
|
||||
*
|
||||
* @return Audio|null
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getAudio(): ?Audio
|
||||
{
|
||||
// Allow bypassing of source video derivation
|
||||
$sourceVideo = $this->deriveSourceVideo()
|
||||
? $this->getSourceVideo()
|
||||
: $this->getModel();
|
||||
if ($this->replaceRelatedAudio()) {
|
||||
$sourceVideo = $this->getSourceVideo(false);
|
||||
} elseif ($this->deriveSourceVideo()) {
|
||||
$sourceVideo = $this->getSourceVideo();
|
||||
} else {
|
||||
$sourceVideo = $this->getModel();
|
||||
}
|
||||
|
||||
// It's possible that the video is not attached to any themes, exit early.
|
||||
if ($sourceVideo === null) {
|
||||
if (! $sourceVideo instanceof Video) {
|
||||
Log::error('Could not derive source video');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// First, attempt to set audio from the source video
|
||||
$audio = $sourceVideo->audio;
|
||||
|
||||
// When uploading a BD version we should get the parent audio of a WEB version and
|
||||
// move the file overwriting the content later. Therefore, the old model is not deleted.
|
||||
if ($this->replaceRelatedAudio() && $audio instanceof Audio) {
|
||||
$moveAction = new MoveAudioAction($audio, Str::replace('webm', 'ogg', $this->getModel()->path()));
|
||||
|
||||
$storageResults = $moveAction->handle();
|
||||
|
||||
$audio = $moveAction->then($storageResults);
|
||||
}
|
||||
|
||||
// Anticipate audio path for FFmpeg save file
|
||||
$audioPath = $audio === null
|
||||
? Str::replace('webm', 'ogg', $sourceVideo->path)
|
||||
@@ -158,44 +166,26 @@ class BackfillAudioAction extends BaseAction
|
||||
}
|
||||
|
||||
// Finally, extract audio from the source video
|
||||
if ($audio === null || $this->overwriteAudio()) {
|
||||
Log::info("Extracting Audio from Video '{$sourceVideo->getName()}'");
|
||||
|
||||
$this->extractAudio($sourceVideo, $audioPath);
|
||||
$results = $this->reconcileAudio($audioPath);
|
||||
$results->toLog();
|
||||
|
||||
if ($audio === null) {
|
||||
$audio = $results->getCreated()->firstWhere(fn (Audio $audio) => $audio->path === $audioPath);
|
||||
if ($audio === null || $this->overwriteAudio() || $this->replaceRelatedAudio()) {
|
||||
if ($this->replaceRelatedAudio()) {
|
||||
$sourceVideo = $this->getModel();
|
||||
}
|
||||
|
||||
return $this->extractAudio($sourceVideo);
|
||||
}
|
||||
|
||||
return $audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source video for the given video.
|
||||
*
|
||||
* @return Video|null
|
||||
*/
|
||||
protected function getSourceVideo(): ?Video
|
||||
protected function getSourceVideo(bool $descending = true): ?Video
|
||||
{
|
||||
$source = null;
|
||||
|
||||
$sourceCandidates = $this->getAdjacentVideos();
|
||||
|
||||
foreach ($sourceCandidates as $sourceCandidate) {
|
||||
if (! $source instanceof Video || $sourceCandidate->getSourcePriority() > $source->getSourcePriority()) {
|
||||
$source = $sourceCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $source;
|
||||
return $this->getAdjacentVideos()->sortBy(
|
||||
fn (Video $video) => $video->getAttribute(Video::ATTRIBUTE_PRIORITY),
|
||||
descending: $descending
|
||||
)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the adjacent videos for sourcing.
|
||||
*
|
||||
* @return Collection<int, Video>
|
||||
*/
|
||||
protected function getAdjacentVideos(): Collection
|
||||
@@ -206,7 +196,7 @@ class BackfillAudioAction extends BaseAction
|
||||
|
||||
$orderByNameQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_NAME]);
|
||||
$orderBySeasonQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_SEASON]);
|
||||
$orderByYearQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_YEAR]);
|
||||
$orderByYearQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_START_DATE]);
|
||||
|
||||
return $builder->whereHas(AnimeTheme::RELATION_VIDEOS, fn (Builder $relationBuilder) => $relationBuilder->whereKey($this->getModel()))
|
||||
->orderBy($orderByYearQuery->toBase())
|
||||
@@ -224,57 +214,51 @@ class BackfillAudioAction extends BaseAction
|
||||
|
||||
/**
|
||||
* Extract audio stream from video and store in filesystem.
|
||||
*
|
||||
* @param Video $video
|
||||
* @param string $audioPath
|
||||
* @return void
|
||||
*/
|
||||
protected function extractAudio(Video $video, string $audioPath): void
|
||||
protected function extractAudio(Video $video): ?Audio
|
||||
{
|
||||
$audioBasename = Str::replace('webm', 'ogg', $video->basename);
|
||||
$audioPath = Storage::disk('local')->path($audioBasename);
|
||||
|
||||
try {
|
||||
foreach (Config::get(AudioConstants::DISKS_QUALIFIED, []) as $audioDisk) {
|
||||
FFMpeg::fromDisk(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED))
|
||||
->open($video->path)
|
||||
->addFilter(new AudioClipFilter(new TimeCode(0, 0, 0, 0)))
|
||||
->addFilter(new AddMetadataFilter())
|
||||
->export()
|
||||
->toDisk($audioDisk)
|
||||
->save($audioPath);
|
||||
}
|
||||
Storage::disk('local')->put(
|
||||
$video->basename,
|
||||
Storage::disk(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED))->get($video->path)
|
||||
);
|
||||
|
||||
Process::run([
|
||||
'ffmpeg',
|
||||
'-v',
|
||||
'quiet',
|
||||
'-i',
|
||||
Storage::disk('local')->path($video->basename),
|
||||
'-vn',
|
||||
'-acodec',
|
||||
'copy',
|
||||
'-f',
|
||||
'ogg',
|
||||
'-y',
|
||||
$audioPath,
|
||||
])
|
||||
->throw();
|
||||
|
||||
$uploadAudio = new UploadAudioAction(
|
||||
new UploadedFile($audioPath, $audioBasename),
|
||||
File::dirname($video->path)
|
||||
);
|
||||
|
||||
$storageResults = $uploadAudio->handle();
|
||||
|
||||
return $uploadAudio->then($storageResults);
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
} finally {
|
||||
FFMpeg::cleanupTemporaryFiles();
|
||||
Storage::disk('local')->delete([$video->basename, $audioBasename]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile audio repositories.
|
||||
*
|
||||
* @param string $audioPath
|
||||
* @return ReconcileResults
|
||||
*/
|
||||
protected function reconcileAudio(string $audioPath): ReconcileResults
|
||||
{
|
||||
$action = new ReconcileAudioRepositoriesAction();
|
||||
|
||||
/** @var RepositoryInterface $sourceRepository */
|
||||
$sourceRepository = App::make(AudioSourceRepository::class);
|
||||
$sourceRepository->handleFilter('path', File::dirname($audioPath));
|
||||
|
||||
/** @var RepositoryInterface $destinationRepository */
|
||||
$destinationRepository = App::make(AudioDestinationRepository::class);
|
||||
$destinationRepository->handleFilter('path', File::dirname($audioPath));
|
||||
|
||||
return $action->reconcileRepositories($sourceRepository, $destinationRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach Audio to model.
|
||||
*
|
||||
* @param Audio $audio
|
||||
* @return void
|
||||
*/
|
||||
protected function attachAudio(Audio $audio): void
|
||||
{
|
||||
if ($this->relation()->isNot($audio)) {
|
||||
|
||||
@@ -12,8 +12,6 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class ReconcileDumpRepositories.
|
||||
*
|
||||
* @extends ReconcileRepositoriesAction<Dump>
|
||||
*/
|
||||
class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
|
||||
@@ -33,12 +31,10 @@ class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
|
||||
|
||||
/**
|
||||
* Callback for create and delete set operation item comparison.
|
||||
*
|
||||
* @return Closure
|
||||
*/
|
||||
protected function diffCallbackForCreateDelete(): Closure
|
||||
{
|
||||
return fn (Dump $first, Dump $second) => [$first->path] <=> [$second->path];
|
||||
return fn (Dump $first, Dump $second): int => [$first->path] <=> [$second->path];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,20 +49,14 @@ class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
|
||||
|
||||
/**
|
||||
* Callback for update set operation item comparison.
|
||||
*
|
||||
* @return Closure
|
||||
*/
|
||||
protected function diffCallbackForUpdate(): Closure
|
||||
{
|
||||
return fn () => 0;
|
||||
return fn (): int => 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source model that has been updated for destination model.
|
||||
*
|
||||
* @param Collection $sourceModels
|
||||
* @param Model $destinationModel
|
||||
* @return Model|null
|
||||
*/
|
||||
protected function resolveUpdatedModel(Collection $sourceModels, Model $destinationModel): ?Model
|
||||
{
|
||||
@@ -75,11 +65,6 @@ class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
|
||||
|
||||
/**
|
||||
* Get reconciliation results.
|
||||
*
|
||||
* @param Collection $created
|
||||
* @param Collection $deleted
|
||||
* @param Collection $updated
|
||||
* @return ReconcileResults
|
||||
*/
|
||||
protected function getResults(Collection $created, Collection $deleted, Collection $updated): ReconcileResults
|
||||
{
|
||||
|
||||
@@ -8,8 +8,6 @@ use App\Actions\Repositories\ReconcileResults;
|
||||
use App\Models\Admin\Dump;
|
||||
|
||||
/**
|
||||
* Class ReconcileDumpResults.
|
||||
*
|
||||
* @extends ReconcileResults<Dump>
|
||||
*/
|
||||
class ReconcileDumpResults extends ReconcileResults
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Repositories\Billing\Balance;
|
||||
|
||||
use App\Actions\Repositories\ReconcileRepositoriesAction;
|
||||
use App\Actions\Repositories\ReconcileResults;
|
||||
use App\Enums\Http\Api\Filter\AllowedDateFormat;
|
||||
use App\Models\Billing\Balance;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class ReconcileBalanceRepositories.
|
||||
*
|
||||
* @extends ReconcileRepositoriesAction<Balance>
|
||||
*/
|
||||
class ReconcileBalanceRepositoriesAction extends ReconcileRepositoriesAction
|
||||
{
|
||||
/**
|
||||
* The columns used for create and delete set operations.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function columnsForCreateDelete(): array
|
||||
{
|
||||
return [
|
||||
Balance::ATTRIBUTE_DATE,
|
||||
Balance::ATTRIBUTE_ID,
|
||||
Balance::ATTRIBUTE_SERVICE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for create and delete set operation item comparison.
|
||||
*
|
||||
* @return Closure
|
||||
*/
|
||||
protected function diffCallbackForCreateDelete(): Closure
|
||||
{
|
||||
return fn (Balance $first, Balance $second) => $first->date->format(AllowedDateFormat::YM) <=> $second->date->format(AllowedDateFormat::YM);
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns used for update set operation.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function columnsForUpdate(): array
|
||||
{
|
||||
return [
|
||||
Balance::ATTRIBUTE_BALANCE,
|
||||
Balance::ATTRIBUTE_DATE,
|
||||
Balance::ATTRIBUTE_FREQUENCY,
|
||||
Balance::ATTRIBUTE_ID,
|
||||
Balance::ATTRIBUTE_SERVICE,
|
||||
Balance::ATTRIBUTE_USAGE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for update set operation item comparison.
|
||||
*
|
||||
* @return Closure
|
||||
*/
|
||||
protected function diffCallbackForUpdate(): Closure
|
||||
{
|
||||
return fn (Balance $first, Balance $second) => [$first->date->format(AllowedDateFormat::YMD), $first->usage, $first->balance]
|
||||
<=> [$second->date->format(AllowedDateFormat::YMD), $second->usage, $second->balance];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source model that has been updated for destination model.
|
||||
*
|
||||
* @param Collection $sourceModels
|
||||
* @param Model $destinationModel
|
||||
* @return Model|null
|
||||
*/
|
||||
protected function resolveUpdatedModel(Collection $sourceModels, Model $destinationModel): ?Model
|
||||
{
|
||||
$formattedDestinationDate = $destinationModel->getAttribute(Balance::ATTRIBUTE_DATE)->format(AllowedDateFormat::YM);
|
||||
|
||||
return $sourceModels->first(
|
||||
fn (Balance $balance) => $balance->date->format(AllowedDateFormat::YM) === $formattedDestinationDate
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reconciliation results.
|
||||
*
|
||||
* @param Collection $created
|
||||
* @param Collection $deleted
|
||||
* @param Collection $updated
|
||||
* @return ReconcileResults
|
||||
*/
|
||||
protected function getResults(Collection $created, Collection $deleted, Collection $updated): ReconcileResults
|
||||
{
|
||||
return new ReconcileBalanceResults($created, $deleted, $updated);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user