Compare commits
724 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 32370e2ec2 | |||
| 8c23dd1b95 | |||
| 9a1df59fc5 | |||
| 8ec1ea2c4a | |||
| d393072b4a | |||
| 074f3a0b19 | |||
| e896be4429 | |||
| 0665666306 | |||
| 751a06513b | |||
| 342c61e9bd | |||
| dd5889ff24 | |||
| 0b86e09f00 | |||
| b62beee787 | |||
| 0207fe82c9 | |||
| 75bb481cd4 | |||
| 6c7c9d8294 | |||
| a145c95d90 | |||
| e426d9be3a | |||
| 8d51e48b37 | |||
| 150f377920 | |||
| 4432fc7e13 | |||
| ef78a84890 | |||
| d6bff05bfb | |||
| 66eadfceac | |||
| fad27de764 | |||
| bd24f3507e | |||
| 8dbf1e1f2c | |||
| dbffc9f438 | |||
| e146247113 | |||
| 52653c866e | |||
| f7e7c03f19 | |||
| 8d8b006bb9 | |||
| 6690563645 | |||
| 6401422d8b | |||
| d17835b246 | |||
| e024ba7767 | |||
| 6f26e1c534 | |||
| ccc0bcefc6 | |||
| 0dacde99e5 | |||
| 8004ae3d42 | |||
| 0d25f48635 | |||
| ec966f5708 | |||
| 9719a05993 | |||
| ae4ce82f8c | |||
| ab138db8b2 | |||
| 5c17a1f124 | |||
| a2496a0a2f | |||
| 2d97560a91 | |||
| 771b857f17 | |||
| 86799daa91 | |||
| 6b9da3be6b | |||
| a564e91dfb | |||
| 9cd8b38143 | |||
| 5b31df77a5 | |||
| 4b6bee9c37 | |||
| 91e1e878f9 | |||
| deeec0cb92 | |||
| a02f7e8999 | |||
| 9812b47be3 | |||
| d6ac2df49d | |||
| 0179c75051 | |||
| d4674c6c1a | |||
| d540779c00 | |||
| 0632f729fe | |||
| 3e7f0e3609 | |||
| 71d0b465ef | |||
| 7683d98dbe | |||
| 93828e5469 | |||
| 73ac7245bb | |||
| 513f1d5094 | |||
| fe75448f19 | |||
| 81e19c2e75 | |||
| 69fcbec1e2 | |||
| 9779bb4c69 | |||
| 6af799f71d | |||
| 41740c993d | |||
| 96e4491a52 | |||
| 8e318758cd | |||
| 25e215296e | |||
| ac00d1df6c | |||
| a316197413 | |||
| ff903570f6 | |||
| ae6ec13c41 | |||
| 7f3ec70c92 | |||
| 3aef3fc841 | |||
| fe4ac10338 | |||
| e5ed41185f | |||
| f04b054969 | |||
| e5a070e0cb | |||
| 3bac5a7cee | |||
| 60834a9a04 | |||
| f01f332a13 | |||
| 1e6b8bf9cd | |||
| 5898436ef3 | |||
| 854821cd26 | |||
| 11b8e0498b | |||
| a84078e119 | |||
| d4a29971cb | |||
| 3ce4bc0552 | |||
| bb0c813274 | |||
| 62c466320d | |||
| 247c1f7763 | |||
| 84a7930736 | |||
| e6b5b0d4a0 | |||
| c8100a8bea | |||
| 4a20ada589 | |||
| 07c05f5aca | |||
| 19c40c15ff | |||
| 6c4c889a6d | |||
| e4fe770666 | |||
| 372a4aad45 | |||
| 9a970df9e6 | |||
| 55de77d59b | |||
| e8db43efd4 | |||
| f33b4e674d | |||
| 2dfbd99825 | |||
| a4f238adcc | |||
| 7168e6222f | |||
| c50851e0f4 | |||
| bd91cdf248 | |||
| ee903cb9bd | |||
| 160deb165f | |||
| c7b126cfc8 | |||
| 82390f388f | |||
| 9e66b2ba29 | |||
| 63b5d48bf8 | |||
| 0d034ed324 | |||
| cba76cf5fc | |||
| c6416af1fd | |||
| 782a598e2f | |||
| ff2028b4f7 | |||
| 95d6c04391 | |||
| 4848507090 | |||
| 0575080e18 | |||
| 80a1f63dd8 | |||
| 6f0837e4b2 | |||
| f166661508 | |||
| 62aa5d6f21 | |||
| bffd1a85ad | |||
| 453fbe459e | |||
| bf3a43a5bc | |||
| 3ec7028bf9 |
@@ -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,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"
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Development Watcharr Image Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
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@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
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,12 @@ 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: 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 +29,15 @@ 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
|
||||
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
|
||||
@@ -37,3 +37,6 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Docker dev volume
|
||||
container_data/
|
||||
|
||||
@@ -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,12 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Backend
|
||||
FROM golang:1.21-alpine AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
COPY server/arr/*.go ./arr/
|
||||
|
||||
# Required so we can build with cgo
|
||||
RUN apk update && apk add --no-cache musl-dev gcc build-base
|
||||
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 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,16 +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
|
||||
|
||||
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,8 +1,55 @@
|
||||
<center>
|
||||
<h1>Watcharr</h1>
|
||||
<img src="./static/logo-col.png" alt="logo" />
|
||||
</center>
|
||||
<h1 align="center">Watcharr</h1>
|
||||
<p align="center"><img src="./static/logo-col.png" alt="logo" width="250" /></p>
|
||||
|
||||
I hope to become the place you store your watched content for later rememberance, but sadly I am still being built. I aspire to be easily self hosted.
|
||||
<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>
|
||||
</p>
|
||||
|
||||
I'm being build with Go and Svelte(Kit).
|
||||
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) movies and tv shows, rate them and track their status.
|
||||
|
||||
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): [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
|
||||
|
||||
<p align="center">
|
||||
|
||||
| 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
|
||||
|
||||
[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.
|
||||
|
||||
# Getting Help
|
||||
|
||||
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!
|
||||
|
||||
@@ -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,68 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
:::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": "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,101 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||
const darkCodeTheme = require("prism-react-renderer/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: 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://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
position: "right"
|
||||
}
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme
|
||||
// darkTheme: darkCodeTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
// Dark theme currently disabled.. no time to fix the icons etc.
|
||||
disableSwitch: true
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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": "2.4.3",
|
||||
"@docusaurus/preset-classic": "2.4.3",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.3"
|
||||
},
|
||||
"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": ">=16.14"
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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";
|
||||
|
||||
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 />
|
||||
</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 |
@@ -5,20 +5,9 @@ services:
|
||||
watcharr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.ui
|
||||
dockerfile: Dockerfile
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- watcharr-server
|
||||
|
||||
watcharr-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.svr
|
||||
container_name: watcharr-server
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
- ./server/.env:/.env
|
||||
- ./container_data:/data
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
- ./container_data:/data
|
||||
@@ -1,34 +1,39 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.0.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.25.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 --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.27.6",
|
||||
"@types/papaparse": "^5.3.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-svelte": "^2.35.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-svelte": "^3.0.3",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^4.5.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.1",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 371 KiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 948 KiB |
|
After Width: | Height: | Size: 866 KiB |
|
After Width: | Height: | Size: 877 KiB |
|
After Width: | Height: | Size: 945 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,73 @@
|
||||
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_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
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,17 +23,86 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserType uint8
|
||||
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
JELLYFIN_USER UserType = 1
|
||||
)
|
||||
|
||||
// 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.
|
||||
// This is incase different users with same name from different services try to signup.
|
||||
type User struct {
|
||||
GormModel
|
||||
Username string `gorm:"notnull,unique" json:"username" binding:"required"`
|
||||
Password string `gorm:"notnull" json:"password" binding:"required"`
|
||||
Watched []Watched
|
||||
Username string `gorm:"uniqueIndex:usr_name_to_type;not null" json:"username" binding:"required"`
|
||||
Password string `gorm:"not null" json:"password" binding:"required"`
|
||||
// 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:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// If ui 'spoilers' should be shown
|
||||
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Username string `json:"Username"`
|
||||
Pw string `json:"Pw"`
|
||||
}
|
||||
|
||||
type JellyfinAuthResponse struct {
|
||||
User 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
|
||||
@@ -37,19 +112,21 @@ type ArgonParams struct {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -57,19 +134,72 @@ 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)
|
||||
// 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, &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
@@ -84,63 +214,215 @@ 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(err.Error())
|
||||
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 = ?", user.Username).Take(&dbUser)
|
||||
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) {
|
||||
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(Config.JELLYFIN_HOST + "/Users/AuthenticateByName")
|
||||
if err != nil {
|
||||
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 {
|
||||
slog.Error("Error marshalling JellyfinAuth JSON", "error", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to marshal json")
|
||||
}
|
||||
// Run auth request
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer(usrJSON))
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
slog.Error("Error reading jellyfin auth response", "error", err.Error())
|
||||
return AuthResponse{}, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("Jellyfin auth non 200 status code", "status_code", res.StatusCode, "error", string(body))
|
||||
return AuthResponse{}, errors.New("incorrect details")
|
||||
}
|
||||
// Process auth response
|
||||
resp := new(JellyfinAuthResponse)
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return AuthResponse{}, errors.New("failed to process response")
|
||||
}
|
||||
if resp.User.ID == "" {
|
||||
return AuthResponse{}, errors.New("jellyfin returned empty user id")
|
||||
}
|
||||
|
||||
dbUser := new(User)
|
||||
dbRes := db.Where("third_party_id = ?", resp.User.ID).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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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()),
|
||||
@@ -235,3 +517,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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 `json:",omitempty"`
|
||||
|
||||
// Optional: Provide your own TMDB API Key.
|
||||
// If unprovided, the default Watcharr API key will be used.
|
||||
TMDB_KEY string `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `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,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. set defaults here, then `readConfig`
|
||||
// will overwrite if provided in watcharr.json cfg file.
|
||||
Config = ServerConfig{
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
)
|
||||
|
||||
// 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.
|
||||
// Currently only JWT_SECRET is required, so this method
|
||||
// generates a secret.
|
||||
func generateConfig() error {
|
||||
key, err := generateString(64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := ServerConfig{JWT_SECRET: key}
|
||||
barej, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Config.JWT_SECRET = cfg.JWT_SECRET
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
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,8 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
@@ -13,18 +15,181 @@ 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,unique"`
|
||||
Title string `json:"title"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
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)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
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, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
resp := new(TMDBShowDetails)
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
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)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv cast request!", 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 {
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return TMDBPersonDetails{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
resp := new(TMDBPersonCombinedCredits)
|
||||
err := tmdbRequest("/person/"+id+"/combined_credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,64 +1,50 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.20
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cache v1.2.0
|
||||
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-rc.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.7
|
||||
github.com/uptrace/bun v1.1.12
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12
|
||||
golang.org/x/crypto v0.7.0
|
||||
gorm.io/driver/sqlite v1.4.4
|
||||
gorm.io/gorm v1.24.6
|
||||
golang.org/x/crypto v0.15.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
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.10.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.0 // 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.15.5 // 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.17 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.16 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // 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.0 // 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.9.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/tools v0.6.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.5.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.3 // 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.21.0 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
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/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.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
|
||||
github.com/bytedance/sonic v1.10.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 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/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/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.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
|
||||
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
|
||||
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/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.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
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.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
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=
|
||||
@@ -26,70 +35,66 @@ github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl
|
||||
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/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
|
||||
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
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/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.1.0 h1:UGKbA/IPjtS6zLcdB7i5TyACMgSbOTiR8qzXgw8HWQU=
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
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/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/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
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-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/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.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/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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/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.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
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=
|
||||
@@ -100,105 +105,57 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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/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/driver/sqliteshim v1.1.12 h1:GMbSa7Pjjk4kjF8XURz5uMLe2PbN98e6t00sp0rx2Eo=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12/go.mod h1:u67g2ewzoMDCCAqjliHAM/BJjEXfoExXlFXhx3TnXRs=
|
||||
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.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/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/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
|
||||
golang.org/x/arch v0.5.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/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/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
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/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.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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/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=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
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/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.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
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/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=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.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/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/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.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/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,144 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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(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(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) {
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: contentId,
|
||||
ContentType: contentType,
|
||||
Rating: ar.Rating,
|
||||
}, 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)
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type JellyfinItemSearchResponse struct {
|
||||
Items []JellyfinItems `json:"Items"`
|
||||
}
|
||||
|
||||
type JellyfinItems struct {
|
||||
Type string `json:"Type"`
|
||||
ServerID string `json:"ServerId"`
|
||||
Id string `json:"Id"`
|
||||
ProviderIds struct {
|
||||
Tmdb string `json:"Tmdb"`
|
||||
} `json:"ProviderIds"`
|
||||
}
|
||||
|
||||
type JFContentFindResponse struct {
|
||||
HasContent bool `json:"hasContent"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
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 Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
return JFContentFindResponse{}, errors.New("jellyfin login not enabled")
|
||||
}
|
||||
if userType != JELLYFIN_USER || userThirdPartyId == "" {
|
||||
slog.Error("User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
|
||||
return JFContentFindResponse{}, errors.New("not jellyfin user")
|
||||
}
|
||||
if userThirdPartyAuth == "" {
|
||||
slog.Error("User has no thirdPartyAuth token..")
|
||||
return JFContentFindResponse{}, errors.New("user has no jellyfin auth token")
|
||||
}
|
||||
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,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,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Joined time.Time `json:"joined"`
|
||||
ShowsWatched int32 `json:"showsWatched"`
|
||||
MoviesWatched int32 `json:"moviesWatched"`
|
||||
}
|
||||
|
||||
// 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").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
|
||||
)
|
||||
for _, w := range *watched {
|
||||
if w.Status == FINISHED {
|
||||
if w.Content.Type == SHOW {
|
||||
showsWatched++
|
||||
} else if w.Content.Type == MOVIE {
|
||||
moviesWatched++
|
||||
}
|
||||
}
|
||||
}
|
||||
profile := Profile{Joined: user.CreatedAt, ShowsWatched: showsWatched, MoviesWatched: moviesWatched}
|
||||
return profile, nil
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache"
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -12,29 +17,65 @@ type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type BaseRouter struct {
|
||||
db *gorm.DB
|
||||
rg *gin.Engine
|
||||
type KeyValueRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
func newBaseRouter(db *gorm.DB, rg *gin.Engine) *BaseRouter {
|
||||
type BaseRouter struct {
|
||||
db *gorm.DB
|
||||
rg *gin.RouterGroup
|
||||
ms *persistence.InMemoryStore
|
||||
}
|
||||
|
||||
func newBaseRouter(db *gorm.DB, rg *gin.RouterGroup) *BaseRouter {
|
||||
return &BaseRouter{
|
||||
db: db,
|
||||
rg: rg,
|
||||
ms: persistence.NewInMemoryStore(time.Hour * 24),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addContentRoutes() {
|
||||
content := b.rg.Group("/content").Use(AuthRequired())
|
||||
// Since we cannot remove these setup routes after they are registered,
|
||||
// each route/service should ensure we are still in setup before continuing.
|
||||
// After server restart, these routes shouldn't exist if setup finished
|
||||
// (currently it is finished if a user is created).
|
||||
//
|
||||
// Each controller can check ServerInSetup var first, then each service
|
||||
// can double check what it needs to (eg create_admin service, registerFirstUser,
|
||||
// will check that no users exist).
|
||||
func (b *BaseRouter) addSetupRoutes() {
|
||||
setup := b.rg.Group("/setup")
|
||||
|
||||
// Get trending content
|
||||
// content.GET("/", func(c *gin.Context) {
|
||||
// c.JSON(http.StatusOK, getWatched(b.db))
|
||||
// })
|
||||
setup.POST("/create_admin", func(c *gin.Context) {
|
||||
if !ServerInSetup {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "not in setup"})
|
||||
return
|
||||
}
|
||||
var user UserRegisterRequest
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := registerFirstUser(&user, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
} else {
|
||||
// Set in setup to false after first user registered successfully
|
||||
ServerInSetup = false
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addContentRoutes() {
|
||||
content := b.rg.Group("/content").Use(AuthRequired(nil))
|
||||
exp := time.Hour * 24
|
||||
|
||||
// Search for content
|
||||
content.GET("/:query", func(c *gin.Context) {
|
||||
println(c.Param("query"))
|
||||
content.GET("/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
// println(c.Param("query"))
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -45,23 +86,186 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get movie details (for movie page)
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get movie cast
|
||||
content.GET("/movie/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get season details
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := seasonDetails(c.Param("id"), c.Param("num"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get person details
|
||||
content.GET("/person/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := personDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get person credits
|
||||
content.GET("/person/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := personCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Discover movies
|
||||
content.GET("/discover/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := discoverMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Discover shows
|
||||
content.GET("/discover/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := discoverTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get all trending (movies, tv, people)
|
||||
content.GET("/trending", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := allTrending()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Upcoming Movies
|
||||
content.GET("/upcoming/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := upcomingMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Upcoming Tv
|
||||
content.GET("/upcoming/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content, err := upcomingTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addWatchedRoutes() {
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired())
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
|
||||
|
||||
watched.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
c.JSON(http.StatusOK, getWatched(b.db, userId))
|
||||
})
|
||||
|
||||
watched.GET(":id/:username", func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("getPublicWatched route failed to convert id param to uint", "id", id)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getPublicWatched(b.db, uint(id), c.Param("username"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
watched.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addWatched(b.db, userId, ar)
|
||||
response, err := addWatched(b.db, userId, ar, ADDED_WATCHED)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -92,6 +296,91 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
watched.DELETE(":id", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
if err == nil {
|
||||
response, err := removeWatched(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
watched.POST("/season", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedSeasonAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addWatchedSeason(b.db, userId, ar, ADDED_WATCHED)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
watched.DELETE("/season/:id", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := rmWatchedSeason(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
activity := b.rg.Group("/activity").Use(AuthRequired(nil))
|
||||
|
||||
activity.GET(":watchedId", func(c *gin.Context) {
|
||||
watchedId, err := strconv.ParseUint(c.Param("watchedId"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "check watched id route param"})
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
activity, err := getActivity(b.db, userId, uint(watchedId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, activity)
|
||||
})
|
||||
|
||||
activity.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar ActivityAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addActivity(b.db, userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addAuthRoutes() {
|
||||
@@ -112,11 +401,11 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
// Jellyfin login
|
||||
auth.POST("/jellyfin", func(c *gin.Context) {
|
||||
var user User
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := register(&user, b.db)
|
||||
response, err := loginJellyfin(&user, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -126,4 +415,415 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
var user UserRegisterRequest
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := register(&user, PERM_NONE, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Get available auth providers
|
||||
auth.GET("/available", func(c *gin.Context) {
|
||||
availableAuthProviders := []string{}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "jellyfin")
|
||||
}
|
||||
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
IsInSetup: ServerInSetup,
|
||||
})
|
||||
})
|
||||
|
||||
// Request admin token
|
||||
auth.Use(AuthRequired(nil)).GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
token, err := createOneUseToken(b.db, TOKENTYPE_ADMIN, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
// Use admin token
|
||||
auth.Use(AuthRequired(nil)).POST("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var atr UseAdminTokenRequest
|
||||
if c.ShouldBindJSON(&atr) == nil {
|
||||
err := useAdminToken(&atr, b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addProfileRoutes() {
|
||||
profile := b.rg.Group("/profile").Use(AuthRequired(nil))
|
||||
|
||||
// Get user profile details
|
||||
profile.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getProfile(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addJellyfinRoutes() {
|
||||
jf := b.rg.Group("/jellyfin").Use(AuthRequired(b.db))
|
||||
|
||||
// Check if jf has item
|
||||
jf.GET("/:type/:name/:tmdbId", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
username := c.MustGet("username").(string)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
response, err := jellyfinContentFind(userId, userType, username, userThirdPartyId, userThirdPartyAuth, c.Param("type"), c.Param("name"), c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addUserRoutes() {
|
||||
u := b.rg.Group("/user").Use(AuthRequired(b.db))
|
||||
|
||||
// Get current user info
|
||||
u.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getUserInfo(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Update current user settings
|
||||
u.POST("/update", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ur UserSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
response, err := userUpdate(b.db, userId, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Get current user setting
|
||||
u.GET("/settings", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := userGetSettings(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Search users
|
||||
u.GET("/search/:query", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := userSearch(b.db, userId, c.Param("query"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addImportRoutes() {
|
||||
imprt := b.rg.Group("/import").Use(AuthRequired(nil))
|
||||
|
||||
imprt.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar ImportRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := importContent(b.db, userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addServerRoutes() {
|
||||
server := b.rg.Group("/server").Use(AuthRequired(b.db), AdminRequired())
|
||||
|
||||
// Get server config (minus very sensitive fields, like JWT_SECRET)
|
||||
server.GET("/config", func(c *gin.Context) {
|
||||
// Return new ServerConfig with only the fields we want to show in settings ui
|
||||
c.JSON(http.StatusOK, Config.GetSafe())
|
||||
})
|
||||
|
||||
// Update config
|
||||
server.POST("/config", func(c *gin.Context) {
|
||||
var ur KeyValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := updateConfig(ur.Key, ur.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Get server stats
|
||||
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getServerStats(b.db))
|
||||
}))
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addFeatureRoutes() {
|
||||
feature := b.rg.Group("/features").Use(AuthRequired(b.db))
|
||||
|
||||
// Get enabled features (aka functionality)
|
||||
feature.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getEnabledFeatures(c.GetInt("userPermissions")))
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addSonarrRoutes() {
|
||||
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db), AdminRequired())
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", func(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
resp, err := testSonarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Used to get config for specific server (quality profile, root folder, etc)
|
||||
s.GET("/config/:name", func(c *gin.Context) {
|
||||
server, err := getSonarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := testSonarr(ArrTestParams{Host: server.Host, Key: server.Key})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// Add sonarr server into config
|
||||
s.POST("/add", func(c *gin.Context) {
|
||||
var ur SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := addSonarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Edit sonarr servers config
|
||||
s.POST("/edit", func(c *gin.Context) {
|
||||
var ur SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := editSonarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Remove sonarr server
|
||||
s.POST("/rm/:name", func(c *gin.Context) {
|
||||
err := rmSonarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
// Get safe config for all sonarr servers
|
||||
s.GET("", func(c *gin.Context) {
|
||||
response := getSonarrsSafe()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Request a show
|
||||
s.POST("/request", func(c *gin.Context) {
|
||||
var ur arr.SonarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
err = sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addRadarrRoutes() {
|
||||
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db), AdminRequired())
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", func(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
resp, err := testRadarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Get config for specific server
|
||||
s.GET("/config/:name", func(c *gin.Context) {
|
||||
server, err := getRadarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := testRadarr(ArrTestParams{Host: server.Host, Key: server.Key})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
s.POST("/add", func(c *gin.Context) {
|
||||
var ur RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := addRadarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.POST("/edit", func(c *gin.Context) {
|
||||
var ur RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := editRadarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.POST("/rm/:name", func(c *gin.Context) {
|
||||
err := rmRadarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
s.GET("", func(c *gin.Context) {
|
||||
response := getRadarrsSafe()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.POST("/request", func(c *gin.Context) {
|
||||
var ur arr.RadarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
err = radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Setup recurring tasks (eg cleanup every x mins)
|
||||
func setupTasks(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)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
@@ -24,13 +26,14 @@ type TMDBSearchMultiResults struct {
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
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"`
|
||||
@@ -46,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 {
|
||||
@@ -61,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"`
|
||||
@@ -74,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 {
|
||||
@@ -108,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"`
|
||||
@@ -118,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 {
|
||||
@@ -132,10 +141,365 @@ 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 {
|
||||
Birthday string `json:"birthday"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Deathday string `json:"deathday"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AlsoKnownAs []string `json:"also_known_as"`
|
||||
Gender int8 `json:"gender"`
|
||||
Biography string `json:"biography"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
PlaceOfBirth string `json:"place_of_birth"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
Adult bool `json:"adult"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
Homepage string `json:"homepage"`
|
||||
}
|
||||
|
||||
type TMDBPersonCombinedCredits struct {
|
||||
ID int `json:"id"`
|
||||
Cast []TMDBPersonCombinedCreditsCast `json:"cast"`
|
||||
// crew TMDBPersonCombinedCreditsCrew
|
||||
}
|
||||
|
||||
type TMDBPersonCombinedCreditsCast struct {
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
Overview string `json:"overview"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalName string `json:"original_name"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
Name string `json:"name"`
|
||||
MediaType string `json:"media_type"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
Character string `json:"character"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
CreditID string `json:"credit_id"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Video bool `json:"video"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Adult bool `json:"adult"`
|
||||
}
|
||||
|
||||
type TMDBContentCredits struct {
|
||||
ID int `json:"id"`
|
||||
Cast []struct {
|
||||
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"`
|
||||
CastID int `json:"cast_id"`
|
||||
Character string `json:"character"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Order int `json:"order"`
|
||||
} `json:"cast"`
|
||||
Crew []struct {
|
||||
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"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Department string `json:"department"`
|
||||
Job string `json:"job"`
|
||||
} `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")
|
||||
@@ -146,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)
|
||||
@@ -166,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,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Public user details for search results
|
||||
type PublicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Private user details, for returning users details to themselves
|
||||
type PrivateUser struct {
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
}
|
||||
|
||||
// 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", "failed to retrieve user from database")
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
if ur.HideSpoilers != nil {
|
||||
user.HideSpoilers = ur.HideSpoilers
|
||||
}
|
||||
if ur.Private != nil {
|
||||
user.Private = ur.Private
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, 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", "failed to retrieve user from database")
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, 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", "failed to query database")
|
||||
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").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
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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,43 +27,161 @@ type GormModel struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("Watcharr Starting")
|
||||
var (
|
||||
ServerInSetup = false
|
||||
logLevel = new(slog.LevelVar)
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
panic("Failed to load vars from .env file")
|
||||
// Do not fail if file does not exist
|
||||
if !os.IsNotExist(err) {
|
||||
log.Fatal("Failed to load vars from .env file:", err)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create data dir:", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
ensureEnv()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to database")
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(&User{}, &Content{}, &Watched{})
|
||||
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &WatchedSeason{}, &Activity{}, &Token{})
|
||||
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", "OPTIONS"},
|
||||
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"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
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.addWatchedRoutes()
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addImportRoutes()
|
||||
br.addServerRoutes()
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.rg.Static("/img", "./data/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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirExists(dir string) error {
|
||||
err := os.MkdirAll(dir, 0764)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -20,134 +22,312 @@ const (
|
||||
FINISHED WatchedStatus = "FINISHED"
|
||||
WATCHING WatchedStatus = "WATCHING"
|
||||
PLANNED WatchedStatus = "PLANNED"
|
||||
HOLD WatchedStatus = "ONHOLD"
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
type Watched struct {
|
||||
GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
ContentID int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content Content `json:"content"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
ContentID int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content Content `json:"content"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=5"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
Status WatchedStatus `json:"status" binding:"required_without=Rating"`
|
||||
Rating int8 `json:"rating" binding:"max=5,required_without=Status"`
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts"`
|
||||
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts"`
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
}
|
||||
|
||||
type WatchedUpdateResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
type WatchedRemoveResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Activity").Preload("WatchedSeasons").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
return *watched
|
||||
}
|
||||
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error) {
|
||||
println(ar.ContentType, ar.ContentID)
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
|
||||
if err != nil {
|
||||
fmt.Printf("addWatched tmdb api request failed: %+v", err)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
var (
|
||||
id int
|
||||
title string
|
||||
overview string
|
||||
posterPath string
|
||||
)
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
if ar.ContentType == "movie" {
|
||||
content := new(TMDBMovieDetails)
|
||||
err = json.Unmarshal([]byte(resp), &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal movie details:", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Title
|
||||
} else {
|
||||
content := new(TMDBShowDetails)
|
||||
err = json.Unmarshal(resp, &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal show details:", err)
|
||||
return Watched{}, errors.New("failed to process show details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Name
|
||||
}
|
||||
// Save the content in our db
|
||||
println("id, etc:", id, title, overview, posterPath, "<-- end")
|
||||
if id == 0 || title == "" {
|
||||
println("addWatched, returned content missing id or title!", id, title)
|
||||
return Watched{}, errors.New("content response missing id or title")
|
||||
}
|
||||
content := Content{ID: id, Title: title, Overview: overview, PosterPath: posterPath}
|
||||
res := db.Create(&content)
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
// First we need to make sure the users list is public
|
||||
user := new(User)
|
||||
// Figure we require knowlege of the users id and name to make it
|
||||
// harder to just type in random ids and see someones list.. dunno
|
||||
// if this is a thing we need but its here.. for now at least.
|
||||
res := db.Where("id = ? AND username = ?", userId, username).Take(&user)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if !strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
println("Error creating content in database:", res.Error.Error())
|
||||
return Watched{}, errors.New("failed to cache content in database")
|
||||
}
|
||||
slog.Error("Failed to get user for getPublicWatched request")
|
||||
return []Watched{}, errors.New("failed to check privacy settings")
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
|
||||
if err != nil {
|
||||
println("Failed to download content image!", err.Error())
|
||||
}
|
||||
if user.Private != nil && *user.Private {
|
||||
slog.Error("getPublicWatched attempted to get a private list")
|
||||
return []Watched{}, errors.New("this watched list is private")
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
|
||||
var content Content
|
||||
db.Where("tmdb_id = ?", ar.ContentID).Find(&content)
|
||||
|
||||
// Create content if not found from our db
|
||||
if content == (Content{}) {
|
||||
slog.Debug("Content not in db, fetching...")
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("addWatched content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
var (
|
||||
id int
|
||||
title string
|
||||
overview string
|
||||
posterPath string
|
||||
releaseDate time.Time
|
||||
popularity float32
|
||||
voteAverage float32
|
||||
voteCount uint32
|
||||
imdbID string
|
||||
status string
|
||||
budget uint32
|
||||
revenue uint32
|
||||
runtime uint32
|
||||
numberOfEpisodes uint32
|
||||
numberOfSeasons uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
if ar.ContentType == "movie" {
|
||||
content := new(TMDBMovieDetails)
|
||||
err = json.Unmarshal([]byte(resp), &content)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Title
|
||||
releaseDate, err = time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
voteCount = content.VoteCount
|
||||
imdbID = content.ImdbID
|
||||
status = content.Status
|
||||
budget = content.Budget
|
||||
revenue = content.Revenue
|
||||
runtime = content.Runtime
|
||||
} else {
|
||||
content := new(TMDBShowDetails)
|
||||
err = json.Unmarshal(resp, &content)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal show details", "error", err)
|
||||
return Watched{}, errors.New("failed to process show details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Name
|
||||
releaseDate, err = time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
voteCount = content.VoteCount
|
||||
status = content.Status
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
numberOfEpisodes = content.NumberOfEpisodes
|
||||
numberOfSeasons = content.NumberOfSeasons
|
||||
}
|
||||
// Save the content in our db
|
||||
slog.Info("Saving content to db", "id", id, "title", title)
|
||||
if id == 0 || title == "" {
|
||||
slog.Error("addWatched, returned content missing id or title!", "id", id, "title", title)
|
||||
return Watched{}, errors.New("content response missing id or title")
|
||||
}
|
||||
content = Content{
|
||||
TmdbID: id,
|
||||
Title: title,
|
||||
Overview: overview,
|
||||
PosterPath: posterPath,
|
||||
Type: ar.ContentType,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: popularity,
|
||||
VoteAverage: voteAverage,
|
||||
VoteCount: voteCount,
|
||||
ImdbID: imdbID,
|
||||
Status: status,
|
||||
Budget: budget,
|
||||
Revenue: revenue,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: numberOfEpisodes,
|
||||
NumberOfSeasons: numberOfSeasons,
|
||||
}
|
||||
res := db.Create(&content)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if !strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
slog.Error("Error creating content in database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
|
||||
if err != nil {
|
||||
slog.Error("Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Error if content has no id
|
||||
if content.ID == 0 {
|
||||
return Watched{}, errors.New("failed to find content id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
ar.Status = FINISHED
|
||||
ar.Status = WATCHING
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: id}
|
||||
res = db.Create(&watched)
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: content.ID}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
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("addWatched: 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("addWatched: 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")
|
||||
}
|
||||
println("Error adding watched content to database:", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
println(res.RowsAffected)
|
||||
fmt.Printf("%+v\n", watched)
|
||||
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.Content = content
|
||||
return watched, nil
|
||||
}
|
||||
|
||||
func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (bool, error) {
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Updates(Watched{Rating: ar.Rating, Status: ar.Status})
|
||||
// this method is too ugly to look at please make him look better, future irhm
|
||||
func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (WatchedUpdateResponse, error) {
|
||||
slog.Debug("UpdateWatched", "request_data", ar)
|
||||
upwat := Watched{}
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Take(&upwat)
|
||||
if res.Error != nil {
|
||||
println("Watched entry update failed:", id, res.Error.Error())
|
||||
return false, errors.New("failed to update watched entry")
|
||||
slog.Error("Watched entry update failed:", "id", id, "error", res.Error.Error())
|
||||
return WatchedUpdateResponse{}, errors.New("failed to update watched entry")
|
||||
}
|
||||
originalThoughts := upwat.Thoughts
|
||||
if ar.Rating != 0 {
|
||||
upwat.Rating = ar.Rating
|
||||
}
|
||||
if ar.Status != "" {
|
||||
upwat.Status = ar.Status
|
||||
}
|
||||
if ar.Thoughts != "" {
|
||||
upwat.Thoughts = ar.Thoughts
|
||||
}
|
||||
if ar.RemoveThoughts {
|
||||
upwat.Thoughts = ""
|
||||
}
|
||||
res = db.Save(upwat)
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedUpdateResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
addedActivity := Activity{}
|
||||
if ar.Rating != 0 {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: RATING_CHANGED, Data: strconv.Itoa(int(ar.Rating))})
|
||||
}
|
||||
if ar.Status != "" {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: STATUS_CHANGED, Data: string(ar.Status)})
|
||||
}
|
||||
if ar.Thoughts != "" {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: THOUGHTS_CHANGED})
|
||||
}
|
||||
if ar.RemoveThoughts {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: THOUGHTS_REMOVED, Data: originalThoughts})
|
||||
}
|
||||
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
// Since other tables (eg activities) will link their rows to a watched_id, it's best to soft
|
||||
// delete, so if user restores watched item they still have activity for example (also so
|
||||
// someone else wont get other users activity if auto increment gives them the same watched id).
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Delete(&Watched{})
|
||||
if res.Error != nil {
|
||||
slog.Error("Removing watched entry failed", "id", id, "error", res.Error.Error())
|
||||
return WatchedRemoveResponse{}, errors.New("failed to remove watched entry")
|
||||
}
|
||||
if res.RowsAffected <= 0 {
|
||||
return false, errors.New("no watched entry found")
|
||||
return WatchedRemoveResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
return true, nil
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: REMOVED_WATCHED})
|
||||
return WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func download(url string, outf string) (err error) {
|
||||
println("Attempting to download file from", url, "to", outf)
|
||||
slog.Debug("Attempting to download file", "url", url, "outf", outf)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// UniqueIndex applied between WatchedID and SeasonNumber to avoid duplicates incase logic fails.
|
||||
type WatchedSeason struct {
|
||||
GormModel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
SeasonNumber int `json:"seasonNumber" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
|
||||
type WatchedSeasonAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
|
||||
type WatchedSeasonAddResponse struct {
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons"`
|
||||
AddedActivity Activity `json:"addedActivity"`
|
||||
}
|
||||
|
||||
// Add/edit a watched season.
|
||||
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at ActivityType) (WatchedSeasonAddResponse, error) {
|
||||
slog.Debug("Adding watched season item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedSeasons").Find(&w); resp.Error != nil {
|
||||
slog.Error("Failed when adding a watched season", "error", "failed to get watched item from db")
|
||||
return WatchedSeasonAddResponse{}, errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("Failed when adding a watched season", "error", "watched item does not exist in db")
|
||||
return WatchedSeasonAddResponse{}, errors.New("can't add a watched season for a show that doesnt have a status itself")
|
||||
}
|
||||
if w.Content.Type != SHOW {
|
||||
return WatchedSeasonAddResponse{}, errors.New("can't add watched season for non show content")
|
||||
}
|
||||
var found bool
|
||||
for i, ws := range w.WatchedSeasons {
|
||||
if ws.SeasonNumber == ar.SeasonNumber {
|
||||
slog.Debug("Existing watched season item found, updating existing")
|
||||
found = true
|
||||
if ar.Status != "" {
|
||||
w.WatchedSeasons[i].Status = ar.Status
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
w.WatchedSeasons[i].Rating = ar.Rating
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
var addedActivity Activity
|
||||
if !found {
|
||||
slog.Debug("Existing watched season not found, adding as new entry")
|
||||
w.WatchedSeasons = append(w.WatchedSeasons, WatchedSeason{
|
||||
UserID: userId,
|
||||
WatchedID: ar.WatchedID,
|
||||
SeasonNumber: ar.SeasonNumber,
|
||||
Status: ar.Status,
|
||||
Rating: ar.Rating,
|
||||
})
|
||||
}
|
||||
if resp := db.Save(&w.WatchedSeasons); resp.Error != nil {
|
||||
slog.Debug("Failed to save watched season item in db", "error", resp.Error)
|
||||
return WatchedSeasonAddResponse{}, errors.New("failed to save")
|
||||
}
|
||||
// Add activity
|
||||
if found {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)})
|
||||
}
|
||||
return WatchedSeasonAddResponse{
|
||||
WatchedSeasons: w.WatchedSeasons,
|
||||
AddedActivity: addedActivity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Remove a watched season
|
||||
func rmWatchedSeason(db *gorm.DB, userId uint, seasonId uint) (Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "season_id", seasonId)
|
||||
var watchedSeason WatchedSeason
|
||||
resp := db.Clauses(clause.Returning{}).Model(&WatchedSeason{}).Unscoped().Where("id = ? AND user_id = ?", seasonId, userId).Delete(&watchedSeason)
|
||||
if resp.Error != nil {
|
||||
slog.Error("Failed when removing a watched season", "error", resp.Error)
|
||||
return Activity{}, errors.New("failed when removing watched season")
|
||||
}
|
||||
if resp.RowsAffected == 0 {
|
||||
slog.Error("Failed when removing a watched season", "error", "zero rows affected")
|
||||
return Activity{}, errors.New("wasn't removed from db.. may not exist")
|
||||
}
|
||||
slog.Debug("rmWatchedSeason, deleted row", "row", watchedSeason)
|
||||
if watchedSeason.ID != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{
|
||||
"season": watchedSeason.SeasonNumber,
|
||||
"status": watchedSeason.Status,
|
||||
"rating": watchedSeason.Rating,
|
||||
})
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedSeason.WatchedID, Type: SEASON_REMOVED, Data: string(json)})
|
||||
return addedActivity, nil
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import type { Activity } from "@/types";
|
||||
import { getOrdinalSuffix, months } from "./util/helpers";
|
||||
|
||||
export let activity: Activity[] | undefined;
|
||||
|
||||
let groupedActivities: { [index: string]: any };
|
||||
|
||||
$: {
|
||||
groupedActivities = getGroupedActivity(activity);
|
||||
}
|
||||
|
||||
function getMsg(a: Activity) {
|
||||
switch (a?.type) {
|
||||
case "ADDED_WATCHED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `added to watched list${data?.status ? ` as ${data.status?.toLowerCase()}` : ""}${
|
||||
data?.rating ? ` with ${data.rating} stars` : ""
|
||||
}`;
|
||||
}
|
||||
return "added to watched list";
|
||||
case "REMOVED_WATCHED":
|
||||
return "removed from watched list";
|
||||
case "RATING_CHANGED":
|
||||
if (a.data) {
|
||||
return `rating changed to ${a.data}`;
|
||||
}
|
||||
return "rating changed";
|
||||
case "STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
return `status changed to ${a.data?.toLowerCase()}`;
|
||||
}
|
||||
return "status changed";
|
||||
case "THOUGHTS_CHANGED":
|
||||
return "thoughts changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
return "thoughts removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "imported";
|
||||
case "IMPORTED_RATING":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
if (data.rating) {
|
||||
return `rating changed to ${data.rating}`;
|
||||
} else {
|
||||
return "added to watchlist with no rating";
|
||||
}
|
||||
}
|
||||
return "imported rating";
|
||||
case "SEASON_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `season ${data.season} added as ${data.status?.toLowerCase()}`;
|
||||
}
|
||||
return "season added";
|
||||
case "SEASON_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `changed season ${data.season} rating to ${data.rating}`;
|
||||
}
|
||||
return "season rating changed";
|
||||
case "SEASON_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `changed season ${data.season} status to ${data.status?.toLowerCase()}`;
|
||||
}
|
||||
return "season status changed";
|
||||
case "SEASON_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `season ${data.season} status removed`;
|
||||
}
|
||||
return "season removed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get what will be the visible date the activity was 'created'.
|
||||
* @returns customDate if defined or createdAt if not.
|
||||
*/
|
||||
function getCreatedAtVis(a: Activity) {
|
||||
return Date.parse(a.customDate ?? a.createdAt);
|
||||
}
|
||||
|
||||
function getGroupedActivity(activities?: Activity[]) {
|
||||
activities = activities?.filter((a) => a.type);
|
||||
const a = activities?.sort((a, b) => getCreatedAtVis(b) - getCreatedAtVis(a));
|
||||
let grouped: { [index: string]: any } = {};
|
||||
if (a) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const activity = a[i];
|
||||
const date = new Date(getCreatedAtVis(activity));
|
||||
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
if (grouped[key]) {
|
||||
grouped[key].push(activity);
|
||||
} else {
|
||||
grouped[key] = [activity];
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="activity">
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
<ul>
|
||||
{#each Object.keys(groupedActivities) as k}
|
||||
<h3>{k}</h3>
|
||||
|
||||
{#each groupedActivities[k] as a}
|
||||
{@const d = new Date(getCreatedAtVis(a))}
|
||||
<li>
|
||||
<span title={d.toDateString()}>
|
||||
{toDayTime(d)}
|
||||
</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span>You Have No Activity!</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.activity {
|
||||
width: 100%;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-left: calc(30px + 8px);
|
||||
list-style: none;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
padding-bottom: 1px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
|
||||
span {
|
||||
width: max-content;
|
||||
margin-left: 0px;
|
||||
|
||||
&:first-child {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background-color: $accent-color;
|
||||
text-transform: capitalize;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: calc(30px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let value: boolean = false;
|
||||
export let toggled: (on: boolean) => void = () => {};
|
||||
export let disabled = false;
|
||||
|
||||
let actualDisabled = false;
|
||||
|
||||
$: {
|
||||
// In cases where we disable and enable the checkbox super fast
|
||||
// it ends up looking like a jittery mess. To circumvent this
|
||||
// issue, we add a small delay before undisabling.
|
||||
// (eg. fast internet and net request dis and re enables it quickly)
|
||||
if (disabled) {
|
||||
actualDisabled = true;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
actualDisabled = disabled;
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
function checkboxChange(e: Event) {
|
||||
toggled((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toggle-pill-color">
|
||||
<input
|
||||
bind:checked={value}
|
||||
disabled={actualDisabled}
|
||||
type="checkbox"
|
||||
id={name}
|
||||
on:change={checkboxChange}
|
||||
/>
|
||||
<label for={name}></label>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.toggle-pill-color {
|
||||
width: min-content;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
|
||||
&:checked + label {
|
||||
background: $success;
|
||||
|
||||
&::before {
|
||||
left: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + label {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 1.6em;
|
||||
border-radius: 1em;
|
||||
background: $error;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
opacity 100ms ease-in-out,
|
||||
opacity 100ms ease-in-out;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
left: 0.2em;
|
||||
top: 0.2em;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import type { DropDownItem } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let options: string[] | DropDownItem[];
|
||||
export let active: string | number | undefined = undefined;
|
||||
export let placeholder: string;
|
||||
export let blendIn: boolean = false;
|
||||
export let disabled = false;
|
||||
export let onChange: () => void = () => {};
|
||||
|
||||
let activeValue: string;
|
||||
let open = false;
|
||||
|
||||
$: {
|
||||
if (typeof active === "string") {
|
||||
activeValue = active;
|
||||
} else {
|
||||
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
|
||||
if (v && typeof v !== "string") activeValue = v.value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
open ? "is-open" : "",
|
||||
typeof active === "undefined" ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : ""
|
||||
].join(" ")}
|
||||
>
|
||||
<button on:click={() => (open = !open)} {disabled}>
|
||||
{activeValue ? activeValue : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul>
|
||||
{#each options.filter((o) => (typeof o === "string" ? o !== active : o.id !== active)) as o}
|
||||
<li>
|
||||
<button
|
||||
class="plain"
|
||||
on:click={() => {
|
||||
active = typeof o == "string" ? o : o.id;
|
||||
open = false;
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
{typeof o == "string" ? o : o.value}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: relative;
|
||||
|
||||
&.placeholder-shown {
|
||||
& > button {
|
||||
color: #8e8e8e;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.blend-in {
|
||||
& > button {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
ul {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& > button {
|
||||
border-color: $text-color;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
gap: 3px;
|
||||
text-transform: capitalize;
|
||||
|
||||
:global(svg) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
display: none;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
border: 2px solid $text-color;
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
background-color: $bg-color;
|
||||
z-index: 99;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
text-transform: capitalize;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response?.data?.error}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 5px;
|
||||
text-transform: capitalize;
|
||||
font-size: 16px;
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
export let title: string | undefined;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<ul>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
padding: 15px 30px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts" context="module">
|
||||
export type Icon = "check" | "clock" | "calendar";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Icon } from "@/types";
|
||||
|
||||
export let i: Icon;
|
||||
export let wh: number | string = 24;
|
||||
export let facing: "up" | "down" | "left" | "right" | undefined = undefined;
|
||||
</script>
|
||||
|
||||
{#if i == "check"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
{#if i === "check"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -17,16 +17,231 @@
|
||||
d="M416 128L192 384l-96-96"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i == "clock"}
|
||||
{:else if i === "clock"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.13 48 48 141.13 48 256c0 114.69 93.32 208 208 208 114.86 0 208-93.14 208-208 0-114.69-93.31-208-208-208zm108 240H244a4 4 0 01-4-4V116a4 4 0 014-4h24a4 4 0 014 4v140h92a4 4 0 014 4v24a4 4 0 01-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i == "calendar"}
|
||||
{:else if i === "play"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M133 440a35.37 35.37 0 01-17.5-4.67c-12-6.8-19.46-20-19.46-34.33V111c0-14.37 7.46-27.53 19.46-34.33a35.13 35.13 0 0135.77.45l247.85 148.36a36 36 0 010 61l-247.89 148.4A35.5 35.5 0 01133 440z"
|
||||
/></svg
|
||||
>
|
||||
{:else if i === "calendar"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M32 456a24 24 0 0024 24h400a24 24 0 0024-24V176H32zm80-238.86a9.14 9.14 0 019.14-9.14h109.72a9.14 9.14 0 019.14 9.14v109.72a9.14 9.14 0 01-9.14 9.14H121.14a9.14 9.14 0 01-9.14-9.14zM456 64h-55.92V32h-48v32H159.92V32h-48v32H56a23.8 23.8 0 00-24 23.77V144h448V87.77A23.8 23.8 0 00456 64z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "thumb-down"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M39.94 178l144.16 6.12c4.61.36 23.9 1.22 23.9 25.88 0 23.8-19.16 25.33-24.14 25.88L39.94 242C27.27 241.87 16 227.56 16 210s11.27-31.87 23.94-32zm141.45 131.66L74.65 318C62 318 48 301.31 48 284.12v-.33c0-16.33 11.14-29.63 24.88-29.79l108.45 1.72C208 259 208 275.16 208 282.12c0 22.88-21.8 27.14-26.61 27.54zM90.15 32l89.37 8.93C204 41.86 208 58.18 208 68.4c0 18.39-13.41 24.6-26.67 24.6l-91 3C75.78 95.78 64 81.51 64 64s11.68-31.66 26.15-32zm-34.36 71.5l126.4 6.22c9.39.63 25.81 3 25.81 26.36 0 12-4.35 25.62-25 27.53L55.79 167.5C42.65 167.35 32 154 32 136.08s10.65-32.43 23.79-32.58z"
|
||||
/><path
|
||||
d="M378.45 273.93A15.84 15.84 0 01386 272a15.93 15.93 0 00-7.51 1.91zM337.86 343.22l-.13.22a2.53 2.53 0 01.13-.22c20.5-35.51 30.36-55 33.82-62-3.47 7.06-13.34 26.51-33.82 62z"
|
||||
fill="none"
|
||||
/><path d="M372.66 279.16l-1 2a16.29 16.29 0 016.77-7.26 16.48 16.48 0 00-5.77 5.26z" /><path
|
||||
d="M195.94 459.38C205.37 472.67 221 480 240 480a16 16 0 0014.31-8.85c3-6.06 15.25-24 28.19-42.9 18-26.33 40.35-59.08 55.23-84.81l.13-.22c20.48-35.49 30.35-54.94 33.82-62l1-2a16.48 16.48 0 015.79-5.23A15.93 15.93 0 01386 272h25.32A84.7 84.7 0 00496 187.3v-38.6A84.7 84.7 0 00411.31 64h-48.79a17.46 17.46 0 01-9.58-2.89C330 46.13 286.66 32 240 32c-7.45 0-14.19.14-20.27.38a8 8 0 00-6.2 12.68l.1.14C222.2 57.59 224 71 224 80a61.16 61.16 0 01-5.19 24.77 17.38 17.38 0 000 14.06 63.81 63.81 0 010 50.39 17.32 17.32 0 000 14 62.13 62.13 0 010 49.58 18.13 18.13 0 000 14.68A60.41 60.41 0 01224 273c0 8.2-2 21.3-8 31.18a15.66 15.66 0 00-1.14 13.65c.38 1 .76 2.06 1.13 3.17a24.8 24.8 0 01.86 11.57c-3 19.35-9.67 36.3-16.74 54.16-3.08 7.78-6.27 15.82-9.22 24.27-6.14 17.56-4.3 35.2 5.05 48.38z"
|
||||
/></svg
|
||||
>
|
||||
{:else if i === "pause"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-32 272a16 16 0 01-32 0V192a16 16 0 0132 0zm96 0a16 16 0 01-32 0V192a16 16 0 0132 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "jellyfin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={wh} height={wh}>
|
||||
<path
|
||||
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "trash"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M112 112l20 320c.95 18.49 14.4 32 32 32h184c17.67 0 30.87-13.51 32-32l20-320"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M80 112h352"
|
||||
/>
|
||||
<path
|
||||
d="M192 112V72h0a23.93 23.93 0 0124-24h80a23.93 23.93 0 0124 24h0v40M256 176v224M184 176l8 224M328 176l-8 224"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "close"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M368 368L144 144M368 144L144 368"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "reel"}
|
||||
<svg
|
||||
id="reel"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width={wh}
|
||||
height={wh}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M426 138c-1-37-2-74-6-109-14-7-33-12-54-9-29 9-49 31-80 36-50 9-80-25-116-37-7-2-20 1-25-2l-3 2c-21 6-42 11-51 29-4 74-3 149-3 224 0 74-4 150 6 220 23 9 54 0 79-2 25-7 54-10 82-16 25-9 45-24 75-28 11 0 22 0 31 2 21 8 31 35 60 29l-1-111c-3-78 7-153 6-228Z"
|
||||
/>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M158 133c19-2 55 3 75-2-19-8-49-1-72-4 0-16 5-39-3-51-5 13-3 42 0 57Zm-1-72c3-1 4-2 4-7-6-6-12 7-4 7Zm-8-34 19 1c36 15 61 41 114 38 33-5 54-22 80-34l-1 104c-70 4-143 1-213 4l1-111v-2Zm-15 21c2 4 1 11 0 15-8 1-20 3-28 2-2-4-1-11-1-15 8-2 21-4 29-2Zm268 6c3 3 1 10 2 15-7 3-21 2-29 1-2-4 0-11-1-16 7-4 21-2 28 0ZM133 80v20c-10 0-20-1-29-3l1-17c9-2 18 2 28 0Zm271 8c3 4 2 14 1 18-8-1-22 2-31-1l1-16c9-3 20 2 29-1Zm-300 28c9 2 20 1 31 1v20c-9 2-20-1-30-1 0-7-2-12-1-20m271 5 32 1c-1 6 1 14-1 19-11-1-22 2-32 0 1-7-1-16 1-20Zm-270 34c11-1 19 1 30 1v19c-9 3-21 0-30-1v-19Zm206 139c4 0 12 3 13-1-1-3-14-5-13 1Zm-144-1c5 7 23 1 27-5-9-2-20 2-27 5Zm-12-10c6-12 3-30 4-46 1-22 2-45-1-65l-3-1c-2 35-5 78 0 112Zm66-119 1 1c37 2 78 4 116 4 5 0 13 3 15-3-42-4-90-2-134-4l-1 1c1 0 1 2 3 1Zm-73-8c68 3 145 4 213 0-1 48-5 94-5 142-65 6-141 1-207 5l-2-145 1-2Zm259 6c1 5 0 12-1 17-12-1-24 4-33 0v-18c9-3 25-1 34 1Zm-2 35c0 6 1 14-1 19-11-1-24 0-31 1-3-4-3-16-1-20 7-3 23-1 33 0Zm-303 2c10-2 23 0 34 0l-2 20c-11 0-21-2-31-1 0-6 1-14-1-19Zm303 35-1 20c-9 2-22 1-32 1v-22c10-1 23 1 33 1Zm-302 0c11 2 26-2 33 3 0 7-2 12-1 20-11 0-22 0-32-2v-21Zm302 41c1 5 2 12 0 17-10 3-24 1-36 2v-22c12 1 28-2 36 3Zm-268 2-1 21c-10 2-24-2-34 0 1-7-1-16 1-21h34Zm268 37c2 5-1 11 0 18-10 2-25 0-35 2-2-5 0-14-2-19 9-3 27-2 37-1Zm-268 1v17c-9 3-25 2-35 1v-17c9-2 22-1 35-1Zm20 15c9 2 23 2 30-1-7-3-25-4-30 1Zm59-4c31 9 84 3 123 7 5 17 2 41 3 63-1 4-5 16 1 19 4-23 6-62 3-87-39-3-87-3-130-2Zm139-11c-1 39-4 83 0 121-23-6-40 5-61 7-45 4-97 2-143 6-2-43-1-87-2-130 68 2 137-4 206-4Zm49 34c0 6 2 11 1 17-7 4-21 1-32 2v-18c9-1 21-3 31-1m-270 0c1 8-1 13-1 21-10 0-23 1-30-3v-17c10-3 23 1 31-1m272 35v16c-9 2-21 0-32 0 0-5 1-11-1-16 9-1 24-2 33 0Zm-269 3v21c-10-3-28 1-35-4v-15c9-4 24-3 35-2Zm270 31v16c-8 3-24 2-32 0 0-6-1-11 1-16 9-2 23-2 31 0Zm-270 7 2 19c-10 3-25 0-35 2 0-6-2-15-1-21 11 0 24-2 34 0Zm119 36c-33 7-67 13-103 18 1-7-1-11-1-17 36 1 70 0 104-1Zm-119 1v20c-11 0-23 1-32-2v-17l32-1Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "compass"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M448 256a192 192 0 1 0-384 0 192 192 0 0 0 384 0z"
|
||||
/>
|
||||
<path
|
||||
d="m351 151-118 47a64 64 0 0 0-35 35l-47 118a8 8 0 0 0 10 10l118-47a64 64 0 0 0 35-35l47-118a8 8 0 0 0-10-10zm-95 129a24 24 0 1 1 24-24 24 24 0 0 1-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "document"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M416 221.25V416a48 48 0 0 1-48 48H144a48 48 0 0 1-48-48V96a48 48 0 0 1 48-48h98.75a32 32 0 0 1 22.62 9.37l141.26 141.26a32 32 0 0 1 9.37 22.62z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
d="M256 56v120a32 32 0 0 0 32 32h120m-232 80h160m-160 80h160"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "add"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M256 112v288M400 256H112"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "arrow"}
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M244 400L100 256l144-144M120 256h292"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "chevron"}
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M328 112L184 256l144 144"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "search"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M456.69 421.39L362.6 327.3a173.81 173.81 0 0034.84-104.58C397.44 126.38 319.06 48 222.72 48S48 126.38 48 222.72s78.38 174.72 174.72 174.72A173.81 173.81 0 00327.3 362.6l94.09 94.09a25 25 0 0035.3-35.3zM97.92 222.72a124.8 124.8 0 11124.8 124.8 124.95 124.95 0 01-124.8-124.8z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "sort"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye-closed"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M432 448a15.92 15.92 0 01-11.31-4.69l-352-352a16 16 0 0122.62-22.62l352 352A16 16 0 01432 448zM248 315.85l-51.79-51.79a2 2 0 00-3.39 1.69 64.11 64.11 0 0053.49 53.49 2 2 0 001.69-3.39zM264 196.15L315.87 248a2 2 0 003.4-1.69 64.13 64.13 0 00-53.55-53.55 2 2 0 00-1.72 3.39z"
|
||||
/>
|
||||
<path
|
||||
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
transition: transform 100ms ease-in-out;
|
||||
|
||||
&.up {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
&.right {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
#reel {
|
||||
path:first-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
|
||||
path:last-of-type {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { ListBoxItem } from "@/types";
|
||||
import Checkbox from "./Checkbox.svelte";
|
||||
|
||||
export let options: ListBoxItem[];
|
||||
|
||||
/**
|
||||
* If a checkbox to select all checkboxes is wanted,
|
||||
* provide the text to display besides it.
|
||||
*/
|
||||
export let allCheckBox: string | undefined = undefined;
|
||||
|
||||
let allCheckBoxValue: boolean = false;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if allCheckBox}
|
||||
<div>
|
||||
<Checkbox
|
||||
name={allCheckBox}
|
||||
bind:value={allCheckBoxValue}
|
||||
toggled={(on) => {
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const e = options[i];
|
||||
e.value = on;
|
||||
}
|
||||
options = options;
|
||||
}}
|
||||
/>
|
||||
<span>{allCheckBox}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each options as o}
|
||||
<div>
|
||||
<Checkbox
|
||||
name={o.displayValue}
|
||||
bind:value={o.value}
|
||||
toggled={(on) => {
|
||||
if (!on) {
|
||||
allCheckBoxValue = false;
|
||||
} else if (!allCheckBoxValue) {
|
||||
if (!options.some((o) => !o.value)) {
|
||||
allCheckBoxValue = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{o.displayValue}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let desc: string;
|
||||
export let onClose: (() => void) | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<div class="backdrop"></div>
|
||||
<div class="modal">
|
||||
<div>
|
||||
{#if typeof onClose !== "undefined"}
|
||||
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
|
||||
{/if}
|
||||
<h3 class="norm">{title}</h3>
|
||||
<h5 class="norm">{desc}</h5>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
backdrop-filter: blur(2px) saturate(180%);
|
||||
background-color: color-mix(in srgb, black 85%, transparent);
|
||||
position: fixed;
|
||||
z-index: 99998;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
z-index: 99998;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 300px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
max-height: calc(100% - 20px);
|
||||
background-color: $bg-color;
|
||||
border-radius: 10px;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 100px;
|
||||
transition: margin 100ms ease;
|
||||
outline: 2px solid $text-color;
|
||||
overflow: auto;
|
||||
|
||||
h5 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
button.close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: max-content;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 680px) {
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 15px;
|
||||
|
||||
p {
|
||||
color: white;
|
||||
background-color: rgb(66, 60, 60);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,178 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Rating, WatchedStatus } from "@/types";
|
||||
import { onMount } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
// export let content: Content;
|
||||
export let poster: string | undefined;
|
||||
export let title: string | undefined;
|
||||
export let desc: string | undefined;
|
||||
export let rating: Rating = undefined;
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let onBtnClicked: (type: WatchedStatus, rating?: Rating) => void = () => {};
|
||||
export let onRatingChanged: (rating: Rating) => void = () => {};
|
||||
|
||||
let ratingContainer: HTMLDivElement;
|
||||
|
||||
function handleStarClick(r: Rating) {
|
||||
if (r == rating) return;
|
||||
onRatingChanged(r);
|
||||
}
|
||||
|
||||
function wBtnClicked(type: WatchedStatus) {
|
||||
onBtnClicked(type, rating);
|
||||
}
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<div class="container">
|
||||
<img loading="lazy" src={poster} alt="poster" />
|
||||
<div class="inner">
|
||||
<h2>{title}</h2>
|
||||
<span>{desc}</span>
|
||||
|
||||
<div id="rating-container" class="rating" bind:this={ratingContainer}>
|
||||
<button class="plain{rating === 5 ? ' lit' : ''}" on:click={() => handleStarClick(5)}
|
||||
>*</button
|
||||
>
|
||||
<button class="plain{rating === 4 ? ' lit' : ''}" on:click={() => handleStarClick(4)}
|
||||
>*</button
|
||||
>
|
||||
<button class="plain{rating === 3 ? ' lit' : ''}" on:click={() => handleStarClick(3)}
|
||||
>*</button
|
||||
>
|
||||
<button class="plain{rating === 2 ? ' lit' : ''}" on:click={() => handleStarClick(2)}
|
||||
>*</button
|
||||
>
|
||||
<button class="plain{rating === 1 ? ' lit' : ''}" on:click={() => handleStarClick(1)}
|
||||
>*</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="btn-container">
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
on:click={() => wBtnClicked("PLANNED")}><Icon i="calendar" /></button
|
||||
>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
on:click={() => wBtnClicked("WATCHING")}><Icon i="clock" /></button
|
||||
>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
on:click={() => wBtnClicked("FINISHED")}><Icon i="check" /></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
background-color: rgb(48, 45, 45);
|
||||
overflow: hidden;
|
||||
flex: 1 1;
|
||||
border-radius: 5px;
|
||||
width: 170px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
// aspect-ratio: 2/3;
|
||||
transition: all 150ms ease-in;
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
|
||||
h2 {
|
||||
font-family: unset;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: white;
|
||||
margin: 5px 0 5px 0;
|
||||
font-size: 9px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-flow: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
-webkit-text-stroke: 1.5px white;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
margin: 10px 0 10px 0;
|
||||
|
||||
button {
|
||||
font-size: 35px;
|
||||
font-family: "Rampart One";
|
||||
letter-spacing: 10px;
|
||||
line-height: 52px;
|
||||
height: 38px;
|
||||
|
||||
&:hover,
|
||||
&:hover ~ button,
|
||||
&:global(.lit),
|
||||
&:global(.lit ~ button) {
|
||||
color: gold;
|
||||
-webkit-text-stroke: 1.5px gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-container {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
|
||||
&:hover {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
z-index: 99;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(4px) grayscale(80%);
|
||||
// This makes the background very dark,
|
||||
// but atleast the text is visible.. may want to change later.
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.inner {
|
||||
color: white;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +0,0 @@
|
||||
<div>
|
||||
<ul>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
margin: 20px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
export let rating: number | undefined;
|
||||
export let onChange: (newRating: number) => void;
|
||||
|
||||
let hoveredRating: number | undefined;
|
||||
let shownRating: number | undefined;
|
||||
|
||||
const ratingDesc = [
|
||||
"Apalling",
|
||||
"Horrible",
|
||||
"Very Bad",
|
||||
"Bad",
|
||||
"Average",
|
||||
"Fine",
|
||||
"Good",
|
||||
"Very Good",
|
||||
"Great",
|
||||
"Masterpiece"
|
||||
];
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
onChange(r);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (hoveredRating !== undefined) shownRating = hoveredRating;
|
||||
else if (rating !== undefined) shownRating = rating;
|
||||
else shownRating = undefined;
|
||||
}
|
||||
|
||||
function handleStarHover(r: number) {
|
||||
hoveredRating = r;
|
||||
}
|
||||
|
||||
function handleStarHoverEnd() {
|
||||
hoveredRating = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rating-container">
|
||||
<span>
|
||||
{#if typeof hoveredRating === "number"}
|
||||
{ratingDesc[hoveredRating - 1]}
|
||||
{:else if typeof rating === "number" && rating > 0}
|
||||
{ratingDesc[rating - 1]}
|
||||
{:else}
|
||||
Select Your Rating
|
||||
{/if}
|
||||
</span>
|
||||
<div class="rating">
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{shownRating === v ? ' lit' : ''}"
|
||||
on:click={() => handleStarClick(v)}
|
||||
on:mouseenter={() => handleStarHover(v)}
|
||||
on:mouseleave={() => handleStarHoverEnd()}
|
||||
>
|
||||
*
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rating-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-flow: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color;
|
||||
-webkit-text-stroke: 1.5px $text-color;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
margin: 10px 0 10px 0;
|
||||
padding: 1px;
|
||||
|
||||
button {
|
||||
font-size: 55px;
|
||||
font-family: "Rampart One";
|
||||
letter-spacing: 10px;
|
||||
line-height: 52px;
|
||||
height: 38px;
|
||||
|
||||
&:global(.lit),
|
||||
&:global(.lit ~ button) {
|
||||
color: gold;
|
||||
-webkit-text-stroke: 1.5px gold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
font-size: 45px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
TMDBSeasonDetails,
|
||||
TMDBShowSeason,
|
||||
Watched,
|
||||
WatchedSeasonAddResponse,
|
||||
WatchedStatus
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
import Error from "./Error.svelte";
|
||||
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import { notify } from "./util/notify";
|
||||
import { get } from "svelte/store";
|
||||
import { watchedList } from "@/store";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
|
||||
export let tvId: number;
|
||||
export let seasons: TMDBShowSeason[];
|
||||
export let watchedItem: Watched; // Watched list item id
|
||||
|
||||
let activeSeason = 1;
|
||||
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
|
||||
|
||||
async function sdr(seasonNum: number) {
|
||||
return (await axios.get(`/content/tv/${tvId}/season/${seasonNum}`)).data as TMDBSeasonDetails;
|
||||
}
|
||||
|
||||
// Add/update watched season
|
||||
function updateWatchedSeason(seasonNumber: number, status?: WatchedStatus, rating?: number) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedSeasonAddResponse>(`/watched/season`, {
|
||||
watchedId: watchedItem.id,
|
||||
seasonNumber: seasonNumber,
|
||||
status,
|
||||
rating
|
||||
})
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedSeasons = r.data.watchedSeasons;
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data.addedActivity);
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE", seasonNumber: number) {
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedSeasons?.find((s) => s.seasonNumber === seasonNumber);
|
||||
if (!ws) {
|
||||
notify({ text: "Failed to find watched season id. Please try refreshing.", type: "error" });
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.delete(`/watched/season/${ws.id}`)
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedSeasons = wEntry.watchedSeasons?.filter((s) => s.id !== ws.id);
|
||||
if (r.data) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data);
|
||||
} else {
|
||||
wEntry.activity = [r.data];
|
||||
}
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Removed!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateWatchedSeason(seasonNumber, type);
|
||||
}
|
||||
|
||||
function handleStarClick(rating: number, seasonNumber: number) {
|
||||
updateWatchedSeason(seasonNumber, undefined, rating);
|
||||
}
|
||||
|
||||
$: {
|
||||
seasonDetailsReq = sdr(activeSeason);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ctr">
|
||||
<ul class="seasons">
|
||||
{#each seasons as season}
|
||||
<button
|
||||
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
|
||||
on:click={() => {
|
||||
activeSeason = season.season_number;
|
||||
}}
|
||||
>
|
||||
<h1>{season.name}</h1>
|
||||
{#if season.air_date}
|
||||
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
|
||||
{:else if season.season_number > 0}
|
||||
<h2>TBD</h2>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="last" />
|
||||
</ul>
|
||||
|
||||
<div class="episodes">
|
||||
{#await seasonDetailsReq}
|
||||
<Spinner />
|
||||
{:then season}
|
||||
<div class="episodes-topbar">
|
||||
<h3>{season.name}</h3>
|
||||
{#if watchedItem}
|
||||
{@const ws = watchedItem?.watchedSeasons?.find(
|
||||
(s) => s.seasonNumber === season.season_number
|
||||
)}
|
||||
{#if ws}
|
||||
<div class="rating" style={ws?.rating ? "width: 65px" : "width: 45px"}>
|
||||
<PosterRating
|
||||
rating={ws?.rating}
|
||||
btnTooltip="Season Rating"
|
||||
handleStarClick={(r) => handleStarClick(r, season.season_number)}
|
||||
minimal={true}
|
||||
direction="bot"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="status">
|
||||
<PosterStatus
|
||||
status={ws?.status}
|
||||
btnTooltip="Season Status"
|
||||
handleStatusClick={(t) => handleStatusClick(t, season.season_number)}
|
||||
direction="bot"
|
||||
width="100%"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if season?.episodes?.length > 0}
|
||||
<ul>
|
||||
{#each season.episodes as ep}
|
||||
<SeasonsListEpisode {ep} />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<h3 class="norm">No episodes in this season yet!</h3>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load season details!" error={err} />
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.ctr {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.episodes {
|
||||
width: 100%;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
min-width: fit-content;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
transition: top 200ms ease-in-out;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 18px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 2px solid #302d2d;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
max-width: 220px;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: white;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
&.active {
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* hack to get extra scroll space under last el */
|
||||
.last {
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.episodes-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
min-height: 40px;
|
||||
|
||||
div {
|
||||
transition: width 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.rating {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
&.status {
|
||||
width: 45px;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 960px) {
|
||||
:global(body.nav-shown) ul.seasons {
|
||||
top: $nav-height;
|
||||
height: calc(100vh - $nav-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 960px) {
|
||||
.ctr {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
position: unset;
|
||||
height: unset;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
&:first-of-type {
|
||||
margin-top: unset;
|
||||
}
|
||||
&.active {
|
||||
position: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import type { TMDBSeasonDetailsEpisode } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { userSettings } from "@/store";
|
||||
|
||||
$: settings = $userSettings;
|
||||
|
||||
export let ep: TMDBSeasonDetailsEpisode;
|
||||
|
||||
let isHidden = false;
|
||||
|
||||
$: {
|
||||
if (settings) isHidden = settings.hideSpoilers;
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class={isHidden ? "dont-spoil" : ""}>
|
||||
{#if ep.still_path}
|
||||
<img src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`} alt="" />
|
||||
{:else}
|
||||
<div class="no-still" />
|
||||
{/if}
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
<span class="episode-name">{ep.name}</span>
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{Math.round(ep.vote_average * 10) / 10}
|
||||
</span>
|
||||
{#if isHidden}
|
||||
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
|
||||
<Icon i="eye-closed" wh={34} />
|
||||
<span>Click To Reveal</span>
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
|
||||
img,
|
||||
.no-still {
|
||||
width: 227px;
|
||||
min-width: 227px;
|
||||
height: 127px;
|
||||
min-height: 127px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
object-fit: fill;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
color: $rating-color;
|
||||
font-weight: bolder;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $rating-color;
|
||||
font-size: 25px;
|
||||
line-height: 0.7;
|
||||
margin-top: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.spoiler-text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: bolder;
|
||||
font-size: 20px;
|
||||
fill: $text-color;
|
||||
opacity: 0;
|
||||
transition:
|
||||
visibility 150ms ease-in,
|
||||
opacity 150ms ease-in;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
text-shadow: 0 0 6px $bg-color;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
filter: drop-shadow(0 0 8px $bg-color);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-name,
|
||||
img {
|
||||
transition: filter 150ms ease-out;
|
||||
}
|
||||
|
||||
&.dont-spoil {
|
||||
.episode-name {
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
img {
|
||||
filter: blur(6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
li {
|
||||
align-items: center;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="container">
|
||||
<div class="spinner" />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 80px;
|
||||
aspect-ratio: 1;
|
||||
border: 10px solid $bg-color;
|
||||
background: radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 0/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 0/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 100%/20px 20px,
|
||||
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 100%/20px 20px,
|
||||
linear-gradient($text-color 0 0) center/40px 40px, $bg-color;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(4px) contrast(10);
|
||||
animation: spin 800ms infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
background-position: 100% 0, 100% 100%, 0 100%, 0 0, center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
export let style: string = "";
|
||||
</script>
|
||||
|
||||
<div class="loader" {style}></div>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 13px;
|
||||
border: 2px solid #000;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
import type { WatchedStatus } from "../types";
|
||||
import tooltip from "./actions/tooltip";
|
||||
|
||||
export let status: WatchedStatus | undefined;
|
||||
export let onChange: (newStatus: WatchedStatus) => void;
|
||||
</script>
|
||||
|
||||
<div class="status">
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
on:click={() => onChange("PLANNED")}
|
||||
use:tooltip={{ text: "Planned", pos: "top" }}
|
||||
>
|
||||
<Icon i="calendar" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
on:click={() => onChange("WATCHING")}
|
||||
use:tooltip={{ text: "Watching", pos: "top" }}
|
||||
>
|
||||
<Icon i="clock" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
on:click={() => onChange("FINISHED")}
|
||||
use:tooltip={{ text: "Finished", pos: "top" }}
|
||||
>
|
||||
<Icon i="check" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "HOLD" ? "not-active" : ""}
|
||||
on:click={() => onChange("HOLD")}
|
||||
use:tooltip={{ text: "On Hold", pos: "top" }}
|
||||
>
|
||||
<Icon i="pause" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "DROPPED" ? "not-active" : ""}
|
||||
on:click={() => onChange("DROPPED")}
|
||||
use:tooltip={{ text: "Dropped", pos: "top" }}
|
||||
>
|
||||
<Icon i="thumb-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.status {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||