Compare commits
923 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b21d2bf603 | |||
| 0667f33250 | |||
| 0da264480c | |||
| 622d10e743 | |||
| 7c216f4691 | |||
| b5f9700345 | |||
| 7e03557e56 | |||
| 10a79a31cd | |||
| e41d408cd0 | |||
| 6b22b4a4e2 | |||
| 67c3083edd | |||
| e968b7ff28 | |||
| adfd983a68 | |||
| b32ba86c5d | |||
| ebb0b132bd | |||
| 4f1fbf4a45 | |||
| 74f896a8bc | |||
| 3b1d42c148 | |||
| 7a902212a5 | |||
| 2a718ef6d8 | |||
| f71dc287bd | |||
| 77d2c6f49a | |||
| 3eb454cc93 | |||
| 8a5fd8182f | |||
| aa7a38b2ee | |||
| ed7a780c44 | |||
| 32f72f382e | |||
| 2e0ea4d6c7 | |||
| 2a9a3691a3 | |||
| 49692f5e02 | |||
| 5f06e7b52f | |||
| 1933ca350d | |||
| 337957fb9f | |||
| 458c91d505 | |||
| 5498236b48 | |||
| 0e7cdb0199 | |||
| 7d25b0bf5d | |||
| ccadcf2152 | |||
| 8e0fb190d1 | |||
| 62033c66aa | |||
| 95130fd4fb | |||
| c4cebb717c | |||
| d92dd51bc8 | |||
| 4e4f8f9fcf | |||
| 11a0ec55d5 | |||
| 14b3f38b5d | |||
| 5799cd649b | |||
| d4b873ec7d | |||
| d00ae04074 | |||
| 8955ca7b26 | |||
| 82c6729a22 | |||
| 1929272705 | |||
| 709dead520 | |||
| 7cd348804c | |||
| ba91f65cee | |||
| a09488dbba | |||
| 111b2b1afd | |||
| c0ff3205a6 | |||
| 92815f6907 | |||
| d9257e767f | |||
| 26ec0f592a | |||
| 1e4814d50a | |||
| bbe2123c1e | |||
| d7c2897698 | |||
| 8cd930369d | |||
| 4cea72d8e9 | |||
| e9a4629c2a | |||
| 3e02af9c40 | |||
| 2a5a470347 | |||
| 07e3f5f7a8 | |||
| 1b83c70b5e | |||
| 1e5cdc151b | |||
| dcb21a6e1e | |||
| 0b4f3d5c97 | |||
| 0d8ea359e4 | |||
| 1c44b1812a | |||
| 3d5cf70ec0 | |||
| f1f26d0f57 | |||
| e3fe047afd | |||
| 4afc32f9d1 | |||
| 7ea78f2d57 | |||
| 6092410b20 | |||
| 8f5293a769 | |||
| 6d25356d30 | |||
| dbd86b89c7 | |||
| bf0b4fbb6a | |||
| 5585053b85 | |||
| a61b82e9af | |||
| d4fe6d3653 | |||
| 6d2ab6e7e4 | |||
| c5e6ae702a | |||
| 2a9b0443f5 | |||
| e1e933af16 | |||
| a5fbecd32a | |||
| 98006efb03 | |||
| 926b0f8267 | |||
| cc3f21aa93 | |||
| e311bcd901 | |||
| eb66baba80 | |||
| 09795b4da3 | |||
| 8088184e6b | |||
| 1b6c56dd8f | |||
| 86195eb682 | |||
| f40ffa124c | |||
| 704b6380c7 | |||
| b434665caf | |||
| 3554d4bc60 | |||
| 917ddd5401 | |||
| e7cf21fdb2 | |||
| c932bd188a | |||
| e5d17bee81 | |||
| 65836a1654 | |||
| 0f7bdaffbb | |||
| 30dae4c5c9 | |||
| f67d1641db | |||
| 0ff02e00c9 | |||
| 194081ac2f | |||
| 896f80ebd6 | |||
| 862279e914 | |||
| bdf5cf54af | |||
| 4323ab7086 | |||
| 555b237ed0 | |||
| 5146af2672 | |||
| 4d3da51ae7 | |||
| 9cba877fe6 | |||
| 924ffcb33f | |||
| b638062d36 | |||
| da64b5ca59 | |||
| 8f2d9b0984 | |||
| c2e85ff8b3 | |||
| 3c9d074169 | |||
| 7a254222a1 | |||
| 4662977677 | |||
| 798c80f69b | |||
| d8e4182824 | |||
| ac9fb8c28d | |||
| d0b1b9ed17 | |||
| 548643327d | |||
| a86d2a218a | |||
| 21ecc4cbbf | |||
| 2e19cad3a8 | |||
| 8bffc7380e | |||
| 96720caace | |||
| 6c130952d7 | |||
| 3c1fc0e950 | |||
| d95990fc56 | |||
| 9d0a433121 | |||
| 2bd02439c2 | |||
| 6a1fdd69e3 | |||
| fb7dc5d0f8 | |||
| c3d480d47b | |||
| 274c97bcdd | |||
| d6b5fc9cd3 | |||
| 4de9865a39 | |||
| d278f2cf98 | |||
| 5aec83c2f0 | |||
| fbda3d4b9b | |||
| dde8fad248 | |||
| e8309568bc | |||
| 997e3afb7e | |||
| 891fc51bcd | |||
| ebe42f1d34 | |||
| b799c660ca | |||
| a10f570983 | |||
| f205f96010 | |||
| 735a98e0b2 | |||
| f87aa70a3b | |||
| a2dc418ee6 | |||
| 4c7982f7be | |||
| 43d603c212 | |||
| ebf623ed1b | |||
| d925519beb | |||
| 07b72452e8 | |||
| ab4781bec0 | |||
| 7719b6336c | |||
| e749e94bfb | |||
| c1ceb17ada | |||
| 91dd00b556 | |||
| 60e873b4c0 | |||
| 4333591c9c | |||
| 462a46aabb | |||
| c766d68d49 | |||
| 6b7c38aa7a | |||
| 367c79f29c | |||
| c59eb82a1a | |||
| 3d40e5554f | |||
| 75f091cfa0 | |||
| 1d0abe4982 | |||
| f8490481a5 | |||
| 1adc56f965 | |||
| 8861233aaa | |||
| 3c4718bc45 | |||
| f6fa9e7666 | |||
| 98a7c370a8 | |||
| 37f9bffb13 | |||
| a4573fe52a | |||
| aeaaccda88 | |||
| 3a2ce0c718 | |||
| 2ac80d0b8c | |||
| 3ff5d9836b | |||
| e6d486c2a2 | |||
| c4e591e2f2 | |||
| 0eb1a88103 | |||
| ffe6afe2d2 | |||
| c767572905 | |||
| da803220e4 | |||
| 5c4f023fd8 | |||
| 7e2e64f394 | |||
| 9609909122 | |||
| 7522784f18 | |||
| 8d5379e38b | |||
| 8065349a5c | |||
| 26c84ae7c7 | |||
| f190bffdd0 | |||
| e0f8d753bf | |||
| b6223c7ac8 | |||
| ffd8bbe442 | |||
| bc86b8f6b4 | |||
| 0cb5c01b5e | |||
| 0393966ecc | |||
| b3ba00cd0d | |||
| 5084a5b724 | |||
| 43a484f949 | |||
| 8434e852ce | |||
| e541dc5cdb | |||
| 4a20819803 | |||
| 9a568b9da7 | |||
| 9176d97eee | |||
| 1cbc065b7b | |||
| b4865aa789 | |||
| 0c29927ef6 | |||
| 75e499a070 | |||
| fe589e7de5 | |||
| 9d7d6b0e3a | |||
| 5fb720cca4 | |||
| 2084b42098 | |||
| d26db7759f | |||
| 89311d4487 | |||
| 72dca29d69 | |||
| 36b3bde00e | |||
| 425f0da422 | |||
| 6773736113 | |||
| 51ec248b67 | |||
| 702084c83c | |||
| 2aa5f0c4fa | |||
| 359f5a372a | |||
| 03a8397498 | |||
| 034e6b10e7 | |||
| 8100c5f9a9 | |||
| 95c8f2d5b0 | |||
| be29170f6d | |||
| f02699b089 | |||
| 82b7a3d0f6 | |||
| 8608ad4fd6 | |||
| ab96107797 | |||
| a38291c9b9 | |||
| 67cfd03a1d | |||
| c4fa99b5aa | |||
| 1d904a7188 | |||
| be8cee5378 | |||
| 128a3d4cc6 | |||
| 610fb3d0c0 | |||
| 2dd01f296b | |||
| 6e118085a9 | |||
| 2f3400d4d7 | |||
| da787741d8 | |||
| 97bdf2f89c | |||
| a8c8431715 | |||
| 18e3e333c6 | |||
| f7ddb1e14b | |||
| e5c513161e | |||
| 55235839ff | |||
| e5536c5429 | |||
| 02fc0ccb94 | |||
| 83dcb85e5b | |||
| 155cd21b40 | |||
| f06da5b26e | |||
| e00bfa0187 | |||
| 4a008e4c2f | |||
| 8c84fe29f9 | |||
| 3bd03e4e97 | |||
| 4d65a16584 | |||
| 21b4e49aa0 | |||
| 1bcc521d3a | |||
| 9fa85e3191 | |||
| 55ff90a190 | |||
| 2bf7639ecf | |||
| 2aabd94311 | |||
| 17a61c0248 | |||
| a17c7a4ce5 | |||
| f31009fa5e | |||
| 559ac8945a | |||
| 1ce7f5a938 | |||
| d634956d76 | |||
| 3275ad6620 | |||
| 3ac9e94f89 | |||
| 9562b3fef0 | |||
| ae95329d06 | |||
| 1e81872876 | |||
| 90eb422c9d | |||
| 3bfcb741f1 | |||
| e04d72e565 | |||
| 99aa4e564a | |||
| eac5c5a91e | |||
| 82e9a4c0ea | |||
| 4dd494dc80 | |||
| 4422e83cd7 | |||
| eec6e00dac | |||
| 579736eb10 | |||
| 06fadeffa1 | |||
| 97c87ecbf0 | |||
| e55593d62b | |||
| 2a3b5c8746 | |||
| 52097df861 | |||
| 92637aef3d | |||
| 02f537c5bf | |||
| c63ab3cddf | |||
| cf7457a671 | |||
| 095cc21ea4 | |||
| 983f4efa95 | |||
| 4573d69bbf | |||
| 07cbcb4d41 | |||
| 71e31df2bf | |||
| f969c591c1 | |||
| 6d1e95ff5c | |||
| e7a6a888b1 | |||
| ab9fdddc14 | |||
| 6160e906df | |||
| dafac30df1 | |||
| e98b6a383d | |||
| 679168d88c | |||
| 8679cdf0c6 | |||
| 27e2e98ca3 | |||
| 3f90b2f0ca | |||
| dfa4e81dc2 | |||
| 3d3bfa50bb | |||
| f4a4b7c6d7 | |||
| db1b1bee65 | |||
| 53aa76f009 | |||
| 24fe7f4984 | |||
| 99d9d4866a | |||
| 7b0594a39b | |||
| 628dfef8d1 | |||
| a3e305478b | |||
| 0ca133f002 | |||
| 0586e91b31 | |||
| 3dbcc7b69f | |||
| 784b6f2559 | |||
| d61711ace3 | |||
| c520cd6be0 | |||
| 00a3b7041e | |||
| 6a69fbdab2 | |||
| deb30ebf03 | |||
| 985e5b3d39 | |||
| 2b3dcd57d3 | |||
| 4200b03736 | |||
| 7190a43604 | |||
| 7c77d9c536 | |||
| bd844b17c4 | |||
| 9bf3e6728c | |||
| 78542120da | |||
| 6148773965 | |||
| 1796bc9fb7 | |||
| 86c82181e1 | |||
| 51aec112b4 | |||
| bffb7960cc | |||
| 075b688605 | |||
| 6f10d221cf | |||
| 37fad5b8d3 | |||
| 8c544535c6 | |||
| 23d407fbd6 | |||
| 20ca9ff2be | |||
| 1b8a8ba491 | |||
| e7d93649c2 | |||
| 53b8b1d500 | |||
| 6ae214bd98 | |||
| 7f067c62a4 | |||
| 2e6bfc9985 | |||
| 33ea4cd3fe | |||
| 6211901602 | |||
| 4a50de48f0 | |||
| b58b92b00f | |||
| 427c70ccb3 | |||
| 8d0af94f9a | |||
| bbaa830fd9 | |||
| 221de60c11 | |||
| c0ff48dc1f | |||
| ea4dade1f7 | |||
| 6432b18beb | |||
| 16b4b87185 | |||
| 126db58cc4 | |||
| 67d62ef957 | |||
| 3e1607e09d | |||
| cbbc4de5a0 | |||
| b4f16f12e3 | |||
| 56bf80de7e | |||
| e2e791570e | |||
| d65f1891bb | |||
| 35b1ea951b | |||
| d76dfbec95 | |||
| 095015329a | |||
| c7c1760a96 | |||
| 74de1f3039 | |||
| 0ba1cc35c5 | |||
| cb09d71d3d | |||
| 62bd6c8353 | |||
| 75cf586407 | |||
| 25f747b6bb | |||
| d3fdd3cc1e | |||
| 9022654bdb | |||
| 71daab3e6c | |||
| f4b87bdc2a | |||
| 9e7981e80b | |||
| d0a86c4c85 | |||
| 06283ca0e4 | |||
| 2336cc3680 | |||
| 312c192ccb | |||
| dbc0c015ef | |||
| 5b00825b18 | |||
| 2a2b6119f7 | |||
| fbd3215be3 | |||
| b26e59664f | |||
| 0a035c2c23 | |||
| 9dc63f4ed8 | |||
| bb8cfe1b52 | |||
| 8bdc459bc0 | |||
| 5c37f29382 | |||
| aacc4b1285 | |||
| a3a037775f | |||
| 868ddfc510 | |||
| 12eeeb91a2 | |||
| e16cbfc49e | |||
| d38d7dfd56 | |||
| 0fc895b667 | |||
| 24b986572a | |||
| a3aa9adc0e | |||
| 3c6b6ae1a5 | |||
| c8f8869c28 | |||
| 53895462a5 | |||
| 6ce3099396 | |||
| 068e6a1684 | |||
| e5052e265d | |||
| 79b4fe1d95 | |||
| 328bbedcf0 | |||
| 10285bffd7 | |||
| d7b1b4cf46 | |||
| 7b0045fd6c | |||
| 0554ba2667 | |||
| a06ad2fd3f | |||
| 8d69043779 | |||
| 7634f80270 | |||
| 6caed2fd36 | |||
| ae3518cdc5 | |||
| d5349322ca | |||
| 837db05e6e | |||
| d1f2255f65 | |||
| fab012ae09 | |||
| a3559381bd | |||
| 21a2dc1bf4 | |||
| 6a461af848 | |||
| aed92177f3 | |||
| a52463259a | |||
| 4590c6bd56 | |||
| efb442b224 | |||
| dd364aca9a | |||
| 768d3add59 | |||
| a18a5ee23a | |||
| 71f294552d | |||
| 5d268b93be | |||
| 856444bd9f | |||
| 14e3277e6d | |||
| 89aa4b477f | |||
| f4b22c76d6 | |||
| 83472ae329 | |||
| 345a465f0f | |||
| 7e7a9c886d | |||
| 2abde13fb3 | |||
| 57d8de9b54 | |||
| 02ae13d8f8 | |||
| 3641105ce2 | |||
| 4dfd84d807 | |||
| 088fda01a7 | |||
| d827ef7d61 | |||
| d117416367 | |||
| 4c3ce990c9 | |||
| ba34594c2e | |||
| 5aecf064ba | |||
| ca052df6e0 | |||
| b95c4852ce | |||
| 38ee6253da | |||
| 0d46917e8d | |||
| b406a49c9c | |||
| 23b02c7e40 | |||
| 0372c8dfd7 | |||
| 0197c5eaeb | |||
| 7bd7f98c91 | |||
| 382c0b4c48 | |||
| 6a46e3b7e2 | |||
| 701219b517 | |||
| 7e5f1b6ad6 | |||
| d20b1ca0f2 | |||
| 852ff0403e | |||
| 3d52d96215 | |||
| a3abc549b1 | |||
| 3dfb88bfb4 | |||
| 731ebce307 | |||
| 1eacde4555 | |||
| d48e7286d8 | |||
| 27f081a8c6 | |||
| 17d1a33867 | |||
| 8acc07cb94 | |||
| 7335c83a08 | |||
| 695aa5bb0b | |||
| f7685f22c6 | |||
| 952ef27781 | |||
| e8f186fd54 | |||
| d0a964775e | |||
| 866432d22e | |||
| 5dce12c73b | |||
| 6d002b4d1a | |||
| 5a66b51b00 | |||
| 16d53be62c | |||
| afe1947bd3 | |||
| 40bd349e22 | |||
| faff9ff366 | |||
| e7b0d4f3d1 | |||
| a8650a47a5 | |||
| 1a0319f316 | |||
| fc943d2eea | |||
| edc292be19 | |||
| 01d78695a8 | |||
| be14a4689c | |||
| aea4aab78a | |||
| 0619338a97 | |||
| 41677ea717 | |||
| 6304c33436 | |||
| 41af9c9fe1 | |||
| bade703b4d | |||
| 7220684a97 | |||
| da7f62af12 | |||
| 9054729069 | |||
| 444105955e | |||
| a23bfc5fac | |||
| 0934f57fdb | |||
| 4669994b4a | |||
| 1135e1132a | |||
| a22c830357 | |||
| f09005d171 | |||
| 459c729da1 | |||
| aacdfebdcf | |||
| 41ad80c1a9 | |||
| a472b18981 | |||
| 7d92901f80 | |||
| 0a83567c11 | |||
| 007a87b842 | |||
| cbac14f77e | |||
| a6fcd93244 | |||
| 2801c5540f | |||
| 20b3a2631a | |||
| 3a7c6c07d5 | |||
| 6b8d686dae | |||
| 83d869417b | |||
| 9bf216537c | |||
| 611b2bbd43 | |||
| 2b35e0b270 | |||
| 7e57e50b99 | |||
| 0703a511c0 | |||
| fba76e38fb | |||
| 7960876873 | |||
| 2395908c20 | |||
| a3e8877199 | |||
| bca9336d4f | |||
| 4565af412b | |||
| 232d704d9f | |||
| 869b13f473 | |||
| b28e06f559 | |||
| 3af9177849 | |||
| f83ec47bc3 | |||
| 4cda259a2a | |||
| 4a6932f5b1 | |||
| 0b508772b8 | |||
| e5dfe96186 | |||
| 1a20e1cdd9 | |||
| 6f47c291cb | |||
| 79a64fd667 | |||
| dba92dc3e0 | |||
| f79393d072 | |||
| 350c6db596 | |||
| b9c0a9f700 | |||
| 03572c1324 | |||
| 1eef015e5e | |||
| 00d284bb07 | |||
| 56b44bd9ab | |||
| 170c96ac8a | |||
| 416ce62081 | |||
| 06bfae19ed | |||
| c3b0411bb5 | |||
| ba3b8d9281 | |||
| 1378193031 | |||
| 59a705222f | |||
| 1e50a4fa5b | |||
| 2b36272c2d | |||
| e1ead91d2c | |||
| 80e7062151 | |||
| 57d124c31e | |||
| 9ebfc2d18a | |||
| a07d9e2882 | |||
| e605c8e71a | |||
| 1387aa3a82 | |||
| e29f782616 | |||
| da3b932be2 | |||
| 0a649acc0f | |||
| 4db88d040c | |||
| cf6136ce92 | |||
| 1acdd245a2 | |||
| 1c31dd31ac | |||
| e9b75abe16 | |||
| b098a25c6a | |||
| 9081d3d752 | |||
| 383e0bf2f4 | |||
| a600affaac | |||
| c436219b62 | |||
| fd726c4cbe | |||
| 828f6f3aa3 | |||
| 5b14857c8d | |||
| 82acd83395 | |||
| 93ebc0380f | |||
| 5f4efac9ee | |||
| efe697e1e9 | |||
| b90458ebb9 | |||
| bade3f54cb | |||
| 975fddfc6b | |||
| 31c23ac860 | |||
| 7519098d47 | |||
| 4745d528f2 | |||
| a4d0acb5dd | |||
| 10bd2e4bd3 | |||
| a0f6991746 | |||
| 61cdf91382 | |||
| 7f15f796f1 | |||
| 31ace217be | |||
| c502950473 | |||
| 60e0652123 | |||
| 53f2ae1bdb | |||
| 118c6dfba4 | |||
| 4d789d46c6 | |||
| f461f56299 | |||
| b16e18fe13 | |||
| 9055bdd58a | |||
| e1e900d7f8 | |||
| 617be082ac | |||
| 6738963141 | |||
| 64c1f7fc11 | |||
| 147561f8c7 | |||
| 8a5cc8bdef | |||
| 6fafdf5dc2 | |||
| a9e871a393 | |||
| b10c17d485 | |||
| ccfd5a8fcb | |||
| f6ef3f9fa1 | |||
| 690ae67f2a | |||
| d6666a8072 | |||
| 9d8140a137 | |||
| a701528f7f | |||
| 87fec68591 | |||
| d425dda240 | |||
| 12f33bfb3f | |||
| cc1df09cb8 | |||
| 5f1ef5145a | |||
| 405c8169cf | |||
| 9b2259a9a3 | |||
| 9b8fa436b8 | |||
| c28a1e3793 | |||
| 90dfd39f91 | |||
| 78d14c49b4 | |||
| 2923337258 | |||
| 7ad6621fbb | |||
| bfab3b2802 | |||
| 9283b818f1 | |||
| cc00896fa4 | |||
| a61f305f03 | |||
| c4b6e41016 | |||
| 0e4223787f | |||
| d9abc061ed | |||
| 8f4ea45428 | |||
| 6366377e9e | |||
| 669bfa026d | |||
| 15c6d902f2 | |||
| 80f119ca81 | |||
| c287e2fb76 | |||
| 4c597508b3 | |||
| 42fff9c3cb | |||
| c0d6bc0f4d | |||
| 98a5818498 | |||
| d228da76c1 | |||
| 75073e48ad | |||
| 3702fa5e36 | |||
| 7bb56331ee | |||
| b953a41485 | |||
| a3ead1ca7e | |||
| aeb49bd587 | |||
| c4ac52267b | |||
| b61d8459e0 | |||
| a12c23a866 | |||
| ebb43b67c3 | |||
| 080a1f503e | |||
| 0b46205f52 | |||
| 79df259714 | |||
| 7b70889118 | |||
| f5135e0825 | |||
| 3fa9dbd109 | |||
| b2438c735b | |||
| f35c42bce5 | |||
| fd68d7d975 | |||
| 00e45aa6d0 | |||
| ce9c92010f | |||
| 0caa18e587 | |||
| db43c313b8 | |||
| 328f035d1e | |||
| f1db1ff2b4 | |||
| 2c6fa24d65 | |||
| f3b4b7acc4 | |||
| bed4a922c5 | |||
| fc64a66f47 | |||
| 083381d4d5 | |||
| b4f3bf906b | |||
| 12736f91c9 | |||
| e1e0ad6db6 | |||
| f403e228f6 | |||
| 242656e2f4 | |||
| 62ffee4d84 | |||
| b83d567306 | |||
| 9d67db2466 | |||
| fb8dec81f1 | |||
| 115ee57687 | |||
| 13c4aff1d3 | |||
| 320a88f1db | |||
| f175b74320 | |||
| 941e8879fd | |||
| a739862ff1 | |||
| c240de748b | |||
| 80d343e12f | |||
| 3247860cce | |||
| 67c9edb63d | |||
| e025d5fbab | |||
| fdfda036d4 | |||
| 329fc33c8a | |||
| 9b8b59df5e | |||
| 5d0bbd9825 | |||
| 4a910c9c57 | |||
| b7ce96c576 | |||
| 34b756d039 | |||
| 3aec67865a | |||
| a051a07b1a | |||
| 2da1af2f12 | |||
| bf0b93f90f | |||
| 76ebdbfe8b | |||
| cc94be7d9b | |||
| 11e575faf1 | |||
| 658c4ba114 | |||
| a62a05790f | |||
| 2dbbd08ea3 | |||
| 8d4297211a | |||
| 7d8bc81aa9 | |||
| 780f922c22 | |||
| e1420ed4c4 | |||
| 89849a0957 | |||
| 19547861ab | |||
| 05739dc085 | |||
| d17f24fab9 | |||
| 075eb01533 | |||
| 07f236385f | |||
| 1359279e8c | |||
| 577c90402b | |||
| 9c9436b420 | |||
| c9a7ec5f66 | |||
| 54c0aca002 | |||
| 589bd3f86d | |||
| ffbad648d8 | |||
| c187c7e09d | |||
| e57b45e0b8 | |||
| 94c12ac428 | |||
| a65604e315 | |||
| 73f6bd2dd5 | |||
| 993bdeb5f1 | |||
| c06609cc2e | |||
| fe2751d9a4 | |||
| 873833a381 | |||
| 98f5dc5394 | |||
| 82ec4aa0e7 | |||
| 7ab1d4b7a1 | |||
| 0f29aeb1d0 | |||
| c295cc490a | |||
| bdf9b1d3d0 | |||
| db6304e5f9 | |||
| a61f0dbbf3 | |||
| 84cd421368 | |||
| 9c32a73311 | |||
| cf90b8537b | |||
| 43da5706ed | |||
| 2e301c33a4 | |||
| 9dddac9820 | |||
| e9a17a0f4a | |||
| 4482da9ec7 | |||
| 0753346954 | |||
| 4878e8d32b | |||
| 86aef4fdd2 | |||
| 35dcaf11a1 | |||
| 2a69951d2e | |||
| 67d17c5a82 | |||
| bbd5533ac8 | |||
| e234524532 | |||
| c4ec47ec97 | |||
| 86b0f1a917 | |||
| c929911187 | |||
| 736a06d362 | |||
| 755c4ca15f | |||
| a0fbb0ffef | |||
| e356ad604e | |||
| 1c6df50622 | |||
| 88796292be | |||
| b97ea7076d | |||
| ee2e573083 | |||
| c90f59bea3 | |||
| 373f758e54 | |||
| 5e6cf832d8 | |||
| af0e1d7449 | |||
| c53e06d79e | |||
| 060fbe9f3c | |||
| 4dc922ef46 | |||
| 850f348702 | |||
| b19f494166 | |||
| 9293cbb79e | |||
| d98834ed72 | |||
| a09e271258 | |||
| 8b27d6dc71 | |||
| 3cbc76513f | |||
| 4c4804c89b | |||
| 09d121890b | |||
| fc170377f6 | |||
| 773958d4e0 | |||
| 207ce5298d | |||
| b357e37b00 | |||
| e8ecc28631 | |||
| cf92060c46 | |||
| 205005e72d | |||
| 86e7565782 | |||
| d4254f2e77 | |||
| 3a24b7cc8f | |||
| 5dad17a992 | |||
| effd7a6765 | |||
| 543cbcfb47 | |||
| 556d401acd | |||
| 824da069df | |||
| 0c4649b162 | |||
| 8cc2f049c9 | |||
| fea8a4dce4 | |||
| 81755d60f9 | |||
| 214ea7133e | |||
| df61b04878 | |||
| 1bd1f940d3 | |||
| 3de1298a9c | |||
| bb7047d8df | |||
| b97f939954 | |||
| bc9c568a32 | |||
| f524f79dde | |||
| 20fd6decf6 | |||
| beaf935c7f | |||
| 1876f53ba4 | |||
| 5df24a8327 | |||
| 2ad979a7c5 | |||
| d8e18ad854 | |||
| 8450880cc9 | |||
| 937e83b17b | |||
| 624d0eef23 | |||
| 27315bb890 | |||
| 15dfcfd477 | |||
| 2f2ed4ee4e | |||
| 971de1b458 | |||
| 3cedd75abd | |||
| 3486fb1521 | |||
| f26d7bb846 | |||
| 8e8fe57a6e | |||
| 63d4d276fe | |||
| 6fa49603a9 | |||
| b1e149a0ed | |||
| 7ac83d4744 | |||
| db26be7033 | |||
| 213264ad23 | |||
| 751674e60f | |||
| 28baca9e2a | |||
| 70ecdf82d7 | |||
| 5b572edd34 | |||
| 8faa43b919 | |||
| 27f362f0a1 | |||
| 290654b17a | |||
| 54178bd8e6 | |||
| 444e884d5f | |||
| 944492375b | |||
| 3fbef9171d | |||
| d2a08367e2 | |||
| 7c3b8cd5ce | |||
| a1f9318ef3 | |||
| 7785a07bb0 | |||
| c09f4a1be5 | |||
| 8abb29ac45 | |||
| 7313910483 | |||
| 9625378eb5 | |||
| ff0eb750bb | |||
| a8a3960705 | |||
| 0ecf8ef145 | |||
| 707a748bb2 | |||
| eec1d6a4c3 | |||
| 8e43ee21a7 | |||
| 80b808c39e | |||
| eb27892e9a | |||
| 889c7e32d2 | |||
| ca1a91e992 | |||
| 635eb7609c | |||
| 3fefdfdfb8 | |||
| 43ffa6d694 | |||
| 23164858f8 | |||
| 2134e78e47 |
@@ -7,6 +7,8 @@ node_modules
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
/doc
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: false
|
||||
}
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: ["plugin:svelte/recommended"],
|
||||
plugins: ["@typescript-eslint"],
|
||||
ignorePatterns: ["*.cjs", "*.config.js"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.svelte"],
|
||||
parser: "svelte-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser"
|
||||
}
|
||||
}
|
||||
],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
extraFileExtensions: [".svelte"],
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- Make sure your code is formatted by running `npm run format` or using prettier manually. -->
|
||||
|
||||
### Changes made
|
||||
|
||||
<!-- Describe changes made here. If changes are visual a screenshot could be useful! -->
|
||||
@@ -0,0 +1,27 @@
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Web UI
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ui"
|
||||
|
||||
# Server
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/server"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "server"
|
||||
|
||||
# Workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # / will look in /.github/workflows directory by default
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "workflow"
|
||||
@@ -1,42 +1,45 @@
|
||||
name: Development Watcharr Container Release
|
||||
# Multi arch support doc/guide:
|
||||
# https://docs.docker.com/build/ci/github-actions/multi-platform/
|
||||
|
||||
name: Development Watcharr Image Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-images:
|
||||
release-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
# Run for each dockerfile (one for server and one for ui images)
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile.svr
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
- dockerfile: ./Dockerfile.ui
|
||||
image: ghcr.io/sbondco/watcharr-ui
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ matrix.image }}:dev
|
||||
labels: ${{ matrix.image }}:${{ github.sha }}
|
||||
tags: ${{ env.image }}:dev
|
||||
labels: ${{ env.image }}:${{ github.sha }}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
name: Release Watcharr Containers
|
||||
name: Release Watcharr Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-images:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -14,22 +16,18 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
# Run for each dockerfile (one for server and one for ui images)
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile.svr
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
- dockerfile: ./Dockerfile.ui
|
||||
image: ghcr.io/sbondco/watcharr-ui
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -37,15 +35,16 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
images: ${{ env.image }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Publish Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- ".github/workflows/publish-docs.yml"
|
||||
- "doc/**"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: doc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build website
|
||||
run: npm run build
|
||||
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
publish_dir: ./doc/build
|
||||
cname: watcharr.app
|
||||
# The following lines assign commit authorship to the official
|
||||
# GH-Actions bot for deploys to `gh-pages` branch:
|
||||
# https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
# The GH actions bot is used by default if you didn't specify the two fields.
|
||||
# You can swap them out with your own user credentials.
|
||||
user_name: github-actions[bot]
|
||||
user_email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Test doc deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- ".github/workflows/test-docs.yml"
|
||||
- "doc/**"
|
||||
|
||||
jobs:
|
||||
test-deploy:
|
||||
name: Test deployment
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: doc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Test build website
|
||||
run: npm run build
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Test PR Changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
linter_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Get dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Check for linting/formatting errors
|
||||
run: npm run lint
|
||||
@@ -37,3 +37,11 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Docker dev volume
|
||||
container_data/
|
||||
data/
|
||||
|
||||
# Build artifacts
|
||||
server/ui
|
||||
server/watcharr
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "always",
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte"
|
||||
],
|
||||
"pluginSearchDirs": [
|
||||
"."
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Contributing 🎉
|
||||
|
||||
First of all, thank you for looking into contributing! 🫡
|
||||
|
||||
Feel free to contribute in any way: 🐞 bug reports, 💡 ideas, 🥖 new features etc - everything is welcome!
|
||||
|
||||
For big PRs (lots of changes/big change in the way something works), if you'd like, it can be discussed first in an issue (dont want to waste anyones time!).
|
||||
|
||||
## Project Board
|
||||
|
||||
We have a [board to organize new features and bug fixes](https://github.com/orgs/sbondCo/projects/9/views/2) to be worked on. If you see something you'd like to work on, just ask in the issue and it can be assigned to you. Even if it isn't set for the next milestone already, it can be.
|
||||
@@ -1,11 +0,0 @@
|
||||
:80 {
|
||||
# Reverse proxy for frontend
|
||||
#reverse_proxy 172.17.0.1:3000
|
||||
reverse_proxy watcharr:3000
|
||||
|
||||
# Route requests to /api/ to backend
|
||||
handle_path /api/* {
|
||||
#reverse_proxy 172.17.0.1:3080
|
||||
reverse_proxy watcharr-server:3080
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Backend
|
||||
FROM golang:1.21-alpine AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
COPY server/arr/*.go ./arr/
|
||||
COPY server/game/*.go ./game/
|
||||
|
||||
# Required so we can build with cgo
|
||||
RUN apk update && apk add --no-cache musl-dev gcc build-base
|
||||
|
||||
# CGO_CFLAGS: https://github.com/mattn/go-sqlite3/issues/1164#issuecomment-1635253695
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./watcharr
|
||||
|
||||
# Frontend
|
||||
FROM node:20-alpine AS ui
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
COPY ./static ./static
|
||||
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Production
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
COPY --from=server /server/watcharr /
|
||||
COPY --from=ui /app/build /ui
|
||||
COPY --from=ui /app/package.json /app/package-lock.json /ui
|
||||
RUN cd /ui && npm ci --omit=dev
|
||||
|
||||
EXPOSE 3080
|
||||
|
||||
CMD ["/watcharr"]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Backend
|
||||
FROM golang:1.20 AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
|
||||
RUN go mod download && GOOS=linux go build -o ./watcharr
|
||||
|
||||
# Production
|
||||
FROM debian:11.6 AS runner
|
||||
|
||||
RUN apt-get update && apt-get install ca-certificates -y
|
||||
|
||||
COPY --from=server /server/watcharr /
|
||||
|
||||
EXPOSE 3080
|
||||
|
||||
CMD ["/watcharr"]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Frontend
|
||||
FROM node:19 AS ui
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
COPY ./static ./static
|
||||
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Production
|
||||
FROM node:19 AS runner
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=ui /app/package*.json /app/build ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
@@ -1,92 +1,60 @@
|
||||
<h1 align="center">Watcharr</h1>
|
||||
<p align="center"><img src="./static/logo-col.png" alt="logo" /></p>
|
||||
<p align="center"><img src="./static/logo-col.png" alt="logo" width="250" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sbondCo/Watcharr/pkgs/container/watcharr"><img src="https://img.shields.io/github/v/release/sbondCo/Watcharr?label=version&style=for-the-badge" /></a>
|
||||
<a href="https://beta.watcharr.app"><img src="https://img.shields.io/website?label=DEMO&style=for-the-badge&url=https%3A%2F%2Fbeta.watcharr.app" /></a>
|
||||
<a href="https://watcharr.app"><img src="https://img.shields.io/website?label=DOCS&style=for-the-badge&url=https%3A%2F%2Fwatcharr.app" /></a>
|
||||
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
|
||||
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
|
||||
<a href="https://matrix.to/#/#watcharr:matrix.org"><img src="https://img.shields.io/matrix/watcharr%3Amatrix.org?style=for-the-badge&logo=matrix" /></a>
|
||||
</p>
|
||||
|
||||
I'm the place you store your watched content for later rememberance, sadly I am still in my infancy. I aspire to be easily self hosted.
|
||||
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) **movies** and **tv shows** (and **anime**), rate them and track their status.
|
||||
|
||||
With [some extra configuration](https://watcharr.app/docs/server_config/game-support-igdb) I can also track your **video games**.
|
||||
|
||||
I am built with Go and Svelte(Kit).
|
||||
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too before release): [https://watcharr.lab.sbond.co/](https://watcharr.lab.sbond.co/)
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
|
||||
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
|
||||
|
||||
### Contents
|
||||
|
||||
- [Screenshots](#screenshots)
|
||||
- [Setup](#set-up)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Getting Help](#getting-help)
|
||||
|
||||
# Screenshots
|
||||
|
||||
<h3 align="center">Watched List</h3>
|
||||
<p align="center">
|
||||
<img src="./screenshot/homepage.png" alt="Watched List" />
|
||||
|
||||
| Watched Show Hover | Watched Show Status Change | Show Details |
|
||||
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/show-details-page.png" alt="Content Details Page" /> |
|
||||
| Homepage | Watched Show Hover |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> |
|
||||
|
||||
| Watched Show Status Change | Movie Details |
|
||||
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png" alt="Content Details Page" /> |
|
||||
|
||||
| User Profile | Discover |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| <img src="./screenshot/user-profile.png" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png" alt="Discover Page" /> |
|
||||
|
||||
| Dark Homepage | Dark Content Details |
|
||||
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-dark.png" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png" alt="Dark Theme Content Page" /> |
|
||||
|
||||
</p>
|
||||
|
||||
# Set Up
|
||||
|
||||
Currently we have docker images for each version that are fairly simple to setup, but it is a lot more complex that I would like it to be. Hopefully this improves in the future.
|
||||
[Checkout our documentation](https://watcharr.app/docs/category/installation) for an up to date guide on setup! If you hate manuals, but love docker, this [docker-compose.yml](./docker-compose.yml) file is your friend.
|
||||
|
||||
For now though, we have two container images ([UI](https://github.com/sbondCo/Watcharr/pkgs/container/watcharr-ui) & [Server](https://github.com/sbondCo/Watcharr/pkgs/container/watcharr-ui)) that work together through the use of a proxy.
|
||||
# Getting Help
|
||||
|
||||
Here's a simple setup to get you started based from visible files in the repo, which you can also check out. We use Caddy here, but use whatever you are comfortable with!
|
||||
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
|
||||
|
||||
**docker-compose.yml**
|
||||
|
||||
```
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
# Watcharr API
|
||||
watcharr:
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
# .env file to configure watcharr
|
||||
- ./.env:/.env
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
|
||||
# Watcharr Frontend
|
||||
watcharr-ui:
|
||||
image: ghcr.io/sbondco/watcharr-ui:latest
|
||||
container_name: watcharr-ui
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- watcharr
|
||||
|
||||
# Caddy - Used as reverse proxy to services
|
||||
# and to re-route requests
|
||||
caddy:
|
||||
image: caddy:2.6
|
||||
container_name: watcharr-caddy
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
**Caddyfile**
|
||||
|
||||
```
|
||||
:80 {
|
||||
# Reverse proxy for frontend
|
||||
reverse_proxy watcharr:3000
|
||||
|
||||
# Route requests to /api/ to backend
|
||||
handle_path /api/* {
|
||||
reverse_proxy watcharr-server:3080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**.env**
|
||||
|
||||
```
|
||||
# Used to sign JWT tokens. Make sure to make
|
||||
# it strong, just like a very long, complicated password.
|
||||
JWT_SECRET=MAKE_ME_RANDOM_AND_LONG
|
||||
|
||||
# Optional: Point to your Jellyfin install
|
||||
# to enable it as an auth provider.
|
||||
JELLYFIN_HOST=https://my.jellyfin.example
|
||||
```
|
||||
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Choose how you would like to install Watcharr."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Docker Compose
|
||||
|
||||
## Installing
|
||||
|
||||
Installing Watcharr with a docker compose file is easy. You can copy the example below to get started:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
# to use an actual version, then when updating check the releases for changelogs.
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
:::danger first account
|
||||
|
||||
When **first** running Watcharr, make sure only you have access. The first user created will become admin.
|
||||
|
||||
:::
|
||||
|
||||
You can now start `Watcharr` like so:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you didn't change the ports in the example, the server will be available at [http://localhost:3080/](http://localhost:3080/).
|
||||
|
||||
## Updating
|
||||
|
||||
:::danger Take care
|
||||
|
||||
We try taking care as to not release breaking changes, however it is still recommended that
|
||||
you lookover changelogs before updating!
|
||||
|
||||
Breaking changes are marked at the top of releases: https://github.com/sbondCo/Watcharr/releases
|
||||
|
||||
:::
|
||||
|
||||
Updating your server can be done in two steps:
|
||||
|
||||
1. Update the `image` version in your `docker-compose.yml` file.
|
||||
Skip this step if you are using the `latest` tag.
|
||||
|
||||
```yaml
|
||||
# eg. update v1.19.0 to v1.20.0 (or whatever version you are updating to)
|
||||
image: ghcr.io/sbondco/watcharr:v1.19.0
|
||||
```
|
||||
|
||||
2. Pull the new changes and re-create your container:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
And that is it!
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
# From Source
|
||||
|
||||
## Installing
|
||||
|
||||
1. Get the code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sbondCo/Watcharr.git && \
|
||||
cd Watcharr
|
||||
```
|
||||
|
||||
If you want a specific version, checkout a version tag (replace `<tag>` with the tag):
|
||||
|
||||
```bash
|
||||
git checkout <tag>
|
||||
```
|
||||
|
||||
2. Build the frontend
|
||||
|
||||
The server will use the built files, so we move them to the `ui` folder, besides where it's binary will be built.
|
||||
|
||||
```bash
|
||||
npm i && \
|
||||
npm run build && \
|
||||
mv ./build ./server/ui
|
||||
```
|
||||
|
||||
3. Build the server
|
||||
|
||||
```bash
|
||||
cd server && \
|
||||
go mod download && \
|
||||
GOOS=linux go build -o ./watcharr
|
||||
```
|
||||
|
||||
4. Run the server
|
||||
|
||||
:::danger first account
|
||||
|
||||
When **first** running Watcharr, make sure only you have access. The first user created will become admin.
|
||||
|
||||
:::
|
||||
|
||||
```bash
|
||||
./watcharr
|
||||
```
|
||||
|
||||
5. Visit [http://localhost:3080/](http://localhost:3080/) and setup Watcharr.
|
||||
|
||||
## Updating
|
||||
|
||||
:::danger Take care
|
||||
|
||||
We try taking care as to not release breaking changes, however it is still recommended that
|
||||
you lookover changelogs before updating!
|
||||
|
||||
Breaking changes are marked at the top of releases: https://github.com/sbondCo/Watcharr/releases
|
||||
|
||||
:::
|
||||
|
||||
Updating is the same as installing except:
|
||||
|
||||
1. Cleanup old build. Delete the `ui` folder and the `watcharr` binary.
|
||||
2. Replace step one with getting the new code:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
If you checked out a tag, update to the new tag as well.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Watcharr is for the peeps who like to keep a list of all the content they have ever watched.
|
||||
|
||||
If you would like more insight, the [adding to your watchlist guide](/docs/usage/adding-to-watchlist)
|
||||
is a great example of how Watcharr is used.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Server Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Learn how to configure your server."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Games Support (via IGDB/Twitch)
|
||||
|
||||
## Overview
|
||||
|
||||
Game support in Watcharr, currently, must be enabled by you.
|
||||
|
||||
This involves setting up a Twitch developer account (phone number needed for 2FA which is required) and entering application credentials into Watcharr, that can then be used for looking up game info via IGDB.
|
||||
|
||||
P.S. These steps are stolen from [igdb api docs](https://api-docs.igdb.com/#account-creation), I've tried to make it easier to follow with screenshots.
|
||||
|
||||
## Steps
|
||||
|
||||
### Setup On Twitch
|
||||
|
||||
1. Sign up for a [Twitch](https://dev.twitch.tv/login) account.
|
||||
Click login with twitch, then create an account in the Sign Up tab.
|
||||
2. Enable two factor authentication in your [account Security settings](https://www.twitch.tv/settings/security) (currently requires a phone number).
|
||||
3. [Register a new Application in the Twitch Developer Console](https://dev.twitch.tv/console/apps/create).
|
||||
These settings will do (name can be anything, we don't use OAuth, but a redirect url is required; localhost will do):
|
||||
|
||||

|
||||
|
||||
4. [Click manage](https://dev.twitch.tv/console/apps) on your newly created application.
|
||||
5. Click 'New Secret' to generate a new Client Secret.
|
||||
6. Take a note of your Client ID and new Client Secret (by leaving this tab open, until we are ready to copy the values into Watcharr).
|
||||
|
||||
### Setup On Watcharr
|
||||
|
||||
1. Login to Watcharr with an Admin account and navigate to server settings.
|
||||
|
||||

|
||||
|
||||
2. Scroll down and click on the Twitch service.
|
||||
|
||||

|
||||
|
||||
3. Enter your Client ID and Client Secret (from Twitch Application page, step 6 on twitch setup steps)
|
||||
|
||||

|
||||
|
||||
4. Click save, and start searching for your favourite games!
|
||||
|
||||

|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Usage",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Hopefully Watcharr is easy enough to get started with and use, but incase you are stuck, one of these usage guides may help."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Adding To Your Watchlist
|
||||
|
||||
Adding movies or shows to your watchlist is the core feature of Watcharr.
|
||||
|
||||
The homepage is where your list will appear once you have added some.
|
||||
|
||||
To get started, you can follow along with these steps to add your first movie/show:
|
||||
|
||||
1. Search for the content:
|
||||
|
||||

|
||||
|
||||
2. Hover or click on the content you want to add:
|
||||
|
||||

|
||||
|
||||
3. Clicking the + (Plus) icon will let you give the content a status.
|
||||
|
||||
Currently supported statuses include: `Finished`, `Watching`, `Held`, `Planned`, `Dropped`.
|
||||
|
||||

|
||||
|
||||
4. Rate the content.
|
||||
|
||||

|
||||
|
||||
If you prefer, you can also do this by clicking/tapping on the active poster again.
|
||||
This will take you to the contents full page. Here you can see all the options, including
|
||||
adding `Thoughts` for the content.
|
||||
|
||||

|
||||
|
||||
`Thoughts` can be anything you would like to write down or remember about the specific movie/show.
|
||||
For example, sometimes I like to write something specific that I _really_ liked about the content,
|
||||
or for some shows, I write down my favourite seasons and how many times I have watched them.
|
||||
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 369 KiB |
@@ -0,0 +1,110 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
const { themes } = require("prism-react-renderer");
|
||||
const lightTheme = themes.github;
|
||||
const darkTheme = themes.dracula;
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: "Watcharr Docs",
|
||||
tagline:
|
||||
"Open source, self-hostable watched list for all your content with user authentication, modern and clean UI and a very simple setup. ",
|
||||
favicon: "img/favicon.png",
|
||||
|
||||
// Set the production url of your site here
|
||||
url: "https://watcharr.app",
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: "/",
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: "sbondCo", // Usually your GitHub org/user name.
|
||||
projectName: "Watcharr", // Usually your repo name.
|
||||
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
trailingSlash: false,
|
||||
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||
// to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"]
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
blog: {
|
||||
showReadingTime: true,
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
theme: {
|
||||
customCss: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
require.resolve("./src/css/custom.css")
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
image: "img/social-card.png",
|
||||
navbar: {
|
||||
title: "Watcharr",
|
||||
logo: {
|
||||
alt: "Watcharr Logo",
|
||||
src: "img/favicon.png"
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "tutorialSidebar",
|
||||
position: "left",
|
||||
label: "Docs"
|
||||
},
|
||||
{
|
||||
type: "docsVersionDropdown"
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right"
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
position: "right"
|
||||
}
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
// Dark theme currently disabled.. no time to fix the icons etc.
|
||||
disableSwitch: true
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "watcharr-doc",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{ type: "autogenerated", dirName: "." }]
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const FeatureList = [
|
||||
{
|
||||
title: "Free and Open",
|
||||
Svg: require("@site/static/img/icon/code-slash.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is free and open source software distributed under the MIT license. Feel free to
|
||||
browse, modify or contribute!
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Simple",
|
||||
Svg: require("@site/static/img/icon/happy.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is incredibly easy to dive into. Install without hassle and get started with the
|
||||
seamless and intuitive user experience.
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Built With Go and Svelte",
|
||||
Svg: require("@site/static/img/icon/go.svg").default,
|
||||
description: (
|
||||
<>Don't know what else to add here, so now you know Watcharr is built with Go and Svelte.</>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
function Feature({ Svg, title, description }) {
|
||||
return (
|
||||
<div className={clsx("col col--4")}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageFeatures() {
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect } from "react";
|
||||
import styles from "./styles.module.css";
|
||||
import clsx from "clsx";
|
||||
import Viewer from "viewerjs";
|
||||
|
||||
function TwoColFeat({ title, description, imgSrc }) {
|
||||
return (
|
||||
<div className={clsx(styles.twoCol)}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={imgSrc} alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageTwoColFeats() {
|
||||
useEffect(() => {
|
||||
const ctr = document.getElementById("homepage-two-col-feats");
|
||||
console.debug("Starting image viewer", ctr);
|
||||
new Viewer(ctr, {
|
||||
navbar: 0,
|
||||
toolbar: {
|
||||
zoomIn: 2,
|
||||
zoomOut: 2,
|
||||
next: 2,
|
||||
prev: 2
|
||||
},
|
||||
title: 0,
|
||||
className: "twoColViewer"
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
|
||||
<TwoColFeat
|
||||
title="Neat UI"
|
||||
description="We just look lightweight."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Easy To Use"
|
||||
description="Not much to it, other than ease of use."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="In-Depth Details"
|
||||
description="About your favourite content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Personal Stats and Configuration"
|
||||
description="Make it how you like."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Discovery"
|
||||
description="Find the latest trending content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Dark Theme"
|
||||
description="For you who are still unsatisfied. ;("
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.twoColFeats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
max-width: 1300px;
|
||||
}
|
||||
|
||||
.twoCol {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 50px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
}
|
||||
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: row-reverse;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 0px 5px rgb(121, 121, 121);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 850px) {
|
||||
.twoCol,
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.twoColViewer .viewer-close {
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* Stupid poopin fix for viewerjs because whatever is bundling the css
|
||||
isn't keeping the order of this correctly. All this time wasted and it
|
||||
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
|
||||
.viewer-in {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
import HomepageFeatures from "@site/src/components/HomepageFeatures";
|
||||
import LogoCol from "@site/static/img/logo-col.png";
|
||||
|
||||
import styles from "./index.module.css";
|
||||
import clsx from "clsx";
|
||||
import { HomepageTwoColFeats } from "../components/TwoColFeat";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<header className="hero shadow--lw">
|
||||
<div className={styles.heroContainer}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<img src={LogoCol} />
|
||||
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link className="button button--secondary button--lg" to="/docs/introduction">
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
className="button button--secondary button--outline button--lg"
|
||||
to="https://beta.watcharr.app"
|
||||
target="_blank"
|
||||
>
|
||||
Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageTwoColFeats />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||
* and scoped locally.
|
||||
*/
|
||||
|
||||
.heroContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 600px;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroWrapper img {
|
||||
width: 30vh;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Markdown page example
|
||||
---
|
||||
|
||||
# Markdown page example
|
||||
|
||||
You don't need React to write simple standalone pages.
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M160 389a20.91 20.91 0 01-13.82-5.2l-128-112a21 21 0 010-31.6l128-112a21 21 0 0127.66 31.61L63.89 256l109.94 96.19A21 21 0 01160 389zM352 389a21 21 0 01-13.84-36.81L448.11 256l-109.94-96.19a21 21 0 0127.66-31.61l128 112a21 21 0 010 31.6l-128 112A20.89 20.89 0 01352 389zM208 437a21 21 0 01-20.12-27l96-320a21 21 0 1140.23 12l-96 320A21 21 0 01208 437z"/></svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Go</title><path d="M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M414.39 97.61A224 224 0 1097.61 414.39 224 224 0 10414.39 97.61zM184 208a24 24 0 11-24 24 23.94 23.94 0 0124-24zm167.67 106.17c-12 40.3-50.2 69.83-95.62 69.83s-83.62-29.53-95.72-69.83a8 8 0 017.83-10.17h175.69a8 8 0 017.82 10.17zM328 256a24 24 0 1124-24 23.94 23.94 0 01-24 24z"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -1,39 +1,13 @@
|
||||
version: "3"
|
||||
|
||||
networks:
|
||||
watcharr:
|
||||
|
||||
# To test prod
|
||||
services:
|
||||
# Port 3000
|
||||
watcharr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.ui
|
||||
dockerfile: Dockerfile
|
||||
container_name: watcharr
|
||||
networks:
|
||||
- watcharr
|
||||
depends_on:
|
||||
- watcharr-server
|
||||
|
||||
# Port 3080
|
||||
watcharr-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.svr
|
||||
container_name: watcharr-server
|
||||
networks:
|
||||
- watcharr
|
||||
volumes:
|
||||
- ./server/.env:/.env
|
||||
- ./container_data:/data
|
||||
|
||||
caddy:
|
||||
image: caddy:2.6
|
||||
container_name: watcharr-caddy
|
||||
ports:
|
||||
- "8080:80"
|
||||
networks:
|
||||
- watcharr
|
||||
- 3080:3080
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- ./container_data:/data
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
# Watcharr API
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
# to use an actual version, then when updating check the releases for changelogs.
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
- ./server/.env:/.env
|
||||
- ./container_data:/data
|
||||
|
||||
# Watcharr Frontend
|
||||
watcharr-ui:
|
||||
image: ghcr.io/sbondco/watcharr-ui:latest
|
||||
container_name: watcharr-ui
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- watcharr
|
||||
|
||||
caddy:
|
||||
image: caddy:2.6
|
||||
container_name: watcharr-caddy
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.2.3",
|
||||
"@sveltejs/kit": "^1.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-svelte": "^2.10.0",
|
||||
"svelte": "^3.58.0",
|
||||
"svelte-check": "^3.2.0",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.2.1"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.3.4"
|
||||
}
|
||||
"name": "watcharr",
|
||||
"version": "1.35.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^4.0.1",
|
||||
"@sveltejs/kit": "^2.5.3",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vite-pwa/sveltekit": "^0.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.1",
|
||||
"svelte": "^4.2.10",
|
||||
"svelte-check": "^3.6.4",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityType string
|
||||
|
||||
var (
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
|
||||
IMPORTED_WATCHED_PLEX ActivityType = "IMPORTED_WATCHED_PLEX"
|
||||
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
|
||||
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
|
||||
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
|
||||
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
|
||||
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
|
||||
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
|
||||
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
|
||||
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
|
||||
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
|
||||
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
GormModel
|
||||
// ID of user this activity is linked to, so it can be easily
|
||||
// secured (users can only view their own activities).
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// ID of watched list item this activity is linked to.
|
||||
WatchedID uint `json:"watchedId" gorm:"not null"`
|
||||
// Type of activity.
|
||||
Type ActivityType `json:"type" gorm:"not null"`
|
||||
// Holds custom data (ex, if rating changed, this can
|
||||
// hold new rating - if status changed, this will hold that).
|
||||
Data string `json:"data" gorm:"not null"`
|
||||
// Custom date for the activity, that the user can define.
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityAddRequest struct {
|
||||
WatchedID uint `json:"watchedId" binding:"required"`
|
||||
Type ActivityType `json:"type" binding:"required"`
|
||||
Data string `json:"data" binding:"required"`
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityUpdateRequest struct {
|
||||
CustomDate time.Time `json:"customDate" binding:"required"`
|
||||
}
|
||||
|
||||
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
|
||||
activity := new([]Activity)
|
||||
res := db.Model(&Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed getting activity from database", "error", res.Error.Error())
|
||||
return []Activity{}, errors.New("failed getting activity")
|
||||
}
|
||||
return *activity, nil
|
||||
}
|
||||
|
||||
func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, error) {
|
||||
if ar.WatchedID == 0 {
|
||||
return Activity{}, errors.New("watchedId must be set to add an activity")
|
||||
}
|
||||
activity := Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data, CustomDate: ar.CustomDate}
|
||||
res := db.Create(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding activity to database", "error", res.Error.Error())
|
||||
return Activity{}, errors.New("failed adding new activity to database")
|
||||
}
|
||||
slog.Debug("Adding activity", "added_activity", activity)
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func updateActivity(db *gorm.DB, userId uint, id uint, activityUpdateRequest ActivityUpdateRequest) error {
|
||||
if id == 0 {
|
||||
return errors.New("id must be set to update an activity")
|
||||
}
|
||||
if activityUpdateRequest.CustomDate.IsZero() {
|
||||
return errors.New("customDate must be set to update an activity")
|
||||
}
|
||||
res := db.Model(&Activity{}).Where("user_id = ? AND id = ?", userId, id).Update("custom_date", activityUpdateRequest.CustomDate)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating activity in database", "error", res.Error.Error())
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were updated. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
slog.Debug("Updating activity", "updated_activity", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteActivity(db *gorm.DB, userId uint, id uint) error {
|
||||
if id == 0 {
|
||||
return errors.New("an id must be provided to delete an activity")
|
||||
}
|
||||
res := db.Where("user_id = ?", userId).Delete(&Activity{}, id)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error deleting activity in database", "error", res.Error.Error())
|
||||
return errors.New("failed deleting activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were deleted. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
return errors.New("failed deleting activity from database")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
)
|
||||
|
||||
type ArrSettings struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
type SonarrSettings struct {
|
||||
ArrSettings
|
||||
QualityProfile int `json:"qualityProfile,omitempty"`
|
||||
RootFolder int `json:"rootFolder,omitempty"`
|
||||
LanguageProfile int `json:"languageProfile,omitempty"`
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
// TODO eventually separate profiles and root for anime content (i can see diff language profile being useful)
|
||||
}
|
||||
|
||||
func (s *SonarrSettings) safe() SonarrSettings {
|
||||
s.Key = ""
|
||||
return *s
|
||||
}
|
||||
|
||||
type RadarrSettings struct {
|
||||
ArrSettings
|
||||
QualityProfile int `json:"qualityProfile,omitempty"`
|
||||
RootFolder int `json:"rootFolder,omitempty"`
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
}
|
||||
|
||||
func (s *RadarrSettings) safe() RadarrSettings {
|
||||
s.Key = ""
|
||||
return *s
|
||||
}
|
||||
|
||||
type ArrTestParams struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
type SonarrTestResponse struct {
|
||||
QualityProfiles []arr.QualityProfile `json:"qualityProfiles"`
|
||||
RootFolders []arr.RootFolder `json:"rootFolders"`
|
||||
LanguageProfiles []arr.LanguageProfile `json:"languageProfiles"`
|
||||
}
|
||||
|
||||
type RadarrTestResponse struct {
|
||||
QualityProfiles []arr.QualityProfile `json:"qualityProfiles"`
|
||||
RootFolders []arr.RootFolder `json:"rootFolders"`
|
||||
LanguageProfiles []arr.LanguageProfile `json:"languageProfiles"`
|
||||
}
|
||||
|
||||
func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
|
||||
sonarr := arr.New(arr.SONARR, &p.Host, &p.Key)
|
||||
qps, err := sonarr.GetQualityProfiles()
|
||||
if err != nil {
|
||||
slog.Error("testSonarr failed to get quality profiles!", "error", err)
|
||||
return SonarrTestResponse{}, errors.New("failed to get quality profiles")
|
||||
}
|
||||
rfs, err := sonarr.GetRootFolders()
|
||||
if err != nil {
|
||||
slog.Error("testSonarr failed to get root folders!", "error", err)
|
||||
return SonarrTestResponse{}, errors.New("failed to get root folders")
|
||||
}
|
||||
lps, err := sonarr.GetLangaugeProfiles()
|
||||
if err != nil {
|
||||
slog.Error("testSonarr failed to get language profiles!", "error", err)
|
||||
return SonarrTestResponse{}, errors.New("failed to get language profiles")
|
||||
}
|
||||
return SonarrTestResponse{QualityProfiles: qps, RootFolders: rfs, LanguageProfiles: lps}, nil
|
||||
}
|
||||
|
||||
func testRadarr(p ArrTestParams) (RadarrTestResponse, error) {
|
||||
radarr := arr.New(arr.RADARR, &p.Host, &p.Key)
|
||||
qps, err := radarr.GetQualityProfiles()
|
||||
if err != nil {
|
||||
slog.Error("testRadarr failed to get quality profiles!", "error", err)
|
||||
return RadarrTestResponse{}, errors.New("failed to get quality profiles")
|
||||
}
|
||||
rfs, err := radarr.GetRootFolders()
|
||||
if err != nil {
|
||||
slog.Error("testRadarr failed to get root folders!", "error", err)
|
||||
return RadarrTestResponse{}, errors.New("failed to get root folders")
|
||||
}
|
||||
return RadarrTestResponse{QualityProfiles: qps, RootFolders: rfs}, nil
|
||||
}
|
||||
|
||||
// TODO any way to simplify (deduplicate/reuse) these methods (and the whole file tbh) would be very good
|
||||
|
||||
// Add sonarr server to config
|
||||
func addSonarr(s SonarrSettings) error {
|
||||
for _, v := range Config.SONARR {
|
||||
if v.Name == s.Name {
|
||||
// Server exists with this name...
|
||||
return errors.New("server with that name already exists")
|
||||
}
|
||||
}
|
||||
Config.SONARR = append(Config.SONARR, s)
|
||||
writeConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Edit sonarr server in config
|
||||
func editSonarr(s SonarrSettings) error {
|
||||
for i, v := range Config.SONARR {
|
||||
if v.Name == s.Name {
|
||||
Config.SONARR[i] = s
|
||||
writeConfig()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't edit server that does not exist")
|
||||
}
|
||||
|
||||
func rmSonarr(name string) error {
|
||||
for i, v := range Config.SONARR {
|
||||
if v.Name == name {
|
||||
Config.SONARR = append(Config.SONARR[:i], Config.SONARR[i+1:]...)
|
||||
writeConfig()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't remove a server that does not exist")
|
||||
}
|
||||
|
||||
func getSonarr(name string) (SonarrSettings, error) {
|
||||
for i, v := range Config.SONARR {
|
||||
if v.Name == name {
|
||||
return Config.SONARR[i], nil
|
||||
}
|
||||
}
|
||||
return SonarrSettings{}, errors.New("server not found")
|
||||
}
|
||||
|
||||
// Get list of sonarr servers without api keys.
|
||||
// Regular users with access to adding to sonarr will request this.
|
||||
func getSonarrsSafe() []SonarrSettings {
|
||||
s := []SonarrSettings{}
|
||||
for _, v := range Config.SONARR {
|
||||
s = append(s, v.safe())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Add radarr server to config
|
||||
func addRadarr(s RadarrSettings) error {
|
||||
for _, v := range Config.RADARR {
|
||||
if v.Name == s.Name {
|
||||
// Server exists with this name...
|
||||
return errors.New("server with that name already exists")
|
||||
}
|
||||
}
|
||||
Config.RADARR = append(Config.RADARR, s)
|
||||
writeConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Edit radarr server in config
|
||||
func editRadarr(s RadarrSettings) error {
|
||||
for i, v := range Config.RADARR {
|
||||
if v.Name == s.Name {
|
||||
Config.RADARR[i] = s
|
||||
writeConfig()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't edit server that does not exist")
|
||||
}
|
||||
|
||||
func rmRadarr(name string) error {
|
||||
for i, v := range Config.RADARR {
|
||||
if v.Name == name {
|
||||
Config.RADARR = append(Config.RADARR[:i], Config.RADARR[i+1:]...)
|
||||
writeConfig()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't remove a server that does not exist")
|
||||
}
|
||||
|
||||
func getRadarr(name string) (RadarrSettings, error) {
|
||||
for i, v := range Config.RADARR {
|
||||
if v.Name == name {
|
||||
return Config.RADARR[i], nil
|
||||
}
|
||||
}
|
||||
return RadarrSettings{}, errors.New("server not found")
|
||||
}
|
||||
|
||||
// Get list of radarr servers without api keys.
|
||||
// Regular users with access to adding to radarr will request this.
|
||||
func getRadarrsSafe() []RadarrSettings {
|
||||
s := []RadarrSettings{}
|
||||
for _, v := range Config.RADARR {
|
||||
s = append(s, v.safe())
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Sonarr and Radarr
|
||||
|
||||
package arr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ArrType string
|
||||
|
||||
var (
|
||||
SONARR ArrType = "SONARR"
|
||||
RADARR ArrType = "RADARR"
|
||||
)
|
||||
|
||||
type Arr struct {
|
||||
// Type of Arr we want to use.
|
||||
// Each servars api might differ, so this will
|
||||
// allow us to make those changes when needed.
|
||||
Type ArrType
|
||||
// Hostname to the Arr sever
|
||||
Host *string
|
||||
// Api key for the Arr server.
|
||||
Key *string
|
||||
}
|
||||
|
||||
type ArrRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
QualityProfile int `json:"qualityProfile"` // id
|
||||
RootFolder string `json:"rootFolder"` // path
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
Title string `json:"title"` // content name
|
||||
Year int `json:"year"` // content year
|
||||
}
|
||||
|
||||
type SonarrRequest struct {
|
||||
ArrRequest
|
||||
TVDBID int `json:"tvdbId"`
|
||||
LanguageProfile int `json:"languageProfile"` // id
|
||||
SeriesType string `json:"seriesType"`
|
||||
Seasons []SonarrSeasons `json:"seasons"`
|
||||
}
|
||||
|
||||
type RadarrRequest struct {
|
||||
ArrRequest
|
||||
TMDBID int `json:"tmdbId"`
|
||||
}
|
||||
|
||||
type SonarrSeasons struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Monitored bool `json:"monitored"`
|
||||
}
|
||||
|
||||
func New(t ArrType, host *string, key *string) *Arr {
|
||||
return &Arr{
|
||||
Type: t,
|
||||
Host: host,
|
||||
Key: key,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Arr) GetQualityProfiles() ([]QualityProfile, error) {
|
||||
slog.Info("GetQualityProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []QualityProfile
|
||||
err := request(*a.Host, "/qualityprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetQualityProfiles request failed", "service", a.Type, "error", err)
|
||||
return []QualityProfile{}, errors.New("request to service failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *Arr) GetRootFolders() ([]RootFolder, error) {
|
||||
slog.Info("GetRootFolders", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []RootFolder
|
||||
err := request(*a.Host, "/rootfolder", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetRootFolders request failed", "service", a.Type, "error", err)
|
||||
return []RootFolder{}, errors.New("request to service failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *Arr) GetLangaugeProfiles() ([]LanguageProfile, error) {
|
||||
slog.Info("GetLangaugeProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []LanguageProfile
|
||||
// languageprofile supposedly deprecated.. but new language endpoint doesnt seem to work.. note probs to switch soon
|
||||
err := request(*a.Host, "/languageprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetLangaugeProfiles request failed", "service", a.Type, "error", err)
|
||||
return []LanguageProfile{}, errors.New("request to service failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TODO do title! and year?
|
||||
func (a *Arr) BuildAddShowBody(r SonarrRequest) map[string]interface{} {
|
||||
req := map[string]interface{}{
|
||||
"title": r.Title,
|
||||
"year": r.Year,
|
||||
"qualityProfileId": r.QualityProfile,
|
||||
"languageProfileId": r.LanguageProfile,
|
||||
"seasonFolder": true,
|
||||
"monitored": true,
|
||||
"tvdbId": r.TVDBID,
|
||||
"seriesType": r.SeriesType,
|
||||
"seasons": r.Seasons,
|
||||
"addOptions": map[string]interface{}{
|
||||
"ignoreEpisodesWithFiles": true,
|
||||
"searchForMissingEpisodes": r.AutomaticSearch,
|
||||
},
|
||||
"rootFolderPath": r.RootFolder,
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func (a *Arr) BuildAddMovieBody(r RadarrRequest) map[string]interface{} {
|
||||
req := map[string]interface{}{
|
||||
"title": r.Title,
|
||||
"year": r.Year,
|
||||
"qualityProfileId": r.QualityProfile,
|
||||
"monitored": true,
|
||||
"tmdbId": r.TMDBID,
|
||||
"addOptions": map[string]interface{}{
|
||||
"searchForMovie": r.AutomaticSearch,
|
||||
},
|
||||
"rootFolderPath": r.RootFolder,
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func (a *Arr) AddContent(b map[string]interface{}) error {
|
||||
ep := "series"
|
||||
if a.Type == RADARR {
|
||||
ep = "movie"
|
||||
}
|
||||
slog.Debug("AddContent", "type", ep, "body", b)
|
||||
var resp interface{}
|
||||
err := requestPost(*a.Host, "/"+ep, *a.Key, b, &resp)
|
||||
if err != nil {
|
||||
slog.Error("AddContent request failed", "service", a.Type, "error", err)
|
||||
return errors.New("request to service failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func request(host string, ep string, p map[string]string, resp interface{}) error {
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += "/api/v3" + ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
// Run get request
|
||||
res, err := http.Get(base.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("arr non 200 status code:", "status_code", res.StatusCode)
|
||||
return errors.New(string(body))
|
||||
}
|
||||
// slog.Info("", "body", body)
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestPost(host string, ep string, key string, p map[string]interface{}, resp interface{}) error {
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += "/api/v3" + ep
|
||||
|
||||
var res *http.Response
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
params.Add("apikey", key)
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
jsonp, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err = http.Post(base.String(), "application/json", bytes.NewBuffer(jsonp))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
|
||||
slog.Error("arr non 2xx status code:", "status_code", res.StatusCode)
|
||||
return errors.New(string(body))
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package arr
|
||||
|
||||
type QualityProfile struct {
|
||||
Name string `json:"name"`
|
||||
UpgradeAllowed bool `json:"upgradeAllowed"`
|
||||
Cutoff int `json:"cutoff"`
|
||||
Items []struct {
|
||||
Quality struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Resolution int `json:"resolution"`
|
||||
} `json:"quality,omitempty"`
|
||||
Items []any `json:"items"`
|
||||
Allowed bool `json:"allowed"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ID int `json:"id,omitempty"`
|
||||
} `json:"items"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type RootFolder struct {
|
||||
Path string `json:"path"`
|
||||
Accessible bool `json:"accessible"`
|
||||
FreeSpace int64 `json:"freeSpace"`
|
||||
UnmappedFolders []any `json:"unmappedFolders"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type LanguageProfile struct {
|
||||
Name string `json:"name"`
|
||||
UpgradeAllowed bool `json:"upgradeAllowed"`
|
||||
Cutoff struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"cutoff"`
|
||||
Languages []struct {
|
||||
Language struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"language"`
|
||||
Allowed bool `json:"allowed"`
|
||||
} `json:"languages"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +29,17 @@ type UserType uint8
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
)
|
||||
|
||||
// User Perms
|
||||
// iota auto increments for us so when adding new
|
||||
// perms, add to bottom as to not change other perm
|
||||
// values.
|
||||
const (
|
||||
PERM_NONE int = 1 << iota
|
||||
PERM_ADMIN
|
||||
PERM_REQUEST_CONTENT
|
||||
)
|
||||
|
||||
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
|
||||
@@ -35,12 +48,82 @@ type User struct {
|
||||
GormModel
|
||||
Username string `gorm:"uniqueIndex:usr_name_to_type;not null" json:"username" binding:"required"`
|
||||
Password string `gorm:"not null" json:"password" binding:"required"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
// The type of user/which auth service they originate from.
|
||||
// Empty if from Watcharr, or the name of the service (eg. jellyfin)
|
||||
Type UserType `gorm:"uniqueIndex:usr_name_to_type;not null;default:0" json:"type"`
|
||||
// ID of user from the third party service, this will be used purely for lookup of user at signin.
|
||||
ThirdPartyID string `json:"-"`
|
||||
// Auth token from third party (jellyfin)
|
||||
ThirdPartyAuth string `json:"-"`
|
||||
// Users third party integrations (minus jellyfin for now)
|
||||
UserServices []UserServices `json:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
|
||||
func (u *User) GetSafe() PublicUser {
|
||||
return PublicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Avatar: u.Avatar,
|
||||
Bio: u.Bio,
|
||||
}
|
||||
}
|
||||
|
||||
// This struct uses pointer to the values, so in update user settings,
|
||||
// we can tell which setting is being updated (if not nil..).
|
||||
type UserSettings struct {
|
||||
// Is profile private
|
||||
Private *bool `gorm:"default:false" json:"private"`
|
||||
// Are watched list content thoughts public (profile must also be public is false)
|
||||
PrivateThoughts *bool `gorm:"default:false" json:"privateThoughts"`
|
||||
// If ui 'spoilers' should be shown
|
||||
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
|
||||
// If user wants previously watched items to show in 'Finished' filter,
|
||||
// even if the watched item state has since been changed.
|
||||
// Also if user wants to show in watched stats.
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
}
|
||||
|
||||
// Holds third party service auth tokens for users.
|
||||
// Each service may use the fields in their own way.
|
||||
// Unique index applied between service name and clientID
|
||||
// to ensure no duplicates (no need to apply it against
|
||||
// user_id, no accounts should share an integration).
|
||||
//
|
||||
// Plex:
|
||||
// - AuthToken : Used for requests against plex.tv
|
||||
// - AuthToken2 : Used for requests against home plex server.
|
||||
type UserServices struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// Service/integration name
|
||||
Name string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
// The users id on the third party service
|
||||
ClientID string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
AuthToken string `gorm:"not null;" json:"-"`
|
||||
// Second auth token, generic name so future services can use it without extra confusion.
|
||||
// Ex: We require a second auth token for use with our local server for Plex.
|
||||
AuthToken2 string `json:"-"`
|
||||
UserID uint `gorm:"not null;" json:"-"`
|
||||
}
|
||||
|
||||
// We use a separate struct for registration to avoid confusion
|
||||
// and possible accidents where we allow a user to pass in a
|
||||
// property from the main User struct that shouldn't be allowed.
|
||||
type UserRegisterRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type UseAdminTokenRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type JellyfinAuth struct {
|
||||
@@ -53,12 +136,19 @@ type JellyfinAuthResponse struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"User"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type AvailableAuthProvidersResponse struct {
|
||||
AvailableAuthProviders []string `json:"available"`
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
IsInSetup bool `json:"isInSetup"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
memory uint32
|
||||
iterations uint32
|
||||
@@ -67,20 +157,37 @@ type ArgonParams struct {
|
||||
keyLength uint32
|
||||
}
|
||||
|
||||
func GetPassArgonParams() *ArgonParams {
|
||||
return &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type UserPasswordUpdateRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
println("AuthRequired middleware hit")
|
||||
slog.Debug("AuthRequired middleware hit")
|
||||
atoken := c.GetHeader("Authorization")
|
||||
// Make sure auth header isn't empty
|
||||
if atoken == "" {
|
||||
println("Returning 401, Authorization header not provided")
|
||||
slog.Warn("Returning 401, Authorization header not provided")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
@@ -88,26 +195,73 @@ func AuthRequired() gin.HandlerFunc {
|
||||
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("AuthRequired failed to parse token", "error", err)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
// If token is valid, go to next handler
|
||||
if claims, ok := token.Claims.(*TokenClaims); ok && token.Valid {
|
||||
println("Token valid", claims.Username)
|
||||
// Check if token issuedAt is from before `timeOfNewLoginRequired`.
|
||||
// Basically just so we can logout old tokens and force relogin...
|
||||
// since new changes require the user login again.
|
||||
timeOfNewLoginRequired, _ := time.Parse(time.RFC822, "18 Aug 23 20:30 UTC")
|
||||
if claims.IssuedAt.Before(timeOfNewLoginRequired) {
|
||||
slog.Info("Token is from before timeOfNewLoginRequired.. returning 401", "token_issued_at", claims.IssuedAt, "time_of_new_login_required", timeOfNewLoginRequired)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("Token is valid", "claims", claims)
|
||||
c.Set("userId", claims.UserID)
|
||||
c.Set("userType", claims.Type)
|
||||
// If db passed, get extra user info and set as variables in req context
|
||||
if db != nil {
|
||||
slog.Debug("AuthRequired: db passed.. getting extra user info")
|
||||
dbUser := new(User)
|
||||
res := db.Where("id = ?", claims.UserID).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("AuthRequired: Failed to select user from database", "error", res.Error)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("AuthRequired: fetched extra user info. Setting vars.", "userThirdPartyId", dbUser.ThirdPartyID, "userThirdPartyAuth", "lol this is censored dude")
|
||||
c.Set("userThirdPartyId", dbUser.ThirdPartyID)
|
||||
c.Set("userThirdPartyAuth", dbUser.ThirdPartyAuth)
|
||||
c.Set("username", dbUser.Username)
|
||||
c.Set("userPermissions", dbUser.Permissions)
|
||||
}
|
||||
c.Next()
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
slog.Error("Token is **not** valid")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func register(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
println("Registering", user.Username)
|
||||
hash, err := hashPassword(user.Password, &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
})
|
||||
// Admin only middleware (use after AuthRequired with extra info!)
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if hasPermission(perms, PERM_ADMIN) {
|
||||
slog.Debug("AdminRequired: User has permission to access admin only route", "user_id", userId)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("AdminRequired: User denied permission to access admin only route", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
|
||||
if !Config.SIGNUP_ENABLED {
|
||||
slog.Warn("Register called, but signing up is disabled.")
|
||||
return AuthResponse{}, errors.New("registering is disabled")
|
||||
}
|
||||
var user User = User{Username: ur.Username, Password: ur.Password}
|
||||
slog.Info("A user is registering", "username", user.Username)
|
||||
hash, err := hashPassword(user.Password, GetPassArgonParams())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -115,100 +269,121 @@ func register(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
// Update user obj to replace the plaintext pass with hash
|
||||
user.Password = hash
|
||||
|
||||
// Update user permissions if an initial perm is passed in (1 is default)
|
||||
if initialPerm != 0 && initialPerm != PERM_NONE {
|
||||
slog.Info("User being registered has been given extra initial permissions", "initial_perm", initialPerm)
|
||||
user.Permissions = initialPerm
|
||||
}
|
||||
|
||||
res := db.Create(&user)
|
||||
if res.Error != nil {
|
||||
// If error is because unique contraint failed.. user already exists
|
||||
if strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
println("Unique contraint fail:", res.Error.Error())
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
slog.Error("Registration failed", "error", res.Error.Error(), "error_pretty", "User already exists")
|
||||
return AuthResponse{}, errors.New("User already exists")
|
||||
}
|
||||
panic(err)
|
||||
slog.Error("Registration failed", "error", err, "error_pretty", "Watcharr does not know why this failed, assume database operation failed")
|
||||
return AuthResponse{}, errors.New("unknown error")
|
||||
}
|
||||
|
||||
// Gorm fills our user obj with the ID from db after insert,
|
||||
// just ensure it actually has.
|
||||
if user.ID == 0 {
|
||||
fmt.Println("user.ID not filled out after registration", user.ID)
|
||||
slog.Error("user.ID not filled out after registration", "userId", user.ID)
|
||||
return AuthResponse{}, errors.New("failed to get user id, try login")
|
||||
}
|
||||
|
||||
token, err := signJWT(user)
|
||||
token, err := signJWT(&user)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign new jwt:", err)
|
||||
slog.Error("Registration: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func registerFirstUser(user *UserRegisterRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
// Ensure no users exist
|
||||
var userCount int64
|
||||
uresp := db.Model(&User{}).Count(&userCount)
|
||||
if uresp.Error != nil {
|
||||
slog.Error("registerFirstUser: User count query failed!", "error", uresp.Error)
|
||||
return AuthResponse{}, errors.New("failed to query db for a count of users")
|
||||
}
|
||||
if userCount != 0 {
|
||||
slog.Warn("registerFirstUser: registered users already exist.")
|
||||
return AuthResponse{}, errors.New("first user already registered")
|
||||
}
|
||||
slog.Info("Registering first user.")
|
||||
return register(user, PERM_ADMIN, db)
|
||||
}
|
||||
|
||||
func login(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
fmt.Println("Logging in", user.Username)
|
||||
slog.Debug("A User Is Logging In", "username", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ? AND (type IS NULL OR type = 0)", user.Username).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
fmt.Println("Failed to select user from database for login:", res.Error)
|
||||
slog.Error("Failed to select user from database for login", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("User does not exist")
|
||||
}
|
||||
|
||||
match, err := compareHash(user.Password, dbUser.Password)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to compare pass to hash for login:", err)
|
||||
slog.Error("Failed to compare pass to hash for login", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to login")
|
||||
}
|
||||
if !match {
|
||||
fmt.Println("User failed to provide correct password for login:", match)
|
||||
slog.Error("User failed to provide correct password for login", "hash_matched", match)
|
||||
return AuthResponse{}, errors.New("incorrect details")
|
||||
}
|
||||
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign new jwt:", err)
|
||||
slog.Error("Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
jellyfinHost := os.Getenv("JELLYFIN_HOST")
|
||||
if jellyfinHost == "" {
|
||||
println("Request made to login via Jellyfin, but JELLYFIN_HOST environment variable is not set.")
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
return AuthResponse{}, errors.New("jellyfin login not enabled")
|
||||
}
|
||||
|
||||
base, err := url.Parse(jellyfinHost + "/Users/AuthenticateByName")
|
||||
base, err := url.Parse(Config.JELLYFIN_HOST + "/Users/AuthenticateByName")
|
||||
if err != nil {
|
||||
println("Failed to parse AuthenticateByName api endpoint url:", err.Error())
|
||||
slog.Error("Failed to parse AuthenticateByName api endpoint url", "error", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Marshall struct as json
|
||||
usrJSON, err := json.Marshal(JellyfinAuth{Username: user.Username, Pw: user.Password})
|
||||
if err != nil {
|
||||
println("Error marshalling JellyfinAuth JSON", err.Error())
|
||||
slog.Error("Error marshalling JellyfinAuth JSON", "error", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to marshal json")
|
||||
}
|
||||
// Run auth request
|
||||
// res, err := http.Post(base.String(), "application/json", bytes.NewBuffer(usrJSON))
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer(usrJSON))
|
||||
if err != nil {
|
||||
println("creating request to jellyfin for auth failed:", err)
|
||||
slog.Error("Creating request to jellyfin for auth failed", "error", err)
|
||||
return AuthResponse{}, errors.New("request failed")
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("X-Emby-Authorization", "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor"+user.Username+"\", Version=\"10.8.0\"")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
println("making request to jellyfin for auth failed:", err)
|
||||
slog.Error("making request to jellyfin for auth failed", "error", err)
|
||||
return AuthResponse{}, errors.New("request failed")
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
println("Error reading response", err.Error())
|
||||
slog.Error("Error reading jellyfin auth response", "error", err.Error())
|
||||
return AuthResponse{}, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
println("Jellyfin auth non 200 status code:", res.StatusCode, string(body))
|
||||
slog.Error("Jellyfin auth non 200 status code", "status_code", res.StatusCode, "error", string(body))
|
||||
return AuthResponse{}, errors.New("incorrect details")
|
||||
}
|
||||
// Process auth response
|
||||
@@ -222,38 +397,160 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
|
||||
dbUser := new(User)
|
||||
dbRes := db.Where("third_party_id = ?", resp.User.ID).Take(&dbUser)
|
||||
dbRes := db.Where("third_party_id = ? AND type = ?", resp.User.ID, JELLYFIN_USER).Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
// Record not found, so we should create the user
|
||||
// dbUser will be empty, so we can just reuse it for this purpose.
|
||||
dbUser.ThirdPartyID = resp.User.ID
|
||||
dbUser.ThirdPartyAuth = resp.AccessToken
|
||||
dbUser.Username = resp.User.Name
|
||||
dbUser.Type = JELLYFIN_USER
|
||||
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
println("Failed to create new user in db from jellyfin response:", err.Error())
|
||||
slog.Error("Failed to create new user in db from jellyfin response", "error", dbRes.Error)
|
||||
return AuthResponse{}, errors.New("failed to create new user from jellyfin")
|
||||
}
|
||||
} else {
|
||||
return AuthResponse{}, errors.New("error locating user in db")
|
||||
}
|
||||
}
|
||||
// If user exists.. update their access token in db
|
||||
if resp.AccessToken != "" {
|
||||
slog.Debug("Jellyfin user login - updating user with new access token")
|
||||
dbUser.ThirdPartyAuth = resp.AccessToken
|
||||
db.Save(&dbUser)
|
||||
}
|
||||
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign new jwt:", err)
|
||||
slog.Error("Failed to sign new (jellyfin login) jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
// Login via Plex.
|
||||
func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("Request made to login via Plex, but Plex authentication is disabled")
|
||||
return AuthResponse{}, errors.New("plex login not enabled")
|
||||
}
|
||||
slog.Debug("A Plex User Is Logging In")
|
||||
account, err := fetchPlexAccountFromToken(lr.AuthToken)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Could not fetch Plex account", "error", err)
|
||||
return AuthResponse{}, errors.New("could not fetch plex acount")
|
||||
}
|
||||
if account.Username == "" || account.Id == 0 {
|
||||
slog.Error("loginPlex: Username or id missing from account response:", "username", account.Username, "id", account.Id)
|
||||
return AuthResponse{}, errors.New("data is missing from the plex account response")
|
||||
}
|
||||
dbUser := new(User)
|
||||
userIdQ := db.Select("user_id").Where("name = ? AND client_id = ?", "plex", account.Id).Table("user_services")
|
||||
dbRes := db.Where("type = ?", PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Debug("loginPlex: New plex user attempted login.. creating Watcharr account now.")
|
||||
if err := plexUserHasAccessToPlexHost(lr.AuthToken); err != nil {
|
||||
slog.Error("loginPlex: Cannot register Plex user. Failed to verify they have access to our home plex server.", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to verify plex access")
|
||||
}
|
||||
dbUser.Username = account.Username
|
||||
dbUser.Type = PLEX_USER
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token for the new user! User will still be created, a re-login may fix this issue.", "error", err)
|
||||
}
|
||||
dbUser.UserServices = append(dbUser.UserServices, UserServices{
|
||||
Name: "plex",
|
||||
ClientID: strconv.FormatUint(account.Id, 10),
|
||||
AuthToken: lr.AuthToken,
|
||||
AuthToken2: homeAuthToken,
|
||||
})
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
|
||||
return AuthResponse{}, errors.New("failed to create new user from plex")
|
||||
}
|
||||
} else {
|
||||
slog.Error("loginPlex: Failed to select user from database for login", "error", dbRes.Error)
|
||||
return AuthResponse{}, errors.New("failed to locate user")
|
||||
}
|
||||
} else {
|
||||
// If user exists.. update their access tokens in db
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token!", "error", err)
|
||||
}
|
||||
for i, v := range dbUser.UserServices {
|
||||
if v.Name == "plex" {
|
||||
slog.Info("loginPlex: Found plex user service.. attemping to update")
|
||||
dbUser.UserServices[i].AuthToken = lr.AuthToken
|
||||
if homeAuthToken != "" {
|
||||
dbUser.UserServices[i].AuthToken2 = homeAuthToken
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
db.Save(&dbUser.UserServices)
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
var dbToken Token
|
||||
resp := db.Where("value = ?", req.Token).Take(&dbToken)
|
||||
if resp.Error != nil {
|
||||
slog.Info("useAdminToken failed", "error", "token not found in db")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
if dbToken.Type != TOKENTYPE_ADMIN {
|
||||
slog.Info("useAdminToken failed", "error", "token is of wrong type", "type_wanted", TOKENTYPE_ADMIN, "type_actual", dbToken.Type)
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
dur := time.Since(dbToken.CreatedAt)
|
||||
if dur > tokenMaxAge {
|
||||
slog.Info("useAdminToken failed", "error", "token in db has expired")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
if dbToken.UserID != userId {
|
||||
slog.Info("useAdminToken failed", "error", "token in db is not for this user")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
// Token is valid and for current user.. give user admin.
|
||||
// Incase removing the token after used fails, this is in a transaction so user wont be admin.
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Give user admin
|
||||
if err := tx.Model(&User{}).Where("id = ?", userId).Update("permissions", PERM_ADMIN).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete used token
|
||||
if err := tx.Where("value = ?", req.Token).Delete(&Token{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Info("useAdminToken failed", "error", err, "error_pretty", "using token transaction failed")
|
||||
return errors.New("failed to use token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signJWT(user *User) (token string, err error) {
|
||||
// Create new jwt with claim data
|
||||
jwt := jwt.NewWithClaims(jwt.SigningMethodHS256, TokenClaims{
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Type,
|
||||
jwt.RegisteredClaims{
|
||||
// ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
@@ -348,3 +645,46 @@ func decodeHash(encodedHash string) (p *ArgonParams, salt, hash []byte, err erro
|
||||
|
||||
return p, salt, hash, nil
|
||||
}
|
||||
|
||||
func hasPermission(perms int, reqPerm int) bool {
|
||||
// Admins have permission for everything.
|
||||
if perms&PERM_ADMIN == PERM_ADMIN {
|
||||
return true
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm
|
||||
}
|
||||
|
||||
func userChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint) error {
|
||||
slog.Debug("userChangePassword request running", "user_id", userId)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Select("password").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("userChangePassword failed - failed to retrieve user from database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to retrieve user")
|
||||
}
|
||||
slog.Debug("userChangePassword user found", "user_id", userId)
|
||||
match, err := compareHash(pwds.OldPassword, user.Password)
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to compare passwords", "user_id", userId, "error", err)
|
||||
return errors.New("failed to compare passwords")
|
||||
}
|
||||
if !match {
|
||||
slog.Error("userChangePassword failed - current password hash doesn't match password hash in database", "user_id", userId, "error", err)
|
||||
return errors.New("current password provided doesn't match password in database")
|
||||
}
|
||||
slog.Debug("userChangePassword hash for current password matches hash in the database", "user_id", userId)
|
||||
slog.Debug("userChangePassword hashing new password", "user_id", userId)
|
||||
hash, err := hashPassword(pwds.NewPassword, GetPassArgonParams())
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to hash new password", "user_id", userId, "error", err)
|
||||
return errors.New("failed to hash new password")
|
||||
}
|
||||
slog.Debug("userChangePassword new password hashed", "user_id", userId)
|
||||
if err := db.Model(&User{}).Where("id = ?", userId).Update("password", hash).Error; err != nil {
|
||||
slog.Error("userChangePassword failed - failed to update password in database", "user_id", userId, "error", err)
|
||||
return errors.New("failed to update password")
|
||||
} else {
|
||||
slog.Debug("userChangePassword password updated", "user_id", userId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DataPath = "./data"
|
||||
|
||||
type ServerConfig struct {
|
||||
// Used to sign JWT tokens. Make sure to make
|
||||
// it strong, just like a very long, complicated password.
|
||||
JWT_SECRET string `json:",omitempty"`
|
||||
|
||||
// Optional: Point to your Jellyfin install
|
||||
// to enable it as an auth provider.
|
||||
JELLYFIN_HOST string `json:",omitempty"`
|
||||
|
||||
// Enable/disable signup functionality.
|
||||
// Set to `false` to disable registering an account.
|
||||
SIGNUP_ENABLED bool
|
||||
|
||||
// Optional: Provide your own TMDB API Key.
|
||||
// If unprovided, the default Watcharr API key will be used.
|
||||
TMDB_KEY string `json:",omitempty"`
|
||||
|
||||
// Optional: Point to Plex install to enable plex features.
|
||||
PLEX_HOST string `json:",omitempty"`
|
||||
|
||||
// Optional: Machine identifier of your Plex server.
|
||||
// This is used to ensure only users of your Plex server
|
||||
// can use this Watcharr instance.
|
||||
// Will be fetched automatically when PLEX_HOST is provided via web ui.
|
||||
PLEX_MACHINE_ID string `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `json:",omitempty"`
|
||||
TWITCH game.IGDB `json:",omitempty"`
|
||||
|
||||
// Enable/disable debug logging. Useful for when trying
|
||||
// to figure out exactly what the server is doing at a point
|
||||
// of failure.
|
||||
// Set to `true` to enable.
|
||||
DEBUG bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ServerConfig, but with JWT_SECRET removed from json.
|
||||
// Used for returning to user from get config api request.
|
||||
//
|
||||
// Technically only admins will have access to that api route,
|
||||
// but I feel more comfortable removing it anyways (+ this is
|
||||
// not editable on frontend, so not needed).
|
||||
func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
return ServerConfig{
|
||||
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
|
||||
JELLYFIN_HOST: c.JELLYFIN_HOST,
|
||||
TMDB_KEY: c.TMDB_KEY,
|
||||
PLEX_HOST: c.PLEX_HOST,
|
||||
PLEX_MACHINE_ID: c.PLEX_MACHINE_ID,
|
||||
DEBUG: c.DEBUG,
|
||||
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
|
||||
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
|
||||
TWITCH: game.IGDB{
|
||||
ClientID: c.TWITCH.ClientID,
|
||||
ClientSecret: c.TWITCH.ClientSecret,
|
||||
}, // Dont act safe, this contains twitch secrets, needed for config
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
|
||||
Config = ServerConfig{}
|
||||
)
|
||||
|
||||
// Read config file
|
||||
// Calls generateConfig if file doesn't exist
|
||||
func readConfig() error {
|
||||
cfg, err := os.Open("./data/watcharr.json")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Info("Config file doesn't exist... generating.")
|
||||
if err = generateConfig(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer cfg.Close()
|
||||
jsonParser := json.NewDecoder(cfg)
|
||||
if err = jsonParser.Decode(&Config); err != nil {
|
||||
return err
|
||||
}
|
||||
initFromConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure required config is provided
|
||||
func initFromConfig() error {
|
||||
if Config.JWT_SECRET == "" {
|
||||
log.Fatal("JWT_SECRET missing from config!")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate new barebones watcharr.json config file.
|
||||
// Generates a JWT_SECRET and set default config.
|
||||
func generateConfig() error {
|
||||
key, err := generateString(64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := ServerConfig{
|
||||
JWT_SECRET: key,
|
||||
// Other defaults..
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
barej, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Config = cfg
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
}
|
||||
|
||||
// Update server config property
|
||||
func updateConfig(k string, v any) error {
|
||||
slog.Debug("updateConfig", "k", k, "v", v)
|
||||
if v == nil {
|
||||
return errors.New("invalid value")
|
||||
}
|
||||
if k == "JELLYFIN_HOST" {
|
||||
Config.JELLYFIN_HOST = v.(string)
|
||||
} else if k == "SIGNUP_ENABLED" {
|
||||
Config.SIGNUP_ENABLED = v.(bool)
|
||||
} else if k == "TMDB_KEY" {
|
||||
Config.TMDB_KEY = v.(string)
|
||||
} else if k == "DEBUG" {
|
||||
Config.DEBUG = v.(bool)
|
||||
setLoggingLevel()
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
return writeConfig()
|
||||
}
|
||||
|
||||
// Write current Config to file
|
||||
func writeConfig() error {
|
||||
barej, err := json.MarshalIndent(Config, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
}
|
||||
|
||||
type ServerFeatures struct {
|
||||
Sonarr bool `json:"sonarr"`
|
||||
Radarr bool `json:"radarr"`
|
||||
Games bool `json:"games"`
|
||||
}
|
||||
|
||||
// Get enabled server functionality from Config.
|
||||
// Mainly so the frontend can store this once and know
|
||||
// which btns should be shown, etc.
|
||||
func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
var f ServerFeatures
|
||||
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
// https://github.com/sbondCo/Watcharr/issues/211
|
||||
// Currently requesting permissions have not been setup, only admins for now.
|
||||
if !hasPermission(userPerms, PERM_ADMIN) {
|
||||
return f
|
||||
}
|
||||
if len(Config.SONARR) > 0 {
|
||||
f.Sonarr = true
|
||||
}
|
||||
if len(Config.RADARR) > 0 {
|
||||
f.Radarr = true
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func saveTwitchConfig(c game.IGDB) error {
|
||||
// If existing client id and secret are same.. just return here
|
||||
if (Config.TWITCH.ClientID != nil && c.ClientID != nil && Config.TWITCH.ClientSecret != nil && c.ClientSecret != nil) &&
|
||||
*Config.TWITCH.ClientID == *c.ClientID && *Config.TWITCH.ClientSecret == *c.ClientSecret {
|
||||
slog.Info("saveTwitchConfig: New ClientID and ClientSecret match old ClientID and ClientSecret.. ignoring request to update.")
|
||||
return nil
|
||||
}
|
||||
// Update our config
|
||||
Config.TWITCH.ClientID = c.ClientID
|
||||
Config.TWITCH.ClientSecret = c.ClientSecret
|
||||
Config.TWITCH.AccessToken = ""
|
||||
Config.TWITCH.AccessTokenExpires = time.Time{}
|
||||
// Try to init again
|
||||
err := Config.TWITCH.Init()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to initialize TWITCH", "error", err)
|
||||
return errors.New("initialization with credentials failed")
|
||||
}
|
||||
err = writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to write config", "error", err)
|
||||
return errors.New("failed to save config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ServerStats struct {
|
||||
Users int64 `json:"users"`
|
||||
PrivateUsers int64 `json:"privateUsers"`
|
||||
WatchedMovies int64 `json:"watchedMovies"`
|
||||
WatchedShows int64 `json:"watchedShows"`
|
||||
WatchedSeasons int64 `json:"watchedSeasons"`
|
||||
MostWatchedMovie Content `json:"mostWatchedMovie"`
|
||||
MostWatchedShow Content `json:"mostWatchedShow"`
|
||||
Activities int64 `json:"activities"`
|
||||
}
|
||||
|
||||
// Collect and return server stats
|
||||
// I cant sql so this the best yall gettin
|
||||
func getServerStats(db *gorm.DB) ServerStats {
|
||||
stats := ServerStats{}
|
||||
resp := db.Model(&User{}).Count(&stats.Users).Where("private = 1").Count(&stats.PrivateUsers)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Users query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&WatchedSeason{}).Count(&stats.WatchedSeasons)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedSeasons query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&Activity{}).Count(&stats.Activities)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Activities query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "tv").Find(&Watched{}).Count(&stats.WatchedShows)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedShows query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "movie").Find(&Watched{}).Count(&stats.WatchedMovies)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedMovies query failed", "error", resp.Error)
|
||||
}
|
||||
|
||||
var w Watched
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "tv").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedShow = *w.Content
|
||||
}
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "movie").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedMovie query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedMovie = *w.Content
|
||||
}
|
||||
return stats
|
||||
}
|
||||
@@ -2,6 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
@@ -13,31 +19,200 @@ const (
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TmdbID int `json:"tmdbId" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
Title string `json:"title"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
Type ContentType `json:"type" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TmdbID int `json:"tmdbId" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
Title string `json:"title"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
Type ContentType `json:"type" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
Status string `json:"status"`
|
||||
Budget uint32 `json:"budget"`
|
||||
Revenue uint32 `json:"revenue"`
|
||||
Runtime uint32 `json:"runtime"`
|
||||
NumberOfEpisodes uint32 `json:"numberOfEpisodes"`
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
|
||||
// onlyUpdate - If we should only update existing row if exists, or false to create/update if not exist.
|
||||
func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
slog.Info("Saving content to db", "id", c.TmdbID, "title", c.Title)
|
||||
if c.TmdbID == 0 || c.Title == "" || c.Type == "" {
|
||||
slog.Error("saveContent: content missing id, title or type!", "id", c.TmdbID, "title", c.Title, "type", c.Type)
|
||||
return errors.New("content missing id or title")
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Content{}).Where("type = ? AND tmdb_id = ?", c.Type, c.TmdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveContent: Error updating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached content in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tmdb_id"}, {Name: "type"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"title",
|
||||
"poster_path",
|
||||
"overview",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"vote_average",
|
||||
"vote_count",
|
||||
"imdb_id",
|
||||
"status",
|
||||
"budget",
|
||||
"revenue",
|
||||
"runtime",
|
||||
"number_of_episodes",
|
||||
"number_of_seasons",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveContent: Error creating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join("./data/img", c.PosterPath))
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheContentTv(db *gorm.DB, content TMDBShowDetails, onlyUpdate bool) (Content, error) {
|
||||
slog.Debug("cacheContentTv", "content", content)
|
||||
var (
|
||||
releaseDate time.Time
|
||||
runtime uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
releaseDate, err := time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Name,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: SHOW,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
Status: content.Status,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: content.NumberOfEpisodes,
|
||||
NumberOfSeasons: content.NumberOfSeasons,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentTv: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (Content, error) {
|
||||
var (
|
||||
releaseDate time.Time
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
releaseDate, err := time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Title,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: MOVIE,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
ImdbID: content.ImdbID,
|
||||
Status: content.Status,
|
||||
Budget: content.Budget,
|
||||
Revenue: content.Revenue,
|
||||
Runtime: content.Runtime,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentMovie: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
func transformProviders(c *interface{}, country string) {
|
||||
slog.Debug("transformProviders called", "country", country)
|
||||
if cmap, ok := (*c).(map[string]interface{}); ok {
|
||||
if rmap, ok := cmap["results"].(map[string]interface{}); ok {
|
||||
if val, ok := rmap[country]; ok {
|
||||
slog.Debug("transformProviders: Found country.. overwriting whole object", "new_obj", val)
|
||||
if rvmap, ok := val.(map[string]interface{}); ok {
|
||||
rvmap["country"] = country
|
||||
}
|
||||
*c = val
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find country..", "country", country)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find results property..")
|
||||
}
|
||||
} else {
|
||||
slog.Error("transformProviders: Assertion failed")
|
||||
}
|
||||
}
|
||||
|
||||
func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBSearchMultiResponse)
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": "1"}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete multi search request!", err.Error())
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieDetails(id string) (TMDBMovieDetails, error) {
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, map[string]string{}, &resp)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete movie details request!", err.Error())
|
||||
slog.Error("Failed to complete movie details request!", "error", err.Error())
|
||||
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -45,19 +220,21 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete movie cast request!", err.Error())
|
||||
slog.Error("Failed to complete movie cast request!", "error", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete movie cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(id string) (TMDBShowDetails, error) {
|
||||
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
resp := new(TMDBShowDetails)
|
||||
err := tmdbRequest("/tv/"+id, map[string]string{}, &resp)
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete tv details request!", err.Error())
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -65,17 +242,27 @@ func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete tv cast request!", err.Error())
|
||||
slog.Error("Failed to complete tv cast request!", "error", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
|
||||
resp := new(TMDBSeasonDetails)
|
||||
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete season details request!", "error", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func personDetails(id string) (TMDBPersonDetails, error) {
|
||||
resp := new(TMDBPersonDetails)
|
||||
err := tmdbRequest("/person/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete person details request!", err.Error())
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return TMDBPersonDetails{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
@@ -85,8 +272,62 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
resp := new(TMDBPersonCombinedCredits)
|
||||
err := tmdbRequest("/person/"+id+"/combined_credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete person details request!", err.Error())
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return TMDBPersonCombinedCredits{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
resp := new(TMDBDiscoverShows)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func allTrending() (TMDBTrendingAll, error) {
|
||||
resp := new(TMDBTrendingAll)
|
||||
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func upcomingMovies() (TMDBUpcomingMovies, error) {
|
||||
resp := new(TMDBUpcomingMovies)
|
||||
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
|
||||
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Theres no upcoming endpoint for tv ;( - using discover with future dates
|
||||
func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
resp := new(TMDBUpcomingShows)
|
||||
dFmt := "2006-01-02"
|
||||
mind := time.Now().Format(dFmt)
|
||||
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1", "first_air_date.gte": mind, "first_air_date.lte": maxd, "sort_by": "popularity.desc", "with_type": "2|3"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
|
||||
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Database struct, only internal.
|
||||
type Follow struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
UserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null;check:user_id != followed_user_id" json:"-"`
|
||||
User User `json:"-"`
|
||||
FollowedUserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null" json:"-"`
|
||||
FollowedUser User `json:"-"`
|
||||
}
|
||||
|
||||
// For end users to see.
|
||||
type FollowPublic struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
}
|
||||
|
||||
type FollowThoughts struct {
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
|
||||
func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPublic, error) {
|
||||
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
res := db.Model(&Follow{}).Create(&f)
|
||||
if res.Error != nil {
|
||||
slog.Error("followUser: Error on inserting follow.", "error", res.Error)
|
||||
err := "failed to insert follow"
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
err = "already followed"
|
||||
}
|
||||
return FollowPublic{}, errors.New(err)
|
||||
}
|
||||
// Now get the row with preloaded followed user
|
||||
var nf Follow
|
||||
res = db.Where("user_id = ? AND followed_user_id = ?", currentUserId, toFollowUserId).Preload("FollowedUser", "private = ?", 0).Take(&nf)
|
||||
if res.Error != nil {
|
||||
slog.Error("followUser: Couldn't fetch newly followed user.", "error", res.Error)
|
||||
return FollowPublic{}, errors.New("followed, but failed to fetch followed user")
|
||||
}
|
||||
return FollowPublic{CreatedAt: nf.CreatedAt, FollowedUser: nf.FollowedUser.GetSafe()}, nil
|
||||
}
|
||||
|
||||
func unfollowUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (bool, error) {
|
||||
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
res := db.Delete(&f)
|
||||
if res.Error != nil {
|
||||
slog.Error("unfollowUser: Error deleting follow.", "error", res.Error)
|
||||
err := "failed to remove follow"
|
||||
if res.Error == gorm.ErrRecordNotFound {
|
||||
err = "not following"
|
||||
}
|
||||
return false, errors.New(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Get current users follows
|
||||
func getFollows(db *gorm.DB, userId uint) ([]FollowPublic, error) {
|
||||
var follows []Follow
|
||||
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ?", 0).Find(&follows)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding follows.", "error", res.Error)
|
||||
return []FollowPublic{}, errors.New("failed to find follows")
|
||||
}
|
||||
fpub := []FollowPublic{}
|
||||
for _, v := range follows {
|
||||
// Skip followed users without an ID..
|
||||
// this will be because they have made
|
||||
// their account private after we followed them.
|
||||
if v.FollowedUser.ID == 0 {
|
||||
continue
|
||||
}
|
||||
fpub = append(fpub, FollowPublic{CreatedAt: v.CreatedAt, FollowedUser: v.FollowedUser.GetSafe()})
|
||||
}
|
||||
return fpub, nil
|
||||
}
|
||||
|
||||
// Get followed profile thoughts, rating, etc on specific content.
|
||||
func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId string) ([]FollowThoughts, error) {
|
||||
var follows []Follow
|
||||
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ? AND private_thoughts = ?", 0, 0).Find(&follows)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding follows.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find follows")
|
||||
}
|
||||
slog.Info("getFollowsThoughts")
|
||||
var followIds []uint
|
||||
for _, v := range follows {
|
||||
// Skip empty followedUsers.. they are private.
|
||||
if v.FollowedUser.ID == 0 {
|
||||
continue
|
||||
}
|
||||
followIds = append(followIds, v.FollowedUser.ID)
|
||||
}
|
||||
var contentOrGameId int
|
||||
if mediaType == "game" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Game
|
||||
res = db.Where("igdb_id = ?", mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find content")
|
||||
}
|
||||
contentOrGameId = content.ID
|
||||
} else if mediaType == "movie" || mediaType == "tv" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Content
|
||||
res = db.Where("type = ? AND tmdb_id = ?", mediaType, mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find content")
|
||||
}
|
||||
contentOrGameId = content.ID
|
||||
} else {
|
||||
slog.Error("getFollows: Unrecognized media type (movie, tv or game supported).", "media_type", mediaType)
|
||||
return []FollowThoughts{}, errors.New("unrecognized media type")
|
||||
}
|
||||
// Get list of followeds watcheds for this content
|
||||
var fw []Watched
|
||||
if mediaType == "game" {
|
||||
res = db.Where("game_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
|
||||
} else {
|
||||
res = db.Where("content_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
|
||||
}
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding followed watcheds from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find followed watcheds")
|
||||
}
|
||||
// Create followThoughts array by combining follows and fw(atcheds)
|
||||
ft := []FollowThoughts{}
|
||||
for _, v := range fw {
|
||||
var fu PublicUser
|
||||
for _, f := range follows {
|
||||
if f.FollowedUser.ID == v.UserID {
|
||||
fu = f.FollowedUser.GetSafe()
|
||||
break
|
||||
}
|
||||
}
|
||||
// If we didn't find a related followedUser.. skip this watched entry
|
||||
if fu.ID == 0 {
|
||||
continue
|
||||
}
|
||||
ft = append(ft, FollowThoughts{FollowedUser: fu, Thoughts: v.Thoughts, Status: v.Status, Rating: v.Rating})
|
||||
}
|
||||
return ft, nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
igdbHost = "https://api.igdb.com/v4"
|
||||
tokenGrantType = "client_credentials"
|
||||
)
|
||||
|
||||
var tokenRefreshJobCancel context.CancelFunc
|
||||
|
||||
type IGDB struct {
|
||||
ClientID *string `json:"clientId,omitempty"`
|
||||
ClientSecret *string `json:"clientSecret,omitempty"`
|
||||
AccessToken string `json:"accessToken,omitempty"`
|
||||
AccessTokenExpires time.Time `json:"accessTokenExpires,omitempty"`
|
||||
onTokenRefreshed *func()
|
||||
}
|
||||
|
||||
func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp interface{}) error {
|
||||
// if using igdb host and we have no access token, error before running req
|
||||
if host == igdbHost && (i.ClientID == nil || i.AccessToken == "") {
|
||||
return errors.New("using igdbHost without a clientID or accessToken")
|
||||
}
|
||||
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
slog.Info("req", "base", base.String())
|
||||
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer([]byte(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add igdb auth headers
|
||||
if host == igdbHost {
|
||||
req.Header.Add("Client-ID", *i.ClientID)
|
||||
req.Header.Add("Authorization", "Bearer "+i.AccessToken)
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
|
||||
slog.Error("game non 2xx status code:", "status_code", res.StatusCode)
|
||||
return errors.New(string(body))
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IGDB) getNewAccessToken() (TwitchTokenResponse, error) {
|
||||
var resp TwitchTokenResponse
|
||||
err := i.req(
|
||||
"https://id.twitch.tv/oauth2",
|
||||
"/token",
|
||||
map[string]string{"client_id": *i.ClientID, "client_secret": *i.ClientSecret, "grant_type": tokenGrantType},
|
||||
"",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB init token request failed", "error", err)
|
||||
return TwitchTokenResponse{}, errors.New("token request failed, check client id and secret")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) refreshToken(ctx context.Context) {
|
||||
var exp <-chan time.Time
|
||||
|
||||
if i.AccessToken != "" && !i.AccessTokenExpires.IsZero() && i.AccessTokenExpires.Compare(time.Now()) == 1 {
|
||||
// Stored token not expired.. set exp time to token exp time - 1h.
|
||||
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
||||
} else {
|
||||
// Token expired.. exp now..
|
||||
exp = time.After(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
slog.Info("refreshToken running")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-exp:
|
||||
slog.Info("IGDB refreshToken: Token expired (or is near expiry date)")
|
||||
r, err := i.getNewAccessToken()
|
||||
if err != nil {
|
||||
slog.Error("IGDB refreshToken: Error refreshing token (retrying in 60s):", err)
|
||||
exp = time.After(60 * time.Second)
|
||||
} else {
|
||||
slog.Info("IGDB refreshToken: Token successfully refreshed")
|
||||
i.AccessToken = r.AccessToken
|
||||
i.AccessTokenExpires = time.Now().Add(time.Duration(r.ExpiresIn) * time.Second)
|
||||
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
||||
// Call token refresh callback if set
|
||||
if i.onTokenRefreshed != nil {
|
||||
(*i.onTokenRefreshed)()
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
slog.Info("refreshToken cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *IGDB) OnTokenRefreshed(tokenRefreshed func()) {
|
||||
i.onTokenRefreshed = &tokenRefreshed
|
||||
}
|
||||
|
||||
// Get token and stuff
|
||||
func (i *IGDB) Init() error {
|
||||
// Cancel existing refresh job if we have a cancel func for its context
|
||||
if tokenRefreshJobCancel != nil {
|
||||
slog.Debug("IGDB init: Refresh job running.. cancelling it.")
|
||||
tokenRefreshJobCancel()
|
||||
tokenRefreshJobCancel = nil
|
||||
}
|
||||
// Stop here if we have no client id or secret.
|
||||
if i.ClientID == nil || i.ClientSecret == nil {
|
||||
slog.Error("IGDB init client id and or secret not provided")
|
||||
return errors.New("client id and or secret not provided")
|
||||
}
|
||||
slog.Debug("IGDB init running.")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
tokenRefreshJobCancel = cancel
|
||||
// Get and set first token if needed
|
||||
go i.refreshToken(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IGDB) Search(q string) (GameSearchResponse, error) {
|
||||
slog.Debug("IGDB Search called", "query", q)
|
||||
var resp GameSearchResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
"fields name, cover.image_id, version_title, summary, first_release_date; search \""+q+"\";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB Search request failed!", "error", err)
|
||||
return GameSearchResponse{}, errors.New("request failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
`fields
|
||||
name,
|
||||
cover.image_id,
|
||||
version_title,
|
||||
summary,
|
||||
storyline,
|
||||
first_release_date,
|
||||
artworks.width,
|
||||
artworks.height,
|
||||
artworks.image_id,
|
||||
category,
|
||||
platforms.name,
|
||||
game_modes.name,
|
||||
genres.id,
|
||||
genres.name,
|
||||
involved_companies.developer,
|
||||
involved_companies.publisher,
|
||||
involved_companies.porting,
|
||||
involved_companies.supporting,
|
||||
involved_companies.company.name,
|
||||
involved_companies.company.description,
|
||||
involved_companies.company.slug,
|
||||
involved_companies.company.websites.category,
|
||||
involved_companies.company.websites.trusted,
|
||||
involved_companies.company.websites.url,
|
||||
rating,
|
||||
rating_count,
|
||||
status,
|
||||
url,
|
||||
websites.trusted,
|
||||
websites.category,
|
||||
websites.url,
|
||||
videos.name,
|
||||
videos.video_id,
|
||||
similar_games.id,
|
||||
similar_games.name,
|
||||
similar_games.summary,
|
||||
similar_games.cover.image_id,
|
||||
similar_games.first_release_date;
|
||||
where id = `+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB GameDetails request failed!", "error", err)
|
||||
return GameDetailsResponse{}, errors.New("request failed")
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
return resp[0], nil
|
||||
}
|
||||
return GameDetailsResponse{}, errors.New("no game details recieved")
|
||||
}
|
||||
|
||||
// Basic game details for when we are using them only to update our cache.
|
||||
// In these cases, it's a waste to ask for everything, when we don't need it.
|
||||
func (i *IGDB) GameDetailsBasic(id string) (GameDetailsBasicResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsBasicResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
`fields
|
||||
name,
|
||||
cover.image_id,
|
||||
summary,
|
||||
storyline,
|
||||
first_release_date,
|
||||
category,
|
||||
platforms.name,
|
||||
game_modes.name,
|
||||
genres.name,
|
||||
rating,
|
||||
rating_count,
|
||||
status;
|
||||
where id = `+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB GameDetails request failed!", "error", err)
|
||||
return GameDetailsBasicResponse{}, errors.New("request failed")
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
return resp[0], nil
|
||||
}
|
||||
return GameDetailsBasicResponse{}, errors.New("no game details recieved")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwitchTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// So we can unmarshall the unix timestamps returned from igdb into time.Time.
|
||||
type UnixTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (u *UnixTime) UnmarshalJSON(b []byte) error {
|
||||
var timestamp int64
|
||||
err := json.Unmarshal(b, ×tamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Time = time.Unix(timestamp, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only the fields we request included in each struct
|
||||
|
||||
type GameSearchResponse []struct {
|
||||
ID int `json:"id"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
VersionTitle string `json:"version_title,omitempty"`
|
||||
}
|
||||
|
||||
type GameDetailsResponse struct {
|
||||
ID int `json:"id"`
|
||||
Artworks []struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"artworks"`
|
||||
Category int `json:"category"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
GameModes []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"game_modes"`
|
||||
Genres []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
InvolvedCompanies []struct {
|
||||
ID int `json:"id"`
|
||||
Company struct {
|
||||
ID int `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Websites []struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Trusted bool `json:"trusted"`
|
||||
URL string `json:"url"`
|
||||
} `json:"websites"`
|
||||
} `json:"company"`
|
||||
Developer bool `json:"developer"`
|
||||
Porting bool `json:"porting"`
|
||||
Publisher bool `json:"publisher"`
|
||||
Supporting bool `json:"supporting"`
|
||||
} `json:"involved_companies"`
|
||||
Name string `json:"name"`
|
||||
Platforms []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"platforms"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
Status int `json:"status"`
|
||||
URL string `json:"url"`
|
||||
Videos []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
VideoID string `json:"video_id"`
|
||||
} `json:"videos"`
|
||||
Websites []struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Trusted bool `json:"trusted"`
|
||||
URL string `json:"url"`
|
||||
} `json:"websites"`
|
||||
SimilarGame []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
} `json:"similar_games"`
|
||||
}
|
||||
|
||||
type GameDetailsBasicResponse struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Cover struct {
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
GameModes []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"game_modes"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Name string `json:"name"`
|
||||
Platforms []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"platforms"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// For storing cached games, so we can serve the basic local data for watched list to work
|
||||
type Game struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
IgdbID int `json:"igdbId" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name"`
|
||||
CoverID string `json:"coverId"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
// First release date
|
||||
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"ratingCount"`
|
||||
Status int `json:"status"`
|
||||
Category int `json:"category"`
|
||||
// Arrays turned to strings that may be useful
|
||||
GameModes string `json:"gameModes"`
|
||||
Genres string `json:"genres"`
|
||||
Platforms string `json:"platforms"`
|
||||
// Id to poster image row (cached game cover)
|
||||
PosterID *uint `json:"-"`
|
||||
Poster *Image `json:"poster,omitempty"`
|
||||
}
|
||||
|
||||
type PlayedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
IgdbID int `json:"igdbId" binding:"required"`
|
||||
}
|
||||
|
||||
// Cache(save) game to our table
|
||||
func saveGame(db *gorm.DB, c *Game, onlyUpdate bool) error {
|
||||
slog.Info("Saving game to db", "id", c.IgdbID, "name", c.Name)
|
||||
if c.IgdbID == 0 || c.Name == "" {
|
||||
slog.Error("saveGame: content missing id or name!", "id", c.IgdbID, "name", c.Name)
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
p, err := downloadAndInsertImage(db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
slog.Debug("saveGame: Cached game cover", "p", p)
|
||||
c.PosterID = &p.ID
|
||||
}
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Game{}).Where("igdb_id = ?", c.IgdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveGame: Error updating game in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached game in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "igdb_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name",
|
||||
"cover_id",
|
||||
"summary",
|
||||
"storyline",
|
||||
"release_date",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"status",
|
||||
"game_modes",
|
||||
"genres",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveGame: Error creating game in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache game in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheGame(db *gorm.DB, g game.GameDetailsBasicResponse, onlyUpdate bool) (Game, error) {
|
||||
slog.Debug("cacheGame", "game_details", g)
|
||||
var (
|
||||
gameModes string
|
||||
genres string
|
||||
platforms string
|
||||
)
|
||||
if len(g.GameModes) > 0 {
|
||||
for _, v := range g.GameModes {
|
||||
gameModes += v.Name + "|"
|
||||
}
|
||||
}
|
||||
if len(g.Genres) > 0 {
|
||||
for _, v := range g.Genres {
|
||||
genres += v.Name + "|"
|
||||
}
|
||||
}
|
||||
if len(g.Platforms) > 0 {
|
||||
for _, v := range g.Platforms {
|
||||
platforms += v.Name + "|"
|
||||
}
|
||||
}
|
||||
c := Game{
|
||||
IgdbID: g.ID,
|
||||
Name: g.Name,
|
||||
CoverID: g.Cover.ImageID,
|
||||
Summary: g.Summary,
|
||||
Storyline: g.Storyline,
|
||||
ReleaseDate: &g.FirstReleaseDate.Time,
|
||||
Rating: (g.Rating),
|
||||
RatingCount: g.RatingCount,
|
||||
Status: g.Status,
|
||||
Category: g.Category,
|
||||
GameModes: gameModes,
|
||||
Genres: genres,
|
||||
Platforms: platforms,
|
||||
}
|
||||
err := saveGame(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheGame: Failed to save game!", "error", err)
|
||||
return Game{}, errors.New("failed to save game")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// For adding/updating played games, we will reuse methods defined in watched.go where easily possible.
|
||||
|
||||
func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding played item", "userId", userId, "igdbId", ar.IgdbID)
|
||||
|
||||
var game Game
|
||||
db.Where("igdb_id = ?", ar.IgdbID).Find(&game)
|
||||
|
||||
// Create game if not found from our db
|
||||
if game == (Game{}) {
|
||||
slog.Debug("Game not in db, fetching...")
|
||||
|
||||
resp, err := igdb.GameDetailsBasic(strconv.Itoa(ar.IgdbID))
|
||||
if err != nil {
|
||||
slog.Error("addPlayed content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested games")
|
||||
}
|
||||
|
||||
game, err = cacheGame(db, resp, false)
|
||||
if err != nil {
|
||||
slog.Error("addPlayed failed to cache game", "igdb_id", ar.IgdbID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
// Error if content has no id
|
||||
if game.ID == 0 {
|
||||
return Watched{}, errors.New("failed to find game by id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
ar.Status = FINISHED
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, GameID: &game.ID}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
if watched.DeletedAt.Time.IsZero() {
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addPlayed: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
if res.Error != nil {
|
||||
slog.Error("addPlayed: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("Error adding watched content to database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
}
|
||||
slog.Debug("Added watched list item", "item", watched)
|
||||
|
||||
var activity Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
} else {
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Game = &game
|
||||
return watched, nil
|
||||
}
|
||||
@@ -1,64 +1,52 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.20
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.4.0
|
||||
github.com/gin-gonic/gin v1.9.0
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/gin-contrib/cache v1.2.0
|
||||
github.com/gin-contrib/cors v1.7.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/uptrace/bun v1.1.14
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14
|
||||
golang.org/x/crypto v0.9.0
|
||||
gorm.io/driver/sqlite v1.5.1
|
||||
gorm.io/gorm v1.25.1
|
||||
golang.org/x/crypto v0.21.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.5
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.8.3 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.11.2 // indirect
|
||||
github.com/goccy/go-json v0.10.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.19.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gomodule/redigo v1.8.9 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.16 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.7 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.10 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/tools v0.9.1 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.6 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/sqlite v1.22.1 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,240 +1,126 @@
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
|
||||
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/bytedance/sonic v1.8.3 h1:pf6fGl5eqWYKkx1RcD4qpuX+BIUaduv/wTm5ekWJ80M=
|
||||
github.com/bytedance/sonic v1.8.3/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
|
||||
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.2.0 h1:WA+AJR4kmHDTaLLShCHo/IeWVmmGRZ3Lsr3JQ46tFlE=
|
||||
github.com/gin-contrib/cache v1.2.0/go.mod h1:2KkFL8PSnPF3Tt5E2Jpc3HWuBAUKqGZnClCFMm0tXQI=
|
||||
github.com/gin-contrib/cors v1.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
|
||||
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
|
||||
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
|
||||
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
|
||||
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 h1:tDQ1LjKga657layZ4JLsRdxgvupebc0xuPwRNuTfUgs=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
|
||||
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
|
||||
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
||||
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
|
||||
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
|
||||
github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us=
|
||||
github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU=
|
||||
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.2.10 h1:eimT6Lsr+2lzmSZxPhLFoOWFmQqwk0fllJJ5hEbTXtQ=
|
||||
github.com/ugorji/go/codec v1.2.10/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
|
||||
github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0=
|
||||
github.com/uptrace/bun v1.1.14 h1:S5vvNnjEynJ0CvnrBOD7MIRW7q/WbtvFXrdfy0lddAM=
|
||||
github.com/uptrace/bun v1.1.14/go.mod h1:RHk6DrIisO62dv10pUOJCz5MphXThuOTpVNYEYv7NI8=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12 h1:Ud31nqZmebcQpl151nb108+vtcpxJ7kfXmbPYbALBiI=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12/go.mod h1:Pwg7s31BdF3PMBlWTnYkEn2I9ASsvatt1Ln/AERCTV4=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14 h1:SlwXLxr+N1kEo8Q0cheRlnIZLZlWniEB1OI+jkiLgWE=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14/go.mod h1:9RTEj1l4bB9a4l1Mnc9y4COTwWlFYe1dh6fyxq1rR7A=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12 h1:GMbSa7Pjjk4kjF8XURz5uMLe2PbN98e6t00sp0rx2Eo=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12/go.mod h1:u67g2ewzoMDCCAqjliHAM/BJjEXfoExXlFXhx3TnXRs=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14 h1:DFPUJ6KjDP2myjq15gtYYNngmAFMww1Y2UFZv4tbUw8=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14/go.mod h1:5BFN7V6Sm37Tn7UE4FWNm/F6V3iJPUzAJ7QyRwA5b1k=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
|
||||
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
|
||||
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4=
|
||||
gorm.io/driver/sqlite v1.5.1/go.mod h1:7MZZ2Z8bqyfSQA1gYEV6MagQWj3cpUkJj9Z+d1HEMEQ=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s=
|
||||
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
|
||||
gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
|
||||
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
|
||||
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
|
||||
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
||||
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
||||
modernc.org/libc v1.22.6 h1:cbXU8R+A6aOjRuhsFh3nbDWXO/Hs4ClJRXYB11KmPDo=
|
||||
modernc.org/libc v1.22.6/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
|
||||
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
|
||||
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow=
|
||||
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
||||
modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE=
|
||||
modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
b64 "encoding/base64"
|
||||
)
|
||||
|
||||
// Generate a random string
|
||||
func generateString(len int) (string, error) {
|
||||
key := make([]byte, len)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b64.StdEncoding.EncodeToString([]byte(key)), nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// For user uploaded images
|
||||
type Image struct {
|
||||
ID uint `gorm:"primarykey" json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Hash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
BlurHash string `json:"blurHash"`
|
||||
// Path constructable from hash alone, but I can't decide
|
||||
// if I should have this or not so I figure it's easier
|
||||
// to remove it later than to add it later....... -_-
|
||||
Path string `gorm:"not null" json:"path"`
|
||||
}
|
||||
|
||||
// Insert an image into database
|
||||
func insertImage(db *gorm.DB, hash string, path string, f io.Reader) (Image, error) {
|
||||
bh, _ := getBlurHash(f)
|
||||
img := Image{
|
||||
Hash: hash,
|
||||
Path: path,
|
||||
BlurHash: bh,
|
||||
}
|
||||
r := db.Where(Image{Hash: hash}).FirstOrCreate(&img)
|
||||
if r.Error != nil {
|
||||
slog.Error("insertImage firstOrCreate failed!", "error", r.Error)
|
||||
return Image{}, errors.New("failed to select or create image")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func getBlurHash(img io.Reader) (string, error) {
|
||||
i, _, err := image.Decode(img)
|
||||
if err != nil {
|
||||
// Handle errors
|
||||
slog.Error("getBlurHash decoding image failed", "error", err)
|
||||
return "", errors.New("decoding image failed")
|
||||
}
|
||||
bh, _ := blurhash.Encode(6, 5, i)
|
||||
if err != nil {
|
||||
// Handle errors
|
||||
slog.Error("getBlurHash generation failed", "error", err)
|
||||
return "", errors.New("blur hash generation failed")
|
||||
}
|
||||
slog.Debug("getBlurHash", "hash", bh)
|
||||
return bh, nil
|
||||
}
|
||||
|
||||
func cleanupImages(db *gorm.DB) {
|
||||
slog.Info("cleanupImages running")
|
||||
var unusedImgs []Image
|
||||
// Select images that are not referenced by at least one other row.
|
||||
// Currently only used for user avatars, add new tables when used.
|
||||
db.Raw(`SELECT *
|
||||
FROM images
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.avatar_id = images.id
|
||||
);`).Scan(&unusedImgs)
|
||||
slog.Info("cleanupImages: scanned for unused images", "amount", len(unusedImgs))
|
||||
if len(unusedImgs) > 0 {
|
||||
for _, v := range unusedImgs {
|
||||
slog.Debug("cleanupImages: removing an image", "id", v.ID, "path", v.Path)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Try to delete image from db
|
||||
if err := tx.Where("id = ?", v.ID).Delete(&Image{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// hope its ok to do this sorta thing here :skull:
|
||||
if err := os.Remove(path.Join(DataPath, v.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("cleanupImages: failed to remove image - db row and file kept", "img", v, "error", err)
|
||||
} else {
|
||||
slog.Debug("cleanupImages: successfully removed unused image.", "id", v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isValidImageType(f multipart.File) error {
|
||||
// Read first 512 bytes, since that is all `DetectContentType` will evaluate on.
|
||||
// Reading whole file is a waste.
|
||||
buff := make([]byte, 512)
|
||||
if _, err := f.Read(buff); err != nil {
|
||||
slog.Error("isValidImageType: failed to read file into buffer", "error", err)
|
||||
return errors.New("failed to verify if image is valid")
|
||||
}
|
||||
t := http.DetectContentType(buff)
|
||||
slog.Debug("isValidImageType", "type", t)
|
||||
if t != "image/png" && t != "image/jpeg" && t != "image/webp" && t != "image/gif" {
|
||||
slog.Debug("isValidImageType: rejecting file as not valid (supported) image type")
|
||||
return errors.New("invalid file type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image, error) {
|
||||
slog.Debug("Attempting to download image", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read body into byte array, then create new reader
|
||||
// So we have the ability to seek.
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
log.Fatal(err) // TODO nu le fatal
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Seek back for file
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
|
||||
dataOutP := path.Join(DataPath, outp)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(dataOutP)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(dataOutP), 0764)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(dataOutP)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
} else {
|
||||
return Image{}, err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
// Seek back for insertImage
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
img, err := insertImage(db, hs, outp, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ImportResponseType string
|
||||
|
||||
var (
|
||||
// Successful import
|
||||
IMPORT_SUCCESS ImportResponseType = "IMPORT_SUCCESS"
|
||||
// Import failed for reasons user cant fix
|
||||
IMPORT_FAILED ImportResponseType = "IMPORT_FAILED"
|
||||
// Import query returned multiple results, user must decide
|
||||
IMPORT_MULTI ImportResponseType = "IMPORT_MULTI"
|
||||
// Import query returned zero results, user must provide more info
|
||||
IMPORT_NOTFOUND ImportResponseType = "IMPORT_NOTFOUND"
|
||||
// Item already exists so couldn't import (unique constraint hit when adding)
|
||||
IMPORT_EXISTS ImportResponseType = "IMPORT_EXISTS"
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
Type ImportResponseType `json:"type"`
|
||||
Results []TMDBSearchMultiResults `json:"results"`
|
||||
Match TMDBSearchMultiResults `json:"match"`
|
||||
// On success this will be filled with the new watched entry
|
||||
WatchedEntry Watched `json:"watchedEntry"`
|
||||
}
|
||||
|
||||
func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse, error) {
|
||||
// If tmdbId and type passed in request body
|
||||
// we dont need to use a search tmdb request.
|
||||
// Retrieve the details directly.
|
||||
if ar.TmdbID != 0 && (ar.Type == MOVIE || ar.Type == SHOW) {
|
||||
tid := strconv.Itoa(ar.TmdbID)
|
||||
if ar.Type == MOVIE {
|
||||
cr, err := movieDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("movie details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of movie", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, MOVIE, ar)
|
||||
} else if ar.Type == SHOW {
|
||||
cr, err := tvDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("tv details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of tv", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, SHOW, ar)
|
||||
}
|
||||
}
|
||||
sr, err := searchContent(ar.Name)
|
||||
if err != nil {
|
||||
slog.Error("import: content search failed", "error", err)
|
||||
return ImportResponse{}, errors.New("Content search failed")
|
||||
}
|
||||
pMatches := []TMDBSearchMultiResults{}
|
||||
for _, r := range sr.Results {
|
||||
if r.MediaType != "person" {
|
||||
pMatches = append(pMatches, r)
|
||||
}
|
||||
}
|
||||
resLen := len(pMatches)
|
||||
slog.Debug("import: potential matches", "num_found", resLen)
|
||||
if resLen <= 0 {
|
||||
slog.Debug("import: returning IMPORT_NOTFOUND")
|
||||
return ImportResponse{Type: IMPORT_NOTFOUND}, nil
|
||||
} else if resLen > 1 {
|
||||
slog.Debug("import: multiple results found")
|
||||
// If there are multiple responses, but only one item
|
||||
// from the results is a 100% match for the imported
|
||||
// items name, then consider successful match with that.
|
||||
var perfectMatch TMDBSearchMultiResults
|
||||
for _, r := range pMatches {
|
||||
itemName := r.Name
|
||||
if itemName == "" {
|
||||
itemName = r.Title
|
||||
}
|
||||
if strings.EqualFold(itemName, ar.Name) {
|
||||
slog.Debug("import: multiple results processing: found a perfectMatch", "match", r)
|
||||
if perfectMatch.ID != 0 {
|
||||
// If perfect match has been set before..
|
||||
// quit looking and just show all results.
|
||||
slog.Debug("import: multiple results processing: Second perfectMatch found.. returning all results")
|
||||
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
|
||||
}
|
||||
perfectMatch = r
|
||||
}
|
||||
}
|
||||
// If one perfect match found, import it
|
||||
if perfectMatch.ID != 0 {
|
||||
slog.Debug("import: importing from perfect match")
|
||||
return successfulImport(db, userId, perfectMatch.ID, ContentType(perfectMatch.MediaType), ar)
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
|
||||
} else {
|
||||
slog.Debug("import: success.. only found one result")
|
||||
return successfulImport(db, userId, pMatches[0].ID, ContentType(pMatches[0].MediaType), ar)
|
||||
}
|
||||
}
|
||||
|
||||
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType, ar ImportRequest) (ImportResponse, error) {
|
||||
status := FINISHED
|
||||
if ar.Status != "" {
|
||||
status = ar.Status
|
||||
}
|
||||
// Get the latest date from DatesWatched if we have any.
|
||||
var wDate time.Time
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, dw := range ar.DatesWatched {
|
||||
if dw.After(wDate) {
|
||||
wDate = dw
|
||||
}
|
||||
}
|
||||
}
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: status,
|
||||
ContentID: contentId,
|
||||
ContentType: contentType,
|
||||
Rating: ar.Rating,
|
||||
Thoughts: ar.Thoughts,
|
||||
WatchedDate: wDate,
|
||||
}, IMPORTED_WATCHED)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Error("successfulImport: unique constraint hit.. show must already be on watch list", "error", err)
|
||||
return ImportResponse{Type: IMPORT_EXISTS}, nil
|
||||
}
|
||||
slog.Error("successfulImport: Failed to add content as watched", "error", err)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
// Add activity of the original time the show was added to the users watchlist on whichever platform they are coming from.
|
||||
if ar.RatingCustomDate != nil {
|
||||
var addedActivity Activity
|
||||
if len(w.Activity) > 0 {
|
||||
activityJson, _ := json.Marshal(map[string]interface{}{"rating": ar.Rating, "linkedActivity": w.Activity[0].ID})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_RATING, Data: string(activityJson), CustomDate: ar.RatingCustomDate})
|
||||
} else {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_RATING, Data: strconv.Itoa(int(ar.Rating)), CustomDate: ar.RatingCustomDate})
|
||||
}
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
}
|
||||
// Add all dates watched as activity, if any
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, v := range ar.DatesWatched {
|
||||
customDate := v
|
||||
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add dateswatched activity.", "date", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type JellyfinItemSearchResponse struct {
|
||||
Items []JellyfinItems `json:"Items"`
|
||||
}
|
||||
|
||||
type JellyfinItems struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
ServerID string `json:"ServerId"`
|
||||
Id string `json:"Id"`
|
||||
ProviderIds struct {
|
||||
Tmdb string `json:"Tmdb"`
|
||||
} `json:"ProviderIds"`
|
||||
UserData struct {
|
||||
Rating float64 `json:"Rating"`
|
||||
PlayedPercentage float64 `json:"PlayedPercentage"`
|
||||
UnplayedItemCount int64 `json:"UnplayedItemCount"`
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
PlayCount int64 `json:"PlayCount"`
|
||||
IsFavorite bool `json:"IsFavorite"`
|
||||
Likes bool `json:"Likes"`
|
||||
LastPlayedDate time.Time `json:"LastPlayedDate"`
|
||||
Played bool `json:"Played"`
|
||||
Key string `json:"Key"`
|
||||
ItemId string `json:"ItemId"`
|
||||
} `json:"UserData"`
|
||||
RecursiveItemCount int64 `json:"RecursiveItemCount"`
|
||||
}
|
||||
|
||||
type JFContentFindResponse struct {
|
||||
HasContent bool `json:"hasContent"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
// Jellyfin access middleware, ensures user is a jellyfin user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func JellyfinAccessRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("JellyfinAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("JellyfinAccessRequired: Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != JELLYFIN_USER || userThirdPartyId == "" {
|
||||
slog.Error("JellyfinAccessRequired: User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userThirdPartyAuth == "" {
|
||||
slog.Error("JellyfinAccessRequired: User has no thirdPartyAuth token..")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
|
||||
return errors.New("jellyfin not enabled")
|
||||
}
|
||||
slog.Debug("jellyfinAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse(Config.JELLYFIN_HOST)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
// Run get request
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest(method, base.String(), bytes.NewBuffer([]byte{}))
|
||||
if err != nil {
|
||||
slog.Error("Creating request to jellyfin failed", "error", err)
|
||||
return errors.New("request failed")
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
authHeader := "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor" + username + "\", Version=\"10.8.0\""
|
||||
if userToken != "" {
|
||||
authHeader += ", Token=\"" + userToken + "\""
|
||||
}
|
||||
req.Header.Add("X-Emby-Authorization", authHeader)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("making request to jellyfin for auth failed", "error", err)
|
||||
return errors.New("request failed")
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("Error reading jellyfin auth response", "error", err.Error())
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("Jellyfin auth non 200 status code", "status_code", res.StatusCode, "error", string(body))
|
||||
return errors.New("incorrect details")
|
||||
}
|
||||
// Unmarshal response
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jellyfinContentFind(
|
||||
userId uint,
|
||||
userType UserType,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
contentType string,
|
||||
contentName string,
|
||||
contentTmdbId string,
|
||||
) (JFContentFindResponse, error) {
|
||||
if contentType == "" || contentName == "" {
|
||||
slog.Error("Bad request", "content_type", contentType, "content_name", contentName)
|
||||
return JFContentFindResponse{}, errors.New("content type or name not provided")
|
||||
}
|
||||
if contentType == "tv" {
|
||||
contentType = "series"
|
||||
}
|
||||
|
||||
resp := new(JellyfinItemSearchResponse)
|
||||
err := jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
"searchTerm": contentName,
|
||||
"IncludePeople": "false",
|
||||
"IncludeMedia": "true",
|
||||
"IncludeGenres": "false",
|
||||
"IncludeStudios": "false",
|
||||
"IncludeArtists": "false",
|
||||
"IncludeItemTypes": contentType,
|
||||
"Limit": "5",
|
||||
"Fields": "ProviderIds",
|
||||
"Recursive": "true",
|
||||
"EnableTotalRecordCount": "false",
|
||||
"ImageTypeLimit": "0",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinContentFind: Jellyfin API request failed", "error", err)
|
||||
return JFContentFindResponse{}, errors.New("failed to get jellyfin response")
|
||||
}
|
||||
|
||||
// Find true match from jf search results
|
||||
ret := new(JFContentFindResponse)
|
||||
ret.HasContent = false
|
||||
ret.Url = ""
|
||||
for _, i := range resp.Items {
|
||||
if i.ProviderIds.Tmdb == contentTmdbId {
|
||||
ret.HasContent = true
|
||||
ret.Url = Config.JELLYFIN_HOST + "/web/index.html#!/details?id=" + i.Id + "&serverId=" + i.ServerID
|
||||
}
|
||||
}
|
||||
return *ret, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type JellyfinSeriesSeasonsResponse struct {
|
||||
Items []JellyfinSeriesSeasonItem `json:"Items"`
|
||||
}
|
||||
|
||||
type JellyfinSeriesSeasonItem struct {
|
||||
JellyfinItems
|
||||
// aka the season number
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
}
|
||||
|
||||
type JellyfinSeriesEpisodesResponse struct {
|
||||
Items []JellyfinSeriesEpisodeItem `json:"Items"`
|
||||
}
|
||||
|
||||
type JellyfinSeriesEpisodeItem struct {
|
||||
JellyfinItems
|
||||
// the episode number
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
// the episodes season number
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
}
|
||||
|
||||
type JellyfinSyncResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
// Perform the jellyfin sync.
|
||||
// Gets each type of media separately from jellyfin and attempts to import them.
|
||||
// Errors are added silently to the job.
|
||||
func startJellyfinSync(
|
||||
db *gorm.DB,
|
||||
jobId string,
|
||||
userId uint,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
) {
|
||||
// Get played movies
|
||||
updateJobCurrentTask(jobId, userId, "syncing movies")
|
||||
playedMovies := new(JellyfinItemSearchResponse)
|
||||
err := jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
"Filters": "IsPlayed",
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Fields": "ProviderIds",
|
||||
"Recursive": "true",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&playedMovies,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
|
||||
addJobError(jobId, userId, "failed to get jellyfin response for movies")
|
||||
} else {
|
||||
if len(playedMovies.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: User has no played movies.", "user_id", userId)
|
||||
} else {
|
||||
for _, v := range playedMovies.Items {
|
||||
slog.Info("jellyfinSyncWatched: Importing played movie.", "movie_name", v.Name, "user_id", userId)
|
||||
slog.Debug("jellyfinSyncWatched: Importing played movie.", "full_item", v, "user_id", userId)
|
||||
|
||||
// 1. Ensure we have a tmdbId
|
||||
if v.ProviderIds.Tmdb == "" {
|
||||
slog.Error("jellyfinSyncWatched: Movie to import does not have a tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+v.Name)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name)
|
||||
|
||||
// 2. Imported watched movie
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
WatchedDate: v.UserData.LastPlayedDate,
|
||||
}, IMPORTED_WATCHED_JF)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Error("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
slog.Error("jellyfinSyncWatched: Movie failed to import.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (failed when adding to watched list): "+v.Name)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_JF activity
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "movie_name", v.Name,
|
||||
"movie_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get played series
|
||||
// Can't rely on IsPlayed filter, since we want to get partially played series too.
|
||||
updateJobCurrentTask(jobId, userId, "syncing series")
|
||||
allSeries := new(JellyfinItemSearchResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
"IncludeItemTypes": "Series",
|
||||
"Fields": "ProviderIds,RecursiveItemCount",
|
||||
"Recursive": "true",
|
||||
"IsPlaceHolder": "false",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&allSeries,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
|
||||
addJobError(jobId, userId, "failed to get jellyfin response for series")
|
||||
} else {
|
||||
if len(allSeries.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: No series found.", "user_id", userId)
|
||||
} else {
|
||||
// Import series
|
||||
for _, v := range allSeries.Items {
|
||||
slog.Info("jellyfinSyncWatched: Processing series.", "series_name", v.Name, "user_id", userId)
|
||||
slog.Debug("jellyfinSyncWatched: Processing series.", "full_item", v, "user_id", userId)
|
||||
|
||||
// 1. Make sure show is watched or at least partially watched
|
||||
if !v.UserData.Played && v.UserData.PlayedPercentage <= 0 && v.RecursiveItemCount == v.UserData.UnplayedItemCount {
|
||||
slog.Debug("jellyfinSyncWatched: Skipping unwatched series:", "series_name", v.Name, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1.1. Ensure we have a tmdbId
|
||||
if v.ProviderIds.Tmdb == "" {
|
||||
slog.Error("jellyfinSyncWatched: Series to import does not have a tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (no tmdbId present): "+v.Name)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Series to import does not have a parseable (to int) tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
updateJobCurrentTask(jobId, userId, "syncing serie "+v.Name)
|
||||
|
||||
// 2. Imported watched series
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
WatchedDate: v.UserData.LastPlayedDate,
|
||||
}, IMPORTED_WATCHED_JF)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Info("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.",
|
||||
"series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "watched_id", w.ID)
|
||||
} else {
|
||||
slog.Error("jellyfinSyncWatched: Series failed to import.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (failed when adding to watched list): "+v.Name)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED activity (only if no err above, show also must not have already been on our list)
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "series_name", v.Name,
|
||||
"series_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Import watched seasons for this serie
|
||||
// Get all show seasons (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
|
||||
seriesSeasons := new(JellyfinSeriesSeasonsResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Shows/"+v.Id+"/Seasons",
|
||||
map[string]string{
|
||||
"UserId": userThirdPartyId,
|
||||
"Fields": "ProviderIds",
|
||||
"IsPlaceHolder": "false",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&seriesSeasons,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesSeasons.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesSeasons.Items {
|
||||
slog.Debug("jellyfinSyncWatched: Processing a season.", "full_item", v, "user_id", userId)
|
||||
if !vs.UserData.Played {
|
||||
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed season.", "series_name", v.Name, "season_num", vs.IndexNumber, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.IndexNumber,
|
||||
Status: FINISHED,
|
||||
addActivity: SEASON_ADDED_JF,
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Import watched episodes for this serie
|
||||
// Gets all show episodes (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
|
||||
seriesEpisodes := new(JellyfinSeriesEpisodesResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Shows/"+v.Id+"/Episodes",
|
||||
map[string]string{
|
||||
"UserId": userThirdPartyId,
|
||||
"Fields": "ProviderIds",
|
||||
"IsPlaceHolder": "false",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&seriesEpisodes,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesEpisodes.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesEpisodes.Items {
|
||||
slog.Debug("jellyfinSyncWatched: Processing an episode.", "full_item", v, "user_id", userId)
|
||||
if !vs.UserData.Played {
|
||||
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.ParentIndexNumber)+" episode "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.ParentIndexNumber,
|
||||
EpisodeNumber: vs.IndexNumber,
|
||||
Status: FINISHED,
|
||||
addActivity: EPISODE_ADDED_JF,
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to import series episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+v.Name+" "+vs.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
}
|
||||
|
||||
func jellyfinSyncWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
userType UserType,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
) (JellyfinSyncResponse, error) {
|
||||
jobId, err := addJob("jf_sync", userId)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to create a job", "error", err)
|
||||
return JellyfinSyncResponse{}, errors.New("failed to create job")
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
|
||||
go startJellyfinSync(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
username,
|
||||
userThirdPartyId,
|
||||
userThirdPartyAuth,
|
||||
)
|
||||
|
||||
return JellyfinSyncResponse{JobId: jobId}, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// We will use jobs only for storing/retrieving active job statuses for the client.
|
||||
// Running the job will be done wherever needed, but isn't handled here.
|
||||
// When starting a job elsewhere, we should first add a job here as active to get an `id`,
|
||||
// this id should be used to update the active job so the client can request job status updates.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
type JobStatus string
|
||||
|
||||
var (
|
||||
JOB_CREATED JobStatus = "CREATED"
|
||||
JOB_RUNNING JobStatus = "RUNNING"
|
||||
JOB_DONE JobStatus = "DONE"
|
||||
JOB_CANCELLED JobStatus = "CANCELLED"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
// We can give the job a name simply for showing on the client.
|
||||
Name string `json:"name"`
|
||||
// The current status of the job.
|
||||
Status JobStatus `json:"status"`
|
||||
// The current task we are performing inside the job.
|
||||
// Just so we can portray progress on the client by displaying the current task.
|
||||
CurrentTask string `json:"currentTask,omitempty"`
|
||||
// Errors that occurred in the task
|
||||
Errors []string `json:"errors"`
|
||||
// Stored for access control.
|
||||
UserId uint `json:"-"`
|
||||
}
|
||||
|
||||
var activeJobs = make(map[string]*Job)
|
||||
|
||||
// Add a job to our activeJobs map.
|
||||
// Returns id of job on success, or error if failed to add.
|
||||
func addJob(name string, userId uint) (string, error) {
|
||||
idk, err := generateString(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, ok := activeJobs[idk]
|
||||
if ok {
|
||||
// Lets just hope this doesn't happen, may the odds be with us.
|
||||
return "", errors.New("job already exists with id generated, please try again")
|
||||
}
|
||||
activeJobs[idk] = &Job{
|
||||
Name: name,
|
||||
Status: JOB_CREATED,
|
||||
UserId: userId,
|
||||
}
|
||||
return idk, nil
|
||||
}
|
||||
|
||||
func rmJob(id string, userId uint) {
|
||||
slog.Debug("rmJob: Removing a job.", "id", id)
|
||||
v, ok := activeJobs[id]
|
||||
if ok && v.UserId == userId {
|
||||
delete(activeJobs, id)
|
||||
slog.Debug("rmJob: Removed a job.", "id", id)
|
||||
return
|
||||
}
|
||||
slog.Debug("rmJob: Job to remove does not exist (or not owned by this user).", "id", id, "user_id", userId)
|
||||
}
|
||||
|
||||
// Get a job.
|
||||
// Returns job if found, otherwise errors if job does not exist.
|
||||
func getJob(id string, userId uint) (*Job, error) {
|
||||
j, ok := activeJobs[id]
|
||||
if ok {
|
||||
// Ensure user requesting a job, owns the job.
|
||||
if j.UserId != userId {
|
||||
slog.Warn("getJob: A user tried to access a job they do not own.", "user_id", userId, "job_id", id)
|
||||
return &Job{}, errors.New("job does not exist")
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
return &Job{}, errors.New("job does not exist")
|
||||
}
|
||||
|
||||
// Update a jobs status.
|
||||
func updateJobStatus(id string, userId uint, status JobStatus) error {
|
||||
j, err := getJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobStatus: Failed!", "status", status, "error", err)
|
||||
return err
|
||||
}
|
||||
j.Status = status
|
||||
// If job is set to done, remove it after 1 minute.
|
||||
if status == JOB_DONE {
|
||||
slog.Debug("updateJobStatus: Job set to done. Will be removed after 1m.", "id", id)
|
||||
go func() {
|
||||
time.Sleep(1 * time.Minute)
|
||||
slog.Debug("updateJobStatus: Job done. waited 1m.. removing job now.", "id", id)
|
||||
rmJob(id, userId)
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update a jobs current task.
|
||||
func updateJobCurrentTask(id string, userId uint, ct string) error {
|
||||
j, err := getJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobCurrentTask: Failed!", "ct", ct, "error", err)
|
||||
return err
|
||||
}
|
||||
j.CurrentTask = ct
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add an error to a job.
|
||||
func addJobError(id string, userId uint, e string) error {
|
||||
j, err := getJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobCurrentTask: Failed!", "e", e, "error", err)
|
||||
return err
|
||||
}
|
||||
j.Errors = append(j.Errors, e)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Location middleware
|
||||
func WhereaboutsRequired() gin.HandlerFunc {
|
||||
// TODO: This should also take into account a user
|
||||
// preference (of location.. which also needs to be
|
||||
// added) and server preference (env var). The US is
|
||||
// good enough for now as a default.
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("WhereaboutsRequired middleware hit", "country", "US")
|
||||
c.Set("userCountry", "US")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PlexLoginRequest struct {
|
||||
AuthToken string `json:"token" binding:"required"`
|
||||
ClientIdentifier string `json:"clientIdentifier" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexUser struct {
|
||||
Id uint64 `json:"id" binding:"required"`
|
||||
Uuid string `json:"uuid" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexAccountResponse struct {
|
||||
User PlexUser `json:"user" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexIdentity struct {
|
||||
MediaContainer struct {
|
||||
MachineIdentifier string `json:"machineIdentifier"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
type PlexHostConfigUpdateResponse struct {
|
||||
PLEX_MACHINE_ID string
|
||||
}
|
||||
|
||||
type PlexUsersResponse struct {
|
||||
XMLName xml.Name `xml:"MediaContainer"`
|
||||
Text string `xml:",chardata"`
|
||||
FriendlyName string `xml:"friendlyName,attr"`
|
||||
Identifier string `xml:"identifier,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
TotalSize string `xml:"totalSize,attr"`
|
||||
Size string `xml:"size,attr"`
|
||||
User []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
Title string `xml:"title,attr"`
|
||||
Username string `xml:"username,attr"`
|
||||
Email string `xml:"email,attr"`
|
||||
RecommendationsPlaylistId string `xml:"recommendationsPlaylistId,attr"`
|
||||
Thumb string `xml:"thumb,attr"`
|
||||
Protected string `xml:"protected,attr"`
|
||||
Home string `xml:"home,attr"`
|
||||
AllowTuners string `xml:"allowTuners,attr"`
|
||||
AllowSync string `xml:"allowSync,attr"`
|
||||
AllowCameraUpload string `xml:"allowCameraUpload,attr"`
|
||||
AllowChannels string `xml:"allowChannels,attr"`
|
||||
AllowSubtitleAdmin string `xml:"allowSubtitleAdmin,attr"`
|
||||
FilterAll string `xml:"filterAll,attr"`
|
||||
FilterMovies string `xml:"filterMovies,attr"`
|
||||
FilterMusic string `xml:"filterMusic,attr"`
|
||||
FilterPhotos string `xml:"filterPhotos,attr"`
|
||||
FilterTelevision string `xml:"filterTelevision,attr"`
|
||||
Restricted string `xml:"restricted,attr"`
|
||||
Server []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
ServerId string `xml:"serverId,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
LastSeenAt string `xml:"lastSeenAt,attr"`
|
||||
NumLibraries string `xml:"numLibraries,attr"`
|
||||
AllLibraries string `xml:"allLibraries,attr"`
|
||||
Owned string `xml:"owned,attr"`
|
||||
Pending string `xml:"pending,attr"`
|
||||
} `xml:"Server"`
|
||||
} `xml:"User"`
|
||||
}
|
||||
|
||||
// Plex get libraries response
|
||||
// /library/sections
|
||||
type PlexLibrariesResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Title1 string `json:"title1"`
|
||||
Directory []struct {
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Composite string `json:"composite"`
|
||||
Filters bool `json:"filters"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
Thumb string `json:"thumb"`
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Agent string `json:"agent"`
|
||||
Scanner string `json:"scanner"`
|
||||
Language string `json:"language"`
|
||||
UUID string `json:"uuid"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
CreatedAt int `json:"createdAt"`
|
||||
ScannedAt int `json:"scannedAt"`
|
||||
Content bool `json:"content"`
|
||||
Directory bool `json:"directory"`
|
||||
ContentChangedAt int64 `json:"contentChangedAt"`
|
||||
Hidden int `json:"hidden"`
|
||||
Location []struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
} `json:"Location"`
|
||||
} `json:"Directory"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all library items response (with includeGuids parameter)
|
||||
// /library/sections/{sectionId}/all
|
||||
type PlexLibraryItemsResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
Thumb string `json:"thumb"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
GUID string `json:"guid"` // Plex guid
|
||||
Slug string `json:"slug"`
|
||||
Studio string `json:"studio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ContentRating string `json:"contentRating"`
|
||||
Summary string `json:"summary"`
|
||||
Rating float64 `json:"rating"`
|
||||
UserRating float64 `json:"userRating"`
|
||||
AudienceRating float64 `json:"audienceRating"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
Year int `json:"year"`
|
||||
Tagline string `json:"tagline"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
Duration int `json:"duration"`
|
||||
OriginallyAvailableAt string `json:"originallyAvailableAt"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
AudienceRatingImage string `json:"audienceRatingImage"`
|
||||
HasPremiumPrimaryExtra string `json:"hasPremiumPrimaryExtra"`
|
||||
RatingImage string `json:"ratingImage"`
|
||||
LeafCount int `json:"leafCount,omitempty"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount,omitempty"`
|
||||
Media []struct {
|
||||
ID int `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Bitrate int `json:"bitrate"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
AspectRatio float64 `json:"aspectRatio"`
|
||||
AudioChannels int `json:"audioChannels"`
|
||||
AudioCodec string `json:"audioCodec"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
VideoResolution string `json:"videoResolution"`
|
||||
Container string `json:"container"`
|
||||
VideoFrameRate string `json:"videoFrameRate"`
|
||||
VideoProfile string `json:"videoProfile"`
|
||||
Part []struct {
|
||||
ID int `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Duration int `json:"duration"`
|
||||
File string `json:"file"`
|
||||
Size int64 `json:"size"`
|
||||
Container string `json:"container"`
|
||||
HasThumbnail string `json:"hasThumbnail"`
|
||||
VideoProfile string `json:"videoProfile"`
|
||||
} `json:"Part"`
|
||||
} `json:"Media"`
|
||||
// External ids
|
||||
Guid []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"Guid"`
|
||||
Genre []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Genre"`
|
||||
Country []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Country"`
|
||||
Director []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Director"`
|
||||
Writer []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Writer"`
|
||||
Role []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Role"`
|
||||
ChapterSource string `json:"chapterSource,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all children (seasons) for a serie.
|
||||
// /library/metadata/{ratingKey}/children
|
||||
type PlexLibraryItemSeasonsResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
Key string `json:"key"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
Nocache bool `json:"nocache"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Summary string `json:"summary"`
|
||||
Theme string `json:"theme"`
|
||||
Thumb string `json:"thumb"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Directory []struct {
|
||||
LeafCount int `json:"leafCount"`
|
||||
Thumb string `json:"thumb"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
} `json:"Directory"`
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
ParentRatingKey string `json:"parentRatingKey"`
|
||||
GUID string `json:"guid"`
|
||||
ParentGUID string `json:"parentGuid"`
|
||||
ParentSlug string `json:"parentSlug"`
|
||||
ParentStudio string `json:"parentStudio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ParentKey string `json:"parentKey"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
Summary string `json:"summary"`
|
||||
Index int `json:"index"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
UserRating float64 `json:"userRating,omitempty"`
|
||||
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
ParentThumb string `json:"parentThumb"`
|
||||
ParentTheme string `json:"parentTheme"`
|
||||
LeafCount int `json:"leafCount"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all children (episodes) for a season.
|
||||
// /library/metadata/{ratingKey}/allLeaves
|
||||
type PlexLibraryItemEpisodesResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
Key string `json:"key"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
MixedParents bool `json:"mixedParents"`
|
||||
Nocache bool `json:"nocache"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Theme string `json:"theme"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
ParentRatingKey string `json:"parentRatingKey"`
|
||||
GrandparentRatingKey string `json:"grandparentRatingKey"`
|
||||
GUID string `json:"guid"`
|
||||
GrandparentSlug string `json:"grandparentSlug"`
|
||||
Studio string `json:"studio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
GrandparentKey string `json:"grandparentKey"`
|
||||
ParentKey string `json:"parentKey"`
|
||||
GrandparentTitle string `json:"grandparentTitle"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ContentRating string `json:"contentRating"`
|
||||
Summary string `json:"summary"`
|
||||
Index int `json:"index"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
AudienceRating float64 `json:"audienceRating"`
|
||||
Year int `json:"year"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
ParentThumb string `json:"parentThumb"`
|
||||
GrandparentThumb string `json:"grandparentThumb"`
|
||||
GrandparentArt string `json:"grandparentArt"`
|
||||
GrandparentTheme string `json:"grandparentTheme"`
|
||||
Duration int `json:"duration"`
|
||||
OriginallyAvailableAt string `json:"originallyAvailableAt"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
AudienceRatingImage string `json:"audienceRatingImage"`
|
||||
ChapterSource string `json:"chapterSource"`
|
||||
TitleSort string `json:"titleSort,omitempty"`
|
||||
UserRating float64 `json:"userRating,omitempty"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Response from clients.plex.tv/api/v2/resources
|
||||
// Used to get users auth token for home plex server.
|
||||
type PlexClientResources []struct {
|
||||
Name string `json:"name"`
|
||||
Product string `json:"product"`
|
||||
ProductVersion string `json:"productVersion"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformVersion string `json:"platformVersion"`
|
||||
Device string `json:"device"`
|
||||
ClientIdentifier string `json:"clientIdentifier"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
Provides string `json:"provides"`
|
||||
OwnerID interface{} `json:"ownerId"`
|
||||
SourceTitle interface{} `json:"sourceTitle"`
|
||||
PublicAddress string `json:"publicAddress"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
Owned bool `json:"owned"`
|
||||
Home bool `json:"home"`
|
||||
Synced bool `json:"synced"`
|
||||
Relay bool `json:"relay"`
|
||||
Presence bool `json:"presence"`
|
||||
HTTPSRequired bool `json:"httpsRequired"`
|
||||
PublicAddressMatches bool `json:"publicAddressMatches"`
|
||||
DNSRebindingProtection bool `json:"dnsRebindingProtection"`
|
||||
NatLoopbackSupported bool `json:"natLoopbackSupported"`
|
||||
Connections []struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
URI string `json:"uri"`
|
||||
Local bool `json:"local"`
|
||||
Relay bool `json:"relay"`
|
||||
IPv6 bool `json:"IPv6"`
|
||||
} `json:"connections"`
|
||||
}
|
||||
|
||||
// Plex access middleware, ensures user is a Plex user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func PlexAccessRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("PlexAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("PlexAccessRequired: Plex has not been configured.", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != PLEX_USER {
|
||||
slog.Error("PlexAccessRequired: User is not a Plex user..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
userPlexService := new(UserServices)
|
||||
if res := db.Where("user_id = ? AND name = ?", userId, "plex").Take(&userPlexService); res.Error != nil {
|
||||
slog.Error("PlexAccessRequired: Failed when attempting to get users plex service integration..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userPlexService.ClientID == "" || userPlexService.AuthToken == "" || userPlexService.AuthToken2 == "" {
|
||||
slog.Error("PlexAccessRequired: User has missing details from service (clientId, authToken or authToken2)..", "user_id", userId, "client_id", userPlexService.ClientID)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
c.Set("plexAuthToken", userPlexService.AuthToken)
|
||||
c.Set("plexLocalAuthToken", userPlexService.AuthToken2)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlexIdentity(host string) (PlexIdentity, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", host+"/identity", nil)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pi PlexIdentity
|
||||
err = json.Unmarshal(body, &pi)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
return pi, nil
|
||||
}
|
||||
|
||||
func fetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
slog.Info(string(body))
|
||||
defer resp.Body.Close()
|
||||
var pa PlexAccountResponse
|
||||
err = json.Unmarshal(body, &pa)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
return pa.User, nil
|
||||
}
|
||||
|
||||
// Update plex host setting
|
||||
func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
|
||||
Config.PLEX_HOST = v
|
||||
if Config.PLEX_HOST != "" {
|
||||
pi, err := getPlexIdentity(Config.PLEX_HOST)
|
||||
if err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to get plex server identity!", "error", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to get Plex server identity. Please try setting the Plex Host again or setting PLEX_MACHINE_ID manually in your config file")
|
||||
}
|
||||
if pi.MediaContainer.MachineIdentifier == "" {
|
||||
slog.Error("updateConfigPlexHost: Plex server identity response had no machine id!", "response", pi)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("got Plex server identity, but no machine id was found")
|
||||
}
|
||||
Config.PLEX_MACHINE_ID = pi.MediaContainer.MachineIdentifier
|
||||
} else {
|
||||
Config.PLEX_MACHINE_ID = ""
|
||||
}
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to write updated config to file!", "err", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to write config")
|
||||
}
|
||||
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
|
||||
}
|
||||
|
||||
// If a plex user has access to our home plex server (PLEX_HOST).
|
||||
func plexUserHasAccessToPlexHost(token string) error {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/api/users", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pa PlexUsersResponse
|
||||
err = xml.Unmarshal(body, &pa)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(pa.User) <= 0 {
|
||||
return errors.New("found no users in response")
|
||||
}
|
||||
|
||||
// Now check if any of the users servers include our home server machine id
|
||||
homeServerFound := false
|
||||
userLoop:
|
||||
for _, user := range pa.User {
|
||||
for _, server := range user.Server {
|
||||
if server.MachineIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("plexUserHasAccessToPlexHost: Processing a server.", "server", server.MachineIdentifier)
|
||||
homeServerFound = true
|
||||
break userLoop
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
break
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
return nil
|
||||
}
|
||||
return errors.New("user does not have access to home plex server")
|
||||
}
|
||||
|
||||
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibrariesResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemsResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemSeasonsResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemEpisodesResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
// Gets users auth token for local plex server,
|
||||
// so they can authenticate against it for api requests.
|
||||
func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
req.Header.Set("X-Plex-Client-Identifier", userClientId)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexClientResources
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authToken := ""
|
||||
for _, v := range pl {
|
||||
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
|
||||
if v.AccessToken == "" {
|
||||
slog.Warn("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
continue
|
||||
}
|
||||
authToken = v.AccessToken
|
||||
}
|
||||
}
|
||||
return authToken, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PlexSyncResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
// Perform a Plex sync.
|
||||
// Errors are added silently to the job.
|
||||
func startPlexSync(
|
||||
db *gorm.DB,
|
||||
jobId string,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) {
|
||||
updateJobCurrentTask(jobId, userId, "fetching libraries")
|
||||
libraries, err := getPlexLibraries(userPlexLocalAuth)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch libraries", "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to get plex libraries")
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
return
|
||||
}
|
||||
for _, library := range libraries.MediaContainer.Directory {
|
||||
slog.Debug("plexSyncWatched: Processing a library", "library_title", library.Title, "library_type", library.Type, "user_id", userId)
|
||||
if library.Type == "movie" {
|
||||
updateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
|
||||
movies, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch movies from library", "library", library.Key, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, movie := range movies.MediaContainer.Metadata {
|
||||
if movie.ViewCount == 0 {
|
||||
// Not viewed and not rated, skip importing
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched movie:", "movie_name", movie.Title, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
|
||||
slog.Info("plexSyncWatched: Importing movie.", "movie_name", movie.Title, "user_id", userId)
|
||||
|
||||
// Find tmdb id
|
||||
if len(movie.Guid) <= 0 {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have any external guids.", "movie_name", movie.Title, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbIdStr := ""
|
||||
for _, v := range movie.Guid {
|
||||
if strings.HasPrefix(v.ID, "tmdb://") {
|
||||
tmdbIdStr = v.ID[7:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
Rating: int8(movie.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Error("plexSyncWatched: unique constraint hit. movie must already be on watch list", "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add movie "+movie.Title)
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
|
||||
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if library.Type == "show" {
|
||||
updateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
|
||||
shows, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch shows from library", "library", library.Key, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, show := range shows.MediaContainer.Metadata {
|
||||
if show.ViewedLeafCount != show.LeafCount {
|
||||
// Not viewed, skip importing
|
||||
// (could be improved to set status as watching when viewedLeafCount is higher than 0)
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched show:", "show_name", show.Title, "leaf_count", show.LeafCount, "viewed_leaf_count", show.ViewedLeafCount, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing show "+show.Title)
|
||||
slog.Info("plexSyncWatched: Importing show.", "show_name", show.Title, "user_id", userId)
|
||||
|
||||
tmdbIdStr := ""
|
||||
for _, v := range show.Guid {
|
||||
if strings.HasPrefix(v.ID, "tmdb://") {
|
||||
tmdbIdStr = v.ID[7:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a parseable (to int) tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(show.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
Rating: int8(show.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Info("plexSyncWatched: unique constraint hit. show must already be on watch list", "error", err)
|
||||
} else {
|
||||
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add show "+show.Title)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
|
||||
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import watched seasons for this serie
|
||||
seriesSeasons, err := getPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesSeasons.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no seasons.", "series_name", show.Title, "serie_ids", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesSeasons.MediaContainer.Metadata {
|
||||
slog.Debug("plexSyncWatched: Processing a season.", "full_item", vs, "user_id", userId)
|
||||
if vs.ViewedLeafCount != vs.LeafCount {
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed season.", "series_name", show.Title, "season_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
var seasonLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
seasonLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: SEASON_ADDED_PLEX,
|
||||
addActivityDate: seasonLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import watched episodes for this serie
|
||||
seriesEpisodes, err := getPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesEpisodes.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesEpisodes.MediaContainer.Metadata {
|
||||
slog.Debug("plexSyncWatched: Processing an episode.", "full_item", vs, "user_id", userId)
|
||||
if vs.ViewCount <= 0 {
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
|
||||
var episodeLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
episodeLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.ParentIndex,
|
||||
EpisodeNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: EPISODE_ADDED_PLEX,
|
||||
addActivityDate: episodeLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to import series episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
}
|
||||
|
||||
func plexSyncWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) (PlexSyncResponse, error) {
|
||||
jobId, err := addJob("plex_sync", userId)
|
||||
if err != nil {
|
||||
slog.Error("startPlexSync: Failed to create a job", "error", err)
|
||||
return PlexSyncResponse{}, errors.New("failed to create job")
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
|
||||
go startPlexSync(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
userPlexLocalAuth,
|
||||
)
|
||||
|
||||
return PlexSyncResponse{JobId: jobId}, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Joined time.Time `json:"joined"`
|
||||
ShowsWatched int32 `json:"showsWatched"`
|
||||
MoviesWatched int32 `json:"moviesWatched"`
|
||||
MoviesWatchedRuntime uint32 `json:"moviesWatchedRuntime"`
|
||||
ShowsWatchedRuntime uint32 `json:"showsWatchedRuntime"`
|
||||
}
|
||||
|
||||
// Check if content has been previsouly watched by looking for related activity.
|
||||
func hasBeenPreviouslyWatched(a *[]Activity) bool {
|
||||
wp := false
|
||||
var relatedActivity []Activity
|
||||
for _, v := range *a {
|
||||
if v.Type == ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_WATCHED ||
|
||||
v.Type == STATUS_CHANGED {
|
||||
relatedActivity = append(relatedActivity, v)
|
||||
}
|
||||
}
|
||||
if len(relatedActivity) <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, ra := range relatedActivity {
|
||||
if ra.Type == IMPORTED_ADDED_WATCHED {
|
||||
wp = true
|
||||
break
|
||||
} else if ra.Type == ADDED_WATCHED || ra.Type == IMPORTED_WATCHED {
|
||||
if ra.Data == "" {
|
||||
continue
|
||||
}
|
||||
var v map[string]any
|
||||
err := json.Unmarshal([]byte(ra.Data), &v)
|
||||
if err != nil {
|
||||
slog.Error("Checking ADDED_WATCHED or IMPORTED_WATCHED.. failed to parse json data", "error", err)
|
||||
continue
|
||||
}
|
||||
if status, ok := v["status"]; ok {
|
||||
if status == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ra.Type == STATUS_CHANGED {
|
||||
if ra.Data == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp
|
||||
}
|
||||
|
||||
// Gets any data required for profile page
|
||||
func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
user := new(User)
|
||||
res := db.Model(&User{}).Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get profile:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
showsWatched int32
|
||||
moviesWatched int32
|
||||
moviesWatchedRuntime uint32
|
||||
showsWatchedRuntime uint32
|
||||
)
|
||||
for _, w := range *watched {
|
||||
isFinished := false
|
||||
if w.Status == FINISHED {
|
||||
isFinished = true
|
||||
} else if *user.IncludePreviouslyWatched && hasBeenPreviouslyWatched(&w.Activity) {
|
||||
// If status is not finished and user has IncludePreviouslyWatched enabled,
|
||||
// then we can also check if content hasBeenPreviouslyWatched.
|
||||
isFinished = true
|
||||
}
|
||||
if isFinished {
|
||||
if w.Content == nil {
|
||||
continue
|
||||
}
|
||||
c := *w.Content
|
||||
if c.Type == SHOW {
|
||||
showsWatched++
|
||||
// This aint a science, just a very inaccurate guesstimate.
|
||||
if c.NumberOfEpisodes != 0 {
|
||||
var showRuntime uint32 = 30
|
||||
if c.Runtime != 0 {
|
||||
showRuntime = c.Runtime
|
||||
}
|
||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
||||
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
|
||||
}
|
||||
} else if c.Type == MOVIE {
|
||||
moviesWatched++
|
||||
moviesWatchedRuntime += c.Runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
profile := Profile{
|
||||
Joined: user.CreatedAt,
|
||||
ShowsWatched: showsWatched,
|
||||
MoviesWatched: moviesWatched,
|
||||
MoviesWatchedRuntime: moviesWatchedRuntime,
|
||||
ShowsWatchedRuntime: showsWatchedRuntime,
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Setup recurring tasks (eg cleanup every x mins)
|
||||
func setupTasks(db *gorm.DB) {
|
||||
go setupMinutelyTasks(db)
|
||||
go setupDailyTasks(db)
|
||||
}
|
||||
|
||||
func setupMinutelyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 1 * time.Minute
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Runs funcs that are in the place where we are cleaning.
|
||||
// Bit cleaner and we can keep the related code close to its home.
|
||||
cleanupTokens(db)
|
||||
}
|
||||
}
|
||||
|
||||
func setupDailyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 24 * time.Hour
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
cleanupImages(db)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
@@ -27,11 +29,11 @@ type TMDBSearchMultiResults struct {
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int64 `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
@@ -47,7 +49,7 @@ type TMDBContentDetails struct {
|
||||
} `json:"genres"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Homepage string `json:"homepage"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
Overview string `json:"overview"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
ProductionCompanies []struct {
|
||||
@@ -62,8 +64,8 @@ type TMDBContentDetails struct {
|
||||
} `json:"production_countries"`
|
||||
Status string `json:"status"`
|
||||
Tagline string `json:"tagline"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
SpokenLanguages []struct {
|
||||
EnglishName string `json:"english_name"`
|
||||
Iso6391 string `json:"iso_639_1"`
|
||||
@@ -75,14 +77,20 @@ type TMDBMovieDetails struct {
|
||||
TMDBContentDetails
|
||||
Adult bool `json:"adult"`
|
||||
BelongsToCollection any `json:"belongs_to_collection"`
|
||||
Budget int `json:"budget"`
|
||||
Budget uint32 `json:"budget"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Revenue int `json:"revenue"`
|
||||
Runtime int `json:"runtime"`
|
||||
Revenue uint32 `json:"revenue"`
|
||||
Runtime uint32 `json:"runtime"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
WatchProviders interface{} `json:"watch/providers"`
|
||||
Similar TMDBMovieSimilar `json:"similar"`
|
||||
ExternalIds TMDBExternalIdsMovie `json:"external_ids"`
|
||||
}
|
||||
|
||||
type TMDBShowDetails struct {
|
||||
@@ -109,7 +117,7 @@ type TMDBShowDetails struct {
|
||||
SeasonNumber int `json:"season_number"`
|
||||
StillPath string `json:"still_path"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"last_episode_to_air"`
|
||||
Name string `json:"name"`
|
||||
NextEpisodeToAir any `json:"next_episode_to_air"`
|
||||
@@ -119,8 +127,8 @@ type TMDBShowDetails struct {
|
||||
LogoPath string `json:"logo_path"`
|
||||
OriginCountry string `json:"origin_country"`
|
||||
} `json:"networks"`
|
||||
NumberOfEpisodes int `json:"number_of_episodes"`
|
||||
NumberOfSeasons int `json:"number_of_seasons"`
|
||||
NumberOfEpisodes uint32 `json:"number_of_episodes"`
|
||||
NumberOfSeasons uint32 `json:"number_of_seasons"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Seasons []struct {
|
||||
@@ -133,6 +141,129 @@ type TMDBShowDetails struct {
|
||||
SeasonNumber int `json:"season_number"`
|
||||
} `json:"seasons"`
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
WatchProviders interface{} `json:"watch/providers"`
|
||||
Similar TMDBShowSimilar `json:"similar"`
|
||||
ExternalIds TMDBExternalIdsShow `json:"external_ids"`
|
||||
Keywords TMDBKeywords `json:"keywords"`
|
||||
}
|
||||
|
||||
type WatchProvider struct {
|
||||
ProviderID int `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
DisplayPriority int `json:"display_priority"`
|
||||
}
|
||||
|
||||
type TMDBContentVideos struct {
|
||||
ID int `json:"id"`
|
||||
Results []struct {
|
||||
Iso6391 string `json:"iso_639_1"`
|
||||
Iso31661 string `json:"iso_3166_1"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Site string `json:"site"`
|
||||
Size int `json:"size"`
|
||||
Type string `json:"type"`
|
||||
Official bool `json:"official"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
ID string `json:"id"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBSeasonDetails struct {
|
||||
ID string `json:"_id"`
|
||||
AirDate string `json:"air_date"`
|
||||
Episodes []struct {
|
||||
AirDate string `json:"air_date"`
|
||||
EpisodeNumber int `json:"episode_number"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
ProductionCode string `json:"production_code"`
|
||||
Runtime int `json:"runtime"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
ShowID int `json:"show_id"`
|
||||
StillPath string `json:"still_path"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Crew []struct {
|
||||
Department string `json:"department"`
|
||||
Job string `json:"job"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"crew"`
|
||||
GuestStars []struct {
|
||||
Character string `json:"character"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Order int `json:"order"`
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"guest_stars"`
|
||||
} `json:"episodes"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
ID0 int `json:"id"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
}
|
||||
|
||||
type TMDBShowSimilar struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBMovieSimilar struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBPersonDetails struct {
|
||||
@@ -171,7 +302,7 @@ type TMDBPersonCombinedCreditsCast struct {
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
Character string `json:"character"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
@@ -214,8 +345,161 @@ type TMDBContentCredits struct {
|
||||
} `json:"crew"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverMovies struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBTrendingAll struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingMovies struct {
|
||||
Dates struct {
|
||||
Maximum string `json:"maximum"`
|
||||
Minimum string `json:"minimum"`
|
||||
} `json:"dates"`
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBExternalIds struct {
|
||||
ID int `json:"id"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
WikidataID string `json:"wikidata_id"`
|
||||
FacebookID string `json:"facebook_id"`
|
||||
InstagramID string `json:"instagram_id"`
|
||||
TwitterID string `json:"twitter_id"`
|
||||
}
|
||||
|
||||
type TMDBExternalIdsMovie struct {
|
||||
TMDBExternalIds
|
||||
}
|
||||
|
||||
type TMDBExternalIdsShow struct {
|
||||
TMDBExternalIds
|
||||
FreebaseMid string `json:"freebase_mid"`
|
||||
FreebaseID string `json:"freebase_id"`
|
||||
TvdbID int `json:"tvdb_id"`
|
||||
TvrageID int `json:"tvrage_id"`
|
||||
}
|
||||
|
||||
type TMDBKeywords struct {
|
||||
// ID int `json:"id"`
|
||||
Results []struct {
|
||||
Name string `json:"name"`
|
||||
ID int `json:"id"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func getTMDBKey() string {
|
||||
if Config.TMDB_KEY != "" {
|
||||
return Config.TMDB_KEY
|
||||
}
|
||||
return "d047fa61d926371f277e7a83c9c4ff2c"
|
||||
}
|
||||
|
||||
func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
println("tmdbAPIRequest:", ep)
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse("https://api.themoviedb.org/3")
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to parse api uri")
|
||||
@@ -226,7 +510,7 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
params.Add("api_key", "d047fa61d926371f277e7a83c9c4ff2c")
|
||||
params.Add("api_key", getTMDBKey())
|
||||
params.Add("language", "en-US")
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
@@ -246,7 +530,7 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
println("TMDB non 200 status code:", res.StatusCode)
|
||||
slog.Error("TMDB non 200 status code:", "status_code", res.StatusCode)
|
||||
return nil, errors.New(string(body))
|
||||
}
|
||||
return body, nil
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TokenType string
|
||||
|
||||
var (
|
||||
TOKENTYPE_ADMIN TokenType = "ADMIN"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Value string `gorm:"not null"`
|
||||
Type TokenType `gorm:"not null"`
|
||||
UserID uint `gorm:"not null"`
|
||||
}
|
||||
|
||||
const tokenMaxAge = 2 * time.Minute
|
||||
|
||||
func createOneUseToken(db *gorm.DB, t TokenType, userId uint) (string, error) {
|
||||
token, err := generateString(8)
|
||||
if err != nil {
|
||||
slog.Error("createOneUseToken: Failed to generate string!", "error", err)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
res := db.Create(&Token{Type: t, Value: token, UserID: userId})
|
||||
if res.Error != nil {
|
||||
slog.Error("createOneUseToken: Failed to insert token into db!", "error", res.Error)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Cleans up tokens older than 2m.
|
||||
func cleanupTokens(db *gorm.DB) {
|
||||
slog.Debug("cleanupTokens: Cleaning up old tokens from db")
|
||||
twoMinsAgo := time.Now().Add(-tokenMaxAge)
|
||||
resp := db.Where("created_at < ?", twoMinsAgo).Delete(&Token{})
|
||||
if resp.Error != nil {
|
||||
slog.Error("cleanupTokens: Failed to run DELETE on old tokens!", "error", resp.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Public user details for search results
|
||||
type PublicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
}
|
||||
|
||||
// Private user details, for returning users details to themselves
|
||||
type PrivateUser struct {
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
|
||||
type UserBioUpdateRequest struct {
|
||||
NewBio string `json:"newBio" binding:"max=128"`
|
||||
}
|
||||
|
||||
// Update user settings
|
||||
func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId, "ur", ur)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user update failed", "user_id", userId, "error", res.Error)
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
if ur.HideSpoilers != nil {
|
||||
user.HideSpoilers = ur.HideSpoilers
|
||||
}
|
||||
if ur.Private != nil {
|
||||
user.Private = ur.Private
|
||||
}
|
||||
if ur.PrivateThoughts != nil {
|
||||
user.PrivateThoughts = ur.PrivateThoughts
|
||||
}
|
||||
if ur.IncludePreviouslyWatched != nil {
|
||||
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get failed", "user_id", userId, "error", res.Error)
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error) {
|
||||
slog.Debug("user search request running", "query", q)
|
||||
users := new([]PublicUser)
|
||||
res := db.Where("private = 0 AND username LIKE ? AND id != ?", "%"+q+"%", currentUsersId).Table("users").Find(&users)
|
||||
if res.Error != nil {
|
||||
slog.Error("user search failed", "error", res.Error)
|
||||
return []PublicUser{}, errors.New("failed to find users")
|
||||
}
|
||||
return *users, nil
|
||||
}
|
||||
|
||||
func getUserInfo(db *gorm.DB, currentUsersId uint) (PrivateUser, error) {
|
||||
slog.Debug("user get info request running")
|
||||
user := new(PrivateUser)
|
||||
res := db.Where("id = ?", currentUsersId).Table("users").Preload("Avatar").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get info failed", "error", res.Error)
|
||||
return PrivateUser{}, errors.New("failed to find current user")
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
// For getting a public user's info, when viewing their list for example
|
||||
func getUserPublicInfo(db *gorm.DB, userId uint, username string) (PublicUser, error) {
|
||||
slog.Debug("user get info request running")
|
||||
user := new(PublicUser)
|
||||
res := db.Where("private = 0 AND id = ? AND username = ?", userId, username).Table("users").Preload("Avatar").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("public user get info failed", "error", res.Error)
|
||||
return PublicUser{}, errors.New("failed to find user")
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
func userUpdateBio(db *gorm.DB, userId uint, newBio string) error {
|
||||
slog.Debug("userUpdateBio request running", "user_id", userId, "newBio", newBio)
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Update("bio", newBio); res.Error != nil {
|
||||
slog.Error("userUpdateBio failed", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update bio")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
slog.Error("failed to get file", "error", err)
|
||||
return Image{}, errors.New("no file found")
|
||||
}
|
||||
|
||||
slog.Debug("an avatar is being uploaded", "name", file.Filename)
|
||||
|
||||
f, _ := file.Open()
|
||||
if err := isValidImageType(f); err != nil {
|
||||
return Image{}, errors.New("invalid image type")
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
log.Fatal(err) // TODO nu le fatal
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
slog.Debug("image hash calculated", "hash", hs, "first_letter", hs[0:1])
|
||||
|
||||
// Upload the file to specific dst.
|
||||
outp := path.Join("img/up/", hs[0:1], hs+filepath.Ext(file.Filename))
|
||||
c.SaveUploadedFile(file, path.Join(DataPath, outp))
|
||||
|
||||
_, err = f.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar seeking back to start of image failed", "error", err)
|
||||
}
|
||||
|
||||
// No need to remove old image, the daily cleanup task will handle removing unused ones.
|
||||
var img Image
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// Insert avatar into db
|
||||
img, err = insertImage(db, hs, outp, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if img.ID == 0 {
|
||||
return errors.New("image has no id")
|
||||
}
|
||||
// Update users avatar to newly inserted
|
||||
if err := tx.Where("id = ?", userId).Updates(&User{AvatarID: img.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar failed!", "error", err)
|
||||
return Image{}, errors.New("uploadUserAvatar transaction failed")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -20,16 +28,28 @@ type GormModel struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
var AvailableAuthProviders = []string{}
|
||||
var (
|
||||
ServerInSetup = false
|
||||
logLevel = new(slog.LevelVar)
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Watcharr Starting")
|
||||
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load vars from .env file:", err)
|
||||
// Do not fail if file does not exist
|
||||
if !os.IsNotExist(err) {
|
||||
log.Fatal("Failed to load vars from .env file:", err)
|
||||
}
|
||||
}
|
||||
ensureEnv()
|
||||
|
||||
multiw := setupLogging()
|
||||
slog.Info("Watcharr Starting")
|
||||
|
||||
if err = readConfig(); err != nil {
|
||||
log.Fatal("Failed to read server config!", err)
|
||||
}
|
||||
|
||||
setLoggingLevel()
|
||||
|
||||
// Ensure data dir exists
|
||||
err = ensureDirExists("./data")
|
||||
@@ -37,18 +57,42 @@ func main() {
|
||||
log.Fatal("Failed to create data dir:", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to database")
|
||||
// Check if we want to be in DEV or PROD
|
||||
isProd := true
|
||||
if os.Getenv("MODE") == "DEV" {
|
||||
slog.Info("Starting in DEV mode")
|
||||
isProd = false
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&User{}, &Content{}, &Watched{})
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(
|
||||
&User{},
|
||||
&UserServices{},
|
||||
&Content{},
|
||||
&Watched{},
|
||||
&WatchedSeason{},
|
||||
&WatchedEpisode{},
|
||||
&Activity{},
|
||||
&Token{},
|
||||
&Follow{},
|
||||
&Image{},
|
||||
&Game{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
}
|
||||
|
||||
gin := gin.Default()
|
||||
gin.Use(cors.New(cors.Config{
|
||||
if isProd {
|
||||
go runUI()
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
gin.DefaultWriter = multiw
|
||||
gine := gin.Default()
|
||||
gine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With"},
|
||||
@@ -56,23 +100,98 @@ func main() {
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
br := newBaseRouter(db, gin)
|
||||
if isProd {
|
||||
// Proxy NoRoute requests to UI server
|
||||
gine.NoRoute(func(c *gin.Context) {
|
||||
director := func(req *http.Request) {
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = "127.0.0.1:3000"
|
||||
}
|
||||
proxy := &httputil.ReverseProxy{Director: director}
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
br := newBaseRouter(db, gine.Group("/api"))
|
||||
// Only add setup routes if there are no users found in db.
|
||||
var userCount int64
|
||||
if uresp := db.Model(&User{}).Count(&userCount); uresp.Error == nil {
|
||||
if userCount != 0 {
|
||||
slog.Debug("registered users found.. skipped creating setup routes.")
|
||||
} else {
|
||||
slog.Info("No users found.. creating setup routes.")
|
||||
ServerInSetup = true
|
||||
br.addSetupRoutes()
|
||||
}
|
||||
} else {
|
||||
slog.Error("Failed to check if any users exist.. not registering setup routes", "error", uresp.Error)
|
||||
}
|
||||
br.addAuthRoutes()
|
||||
br.addContentRoutes()
|
||||
br.addGameRoutes()
|
||||
br.addWatchedRoutes()
|
||||
br.rg.Static("/img", "./data/img")
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addPlexRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addFollowRoutes()
|
||||
br.addImportRoutes()
|
||||
br.addServerRoutes()
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.addJobRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
gin.Run("0.0.0.0:3080")
|
||||
go setupTasks(db)
|
||||
|
||||
gine.Run("0.0.0.0:3080")
|
||||
}
|
||||
|
||||
// Ensure all required environment variables are set.
|
||||
func ensureEnv() {
|
||||
if os.Getenv("JWT_SECRET") == "" {
|
||||
log.Fatal("JWT_SECRET env var missing!")
|
||||
}
|
||||
// Setup slog defaults
|
||||
func setupLogging() io.Writer {
|
||||
// logLevel = new(slog.LevelVar)
|
||||
multiw := io.MultiWriter(&lumberjack.Logger{
|
||||
Filename: "./data/watcharr.log",
|
||||
MaxSize: 1, // megabytes
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // days
|
||||
Compress: false,
|
||||
}, os.Stdout)
|
||||
slog.SetDefault(slog.New(
|
||||
slog.NewTextHandler(multiw, &slog.HandlerOptions{Level: logLevel}),
|
||||
))
|
||||
return multiw
|
||||
}
|
||||
|
||||
if os.Getenv("JELLYFIN_HOST") != "" {
|
||||
AvailableAuthProviders = append(AvailableAuthProviders, "jellyfin")
|
||||
// Set loggin level from config
|
||||
func setLoggingLevel() {
|
||||
if Config.DEBUG {
|
||||
logLevel.Set(slog.LevelDebug)
|
||||
} else {
|
||||
logLevel.Set(slog.LevelInfo)
|
||||
}
|
||||
slog.Info("Logging level set", "logging_level", logLevel)
|
||||
}
|
||||
|
||||
// Run UI server
|
||||
func runUI() {
|
||||
cmd := exec.Command("node", "ui/index.js")
|
||||
cmdReader, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Fatal("UI ERR @ get stdout read pipe: ", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(cmdReader)
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
fmt.Println(scanner.Text())
|
||||
}
|
||||
}()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatal("UI ERR @ command start: ", err)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Fatal("UI ERR @ command wait: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||