669 Commits

Author SHA1 Message Date
Kyrch fec8c51dda feat(filament): default performance items to 1 (#1216) 2026-07-10 18:11:57 -03:00
Kyrch 5dcad93fab fix(command): fix update anime date chunk (#1215) 2026-07-10 12:47:26 -03:00
Kyrch b19185da8f fix(filament): fix anime record null check (#1214) 2026-07-10 11:31:04 -03:00
Kyrch f036cd2d1b feat: add anime dates & mod notes (#1213) 2026-07-09 14:04:08 -03:00
Kyrch 19b340bf75 feat(graphql): siteUrl attribute (#1212) 2026-07-08 19:44:19 -03:00
Kyrch 73c2400a0f chore: bump dependencies (#1211) 2026-07-08 17:46:31 -03:00
Kyrch 5476445ccc feat: add id in some searching contexts (#1210) 2026-06-05 08:21:56 -03:00
Kyrch 6a81ddd1ee refactor: remove serving json api middleware (#1209) 2026-05-30 05:43:32 -03:00
Kyrch 624b076f03 fix(admin): fixed uploads without entries (#1208) 2026-05-28 00:38:04 -03:00
Kyrch 25389ce3d2 chore: bump dependencies (#1207) 2026-05-26 11:42:41 -03:00
Kyrch af2bc6c2dc clean: remove old anime synonyms & media format (#1206) 2026-05-12 04:13:07 -03:00
Kyrch 7182fa39bd fix(search): fix anime synonyms search (#1205) 2026-05-11 18:49:26 -03:00
Kyrch fa27b2dd80 feat(search): add sortable fields & typesense error handler (#1204) 2026-05-10 15:37:39 -03:00
Kyrch 519fc09ef4 docs: add Docker setup (#1203) 2026-05-10 01:45:11 -03:00
Kyrch 65a723f9b5 docker: php 8.5 & typesense & bump elastic and mysql (#1201) 2026-05-09 23:42:40 -03:00
Kyrch e0ea789eeb feat: improve typesense for json:api (#1202) 2026-05-09 18:56:50 -03:00
Kyrch 654f8a7f70 fix(admin): fix db sync command (#1200) 2026-05-08 02:20:41 -03:00
Kyrch d399933cf0 feat(admin): import scout command (#1199) 2026-05-08 02:13:57 -03:00
Kyrch fd2ef1611b clean(api): remove track position (#1198) 2026-05-08 02:02:20 -03:00
Kyrch a3f1a8c7e3 chore(admin): activity logs (#1197) 2026-05-07 21:33:57 -03:00
Kyrch f5e098875f chore: bump dependencies (#1196) 2026-05-07 17:46:56 -03:00
Kyrch 333a519611 feat: implicitly declare typesense fields (#1195) 2026-05-07 17:20:42 -03:00
Kyrch effdcecd79 fix(filament): fix option label when no record (#1194) 2026-05-07 17:06:56 -03:00
Kyrch ddceceda58 feat(graphql): change complexity max value (#1193) 2026-04-28 17:38:01 -03:00
Kyrch 44d1145f4a fix(admin): fix media format setting attribute (#1192) 2026-04-28 15:21:33 -03:00
Kyrch 0e41771f4c clean: remove discord thread features (#1191) 2026-04-26 14:37:24 -03:00
Kyrch 71c4f56a9d feat(graphql): add support for sorting on search (#1190) 2026-04-23 22:43:53 -03:00
Kyrch 74d3ee200a fix(graphql): fix id argument type of video query (#1189) 2026-04-23 13:08:08 -03:00
Kyrch 9613657962 fix: flag only usernames through the moderation rule (#1188) 2026-04-23 11:10:42 -03:00
Kyrch d813ca3b66 fix(graphql): mark mediaFormat arguments and enum as deprecated (#1187) 2026-04-23 10:52:22 -03:00
Kyrch c30ad3e28f fix(graphql): fix mutations and canFindMultiple directive (#1186) 2026-04-23 10:18:43 -03:00
Kyrch 94bcdf1275 chore: bump dependencies (#1185) 2026-04-23 00:48:53 -03:00
Kyrch 14b55970fe fix(graphql): query scope (#1184) 2026-04-22 15:40:38 -03:00
Kyrch 7fff5465c9 feat(graphql): migrate to lighthousephp (#1183) 2026-04-22 15:35:23 -03:00
Kyrch 0404625001 clean: remove unused class (#1182) 2026-04-19 11:40:55 -03:00
Kyrch 72b24f2eb6 feat(graphql): add support for IN operators in complex filtering (#1181) 2026-04-19 11:32:35 -03:00
Kyrch 00c60c58c4 clean(graphql): remove a lot of useless arguments & deletedAt fields (#1180) 2026-04-19 10:58:07 -03:00
Kyrch 5be6f661a4 fix(graphql): add missing studio sort argument (#1179) 2026-04-19 10:02:48 -03:00
Kyrch 4e1d4236fc fix(graphql): dont register pivot types (#1178) 2026-04-19 09:57:37 -03:00
Kyrch 75f391cc65 feat(graphql): build sort enums on demand for better management (#1177) 2026-04-19 09:50:00 -03:00
Kyrch 80d59f7e45 refactor(graphql): remove resolver classes (#1176) 2026-04-19 06:28:58 -03:00
Kyrch b1d7e9ed30 feat(graphql): remove IN filter from Float fields (#1175) 2026-04-19 05:13:07 -03:00
Kyrch a1341d0157 feat(graphql): remove EQ filter from DateTimeTz fields (#1174) 2026-04-19 05:10:39 -03:00
Kyrch cac463383e fix(graphql): fix playlist mutation (#1173) 2026-04-17 14:20:49 -03:00
Kyrch a433a7484b fix: add format fillable (#1172) 2026-04-17 13:58:56 -03:00
Kyrch 8cae74c677 Revert "feat: unguard models (#1167)" (#1171)
This reverts commit 884f6921a0.
2026-04-17 13:55:14 -03:00
Kyrch eeb5fd006a feat: anime format field (#1170) 2026-04-17 13:54:35 -03:00
Kyrch b9b95acb0b feat(graphql): deprecate internal queries and fields (#1169) 2026-04-17 11:06:35 -03:00
Kyrch 582ca2b7dd feat(api): remove guard name fields (#1168) 2026-04-17 10:48:29 -03:00
Kyrch 884f6921a0 feat: unguard models (#1167) 2026-04-17 10:45:33 -03:00
Kyrch 3ae5898927 feat(graphql): deprecate deletedAt field (#1166) 2026-04-17 10:40:23 -03:00
Kyrch 37e21989bb feat(admin): WIP push submission tables (#1165) 2026-04-17 10:34:32 -03:00
Kyrch 756d57bfeb clean: remove submission stages (#1164) 2026-04-10 02:03:16 -03:00
Kyrch 042decc60c feat(graphql): apply rate limit per request (#1163) 2026-04-10 01:03:59 -03:00
Kyrch 2f1a55a433 refactor(graphql): improve graphql rate limiting (#1162) 2026-04-10 00:32:43 -03:00
Kyrch cd11c5fa3b feat(api): improve intermediate paths performance (#1161) 2026-04-10 00:02:36 -03:00
Kyrch 93da5aafe4 fix(api): fix include path with caps (#1160) 2026-04-09 22:53:33 -03:00
Kyrch 07933577c1 feat(admin): add audit relation manager (#1159) 2026-04-09 21:32:15 -03:00
Kyrch 00560e9ab4 style(discord): distinguish between null fields and hidden fields (#1158) 2026-04-09 20:50:51 -03:00
Kyrch 6c4378b29a feat(admin): improve Performance reordering (#1157) 2026-04-09 08:54:10 -03:00
Kyrch a5483871d4 feat: audit pivots (#1156) 2026-04-09 03:14:21 -03:00
Kyrch 549f5b6461 clean: remove artist song model and using performance as pivot (#1155) 2026-04-09 03:07:24 -03:00
Kyrch 544155052c fix(graphql): add missing audio link field (#1154) 2026-04-07 21:03:17 -03:00
Kyrch e522eaf1f9 fix: patch framework eloquent collection bug (#1153) 2026-04-03 20:40:30 -03:00
Kyrch b6d42e21b4 chore: bump scout 11.x (#1152) 2026-04-03 14:41:35 -03:00
Kyrch 4f61a86638 feat: create artist song event (#1151) 2026-04-03 13:32:14 -03:00
Kyrch fcaa13891b feat!: remove memberships and add performance schema to JSON:API (#1150) 2026-04-03 04:20:01 -03:00
Kyrch 8d350a89f4 feat: add IDs for pivots (#1149) 2026-04-02 12:39:11 -03:00
Kyrch 3ec851fc23 fix(search): fix general elasticsearch (#1148) 2026-04-01 09:49:35 -03:00
Kyrch 7d949e8947 chore: bump dependencies (#1147) 2026-03-31 16:47:55 -03:00
Kyrch c42771a411 feat(search): add Typesense support (#1146) 2026-03-31 15:32:32 -03:00
Kyrch fa8d223b4b chore: send User-Agent headers to 3rd party APIs (#1145) 2026-03-29 15:12:28 -03:00
Kyrch 0f6b009e37 fix: fix method name (#1144) 2026-03-26 13:44:24 -03:00
Kyrch f370caf6cf fix(admin): fix optimize image action (#1143) 2026-03-26 13:39:29 -03:00
Kyrch 23df360b60 fix(filament): fix option label when model doesnt use scout (#1142) 2026-03-26 13:24:09 -03:00
Kyrch efcc70c332 refactor: refine pivot events (#1141) 2026-03-26 13:12:17 -03:00
Kyrch 0e1235cf5a fix(filament): fix theme name in entries (#1140) 2026-03-25 22:25:54 -03:00
Kyrch 5b9a8e3976 fix(filament): fix theme name (#1139) 2026-03-25 21:50:10 -03:00
Kyrch d2055c3726 feat(admin): add password reset link action (#1138) 2026-03-25 20:05:29 -03:00
Kyrch 37076c44cd fix: return all fields when no field criteria on JSON:API search (#1137) 2026-03-25 02:43:18 -03:00
Kyrch aff2250f48 chore: bump elastic driver (#1136) 2026-03-24 15:31:00 -03:00
Kyrch fc3f146bb4 chore: Laravel 13 (#1135) 2026-03-23 15:31:57 -03:00
Kyrch 722aaf9c14 chore: formatting minor stuff (#1134) 2026-03-22 23:20:22 -03:00
Kyrch d8f4db64a7 chore: remove intl php extension (#1133) 2026-03-20 20:39:21 -03:00
Kyrch b73d98fc2d chore: prepare dependencies to Laravel 13 (#1132) 2026-03-20 11:33:44 -03:00
Kyrch 6a0ddcad96 fix(filament): change image visibility to public (#1131) 2026-03-16 21:49:09 -03:00
Kyrch a230b803b7 refactor: improve performance action DX (#1130) 2026-03-16 21:26:27 -03:00
Kyrch 5839dfc4c2 fix(admin): fix backfill synonyms (#1129) 2026-03-16 17:58:32 -03:00
Kyrch cd2039d6e1 fix(graphql): fix relation sorting (#1128) 2026-03-16 17:53:37 -03:00
Kyrch b05a6022ac fix(search): increase song title score on theme search (#1127) 2026-03-09 16:08:17 -03:00
Kyrch ea21ce28ee feat: improve search query (mainly theme) (#1126) 2026-03-09 15:45:15 -03:00
Kyrch 2ae33b0fed feat: artist synonyms (#1125) 2026-03-09 14:52:16 -03:00
Kyrch 5d22f66eba refactor: move some traits to action classes (#1124) 2026-03-09 12:08:56 -03:00
Kyrch 8323620f83 refactor(graphql): rename methods (#1123) 2026-03-09 10:41:34 -03:00
Kyrch 6b1f130843 chore: bump dependencies (#1122) 2026-03-09 08:12:33 -03:00
Kyrch e84df9059e fix: attempt to fix feature recursion (#1121) 2026-03-05 17:04:19 -03:00
Kyrch 8386535251 feat(graphql): add middleware to mutations (#1120) 2026-03-05 07:30:09 -03:00
Kyrch 93a48173e5 feat: add tracks position (#1116) 2026-03-04 16:50:17 -03:00
Kyrch 8995240844 refactor: use collections in manage performances action (#1119) 2026-03-04 16:46:43 -03:00
Kyrch 26f9885968 feat(admin): download dump action (#1118) 2026-03-04 11:22:44 -03:00
Kyrch be08cf0d11 feat: reduce artist scout indexes (#1117) 2026-03-04 06:43:31 -03:00
Kyrch 24ec43bdab refactor: policies user null checking (#1115) 2026-02-24 11:42:58 -03:00
Kyrch a80f8d4d35 feat: page roles (#1114) 2026-02-24 02:55:52 -03:00
Kyrch fb05c1770e feat(graphql): add ENTRY_ID and VIDEO_ID to PlaylistTrackFilterableColumns (#1113) 2026-02-23 21:45:21 -03:00
Kyrch e4e28b74b5 fix: fix anime synonym index filename (#1112) 2026-02-19 04:06:14 -03:00
Kyrch 74cfefb20e fix: remove elastic migrate action in import models seeder (#1111) 2026-02-18 20:36:12 -03:00
Kyrch c1bf8be3a1 clean: remove view aggregate (#1110) 2026-02-18 20:32:58 -03:00
Kyrch b66c5cb3da feat: synonyms rework (#1109) 2026-02-18 20:26:46 -03:00
Kyrch e4179cf312 feat(graphql): Me.likes relation (#1103) 2026-02-16 21:29:19 -03:00
Kyrch 73ea075f54 fix: attempt to fix UpdatePlaylistTracks listener (#1102) 2026-02-16 08:28:33 -03:00
Kyrch 92c54ccdf5 chore(graphql): default rate limit to 90 (#1101) 2026-02-16 07:33:41 -03:00
Kyrch d2ced8db70 tests: add mutation tests (#1100) 2026-02-15 16:35:04 -03:00
Kyrch fcf64b6406 feat(graphql): toggle like mutation (#1099) 2026-02-15 07:03:06 -03:00
Kyrch dff1634480 feat: WIP add watch history (#1098) 2026-02-14 03:17:36 -03:00
Kyrch ef56c3e130 docs: improve README (#1097) 2026-02-13 02:43:51 -03:00
Kyrch 5061fc126d clean: remove latest document dump endpoint (#1096) 2026-02-13 01:43:29 -03:00
Kyrch 1f5e48da37 chore: dump document tables weekly (#1095) 2026-02-13 01:35:18 -03:00
Kyrch a2d159a90d clean: remove WithoutInsertSongScope from contexts because of new middleware (#1094) 2026-02-10 02:20:19 -03:00
Kyrch 5e7b03b167 feat(graphql): made animethemeShuffle.spoiler optional (#1093) 2026-02-10 02:10:56 -03:00
Kyrch 747d1d8fc3 refactor(graphql): pass type instances for relations (#1092) 2026-02-09 23:31:55 -03:00
Kyrch e99e692694 feat(graphql): video-tracks relation (#1091) 2026-02-09 20:28:51 -03:00
Kyrch c7e83e0ae9 feat(graphql): animethemeShuffle query (#1090) 2026-02-09 20:10:21 -03:00
Kyrch 3815716a9f feat: dump public attribute (#1089) 2026-02-08 23:10:14 -03:00
Kyrch b685c656df tests: fix video tags unit tests (#1088) 2026-02-08 20:58:11 -03:00
Kyrch 3356167e3b feat: page previous & next relations (#1087) 2026-02-08 20:53:48 -03:00
Kyrch 7d676d3010 feat: improve search with word_delimiter_graph (#1086) 2026-02-08 01:46:02 -03:00
Kyrch f8786cb6bf fix: remove moderation rule from playlist descriptions (#1084) 2026-02-06 04:51:11 -03:00
Kyrch da54035060 fix: entry name (#1083) 2026-02-06 02:45:06 -03:00
Kyrch 5817636003 tests: add missing model tests & sort tests (#1082) 2026-02-05 19:24:38 -03:00
Kyrch 2257d4b6ed fix: add backoff value to jobs (#1081) 2026-02-04 22:24:22 -03:00
Kyrch cdfcc901ab feat(filament): overlay parent action (#1080) 2026-02-04 13:32:26 -03:00
Kyrch af2946155e feat: video priority attribute (#1079) 2026-02-04 13:24:03 -03:00
Kyrch 742dc20106 refactor(graphql): relations extends field (#1078) 2026-02-04 11:16:19 -03:00
Kyrch 03103389b2 feat(graphql): add support for pivot one-to-many relationships (#1077) 2026-02-03 20:02:04 -03:00
Kyrch 44b9354495 fix(graphql): fix eager loading when edges object is requested instead of nodes (#1076) 2026-02-03 03:08:01 -03:00
Kyrch 6c86a0b9de refactor: rename resource classes (#1075) 2026-02-02 04:29:20 -03:00
Kyrch b09ceebe4b feat(filament): add links to performance columns & minor improvements (#1074) 2026-02-01 22:06:56 -03:00
Kyrch 58cc05a07e tests: fix upload video action test (#1073) 2026-01-30 23:40:00 -03:00
Kyrch 7c5075b188 chore!: database changes part 2 (#1072) 2026-01-30 23:04:49 -03:00
Kyrch 60c5351b04 chore!: database & GraphQL changes (#1070) 2026-01-30 20:56:59 -03:00
Kyrch c6f43a395e fix: update maxCount validation logic in FirstArgumentRule (#1071) 2026-01-30 18:59:17 -03:00
Kyrch fa5468d1a5 chore: handle performances when artist is deleted (#1069) 2026-01-30 15:07:34 -03:00
Kyrch 7eac12d788 chore: unlock filament dependency (#1068) 2026-01-29 18:56:01 -03:00
Kyrch a71ca35efb chore: bump dependencies (#1067) 2026-01-28 00:13:07 -03:00
Kyrch 05b6bb0a03 feat(graphql): graphql middleware group (#1066) 2026-01-27 21:02:01 -03:00
Kyrch a4975c8813 fix(graphql): fix aggregate selection set (#1065) 2026-01-27 20:12:50 -03:00
Kyrch 45ea839c4e fix(graphql): fix sandbox csrf & role color/priority nullable (#1064) 2026-01-27 19:58:47 -03:00
Kyrch 1f815a6082 fix(graphql): fix auth session (#1063) 2026-01-27 19:07:00 -03:00
Kyrch fdb6b69687 chore: bump dependencies (#1062) 2026-01-27 13:35:19 -03:00
Kyrch 01411c6f77 feat(graphql): aggregates (#1061) 2026-01-27 00:52:49 -03:00
Kyrch 1f8b9daa1f feat(graphql): relation filter (#1060) 2026-01-26 18:28:15 -03:00
Kyrch 9fb9c5e11b fix(graphql): fix date fields nullable (#1059) 2026-01-26 17:26:18 -03:00
Kyrch d8dc07010a chore: bump to filament v5 (#1058) 2026-01-23 15:54:22 -03:00
Kyrch dfd958ef69 feat(prohibition): add default times for expires_at field & filter/scopes (#1057) 2026-01-21 23:27:44 -03:00
Kyrch 33650905d9 chore: changed prohibition mail's message (#1056) 2026-01-21 19:22:47 -03:00
Kyrch 871b9fe86f fix: fix class name 2026-01-21 17:35:48 -03:00
Kyrch 117970261b fix: attempt to fix event handling (#1055) 2026-01-21 17:26:58 -03:00
Kyrch 1ee1067594 fix: fix events namespaces (#1054) 2026-01-21 16:55:11 -03:00
Kyrch 4120a73228 feat: prohibitions (#1053) 2026-01-21 16:38:31 -03:00
Kyrch 4f7ab1ca5a refactor: change discord action to use graphql (#1052) 2026-01-19 14:41:18 -03:00
Kyrch bcc9dec704 feat(api): add allowed includes for entry (#1051) 2026-01-15 08:04:22 -03:00
Kyrch 6d9d549d52 fix: attempt to fix job parameters (#1050) 2026-01-11 09:29:00 -03:00
Kyrch b7d10d81e8 fix(graphql): fix format exceptions (#1049) 2026-01-07 18:38:51 -03:00
Kyrch 10ae7b865f fix(graphql): fix field aliases with no sub-selections (#1048) 2026-01-06 16:32:46 -03:00
Kyrch d74ba4c8e5 fix(graphql): fix relation filtering (#1047) 2026-01-06 01:19:51 -03:00
Kyrch 84cdc19fb0 feat(dev): make db:sync command smarter (#1046) 2026-01-05 07:05:53 -03:00
Kyrch 22918e3adb tests(graphql): unit filters (#1045) 2026-01-05 01:03:35 -03:00
Kyrch c2d6b81158 fix(graphql): fix print-schema command (#1044) 2026-01-04 03:55:26 -03:00
Kyrch 743b319340 feat(externalprofiles): add validation to token controllers (#1043) 2026-01-03 21:30:23 -03:00
Kyrch 67d759a129 fix(graphql): fix enum filter validation (#1042) 2026-01-03 05:33:22 -03:00
Kyrch d88376a289 feat(graphql): add strict validation to filters (#1041) 2026-01-03 03:52:16 -03:00
Kyrch ece37dcc73 refactor(graphql): improve filters with validation (#1040) 2026-01-03 02:39:25 -03:00
Kyrch 1aa38b391d fix(graphql): fixed where conditions nested (#1039) 2026-01-02 23:06:39 -03:00
Kyrch 6ac84415c8 feat(graphql): where conditions (#1038) 2026-01-01 19:55:41 -03:00
Kyrch bd3419715c feat(api): entry track count field (#1037) 2026-01-01 01:22:21 -03:00
Kyrch 3f1bd8a327 fix: fixed announcements migration (#1036) 2025-12-31 02:10:26 -03:00
Kyrch 110950e0f2 feat: announcement dates (#1035) 2025-12-31 00:29:55 -03:00
Kyrch 1c71ed0571 chore: bump dependencies (#1034) 2025-12-30 18:53:18 -03:00
Kyrch f90c3d3732 refactor: reorganize models (#1033) 2025-12-24 15:31:51 -03:00
Kyrch 60406d89fa docs: update contributing guide (#1032) 2025-12-23 17:29:16 -03:00
Kyrch 90b7a2820e feat(submission): remove virtual hidden fields & submission action (#1031) 2025-12-23 03:27:51 -03:00
Kyrch 48208ce42d refactor: move theme slug generator to observer (#1030) 2025-12-22 20:23:20 -03:00
Kyrch 3d1d2dcfbc docs: fixed failed static analysis in lvl 6 (#1029) 2025-12-22 17:51:37 -03:00
Kyrch d73ff841bd refactor: add observers (#1028) 2025-12-19 22:04:43 -03:00
Kyrch 869677e49f refactor: remove down methods and constants in migrations (#1027) 2025-12-19 20:02:17 -03:00
Kyrch c91de1acf9 fix: remanage redis locks (#1026) 2025-12-18 13:55:38 -03:00
Kyrch 03dd572300 docs: move installation section to README (#1025) 2025-12-18 00:03:56 -03:00
Kyrch 8077b37b9d chore: php 8.5 (#1024) 2025-12-17 22:57:59 -03:00
Kyrch 245aadf381 feat(graphql): authorization messages (#1023) 2025-12-17 20:09:55 -03:00
Kyrch 144994676e clean: remove unused discord actions (#1022) 2025-12-11 11:51:31 -03:00
Kyrch aad47fdc90 feat(submissions): virtuals (#1021) 2025-12-11 11:22:34 -03:00
Kyrch fefff9b8ab feat(submissions): notes field (#1020) 2025-12-10 20:50:16 -03:00
Kyrch 7b786cf911 chore: bump dependencies & clear graphql inputs (#1019) 2025-12-10 08:16:01 -03:00
Kyrch 696a9882a9 feat: WIP submission panel & submission stages (#1018) 2025-12-09 12:04:48 -03:00
Kyrch 8258bc05af style: fix sandbox margin (#1017) 2025-12-01 15:53:19 -03:00
Kyrch 1745f1f32f feat(graphql): sandbox (#1016) 2025-12-01 14:44:12 -03:00
Kyrch ac5df24981 fix(graphql): default to v1 schema (#1015) 2025-11-30 10:07:05 -03:00
Kyrch 037a460add fix(graphql): don't report request errors (#1014) 2025-11-29 03:19:08 -03:00
Kyrch 3306e8502e fix: change optimize action permission (#1013) 2025-11-28 22:11:05 -03:00
Kyrch 01eb8c175d feat: automatically downscale small covers (#1012) 2025-11-28 22:07:11 -03:00
Kyrch ee7549f9c6 chore: bump dependencies (#1011) 2025-11-28 17:51:51 -03:00
Kyrch f95cac61a2 refactor: from reports to submissions (#1010) 2025-11-28 17:24:40 -03:00
Kyrch 3698939f88 fix(search): fixed search result keys (#1009) 2025-11-22 21:01:57 -03:00
Kyrch 75696b7a15 refactor(graphql): remove trashed argument class & centralize filters on criteria (#1008) 2025-11-16 10:58:50 -03:00
Kyrch 2eb9d2e75d fix: studio-resource attach (#1007) 2025-11-16 07:56:32 -03:00
Kyrch a4c2d3a0de refactor: general code improvement (#1006) 2025-11-15 16:00:49 -03:00
Kyrch 44c412fc32 feat(graphql): relevance field for performances & artistmember (#1005) 2025-11-15 10:28:16 -03:00
Kyrch 4a6739b8cd tests(graphql): added non-eloquent queries tests (#1004) 2025-11-14 14:55:50 -03:00
Kyrch 0f290b1677 feat(graphql): sort pivot fields (#1003) 2025-11-13 17:20:32 -03:00
Kyrch f0437bf764 chore: bump dependencies (#1002) 2025-11-13 14:38:05 -03:00
Kyrch d225bd60fc refactor(graphql): filter criteria (#1001) 2025-11-13 09:52:10 -03:00
Kyrch 1f8827996c refactor(graphql): sort criteria (#1000) 2025-11-13 06:12:31 -03:00
Kyrch 59e770bef0 feat: [WIP] support for multiple search drivers (#999) 2025-11-13 04:42:10 -03:00
Kyrch f75765f27e fix(graphql): rate limit on parse error (#998) 2025-11-10 10:34:43 -03:00
Kyrch 7a9dc62022 fix(graphql): missing rate limiting headers (#997) 2025-11-10 09:09:08 -03:00
Kyrch af88a1098e fix: fixed storage action not running update & fixed dirname info (#996) 2025-11-08 10:06:56 -03:00
Kyrch 7b69fbadd8 fix(filament): missing move image action (#995) 2025-11-07 09:33:29 -03:00
Kyrch 07cdddcbcb fix: convert elastic query playloads to dis_max (#994) 2025-11-06 08:49:29 -03:00
Kyrch 8dd25de1a4 feat: anime theme entry likeable (#993) 2025-11-06 05:49:20 -03:00
Kyrch c0ffa7083d feat: optimize image action (#992) 2025-11-03 07:12:25 -03:00
Kyrch 56737563b2 feat(filament): query builders (#991) 2025-11-02 23:18:31 -03:00
Kyrch e5c3273755 chore: bump filament (#990) 2025-11-02 21:50:45 -03:00
Kyrch 848c6efa69 fix: log exceptions in external api action (#989) 2025-10-30 23:47:47 -03:00
Kyrch 5fd00ec028 feat: complex query to handle artist member relevance (#988) 2025-10-30 21:05:28 -03:00
Kyrch 5120342f37 fix: revert to old artistmember sync (#987) 2025-10-30 20:48:37 -03:00
Kyrch 12d53fd99a fix: fixed performance events (#986) 2025-10-30 19:11:53 -03:00
Kyrch e0011d8e98 feat: artist member relevance (#985) 2025-10-30 18:20:49 -03:00
Kyrch 270ca8ed7b fix(events): fixed playlist events (#984) 2025-10-27 16:11:54 -03:00
Kyrch 3de70d1199 fix(filament): fixed wrong variable name (#983) 2025-10-26 17:17:46 -03:00
Kyrch 3f7cbdf24a refactor: remove unused methods from events (#982) 2025-10-26 14:06:17 -03:00
Kyrch efef58eb24 chore: bump dependencies & lint (#981) 2025-10-26 03:09:42 -03:00
Kyrch da93812c12 Feat/performance relevance (#980) 2025-10-25 01:58:27 -03:00
Kyrch 4a7b960d34 feat: song native title (#979) 2025-10-24 03:31:00 -03:00
Kyrch 6721443646 chore: bump dependencies & composer bump (#978) 2025-10-19 00:47:03 -03:00
Kyrch 520578e97a feat(filament): load artists action (#977) 2025-10-19 00:32:11 -03:00
Kyrch 1aa4d020a8 fix: attempt to lock for update (#976) 2025-10-17 15:30:17 -03:00
Kyrch e9246d0e0d fix(discord): convert stringables on formatting (#975) 2025-10-17 14:42:17 -03:00
Kyrch 506c7e115f chore: added new rector rules (#974) 2025-10-10 16:26:40 -03:00
Kyrch f074b82d80 feat(admin): added livechart checkbox in backfill anime (#973) 2025-10-07 05:16:34 -03:00
Kyrch ba7fb60383 feat(admin): added anizip mapping (#972) 2025-10-07 04:53:52 -03:00
Kyrch 269cf22996 feat(graphql): rate limit per query (#971) 2025-10-02 15:25:29 -03:00
Kyrch ad899b7271 chore: rector (#970) 2025-10-01 17:18:31 -03:00
Kyrch ac9b4be61b chore: bump dependencies (#969) 2025-09-29 10:23:51 -03:00
Kyrch 0ed5ad68d8 feat(graphql): allow array in findAnimesByExternalSite.id (#968) 2025-09-25 02:44:24 -03:00
Kyrch b262fdf08c fix(graphql): fix sort by relation (#967) 2025-09-21 03:00:46 -03:00
Kyrch 414836a58f feat(graphql): tracksCount field for AnimeThemeEntry type (#966) 2025-09-20 02:54:42 -03:00
Kyrch 63cb73fe8d feat: groupships (#965) 2025-09-19 12:50:55 -03:00
Kyrch ff6aa20807 fix(graphql): custom resolve info class (#964) 2025-09-17 22:32:54 -03:00
Kyrch ec29509163 chore: bump dependencies (#963) 2025-09-16 15:05:17 -03:00
Kyrch ed8a6b9212 chore: bump dependencies (#962) 2025-09-15 13:28:20 -03:00
Kyrch bc33705162 fix(graphql): dont report client errors (#961) 2025-09-13 17:12:48 -03:00
Kyrch 4241c6ed5e clean: remove developer dashboard 2025-09-12 15:11:39 -03:00
Kyrch 164a9f9573 clean: remove exceptions plugin (#960) 2025-09-12 15:09:14 -03:00
Kyrch 4dc8bb9238 feat(filament): navigation groups (#959) 2025-09-12 15:05:14 -03:00
Kyrch 128a2bf9cf clean: remove views (#958) 2025-09-05 14:16:44 -03:00
Kyrch 8c62a8e892 refactor(graphql): change graphql method naming (#957) 2025-09-05 12:42:30 -03:00
Kyrch 0a05a6c73b feat(graphql): trashed filter (#956) 2025-09-04 07:53:23 -03:00
Kyrch 213727c2a2 refactor(graphql): move eloquent resolvers to actions (#955) 2025-09-04 07:12:05 -03:00
Kyrch a97e1d5abd refactor: move song relation to new performance system (#954) 2025-09-03 16:59:10 -03:00
Kyrch 08dfb5b099 feat(graphql): added format argument for date fields (#953) 2025-09-03 16:17:13 -03:00
Kyrch a4ed8f257e clean: remove encode model (#952) 2025-09-03 16:02:35 -03:00
Kyrch db4495436a feat(search): improve artist search with new performance system (#951) 2025-09-03 08:57:23 -03:00
Kyrch a4d3191294 refactor: fix graphql structure (#950) 2025-09-03 04:57:13 -03:00
Kyrch 69c748dd9c fix(graphql): fixed eager load nodes children (#949) 2025-09-02 10:13:40 -03:00
Kyrch a4ef61b134 feat(graphql)!: remove pagination type from one-to-many relationships (#948) 2025-09-01 11:12:55 -03:00
Kyrch ab572e951c refactor(graphql): move paginator to pagination (#947) 2025-09-01 11:01:40 -03:00
Kyrch b220154cd3 fix: added many-to-many generics & fix its keys (#946) 2025-09-01 09:43:45 -03:00
Kyrch 3173c2818a fix: missing IN scope (#945) 2025-08-31 21:31:48 -03:00
Kyrch ad7c23b788 fix(filament): authorization on demand (#944) 2025-08-31 14:47:49 -03:00
Kyrch 7c22f3acc1 refactor: merge graphql policies (#943) 2025-08-30 22:21:02 -03:00
Kyrch 9963515a31 docs: clean unnecessary docs (#942) 2025-08-29 21:17:01 -03:00
Kyrch bdceea0a7c fix: fixed authorizes pivot middleware (#941) 2025-08-29 07:39:00 -03:00
Kyrch ed8322d62a clean: removed unnecessary eager load for subtitle as it auto eager loads dinamically now (#940) 2025-08-27 23:53:13 -03:00
Kyrch 7e2712386d fix(filament): fixed pivot action logs (#939) 2025-08-27 22:28:43 -03:00
Kyrch 7cdf7c90dc feat(graphql): added missing singular queries (#937) 2025-08-27 21:55:52 -03:00
Kyrch a15a9d141c fix(graphql): missing constraints & handle union constraint (#936) 2025-08-27 11:57:58 -03:00
Kyrch 01827680b6 chore: bump dependencies (#935) 2025-08-27 10:20:55 -03:00
Kyrch 40c334ccb3 feat(graphql): notification union (#934) 2025-08-27 03:47:27 -03:00
Kyrch 7382ed4a37 feat: auditable (#933) 2025-08-27 01:30:42 -03:00
Kyrch 5614c0d4c6 fix: fixed performance events (#932) 2025-08-24 00:41:44 -03:00
Kyrch 4eb60cd6f3 fix: fixed morph mapping (#931) 2025-08-23 04:48:15 -03:00
Kyrch 13074d716a refactor: added morph map (#930) 2025-08-23 04:04:51 -03:00
Kyrch 9b5b69fb01 chore: pest v4 (#929) 2025-08-21 19:52:51 -03:00
Kyrch da39dc0b74 Create config.yml 2025-08-21 16:48:40 -03:00
Kyrch 1348e16648 Added GitHub issue templates 2025-08-21 16:43:31 -03:00
Kyrch 4b3ecbe793 feat(filament): added entry yt tab & song column searchable (#928) 2025-08-18 04:16:07 -03:00
Kyrch eb265c839b feat: added entry <-> resource relation (#927) 2025-08-18 03:23:37 -03:00
Kyrch 9b08e233e3 feat(graphql): nested sorting (#926) 2025-08-18 01:08:42 -03:00
Kyrch dc522d1a5f fix(graphql): graphql config (#925) 2025-08-16 02:13:09 -03:00
Kyrch 444884921c refactor: imageable morph pivot (#924) 2025-08-16 01:52:18 -03:00
Kyrch c540c7bb7f refactor: resourceable morph pivot (#923) 2025-08-15 23:10:53 -03:00
Kyrch 8bfa2bb44c chore: bump dependencies (#922) 2025-08-15 18:32:43 -03:00
Kyrch 7513cf90fe chore(graphql): migrate to rebing/graphql (#921) 2025-08-15 18:21:41 -03:00
Kyrch b4542a50c8 refactor(graphql): remove routeable definition (#920) 2025-08-08 18:56:42 -03:00
Kyrch 5ad3ab5c0f fix(graphql): fixed playlist singular query (#919) 2025-08-08 18:29:22 -03:00
Kyrch 5ac1e9f3fe fix(graphql): fix find by anime in singular (#918) 2025-08-08 01:01:18 -03:00
Kyrch 7fffee1ebb feat(graphql)!: suffix Paginator for plural queries & added singular queries (#917) 2025-08-08 00:06:23 -03:00
Kyrch 87adc2ff32 feat(graphql): AnimeYear.season & AnimeYear.seasons fields (#916) 2025-08-07 19:56:33 -03:00
Kyrch 6a4b3c37c7 fix(graphql): fix serialize enum type (#915) 2025-08-06 20:30:51 -03:00
Kyrch 5501526a1d feat(graphql): handle insert song (#914) 2025-08-06 14:35:54 -03:00
Kyrch 1b5f42e601 feat(graphql): phohibits animes field when no unique year (#913) 2025-08-06 05:32:15 -03:00
Kyrch 6fe8385054 feat(graphql): new AnimeYear type (#912) 2025-08-06 04:56:17 -03:00
Kyrch e1029cb4ab fix(graphql): fixed find animes query site filter (#911) 2025-08-05 00:27:08 -03:00
Kyrch 0484fd73cb fix(filament): fixed belongs to entry (#909) 2025-08-03 05:02:41 -03:00
Kyrch 3a79dc85c8 chore(filament): add charts back (#908) 2025-08-02 04:01:02 -03:00
Kyrch 8f2787553c refactor(graphql): removed HasFields/HasRelations interfaces (#907) 2025-08-02 01:03:53 -03:00
Kyrch 7c146e465f fix(filament): reduce number of gate checks for base actions (#906) 2025-07-31 19:50:04 -03:00
Kyrch 6ae0cf12b1 chore: bump dependencies (#905) 2025-07-31 17:05:00 -03:00
Kyrch 0a9f393c8c fix: fix link attributes when model doesnt exist (#904) 2025-07-31 04:50:48 -03:00
Kyrch 3860f3ef73 tests: fixed tests failing for no reason (#903) 2025-07-31 03:47:17 -03:00
Mani 51bcd875d4 fix: Added link attribute to dump model (#902) 2025-07-30 20:03:34 -03:00
Kyrch f6d75ea29e tests: fix filament tests (#901) 2025-07-30 03:43:00 -03:00
Kyrch a90befebfe feat(graphql): where conditions (#900) 2025-07-30 02:20:46 -03:00
Kyrch e58518f51a feat(tests): pest php (#899) 2025-07-29 23:31:12 -03:00
Kyrch a2ed59cc01 fix: added missing null checks in arrays (#898) 2025-07-28 22:41:13 -03:00
Kyrch 5c5a9fda71 refactor(graphql): sort class support (#897) 2025-07-28 21:10:54 -03:00
Kyrch 1b72050cda feat(graphql): random sorting (#896) 2025-07-28 19:09:10 -03:00
Kyrch 2400f663e1 refactor(graphql): move builders to controllers (#895) 2025-07-28 04:11:24 -03:00
Kyrch 9070068308 feat(graphql): initial commit for graphql reports (#894) 2025-07-28 03:49:14 -03:00
Kyrch aed66607be refactor(graphql): update code schema (#893) 2025-07-27 15:08:00 -03:00
Kyrch 239664851b feat(graphql): added enums {Type}SortableColumns (#892) 2025-07-26 18:58:03 -03:00
Kyrch 799d258857 feat(graphql): mark viewsCount as deprecated (#891) 2025-07-26 16:10:34 -03:00
Kyrch a0788eeeea feat(graphql): overlap edge connection to allow a nodes field (#890) 2025-07-26 03:47:28 -03:00
Kyrch 8065d567f3 feat(graphql): eloquent singular queries (#889) 2025-07-25 23:08:05 -03:00
Kyrch c8266936e5 feat(graphql): findAnimesByExternalSite query (#888) 2025-07-25 17:25:53 -03:00
Kyrch a5c3914478 feat(graphql): argument readonly class (#887) 2025-07-25 16:18:41 -03:00
Kyrch 173407348d refactor(graphql): changed from order to sort (#886) 2025-07-25 03:24:34 -03:00
Kyrch 25cf1a3a08 feat(graphql): added pagination for hasMany relationships (#885) 2025-07-25 01:59:16 -03:00
Kyrch ae26b4135c feat(graphql): search directive as attribute (#884) 2025-07-25 01:37:25 -03:00
Kyrch 904b8a8b65 feat(graphql): ordering (#883) 2025-07-25 01:27:30 -03:00
Kyrch 2cba594edf chore: bump dependencies (#882) 2025-07-24 13:35:49 -03:00
Kyrch 1a07ac1d00 chore: bump recently 2.0 (#881) 2025-07-24 01:48:47 -03:00
Kyrch 5c5e96f2e6 docs: clean unnecessary phpdocs (#880) 2025-07-24 01:22:29 -03:00
Kyrch 076eea744a fix(graphql): fixed edge node type name (#879) 2025-07-23 17:01:23 -03:00
Kyrch 1c23cc3298 fix: reverts nginx stream uri (#878) 2025-07-23 16:53:19 -03:00
Kyrch 7fd7141a38 feat(graphql): added edge types programmatically (#877) 2025-07-23 16:38:02 -03:00
Kyrch 37662dd4c4 feat(graphql): added profile / like mutations (#876) 2025-07-18 21:50:45 -03:00
Kyrch 7bb4c221ce chore: bump dependencies (#875) 2025-07-18 12:32:35 -03:00
Kyrch f8b9aca0fc feat(graphql): added mutations programmatically (#874) 2025-07-18 12:26:58 -03:00
Kyrch 0fb8c9b372 feat(graphql): nested queries (#873) 2025-07-16 10:28:00 -03:00
Kyrch 413498e153 feat: paginate entries from MAL (#872) 2025-07-14 19:35:25 -03:00
Kyrch 7afa555e2a feat(filament): added queries for auth actions (#871) 2025-07-14 03:26:26 -03:00
Kyrch b24dfebbf3 feat: added announcement public attribute (#870) 2025-07-13 10:27:47 -03:00
Kyrch f84a4b33a5 docs: added generic types (#869) 2025-07-12 12:52:29 -03:00
Kyrch 1dd9a8a606 feat: merge filament-v4 into main (#868) 2025-07-11 17:32:02 -03:00
Kyrch 9ad19f260a refactor: updated action namespaces (#848) 2025-07-03 02:19:08 -03:00
Kyrch 6bbfa1e86a refactor: playlist is not viewable (#847) 2025-07-02 03:25:37 -03:00
Kyrch 7b75be439c style: fixed pint php unit method casing (#839) 2025-06-27 03:12:05 -03:00
Kyrch b0dee5998f style: added pint.json (#838) 2025-06-27 02:52:13 -03:00
Kyrch 286796bc0f chore: bump dependencies (#833) 2025-06-23 13:14:37 -03:00
Kyrch 1eaa7fff9b refactor: casting discord message arrays in classes (#832) 2025-06-23 12:55:23 -03:00
Kyrch 77869066f2 feat(graphql): added contracts (#831) 2025-06-22 18:06:23 -03:00
Kyrch e525cd7367 fix: attempt to fix dump reconciliation overlapping (#830) 2025-06-11 17:35:23 -03:00
Kyrch ecf7f2d7da feat(graphql): added localized fields for enum fields (#829) 2025-06-08 00:48:19 -03:00
Kyrch 59f8ce12dc fix: attempt to fix feature enabled directive (#828) 2025-06-04 15:56:42 -03:00
Kyrch 6fc78d3709 refactor(graphql): changed how filter directives are build (#827) 2025-06-04 15:15:21 -03:00
Kyrch f9c932fd30 fix: fixed graphql middleware interfering globally (#826) 2025-06-03 17:17:54 -03:00
Kyrch 3417eb63a1 chore: bump dependencies (#825) 2025-06-03 13:45:45 -03:00
Kyrch e7b2e540c2 feat(graphql): added queries & filters programmatically (#824) 2025-06-02 11:46:23 -03:00
Kyrch a32fa7f6d3 refactor(graphql): define GraphQL structure programmatically instead of SDL (#822) 2025-05-28 13:00:39 -03:00
Kyrch c4385cf6d9 fix: fixed if statement in encode updated event (#821) 2025-05-24 07:02:50 -03:00
Kyrch 682e02895c feat: added encode type attribute (#820) 2025-05-20 04:08:39 -03:00
Kyrch 63395481f7 feat: added likes support (#819) 2025-05-16 08:05:31 -03:00
Kyrch abbadf9fcd chore: external entry/token are not trashable (#818) 2025-05-15 22:21:36 -03:00
Kyrch c7ba086947 fix: add profile index & fixed orphan token & fixed duplicated tokens (#817) 2025-05-13 22:09:27 -03:00
Kyrch 69cc755077 feat: add proper rate limiting for external profile sync job (#816) 2025-05-12 05:47:42 -03:00
Kyrch 6f8bcd9ba0 feat: video throttling (#637) 2025-05-12 02:47:47 -03:00
Kyrch 002358af1b chore: bump dependencies (#815) 2025-05-12 02:32:59 -03:00
Kyrch 38f0e2fa76 feat: GraphQL (#814) 2025-05-12 02:07:27 -03:00
Kyrch 453cc1e72a fix: added prod validation (#812) 2025-05-09 09:50:11 -03:00
Kyrch c0db39c697 feat: added database sync command (#811) 2025-05-09 09:47:11 -03:00
Kyrch b426620534 fix: dump commands should run at 00:00 (#810) 2025-05-05 03:25:21 -03:00
Kyrch 7d5156e937 feat: added views dump (#809) 2025-05-05 03:10:32 -03:00
Kyrch 5962a7c3c2 feat: improve action log fields (#808) 2025-05-04 04:01:39 -03:00
Kyrch efbdcc1e6a feat: added admin dumps (#807) 2025-05-04 00:47:26 -03:00
Kyrch d468811adc feat: added encodes (#806) 2025-05-03 04:19:29 -03:00
Kyrch 6c3837aca2 chore: update roles & removed page sections (#800) 2025-04-29 15:18:37 -03:00
Kyrch 88aeaecafa feat: added better labels & fixed filament search (#799) 2025-04-29 00:40:50 -03:00
Kyrch bc9df20162 feat: added notifications (#795) 2025-04-11 18:38:36 -03:00
Kyrch 9e46ba9a2c feat: added user notifications (#794) 2025-04-10 12:01:29 -03:00
Kyrch 5fb12ae228 refactor: migrate from create/list filament pages to simple modals (#793) 2025-04-08 21:40:03 -03:00
Mani 936aafcc4f chore: Updated Sail setup to use PHP 8.4 (#775)
* Ejected Sail configs to enable XDebug.
2025-04-08 04:12:47 +02:00
Kyrch 7a2fb3fbfd chore: bump to laravel 12 (#792) 2025-04-04 01:41:42 -03:00
Kyrch b181a0b254 feat: merge performances into main (#791) 2025-04-03 18:31:11 -03:00
Kyrch 7178470f50 fix: attempt to fix no authenticated users in job context (#783) 2025-02-08 19:27:58 -03:00
Kyrch d49085432c feat: added user info to the footer of the discord embeds (#782) 2025-02-08 18:41:45 -03:00
Kyrch ad79ed1117 fix: attempt to fix entry name when no theme (#781) 2025-02-08 15:13:11 -03:00
Kyrch fdceea037c fix: fixed entry events on the IN type (#780) 2025-02-04 15:33:03 -03:00
Kyrch d4b39d0c8e tests: fixed tests & entry name (#779) 2025-02-03 00:34:39 -03:00
Kyrch fc7ec8d3d1 fix: fixed insert song abbreviation & entry name (#778) 2025-02-02 22:14:48 -03:00
Kyrch 7bfa1ec0dd fix(filament): fixed video relation manager query (#777) 2025-02-02 16:36:59 -03:00
Kyrch 89ce3e05be feat: added insert songs only to Filament (#776) 2025-02-02 16:23:00 -03:00
Kyrch aad7f54c92 feat(api): added intermediate paths as allowed includes (#774) 2025-01-25 15:17:58 -03:00
Kyrch c406d73270 feat(filament): added playlist fix & sync external profile actions to Filament (#773) 2025-01-15 08:16:21 -03:00
Kyrch d45545e7e1 feat: added anime synonyms to series scout search (#772) 2025-01-10 01:48:54 -03:00
Kyrch d609fbd68a fix: added lock for playlist to prevent race conditions (#771) 2025-01-06 18:48:37 -03:00
Kyrch e10bcfc0df feat: aggregates view (#770) 2025-01-04 05:02:52 -03:00
Kyrch 33f62dae1a feat: WIP initial commit for reports (#769) 2024-12-30 20:34:38 -03:00
Kyrch 912a9c737b feat: added new way to authorize pivots (#768) 2024-12-13 19:12:36 -03:00
Kyrch af1d86f8d8 fix: fixed replace option not working properly (#767) 2024-12-12 16:01:38 -03:00
Kyrch d34309467a feat: added new option to replace audio model instead of creating a new one when backflling (#766) 2024-12-12 14:16:54 -03:00
Kyrch a38c023caa fix: fixed copy/move actions by editing the filesystems config (#765) 2024-12-11 16:32:12 -03:00
Kyrch 310e300bbd feat: added new fields to views table (#764) 2024-12-11 10:48:19 -03:00
Kyrch 2a856c3eee chore: update to php 8.4 (#763) 2024-12-08 19:53:24 -03:00
Kyrch 7be14cb615 feat(filament): added move all action (#762) 2024-12-05 03:11:31 -03:00
Kyrch c67af74c5f fix(filament): added custom checkboxfilter class (#761) 2024-12-02 10:41:10 -03:00
Kyrch 3ff694fba2 feat: added MAL option to external profiles (#760) 2024-11-18 15:23:51 -03:00
Kyrch ed6662c11f tests: added livewire tests for filament resources & bump dependencies (#759) 2024-11-17 04:36:03 -03:00
Kyrch e374b7d1d2 tests: added api schemed tests for external lists (#758) 2024-11-11 20:37:34 -03:00
Kyrch 55e37e1f3c feat: added laravel pulse (#757) 2024-11-09 15:18:53 -03:00
Kyrch 7c23f5fb4b fix(filament): fixed return type in traits (#756) 2024-11-04 12:19:56 -03:00
Kyrch 6c4d9a11c0 refactor(filament): actions and header actions are now using traits to share methods (#755) 2024-10-25 02:09:47 -03:00
Kyrch 3ce9bb09b7 feat: introducing flexible cache to improve performance (#754) 2024-10-22 12:10:44 -03:00
Kyrch 0e6a401154 fix: fixed last commit (#753) 2024-10-08 07:02:59 -03:00
Kyrch fe9a24167c refactor(filament): moved pivot fields to a proper function on relation managers (#752) 2024-10-08 06:31:43 -03:00
Kyrch 08459d85ad feat: added alias field to artist pivots (#751) 2024-10-03 23:22:48 -03:00
Kyrch 2fd17ac2f7 chore: bump dependencies (#750) 2024-09-27 20:39:05 -03:00
Kyrch 4acd5e39df feat: added playlist command to fix cycles (#749) 2024-09-26 01:15:51 -03:00
Kyrch ef0bfb8298 feat: added sync external profile job (#748) 2024-09-24 08:04:49 -03:00
Kyrch 61a8d53fde fix: fixed lock file (#747) 2024-09-23 16:13:31 -03:00
Kyrch 997391f440 feat(filament): added grid style to image list page (#746) 2024-09-23 16:05:44 -03:00
Kyrch 326f8283a6 docs: added generics notation for eloquent relationships (#745) 2024-09-12 16:03:49 -03:00
Kyrch 84a0281169 feat(filament): added recently plugin (#744) 2024-09-12 02:28:40 -03:00
Kyrch 3acac99104 fix: playlist relation might be null in track when cascade (#743) 2024-09-11 15:44:50 -03:00
Kyrch eeff29b0fc fix: external lists routes were moved from api middleware (#742) 2024-09-10 20:29:01 -03:00
Mani 2e0e411dd5 fix: Fixed failing tests because of collation changes (#739) 2024-09-10 20:25:56 -03:00
Kyrch 3ce94d9c40 chore: bump dependencies (#741) 2024-08-31 03:28:27 -03:00
Kyrch 4013c91a63 fix: fixed mostly stuff on external token callback (#740) 2024-08-31 01:57:40 -03:00
Kyrch a27a46305a feat: added a proper function to the token callback controller (#738) 2024-08-29 19:40:07 -03:00
Kyrch 8d4f27f8ce faet: added external token model (#737) 2024-08-28 22:23:06 -03:00
Mani e4c924fdcb fix: Changed the collation of hash id columns so that casing is no longer ignored (#736) 2024-08-28 22:14:10 -03:00
Mani 6fd693189c fix: Fixed anime search query (#735) 2024-08-27 17:36:03 +02:00
Mani 745865394f feat: Improving the theme search by boosting song name (#734) 2024-08-27 17:26:09 +02:00
Kyrch db0fbe9d32 fix: external entries should be read-only (#733) 2024-08-25 00:41:36 -03:00
Kyrch 8a6a2c1420 chore: reverted filament to version 3.2.106 (#732) 2024-08-24 03:37:04 -03:00
Kyrch f782d17eb2 chore: bump dependencies & fixed video notification in upload video action (#731) 2024-08-23 06:38:02 -03:00
Kyrch cc2e3e16cf feat(filament): added tabs to upload video action (#730) 2024-08-21 19:07:40 -03:00
Kyrch d8a673bb74 fix: removed null values from backfill anime responses (#729) 2024-08-20 23:21:48 -03:00
Kyrch cab98d822c clean: removed old track entry_id stuff (#728) 2024-08-20 00:13:05 -03:00
Kyrch 70d325374b feat: laravel 11 (#727) 2024-08-18 23:55:41 -03:00
Kyrch 13f6b23490 fix: fixed some wiki stuff on filament (#726) 2024-08-18 00:56:58 -03:00
Kyrch c04b155767 feat: added allowed included entry path (#725) 2024-08-13 19:48:54 -03:00
Kyrch 6b1b6124ad chore: migrated from abandoned larastan package (#724) 2024-08-13 16:00:33 -03:00
Kyrch 8890171698 chore: bump dependencies (#723) 2024-08-13 02:02:08 -03:00
Kyrch 14c378bd80 feat(filament): added developer dashboard (#722) 2024-08-12 20:14:05 -03:00
Kyrch fdd767f49c feat: added anilist logic for external entry action (#721) 2024-08-02 23:38:39 -03:00
Kyrch 5ccbd0455f refactor: updating resources (#720) 2024-08-01 23:07:18 -03:00
Kyrch ae8747da56 refactor: migrate resource/image creation/attachment to traits (#719) 2024-07-29 20:12:27 -03:00
Kyrch 89fe315c08 tests: fixed submission rule (#718) 2024-07-29 14:08:29 -03:00
Kyrch c02840a580 clean: removed nova (#717) 2024-07-29 13:07:36 -03:00
Kyrch e1b25f46d4 refactor: backfill actions (#716) 2024-07-28 23:58:19 -03:00
Kyrch 7c0e2b1087 fix(filament): fixed some custom fields (#715) 2024-07-14 17:06:39 -03:00
Kyrch 115e5b32f6 fix(filament): added create option using (#714) 2024-07-09 15:41:09 -03:00
Kyrch 85878dc7c1 feat(filament): added custom BelongsTo field & BelongsToColumn (#713) 2024-07-09 14:52:55 -03:00
Kyrch 36d7c3ea34 feat(filament): added custom slug field (#712) 2024-07-06 12:47:27 -03:00
Kyrch 50e0bab1fd feat: added feature flag to enable only localhost middleware (#711) 2024-07-03 02:55:07 -03:00
Kyrch 9238fd3d57 feat: external lists (#710) 2024-07-01 23:46:37 -03:00
Kyrch 294ab4fb75 fix(api): added missing included paths for playlist (#707) 2024-06-30 03:31:45 -03:00
Kyrch acd95fe2ff fix(api): added temporary rule condition for track creation (#706) 2024-06-30 03:07:00 -03:00
Kyrch f68e87aa25 feat: added entry id field on tracks resource (#705) 2024-06-30 02:49:52 -03:00
Kyrch 9a22e2a5b2 fix(filament): removed action log relation from feature resource (#704) 2024-06-28 04:15:05 -03:00
Kyrch 483bec0231 feat(filament): added a refresh for relation managers after every action (#703) 2024-06-28 03:48:36 -03:00
Kyrch ea5683d080 feat: action logs (#702) 2024-06-27 06:22:16 -03:00
Kyrch 847189aad4 feat(filament): added entries tab to create theme form (#701) 2024-06-21 05:03:48 -03:00
Kyrch 41e4a90347 feat(filament): added should force thread option on create discord notification (#700) 2024-06-16 06:31:19 -03:00
Kyrch 0416985efc feat(filament): ignore all file validations feature flag (#699) 2024-06-14 14:41:40 -03:00
Kyrch d53f49654d feat(filament): artist schema on theme page (#698) 2024-06-11 03:12:35 -03:00
Kyrch 6d18ac287c feat(filament): actions on view page (#697) 2024-06-10 16:29:57 -03:00
Kyrch 67f4cd83ce refactor(filament): relation managers (#696) 2024-06-09 19:42:04 -03:00
Kyrch 376e3a96a3 feat(filament): as field on attach actions (#695) 2024-06-08 18:58:35 -03:00
Kyrch 63a86e708a feat: added filament notification listener (#694) 2024-06-08 03:50:00 -03:00
Kyrch 6a76dbf85d feat(api): added ip forwarding (#693) 2024-06-07 23:03:39 -03:00
Kyrch 68999dc4f6 feat(filament): URL hint action on discord message field (#692) 2024-06-06 10:24:32 -03:00
Kyrch c27059b7d2 fix(filament): fixed dump action policies (#691) 2024-06-05 23:01:56 -03:00
Kyrch 354c6b1bc7 feat(filament): added discord message action (#690) 2024-06-05 22:01:03 -03:00
Kyrch 09f91ca426 feat(filament): added image editor (#689) 2024-06-03 23:49:31 -03:00
Kyrch 9f0a6a3ae2 refactor: changed thread pk type (#688) 2024-06-02 14:56:18 -03:00
Kyrch d4dea635b0 fix(filament): fixed actions policies (#687) 2024-06-02 03:38:45 -03:00
Kyrch 80e48e543c fix: fixed slug rule when record is null (#686) 2024-06-02 01:18:36 -03:00
Kyrch f0d1e0687e fix(filament): fixed thread id as big integer (#685) 2024-06-02 00:02:33 -03:00
Kyrch f2e4dce092 style(filament): the panel should use the full width (#684) 2024-06-01 20:36:00 -03:00
Kyrch 80ed802c97 feat: discord bot integration (#683) 2024-05-31 21:20:40 -03:00
Kyrch cbd7fe75d3 fix(filament): fixed attach action (#682) 2024-05-29 14:50:43 -03:00
Kyrch a7f314d357 fix(filament): fixed filter errors (#681) 2024-05-27 03:14:30 -03:00
Kyrch 7f00114415 feat: added description field to playlists (#680) 2024-05-24 17:44:38 -03:00
Kyrch 97b49dbb5e feat: filament (#679) 2024-05-23 17:22:01 -03:00
Mani f33db28908 feat: Added additional include paths (#677) 2024-05-19 01:29:34 +02:00
Kyrch 229b89c46f tests: added synonym by type test (#669) 2024-05-07 21:51:49 -03:00
Kyrch 52dd8c2e94 feat(nova): backfill synonyms action (#668) 2024-05-07 19:18:56 -03:00
Kyrch 74f8395ec4 feat: update amazon music regex pattern (#666) 2024-05-04 15:13:08 -03:00
Kyrch 92dc3a5138 feat: added theme group include to forwardbackward schema (#665) 2024-05-04 14:04:29 -03:00
Kyrch 1744b5457a refactor: changed theme_group relationship name and removed group field (#664) 2024-04-30 20:56:38 -03:00
Kyrch 811e8672e3 feat: added allowed include group to schemas (#663) 2024-04-29 19:33:45 -03:00
Kyrch 2da92a9cf0 feat(seeder): create new synonyms (#661) 2024-04-29 11:03:01 -03:00
Kyrch b265ad0bbc feat: added type attribute to synonym resource (#660) 2024-04-28 23:21:56 -03:00
Kyrch e472a1d3bd feat: migrating theme groups to an appropriate table (#611) (#659) 2024-04-28 16:23:25 -03:00
Kyrch 99153cbf5f feat: added document, avatar & banner facets (#658) 2024-04-24 12:49:04 -03:00
Mani 5100f13dd8 feat: Added permission for revalidating pages (#654)
* Added permissions as allowed include path.
* Added tracks and playlists as allowed include paths to videos
2024-04-16 17:52:53 +02:00
Kyrch 928de7c96c fix: the backfill audio action should rollback db when not completed (#653) 2024-04-04 14:02:06 -03:00
Kyrch 991491f746 fix(api): the comparison & logical operators should use enum names (#652) 2024-04-02 08:07:34 -03:00
Kyrch c2cdb15287 fix: trying to fix the backfill action part 2 (#651) 2024-04-02 07:33:42 -03:00
Kyrch 1f2b81e05c feat: added anidb as song resource (#649) 2024-04-02 00:55:46 -03:00
Kyrch 55a471369a fix: trying to fix the backfill action (#648) 2024-04-01 15:50:07 -03:00
Kyrch e4d068f428 style: formatting recent updates (#646) 2024-03-31 04:47:51 -03:00
Kyrch 856b4b53d7 feat(nova): the video will search for same basename when uploading through video resource (#645) 2024-03-27 22:56:13 -03:00
Kyrch de0d09a68e refactor: improving the getUrlCaptureGroups function for future proposals (#644)
* refactor: improving the getUrlPattern function for future proposals

* feat: added more capture groups to the function

* fix: fixed the youtube resource validate
2024-03-27 18:29:25 -03:00
Kyrch 4f6c7aeed8 refactor: changed update first theme function to eloquent ORM (#643) 2024-03-27 01:10:10 -03:00
Kyrch c7cbed03bf feat: the first theme will be updated when creating a new theme (#642)
* feat: the first theme will be updated  when creating a new theme

* clean: removed the unused import
2024-03-27 00:47:50 -03:00
Mani ee7e2c1412 feat: Added "download" query parameter to video and audio routes to force file download (#640) 2024-03-27 01:58:45 +01:00
Kyrch f50dd32daa feat(admin): the video path attribute is no longer required in the upload action (#639)
* feat: the video path is not required

* refactor: get resolution by arr laravel class

* test: removed the resolution attribute on tests
2024-03-25 22:59:11 -03:00
Kyrch 559db075b1 feat: parsing id from apple music link (#638)
* feat: parsing id from apple music link

* fix: allow special char on youtube resource
2024-03-25 13:54:10 -03:00
Kyrch 4c8eded119 test: playlists/tracks events shouldnt dispatch discord nots (#636) 2024-03-24 17:31:45 -03:00
Kyrch 17ec9613bd refactor: remove the discord notification for playlists (#635) 2024-03-23 22:05:15 -03:00
Kyrch 229d064b69 clean: remove the seeders already run (#634) 2024-03-23 03:20:43 -03:00
Mani a46ed2bcf9 clean: Removed configs migration (#633) 2024-03-22 23:21:09 +01:00
Kyrch 9b18c6ac3d fix(seeder): the media format has 0 as default value (#632) 2024-03-22 18:00:17 -03:00
Kyrch be69acd47a feat: anime images should be moved (#631)
* feat: anime images should be moved

* feat: anime images should be moved
2024-03-22 10:44:35 -03:00
Kyrch 46fcecd5ab fix: the resource validate shouldnt have groups (#628) 2024-03-20 17:57:29 -03:00
Kyrch 68bd228931 tests: filtering anime by media format and better consistency in relationships (#627)
* style: better consistency in relationships

* tests: filtering anime by media format

* tests: fixed checking resource pattern
2024-03-20 15:23:33 -03:00
Kyrch a609bf0be7 fix: the streaming resources should have more patterns (#626)
* fix: the streaming resources should have more patterns

* fix: fixing resources tests
2024-03-15 17:05:45 -03:00
Kyrch 6fefbf372e fix: checking if schema has columns before migrating (#625) 2024-03-09 00:27:23 -03:00
Mani 67828f430b clean: Removed settings package which is now fully replaced by feature flags (#620)
* clean: Removed settings package which is now fully replaced by feature flags

* clean: Remove left-over config values
2024-03-09 02:55:52 +01:00
Mani 342a409bed fix: Fixed some flaky tests (#624) 2024-03-09 02:55:14 +01:00
Kyrch 01952f3ff4 fix: checking images array validation (#623) 2024-03-08 22:34:17 -03:00
Kyrch 03778f8339 feat: moving images to appropriate folders (#622) 2024-03-09 02:14:23 +01:00
Kyrch 46dca69a82 tests: enabled the validation rules features flags for the tests (#621)
* tests: enabled the validation rules features flags for the tests

* tests: fixed array feature to string
2024-03-09 02:14:04 +01:00
Kyrch 5a9fa858b3 feat(admin): parse video resolution in upload video action (#609) 2024-03-09 02:13:35 +01:00
Kyrch c7a9f6af4d refactor: migrate validation rules to features flags (#619) 2024-03-04 13:29:38 -03:00
Mani 0e7def3024 clean: Removed anything billing related (#618) 2024-03-03 21:37:22 +01:00
Mani 3c33fd5d66 feat: Added Laravel Sail as a way to run a local development environment (#617) 2024-03-02 00:14:30 +01:00
Kyrch 1d1509b35b feat(nova): attach audio to related videos action (#616) 2024-02-03 18:44:57 -03:00
Kyrch 55301d3c91 feat: added backfill streaming sites action (#615) 2024-01-05 21:36:43 -03:00
Kyrch 6a2b0f3a51 feat: seeder that updates the theme slug according to our new standard (#614) 2023-12-21 16:01:44 -03:00
Kyrch f8ed4da5a2 feat: anime streaming resources (#613) 2023-11-20 16:12:17 -03:00
Kyrch 1e8e816abc test: fixed the password validator test (#612) 2023-11-18 01:34:44 -03:00
Kyrch 6aa43bbdf9 fix: added attach resources to song policy (#610) 2023-11-13 20:35:34 -03:00
Mani 683f9ff41b test: Fixed failing tests for resource links (#608) 2023-11-01 00:29:37 +01:00
Kyrch 8279465803 test: resources link format test (#607) 2023-10-31 14:15:42 -03:00
Kyrch 708e6e41a0 test: added missing song resource factory (#606) 2023-10-31 13:36:39 -03:00
Kyrch 6fe645d783 test: media_format attribute and song resource pivot (#605) 2023-10-31 13:23:21 -03:00
Kyrch 3aa49ba7ed feat: new included paths to song resources (#604) 2023-10-31 02:36:31 -03:00
Kyrch b39c321683 feat: new resource sites (#603) 2023-10-31 00:33:32 -03:00
Kyrch 8c789924ae feat: songresource pivot (#602) 2023-10-30 14:55:43 -03:00
Kyrch 9b81a5476d feat(nova): backfill external links from anilist resource (#601) 2023-10-30 10:12:06 -03:00
Kyrch 5bfee4aafb feat: included movie format on mal seeder (#600) 2023-10-29 22:26:24 -03:00
Kyrch 9456a47de3 feat: mal seeder (#599) 2023-10-29 21:59:23 -03:00
Kyrch 26c2fb5d38 fix: set null on seeder search (#598) 2023-10-29 19:44:37 -03:00
Kyrch a77f3c5fa4 feat: new media_format attribute to anime (#591) 2023-10-29 18:19:37 -03:00
Mani 5302b2b820 fix(api): Fix track add action timing out because of recursive toArray() method (#597) 2023-10-29 19:00:51 +01:00
Kyrch 1d205a8041 feat(nova): attach images action (#596) 2023-10-27 21:34:38 -03:00
Kyrch 89d4dd3d31 feat(nova): attach resources dialog with multiple inputs for multiple sites (#595) 2023-10-27 03:53:14 -03:00
Kyrch e3baa82f75 feat: discord thread action (#594) 2023-10-26 20:34:04 -03:00
Kyrch 016593b3ba feat(api): added more include paths to artist schema (#593) 2023-10-25 23:25:49 +02:00
Kyrch 5f5d015b79 feat: added more lenses (#590) 2023-09-18 00:24:50 +02:00
paranarimasu d376477705 fix(api): explicitly provide global scope for all defined feature checks (#588) 2023-06-13 12:26:39 -05:00
paranarimasu ce794f40ff fix(api): load aggregate fields for search builder (#587) 2023-06-12 21:54:06 -05:00
paranarimasu 4c33114f38 fix(api): address queue lockout for store playlist track action (#586) 2023-06-11 23:23:38 -05:00
paranarimasu 6015dcbbbf refactor: migration from laravel-enum package to native php enums (#585)
* refactor: migration from laravel-enum package to native php enums

* style: fix StyleCI findings
2023-06-09 14:29:10 -05:00
paranarimasu fd2cebe3cf chore: address deprecations to validation rules in Laravel 10 (#584)
* chore: address deprecations to validation rules in Laravel 10

* style: fix StyleCI findings
2023-06-05 01:01:49 -05:00
paranarimasu d857d922d6 chore: bump dependencies, update stubs & code inspection items (#582) 2023-05-31 00:08:25 -05:00
paranarimasu 54943c9b2c feat: use process facade for FFmpeg routines (#581)
* feat: use process facade for FFmpeg routines

* style: fix StyleCI findings
2023-05-29 23:33:00 -05:00
paranarimasu 5a0e3404ea fix(admin): apply pivot existence rule & subtitles for entry on featured theme form (#580) 2023-05-23 23:36:42 -05:00
paranarimasu 9334d05b13 feat(api): add missing feature allowed include path, feat(admin): narrow feature index query (#579) 2023-05-22 23:21:54 -05:00
Mani dd011b2886 fix: append token to reset password URL correctly (#578) 2023-05-20 19:50:06 -05:00
paranarimasu 02fef3aed2 fix: use class string to resolve db connection for models in validation (#577) 2023-05-20 14:44:47 -05:00
paranarimasu 7ba0416c20 fix: explicitly set database connection on service config (#576)
* fix: explicitly set database connection on service config

* style: address StyleCI finding
2023-05-19 23:53:47 -05:00
paranarimasu e2d0fe3cd6 feat: allow models to specify database connection (#575) 2023-05-15 23:53:18 -05:00
paranarimasu d168afd4e7 #470 feat: add featured theme model (#574)
* feat: add featured theme model

* style: fix StyleCI findings
2023-05-14 02:04:21 -05:00
paranarimasu 088ea287c5 feat: initial migration to pennant features for feature flags and global site config [incremental] (#573)
* feat: initial commit for feature management

* style: fix StyleCI findings

* fix(api): prohibit features of nonnull scope from API & fix(admin): wrong translate key

* style: fix Static Analysis error
2023-04-23 18:54:34 -05:00
paranarimasu 2e930b17ff feat: initial migration to Laravel 10 (#572)
* feat: initial migration to Laravel 10

* style: fix StyleCI findings
2023-04-18 16:22:36 -05:00
Mani 1e586c94a0 feat(api): add one more include path needed for the client (#571) 2023-04-14 19:37:18 -05:00
paranarimasu 295a997354 Api allowed pivots (#570)
* refactor(api): prefer including pivot types to conditional pivot information

* style: fix StyleCI findings
2023-04-14 18:36:29 -05:00
Mani ae9952ad68 feat(api): add additional include paths needed for the client (#569) 2023-04-14 17:01:23 -05:00
paranarimasu 22d57c1fb4 adding logging for insert track action to investigate latency (#568) 2023-04-07 16:17:00 -05:00
paranarimasu a3cf74bb2d refactor(api): remove redundant statements that convert API resources to response (#567)
* refactor(api): remove redundant statements that convert API resources to response

* style: fix StyleCI findigns
2023-04-05 16:29:54 -05:00
paranarimasu c356db1ed7 refactor(api): infer direct relations from resource schema (#566) 2023-04-03 00:23:36 -05:00
paranarimasu cadaa73f18 feat(api): use hashids for playlist route keys (#565) 2023-03-29 00:15:07 -05:00
paranarimasu 638549b329 feat: adding hashids support (#564) 2023-03-25 03:40:21 -05:00
paranarimasu 9c3413789f feat: wrap remaining actions in database transactions where more than one write query may be executed (#563) 2023-03-23 00:32:08 -05:00
paranarimasu 37f6ab1bc1 feat(api): adding playlist tracks_count aggregate field (#562) 2023-03-22 13:42:09 -05:00
paranarimasu 864c70198b feat(api): add content moderation filtering using external service (#561)
* feat(api): add content moderation filtering using external service

* style: fix StyleCI findings
2023-03-20 23:44:15 -05:00
paranarimasu 3ab6a476be feat(auth): prohibit disposable emails (#560) 2023-03-20 11:12:04 -05:00
paranarimasu 8faf07d56f chore: update depdendencies to anticipate Laravel 10 update (#559)
* chore: update depdendencies to anticipate Laravel 10 update

* style: fix StyleCI findings
2023-03-19 23:37:43 -05:00
paranarimasu b6aca7b877 feat(auth): update profile endpoint should support updating username and email separately (#558) 2023-03-18 13:09:56 -05:00
paranarimasu 54af771c4f feat(api): adding track forward & backward endpoints (#557)
* feat(api): adding track forward & backward endpoints

* style: fix StyleCI findings
2023-03-17 17:21:30 -05:00
paranarimasu 69a7f80efb feat: add color and priority attributes to role (#556) 2023-03-15 23:06:00 -05:00
paranarimasu 315de28398 feat(api): add playlists to global search endpoint (#555) 2023-03-14 22:36:48 -05:00
paranarimasu 4dfd46be9a feat(api): adding roles and permissions relations to my schema (#554)
* feat(api): adding roles and permissions relations to my schema

* style: fix StyleCI findings
2023-03-13 17:13:28 -05:00
paranarimasu 5cf9301710 fix(api): fix insert track timeout (#553) 2023-03-12 21:34:44 -05:00
paranarimasu fbff1356a9 fix(api): use database transactions for all track doubly-linked list actions (#552) 2023-03-12 20:50:25 -05:00
paranarimasu a7b74acaa0 fix(api): use nested transactions for store track endpoint (#551) 2023-03-12 18:44:39 -05:00
paranarimasu 3d633be59d fix(api): log each step of setting last playlist property (#550) 2023-03-12 17:49:49 -05:00
paranarimasu 62cba9bd34 fix(api): propagate exception from failed db transaction on insert track (#549) 2023-03-12 17:24:58 -05:00
paranarimasu 78f02c0064 fix(api): explicitly dissociate and associate playlist last relation (#548) 2023-03-12 16:59:08 -05:00
paranarimasu 17f2df3e48 fix(api): use single db transaction for insert action [incremental] (#547) 2023-03-12 16:48:25 -05:00
paranarimasu a721faa645 debug(api): add debug logging to track store action (#546) 2023-03-12 16:31:33 -05:00
paranarimasu 3dcd4e27fc feat(api): add configurable limits to playlist and track creation (#545) 2023-03-12 15:32:31 -05:00
paranarimasu de084e4eda feat(api): adding feature flag for playlist management (#544) 2023-03-11 20:57:49 -06:00
paranarimasu ca7a2b8960 fix(api): resolve soft-deleted playlist for forward and backward index endpoints (#543) 2023-03-10 20:31:39 -06:00
paranarimasu da02b741e1 feat(auth): adding patron role with new permission to bypass feature flags (#542)
* feat(auth): adding patron role with new permission to bypass feature flags

* style: fix StyleCI finding

* fix: wiki editors shouldn't have extended CRUD permissions
2023-03-10 00:36:21 -06:00
paranarimasu 411eb51fbc feat(api): adding playlist image endpoints (#541)
* feat(api): adding playlist image endpoints

* style: fix StyleCI finding
2023-03-08 23:42:13 -06:00
paranarimasu 8ac62b267b fix(auth): remove zxcvbn strength requirement for passwords (#540) 2023-03-07 21:25:44 -06:00
paranarimasu 2328acff83 fix(api): don't format pivot destroy messages like discord messages (#539) 2023-03-07 20:43:34 -06:00
paranarimasu 5a29b6b8ca feat(api): add ability to constrain aggregate fields [incremental] (#537) 2023-03-02 00:44:14 -06:00
paranarimasu ef7f2f8048 feat(api): adding artist member pivot endpoints (#535) 2023-02-28 00:21:59 -06:00
paranarimasu 1e7648b4b4 feat(api): adding artist song pivot endpoints (#534) 2023-02-27 10:04:36 -06:00
paranarimasu a0d560cb32 fix(api): playlist first and last relations should be managed through track DLL actions (#533) 2023-02-26 20:33:33 -06:00
paranarimasu bff3c8601d feat(api): adding studio resource pivot endpoints (#532)
* feat(api): adding studio resource pivot endpoints

* style: fix StyleCI findings
2023-02-26 17:40:35 -06:00
paranarimasu f9c6990171 feat(api): add track update operations for doubly-linked list (#531) 2023-02-26 16:16:59 -06:00
paranarimasu 688381c7b0 feat(api): adding studio image pivot endpoints (#530)
* feat(api): adding studio image pivot endpoints

* style: address StyleCI findings
2023-02-26 01:32:31 -06:00
paranarimasu f87046724b feat(api): add track restore operations for doubly-linked list (#529) 2023-02-26 00:38:13 -06:00
paranarimasu ecf9b83997 feat(api): adding artist resource pivot endpoints (#528) 2023-02-25 22:47:04 -06:00
paranarimasu 3a9d78f122 feat(api): add track force delete operations for doubly-linked list (#527) 2023-02-25 14:07:06 -06:00
paranarimasu 47074aee36 feat(api): adding artist image pivot endpoints (#526) 2023-02-24 00:53:16 -06:00
paranarimasu 779a6a602c feat(api): add track destroy operations for doubly-linked list & flatten flow for insert operations (#525) 2023-02-24 00:21:07 -06:00
paranarimasu 808d0cd56d feat(api): adding anime theme entry video pivot endpoints (#524) 2023-02-23 00:53:54 -06:00
paranarimasu f326a1623d feat(api): add track store insert operations for doubly-linked list (#523)
* feat(api): add track store insert operations for doubly-linked list

* style: fix insert action phpdoc descriptions
2023-02-21 21:35:31 -06:00
paranarimasu d289dcf9df feat(api): adding anime studio pivot endpoints (#522) 2023-02-19 12:14:50 -06:00
paranarimasu 1bd30c8bd5 feat(api): set first relation of playlist when creating initial track (#521)
* feat(api): set first relation of playlist when creating initial track

* fix(api): check that newly created track is the only playlist track
2023-02-19 00:50:07 -06:00
paranarimasu 454a1c02c3 feat(api): adding anime series pivot endpoints (#520) 2023-02-18 16:25:04 -06:00
paranarimasu c414809f40 feat(api): adding requested include paths to track schemas (#519) 2023-02-18 12:17:29 -06:00
paranarimasu bbed2f9bba feat(api): adding crud endpoints for anime resource pivot type (#518)
* feat(api): adding crud endpoints for anime resource pivot type

* fix: wrong binding parameter for anime & new route registration function should specify editability
2023-02-17 22:49:59 -06:00
paranarimasu 71c96a93be fix(api): video tags field should be formatted as string (#517) 2023-02-17 13:03:13 -06:00
paranarimasu 27778857b1 Request action composition (#516)
* refactor(api): prefer composition to strict inheritance for API queries, requests & actions

* style: fix StyleCI findigns
2023-02-16 23:30:57 -06:00
paranarimasu 09aec12920 feat(api): add /me endpoint (#515)
* feat(api): add /me endpoint

* style: fix StyleCI finding
2023-02-08 23:58:02 -06:00
paranarimasu 90bbe971ed test: fix value boundary in billing factories & backfill anime studios needs unique value from external api (#514) 2023-02-07 23:57:09 -06:00
paranarimasu 0fc9fd832c refactor(api): mark fields as renderable for display to the user with custom logic [incremental] (#513)
* refactor(api): mark fields as renderable for display to the user with custom logic

* style: fix StyleCI findings
2023-02-04 23:52:01 -06:00
paranarimasu 128b90dae2 refactor(api): provide query to field to determine selection and aggregation (#512)
* refactor(api): provide query to field to determine selection and aggregation

* style: fix StyleCI findings
2023-02-01 23:31:55 -06:00
paranarimasu e0f6dc7d97 feat(api): adding playlist view count field (#511) 2023-01-29 00:12:05 -06:00
paranarimasu 909e19d709 feat(api): adding audio view count field (#510) 2023-01-25 22:19:35 -06:00
paranarimasu a63addd6fb #488 feat(db): add permission seeder to separate concern from roles (#509)
* feat(db): add permission seeder to separate concern from roles

* fix(db): add permission seeder to base seeder
2023-01-23 23:58:06 -06:00
paranarimasu a97a1663d4 feat(auth): assign default roles on verification (#508) 2023-01-22 22:26:58 -06:00
paranarimasu 3d239a624a feat(auth): add unique constraint to user name (#507) 2023-01-20 23:14:07 -06:00
paranarimasu a8c462fd7f feat(auth): enable fortify account registration (#506) 2023-01-20 00:02:20 -06:00
paranarimasu 12871c3252 feat(api): add endpoint to retrieve the current user's playlists (#505) 2023-01-19 19:06:34 -06:00
paranarimasu 9d51617cd6 refactor(admin): reduce number of queries for upload actions (#504)
* refactor(admin): reduce number of queries for upload actions

* style: fix phpdoc description for upload audio action

* style: fix audio attribute constants
2023-01-15 23:26:21 -06:00
paranarimasu c2e3aecf05 feat(api): initial support for aggregate fields (#503) 2023-01-10 21:43:34 -06:00
paranarimasu d0d0fafe3e fix(api): fix anime video script allowed include path (#501) 2022-12-30 23:53:10 -06:00
Mani 7bb45de3d0 feat(api): Added videoscript include path to anime (#499) 2022-12-30 21:27:28 -06:00
paranarimasu 642815b3ff chore: applying framework updates (#498) 2022-12-28 16:07:30 -06:00
paranarimasu e0ac3ddee2 chore: bump dependencies for php8.2 (#497)
* chore: bump dependencies for php8.2

* fix(test): use php8.2 for test runner

* fix(test): use php8.2 for static analysis test runner
2022-12-28 15:06:59 -06:00
paranarimasu afb432f72f feat(admin): attach resource from detail/inline (#496) 2022-12-23 16:10:32 -06:00
paranarimasu 65392944c3 feat(admin): allow video attributes to be set on upload (#495) 2022-12-23 12:10:58 -06:00
paranarimasu c98058a61d #490 fix(admin): update pattern for mal studio cover image (#492) 2022-12-13 13:55:09 -06:00
paranarimasu 4017d69a1f feat(api): support playlist track adjacency (#491) 2022-12-11 17:19:12 -06:00
paranarimasu cc3ff63177 chore: bump dependencies (#486) 2022-11-21 19:00:48 -06:00
paranarimasu c4220e55d0 feat: add static link for latest db dumps (#485) 2022-11-20 19:18:27 -06:00
paranarimasu eb7f07fe34 fix(api): use truncated name for playlist track routing (#484) 2022-11-08 22:42:12 -06:00
paranarimasu 327f998cb9 Chore 2022 11 07 (#479)
* chore: bump dependencies

* style: fix StyleCI finding
2022-11-07 22:50:00 -06:00
paranarimasu e914fd7e3a feat(api): restrict destroy & update actions to active models (#478) 2022-11-05 22:10:40 -05:00
paranarimasu 76c0734dca fix(api): restrict restore action to trashed models (#476) 2022-11-03 18:40:26 -05:00
paranarimasu d6d59ca4f8 chore: updating dependencies (#475) 2022-10-28 13:48:31 -05:00
paranarimasu 52bd044cf2 feat(api): add search payload for playlists (#474) 2022-10-24 23:40:36 -05:00
paranarimasu 963a291d42 fix(api): corrected searchable import for playlist model (#473) 2022-10-19 13:39:52 -05:00
paranarimasu 517ba0ebf8 Playlists (#472)
* feat: add playlist resources

* style: fix StyleCI findings

* fix(admin): address nova exceptions
2022-10-19 00:32:06 -05:00
paranarimasu 1f7505ac60 fix(admin): remove user menu profile link (#469) 2022-09-26 15:53:14 -05:00
paranarimasu 6d66a3b6d1 feat(auth): remove jetstream auth scaffolding in favor of fortify SPA (#468)
* feat(auth): remove jetstream auth scaffolding in favor of fortify SPA

* fix: don't attempt to install non-existent npm dependencies in test runner
2022-09-26 15:03:01 -05:00
paranarimasu 4c4977ca8c feat(api): migrate transparency page from blade to api (#467)
* feat(api): add transparency endpoint

* chore: remove old transparency implementation

* style: fix StyleCI findings
2022-09-24 14:19:11 -05:00
paranarimasu 4cf68a96ac chore: updating dependencies & adding monitoring logs (#466) 2022-09-24 12:37:28 -05:00
paranarimasu 8b6b196c99 feat(admin): migrate db dumps from github to platform (#465)
* feat: migrate encoding scripts from github to platform

* style: fix StyleCI finding

* fix: don't concatenate updated videos when attempting to attach entry on video upload

* fix(api): fix field dependencies & feat(admin): upload script from video detail screen
2022-09-24 00:01:37 -05:00
paranarimasu 99cf085666 feat(admin): support attaching entry when uploading video & fix(admin): support anidb change requests for artist & studio resources (#464)
* feat(admin): support attaching entry when uploading video & fix(admin): support anidb change requests for artist & studio resources

* style: fix StyleCI finding
2022-09-20 15:21:09 -05:00
paranarimasu 1d93000c6f feat(admin): migrate db dumps from github to platform (#463)
* feat(admin): migrate db dumps from github to platform

* style: fix StyleCI findings
2022-09-18 22:31:29 -05:00
paranarimasu 57cc3d508d chore: prune redundant auditing dependency (#462) 2022-09-06 14:31:37 -05:00
paranarimasu 2792961a06 chore: updating dependencies (#461) 2022-09-05 23:41:55 -05:00
paranarimasu 95134a384c fix(api): validate untrimmed and empty values with custom delimited validation rule (#460)
* fix(api): validate untrimmed and empty values with custom delimited validation rule

* style: fix StyleCI findings
2022-09-05 23:01:54 -05:00
paranarimasu 16c8d1781b fix(admin): enforce stricter normalization of external resource links (#459)
* fix(admin): enforce stricter normalization of external resource links

* fix(admin): value safety when resolving site for resource link format rule
2022-08-31 21:09:28 -05:00
paranarimasu 23afebc70e fix(api): validate null values for filter and sort parameters (#458)
* fix(api): validate null values for filter and sort parameters

* refactor(api): consistency of sort type array check
2022-08-29 15:35:52 -05:00
paranarimasu 7d5cd09768 fix(admin): model action query & fix(api): restricted type validation rules (#457) 2022-08-28 23:29:11 -05:00
paranarimasu b1e2a65b85 fix(admin): theme slug type safety for autocomplete (#456) 2022-08-27 13:16:58 -05:00
3101 changed files with 127156 additions and 97283 deletions
+2 -2
View File
@@ -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]
+111 -54
View File
@@ -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
@@ -73,20 +82,23 @@ REDIS_SCHEME=tcp
REDIS_DB=0
REDIS_CACHE_DB=1
# elastic client
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://localhost:9200
# dump
DUMP_DISK=dumps_local
DUMP_DISK_ROOT=
DUMP_URL="${APP_URL}"
DUMP_PATH=/dump
# 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
@@ -136,31 +148,51 @@ AUDIO_FRA_STREAM_READS=
AUDIO_FRA_DISABLE_ASSERTS=
AUDIO_FRA_VISIBILITY=
# flags
ALLOW_VIDEO_STREAMS=false
ALLOW_AUDIO_STREAMS=false
ALLOW_DISCORD_NOTIFICATIONS=false
ALLOW_VIEW_RECORDING=false
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}"
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
@@ -178,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
@@ -211,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
@@ -224,29 +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_local
SCRIPT_DISK_ROOT=
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"
+307
View File
@@ -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
View File
@@ -1,4 +1,4 @@
* text=auto
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
+49 -53
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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.
+4
View File
@@ -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'
+20
View File
@@ -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.
+25
View File
@@ -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
+1 -12
View File
@@ -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
+8 -9
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
version: 8.1
version: 8.5
preset: laravel
finder:
not-name:
+203 -8
View File
@@ -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.
+18 -28
View File
@@ -5,49 +5,39 @@ declare(strict_types=1);
namespace App\Actions;
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;
}
public function toLog(): void
{
$this->hasFailed()
? Log::error($this->getMessage())
: Log::info($this->getMessage());
}
public function toConsole(Command $command): void
{
$this->hasFailed()
? $command->error($this->getMessage())
: $command->info($this->getMessage());
}
}
@@ -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);
}
}
+15 -16
View File
@@ -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;
}
}
+4 -9
View File
@@ -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();
}
}
+5 -10
View File
@@ -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);
}
}
+47
View File
@@ -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;
}
}
+78
View File
@@ -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;
}
});
}
}
+29
View File
@@ -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;
}
}
+26
View File
@@ -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;
}
}
+42
View File
@@ -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;
}
}
+38
View File
@@ -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);
}
}
+35
View File
@@ -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;
}
+44
View File
@@ -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;
}
+35
View File
@@ -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;
}
}
+16
View File
@@ -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);
}
}
-28
View File
@@ -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();
}
}
+43
View File
@@ -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()));
}
}
+118
View File
@@ -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()));
}
}
-58
View File
@@ -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;
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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\ReconcileAudioRepositories;
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 ReconcileAudioRepositories();
/** @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)) {
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Admin\Dump;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Admin\Dump;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* @extends ReconcileRepositoriesAction<Dump>
*/
class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
{
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return [
Dump::ATTRIBUTE_ID,
Dump::ATTRIBUTE_PATH,
];
}
/**
* Callback for create and delete set operation item comparison.
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Dump $first, Dump $second): int => [$first->path] <=> [$second->path];
}
/**
* The columns used for update set operation.
*
* @return string[]
*/
protected function columnsForUpdate(): array
{
return ['*'];
}
/**
* Callback for update set operation item comparison.
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (): int => 0;
}
/**
* Get source model that has been updated for destination model.
*/
protected function resolveUpdatedModel(Collection $sourceModels, Model $destinationModel): ?Model
{
return null;
}
/**
* Get reconciliation results.
*/
protected function getResults(Collection $created, Collection $deleted, Collection $updated): ReconcileResults
{
return new ReconcileDumpResults($created, $deleted, $updated);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Admin\Dump;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Admin\Dump;
/**
* @extends ReconcileResults<Dump>
*/
class ReconcileDumpResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<Dump>
*/
protected function model(): string
{
return Dump::class;
}
}
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Billing\Balance;
use App\Actions\Repositories\ReconcileRepositories;
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 ReconcileRepositories<Balance>
*/
class ReconcileBalanceRepositories extends ReconcileRepositories
{
/**
* 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