diff --git a/package.json b/package.json index cf77a17d..dac91568 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "node-cache": "5.1.2", "node-gyp": "^8.0.0", "node-schedule": "2.1.1", + "nodebrainz": "^2.1.1", "nodemailer": "6.9.1", "openpgp": "5.7.0", "plex-api": "5.3.2", diff --git a/server/api/musicbrainz/demo.txt b/server/api/musicbrainz/demo.txt new file mode 100644 index 00000000..a664d1a5 --- /dev/null +++ b/server/api/musicbrainz/demo.txt @@ -0,0 +1 @@ +https://www.typescriptlang.org/play?#code/MYewdgzgLgBATgQwO4BEFQTAvDA3gKBhgCIAbASwDMBTAWggAcExiAuPGasAE2u-coJSEagBoYAI2oBzcmHbEAjAE4A7ACZaigAxbVxcV34wwAV1KkYAX1GEY3chAQBbCeWmn05cAoCqAZXgEBgZqOAM7YiMAfQQ4agQ2E3NSWyJiaSMw2nJuJOIAZgA2bgLS7QRaAA4C5W5aAr5KquAJAtp1ZSkAFgKq7upuhGUIoigAT1CFAAUwiHBRmCQQOABrCHYAbQBdNJJ40gSRWmk4EFMGCCTNuyICIgeYKHIoQ4UASTBKORfqRcfiAw4ORnHFxrQJqEcnkFJQAKydCTdVRFWhFahFTQFdTdSi0CQVSqKSiNAplAqKAqUYD-B4OJyudyeZ4+Ei09IiUA8MEQyZ0XJXLa7W4PQHA0FwcGQv4KACCpAkpmc7JInPA3B50sFMB2e0eGGkGx1IseuBMLmoCl4UDOLxgAAtyAxaPaQAwDDBQKYwFB2AVrHrHhwwBaFI6GA63R6vT72DoAyaHmaY76YIpxCHnJaSOHIxHEO6E0Gk+as2GnS6o+IU36i8WODWYOoM6H9sFiHXi2bM9niN7eHBTuceA6nXno8PU4prInhcWDkdqEbdYncgpVIo4VI4dRVA0iqo9wU4cBdAhapUEBISdptJRKF0EIoVcRvnBoLQFwhjhqoDKSCoyioooihaOoxAijYIr3EGYogpqfLQvk8KIsiqLopiDQ4niBIIESJLUGSpSUtSKrPK8vYACr2tQMD+BQzh0faCDcOMMAADLTCq9IuG4HheKyxAvmq3KSryUICtcc5Bgay6JncnqTnGLZliQEggCAjESO2nZBt2rbEKAWasUE7rVkpaa6aaineqmzalr2zFwNwoDxKOEauoWUH1g2Fn2T2YZxC5Ky0QWHbefW+mqcQuaeROtlxqoVklo2-kGbFbqmeFgbWQFOYVnF5kJZZEVdjZsZNipjkrGccCuX8yUKalVUKM4uRIEusBhY1vnFemDkKN1pV6QNbYeWOnnRAW7l5lNwTROGlYMHNZnlVOPXJhZ-V5cQ00ZctBYLQVUYbaNu3BDNk2HXtE1uitR3OoVa1xqdO0ddAinfl17ZFRV05WLOOWqtQXIamJWpSUDcESlKfL5PKirKlDb4fl+P7oP+SjKNjtDaJo6gFCqa5qbu6hwrw3S0N0RRkg0wBVJUygINo7SUOowC9Mo6gSOi2h5EDaNLkKkGBjB+oIIaWzyb1f0tSQQh-nAIbPAAbrR+3ZdLm19XLxASKQphLprPlRb21q2rAi1PY2cKvelQX1ZdVbPTARR29FTnBW5YW-XZ7uObdq2peop2Nt0uv7VlocWQUEfHV5QMKTtVtugA9D7Lv-YnMt2XHj13SnCday7aUezVKz1cbkUu7HZ1tdw73fUXJsl7r9eN2EYBR8NuUGQwmUZzb-sKFAbrdNo8V-TOxbSQCIlg7DoTaiuxbQwhoTwwqSpE8Yet3sMij07j2Oon0lCgVe-S0KoajwlU3CqBz1ASC+KNQJ+1CHN+dC-pj6i3robQcIOjgQFp-Rcy5Z6iiBPBcGiFcjIQRF0NCaIMRYmwviQkWgCJEQpFSGkQNyJvBINRWiABZOIEBmIWBgBQqANF3wcS4kDHijJ+IsnkGyKG89178m4MvbYItoKJlYXxZk3hOFCW4SDdUvDoQCMIS8YhxBSEwAAKJtTANQRi-hXRIBfDAmG4k+GINQiiVBmFsS4kwXhbBpJyQkQIauXe1BlBwiqDubglNuCKG6FUBoG5QISCkHQOEcJhiqAkO47E4SVSC0gcjcg7536C1oL-fI-88a42AeofQUNDG8M3ojYSMjRKLyNkKQhEs5It0bNtAycIADUSwQDABoiwaOxVa47XNiAO0hcq5lR2uA4ANpwDkAIZnYeJARljLABMgA3DAXi5AuBQCWeGTySyABWCAABeezJ7rR7iWYZYBpAUCoUcl6Jyk722co7DWvt2Ah1ucGe5XtQo-RLp0v6qg87jl+VOAFnlu7Z1NuWfOQctpAuUmdXZ9p4gQDaXEKAEAmx40JtM4g7dOqdyys8mA-o3na1lmdEA95yB7NWRYWiKK4BosGSNOput+4UGeMAIQTtm7Vx2v3fM3y6mwsqmdIa4LM662mswbg3KmW92ijdDyzshUkolaKi6+0VrLJHJqgZwr6kKuCOnAAZC-aeQZBGPGGmLaB4o5EINhEgpE5iMLoOsbhfC9jiL4LIko3saiwCV2kaDe1-DIYiMcCs9hEiFBkWqVLFuPTqBjP6fHa5rt9W62kMwQ0GA5WnIMtm850BMCDxhaq5ODsQqysJVnYulaHnVrLcVV54rk6B3TYoVt9b0qBwJT81VjZukGVxdAfFYqe3RX5em7tiaDLNqnoOrakqLrStlRtaWRAWWbvVRGNduq+1KwkNqmVGpA3qyEKQLV+7A73Ujkek99hmDAAvRYCCPkK3ztXTq29115p-r3T+pV0K+rYoA4+zVUqgOzQXccwGiZiA8LgUvcNq8CnIcxgjbeQNiaFAZofBA1B-FeKqLoAoCBuiUxqOErQXQ7y7iKFUKolJX5JNRuA7+aSMYZNvJTECYE4kcZEJAoRpoENvw-l-dGf4ePaGUNkrQcId4KASIobgt5VDtCiVUU+cJujAJ09oPcbMabqG4HCAoBJTyCak0LHUUCxjxuNLUiy4czq9NTVC-NTU-IRyrW5DW2KnkuzdkunWZ1C5guLiyiL5c6ohW8+86Ko6m4zuFaXXs-KosufCztehphpDd3g6vJD5SFEIfQ+Uop2GI0MjEQJSRJSQ0Yfkah2ClXjFIUdWY9CaCsLuqwcSL1eDSKKIogoAASp-YIIh30PGtRVu1GHqtI0TLJBNvKDIectmmwlSVP0e38+rDttbM0RaO9l6udTtAgpOgdgOwH+0qrbX3Aegry3iqHSuryxXYKleMeV4suGEBwiAUUYAlnr4FDvFTZQkOJA6FCeoSgB574aAkB41jyTJOLi4zJhQ-8dC42AlUGzEDKmLdgeUrrJAULIJdX1qxOFBs4IcT6sbyipugDVpKbika2HiMEk12RLXJLCytaLZxCgEeUBcsoYAwBqiMd0JRjUtArxXloHCZQfBOVFAkOoFihMwG2YSeJtjKShM-24wT7QlItCgSAQYpbVW5Rb1WyV0pC8AdtfFpLZzV2Y6622+5JaiXSXAvO42gLHb9UGoe4C+7CgsuwZeel774Vftzy96GwHQZRFMga7GjnVEaK0ModQywdCGHos4k2Z3VPOsOtp06lBrr+vM9sUNwibPSKiaTGtv1k3qAq3ICrIQDejHShp6+VvDPLEYI9XYnv3rRsIf+9PsX9mWH8-qxw4vlOp9wzd8U4NIuyu+4eOtgPZVGxVFuzyoZr2BUgcXVnh48SKdA93sAakmIMSgSMbg5Uzg71AUYWZojaBVBqBATUyUAbhY7sa2Z45-x257hdqKZzZEALae7NbU5b4rz5676F775cKD7jYkAADSLg5Aqw+yfw+SLuTeMILePWFibqnenqK+I2Tiq8EmqS6Stuig-iUBDQz4OGu8pGZm-+dARQJ47Q1MRQqIVQlAyIVMfilG2IigCARQzMZO38ZuaGTB0oK2wuZSPuX+MkTmNwOWFU-yEWseYWZKfKb2P2M8-edwa21hk6vYTSLSbSXAna2KXAFyjg9oQRThqY9+DhwG4esWcAtUQaA6L2U6rhERn2Qeu6meM8Z+5hEMlhAIHWJhJ+NWfBFuOOnGghJA-8-8uMoEigeSUuakwA6gDRwARQ3iSIChFIoEXQmmtA1IOh3AZMHMCAwASmJu5O2+h+oapi9OvWC+A2XerOq+vBMkQ+JAZCpgEAEyTwIAMAAAQuQqYM5GEHwIceMGYd7pvmGgUXSMQdGkLh4XgOvjnhhsvMQAAMKaQMDkCkANbEAOZPDeG2G5zubJoWyh5xRnYNqfLrqRHsBubtqxHYopbjrvYtrmqPBAlrzLYlEe6wT8FW4oGyZ8aaBdrKYkD3z0zACHgSAdC1CnzUCUD+IqHjG0DMkvpSC3hPhO6TEGF3FjAbHEBqKT6zHdbzHsEd42JcG4KOJXG57XDEDcDDEICtHUCO6nhkYaDyYICqDMxpIYjaAY4DBRI+KAk751YkExpsjPE2rpBFHH4kBYYEnZ54EWE6hfE-F-EAlAnX42GB65YNLNLLABEdIIkwBIkGQSBuIvpUh4hlBAJUxEZ7g65260DABqA8y6HnxlDdBxE7RGR8BsSp5NgwkGSzJnDzLABLIrJrIbJOhbIwC7IHJxExZnKhFXLYpFq5qJCnZJ45gXbBbPbRZbRRkeyOEZHFTjkJ6goTpzoTleb9kpG9hgD7GZBgBuRyCUAgDpE+EKAUrfDUqfyHCehOSMrLmjnTmsppHCr2E7TzmbaGqAYyrDkfZXnOEGSjwMDjzZEWoSEKDaHCFcxVB0BqbYhYRszq6I4cnEgohEb670wvyJLY4CE27VG3jAIYF4z6HCaClPDCmfEnEYByAwCfGXrsCqIAASLwVwjBjeNxcxzqCxHBMpy+cp7Orx7pNxHxKphu6pmpt4ASnQ6u+pugvARQxp-Qz85Mz4QJBejxjWdpTRxAh4LR1MQlcIRQROyIkOVQ8Fx83ADM3Q6gnQ+ujR84VuhhhJ5RaF+OGFpluMIhExXh-uAZt+y6Z0iggAyATKD+HtIFnRmdQwAUp7kLm9hGS-H-EcJhVPlmzbJ7EgDCCxVP7RQhGXLhGXmgnsD3m9ookDnECHlUo0qnn0oXlTIFUMDRV-g+idQQAAB0vAKVzKnlLhL+f52JuR1xfIHx3xzgUVvp9FR+G8+JCpoutxnpfFapigGpuMWpwlupYlhpklJpMl5p8lDxgujWQ14prBkp7eTObF3eHFa+xYRCvYRFDKCApF5FNC4EylRhDF8CLBs+bBB1i+LOw28pJek2yAMAxqMAvggapgUAfOVpilB+uB5+zBy80sypqpAlc1QlBQOpolBpElUlppslKo6QfMyIqh+pDQNAwCZQRQupmZ9Q5mShLRjJVQ14WBDwfpIJgZn50UIeeqEZw6i5ie7+HIbxF+WwXp-VPpMV4gxAKAAAUrQG1AAB4WkzF4nOnu64V2aEGPDA6UBtAQ4Py0bUgNA+JBI4j0nUwSC-6WYgwsSgLm6oXElVHECZLAQTwPXtbGHPVMVt6M4fXLFfWcVnXCmS0wBUVhD7FTZaLZq8DqIOCi1cXQ08VKmmU6HqAMz1CnhVBYgcw4SKA8xpLdCKBKFwg6CRJMby3FgKVbWQ1WFuX7kkA9klpPbvnZUirImJ4rnJ5pGErEpTms29jbFtmtUGQqzSDKhYmM1dW8IfH+DDjcA2hjGrAl0u1PUjVK2n7W1IG4522ZLyZE65KUmGSy53iHygTUgeJUy-6gR02E3GW7g3wF0sl9mJif72bO2PBl1F5kFQ15HPUfFFCy7IgDCaD67uJiFZJ1ANFpI-2qDPyqAagVATx+nCmqKyjnJhDl4hiZBZg+gwCUTnBwAcRj4MEK34EvV07MVSmHVL7HW95rHq27zjz67DFCVkw6YNB1CaBAQIB4ibhqktHg7-zQMq3WUAhEnIEb2YVimK3EAuljUC2ensT4Pz1+41JxVWgQl9I7ZLkVVd2R7N3QkAzuES7CJnXWE4kb49VKmS3S3kBy04mOlL2SPK0oVr2VHoX2124TwAVqR6nkbwGUxATcB4gmWqC6CkYg7VByZRISDQOuIakCP4W4lEPu3z6sUUMrE8G+oUHEBA2CwyproTZxAEMf3dUSQTWbDEB42qAE2VBUjUAk3cBk3q4U1pJaW8O0300bXg3l22kGNiZ+0ZOqK6IsRsTTBnDbIgyg07XjWJMsXSkpM+2nV-b80w1KnlOVNE01P631NjF1BNPU0ExAR02UAKP3EdNv1SKEPGKmFj3vHmNS2y1HOOZuU4nCPr0uOE6O74yk4eNKA-0pl1MDGuKK6cx4hMZmbXzCHaDjFn1BNFCxNP3dMD5lE20iOvN27G4qU7i-6gUskZnmbG33j1Dy6Ubq7GluJbjKAswg6wtq22qL2YYOOuWCOiimMoaC0WN3PtNRqdNnOFOKmC0rPIhVPE0bPk3bNU0tP7NtMTMJMSmkPvVLGylUPpPKL9PMQmTDMgCjOjIM04EL3DUmIyse2LGcHsWKs-UkADMmSfH-EQA7HAB0Ux2f3FMfH8uE3VO1ObONNis00SuHMcsC6nPiOu7L2lGV2Mt83cVmOsu3NWP3O062W20ou+IO4gK70qHGmYhZ1a6SUq7aDUB0DKBqaK4xnaAmXjEg5drIUP1WXi7zaS6PV6uXMOtFMVKelkJWMYChCpz+A2h5ug1M2PNfONCkY8xw4cmESgTdAK5nhhKUxyFyatCTslBdBUtPPxvIv2WuP278adBKu9gHEgBQCjyMQUpPBl77uHuHCBvMFTNkNe0KurHSNLOC2bjABZ3EjAJGWjtkiqDAJPjcD0kamkZlsameKxuv2kFCTP00sNujVXMyOlNtsy0dvUBds9vJqxv+kQr5SxGEqvJAm4bPzkaYgnhUwkgKE4jKEFt4jaAdG8D6WdDUCtEruOOW7ruoFgNGZ1G7sKAULviV6Pr0K0ST3mCHFnCCfhBSvXsGtJMzOfXcHfW1acsBtwdPuekvtvvwhpLQHtDfu-tqYAfgvAebh8CAlQfhux2RvTFGNuUR5wo6N3Y2Ou12P+ByAXIFM2VIsvMbuAToHknuMqX9CnhJ1MxhPwHqHaXVCHztD6Uql1Aag3wIH8l4VWfrEZMS3bGwAoDgCACYBLAAAOL4PLIwAABipgwAc9knjFVoklfQ+mmgoF1AZGKO-iHDetZmriT4fAjXMZYNSnEHAA6mogceorVVdXIBg11Fbo+3HTW9gXWwsxG065fkQOBzaZI5QIrKCPQiN4rCRWABN-AFN2a8QOlx9Fl2ALlzAAV2rEV6V+V1e1VyQHUyzP0AiNUHm014xurveIru10zDNbwCS7vc-BiC5N0PULuORg0CoamT4vSXUzUMoL0IbleC5Yi049JpjIBP4kTixkl6rSY4s-kSl4oxtqlY5EOSdpox+WCfZ4-iNMnHFkkSqrzSQLY5jC5+cpe5BHYNJPEK5A4MWmTy8aXZtcp4mIcOcvQn6HnbnbHCpRqMoCjlzAZt0BjlTCWy1xqAoZA+eFAQXbUBZal8otl2oIceAPMrRADVa+ME1UDKPrwCAAIEICIFUlXY3fHtLhpFpDpAVZ7I8lTyOeFYFNHl8q-scq3dh+OHtuWdzXOb75HzFLttT8HzmEzwlllSzbTyOu1MFUNLOGZwRRk6b0lPu2AJb-9WRaQLb+56TyTw8A79QE7zAIIMIGIBLyEdL2mGTAApaX12t0wAyiFZQDAAADrEBHE7luSCcwBTb-GXC0TqDj8wBssxtfMgxq-y4YEMyg5UzI7NAzUAeEu8CUC3geJ5CF-nUKAl-m-l+rKV828R0AAUwABsx6fALwAAlCqI3836367wy3wqrchcQMSXtIC765IdAt4L5luCASWY4QeIbGNwHky50r4GOZQP4jJhI8cQBQAYLUCdrwtPC3+BQBwzCQ6ZCaQCIysmV3D4gkeiuXAS5FqAJA2g+lXrv6wg4qgwBEA7oOS2gGJhf+zvNvm7zDZF8TelEP4pYHeBkUkq8-EQDACf7+AX0IYYECAG-6F9cMOuJASyWi6y56gAwbQqJWKDVACQlmQ8Mxh8SfNFO7AtbpwM772gXkRmGoDdn4G5Am+gggAdZxEFX8SA2XcQTQikHfELAM2WiKnBgAAA5EAEgAYTkJmA2rebvqGFJP88uIAGVO8AgCf8q+CQeZGAAADkMAAAPIg1aEbEK1iABEDjNAB9fIgAIJb4u92+xYLgfYKJQeJe+VgvfDYK+Y0ctKkAvcAiCUIgEhg+IMmpUBvhAJtCLJFoufDiGGMiCJzDgaALsHsA06emIoOvztyTt9KiubGCDCpgng6AepBAWiAKCtBJKhuNXo0G44kAn+cgRWAbF4Df97ergv-nUOEGzddI9pGABoMpDswEchtICFTEib7DMKWuNmHwHJjMkaAhzPvtYJAEd8peTQ0ytrjhCPDHe7g+oaG3wreDiA1w0bncOoBqDCBIvIMI0IcHZtnBovOYR0IC53hrwrQZoP912FSBjBMOe+JJUCY0dhgaLXpsok2CUB4gnUcYIcG2BxoB2Lg1EbULb7qDd46mGar4n+EtBkBGvHQrQCV6cc2gmmc2kAnUBKFbB8Iv0AAnJGzD++gkCgGrHECZJgEJbDoFUHYCBCVgeDLRDAFlDxAQw4gKirxBOLSBxAeXMIKCDACXFXhVQmADUP-7oiEhGTTYNMFlBPBEA5XdgOxDKG0QAAmjgxECkBKAwoy-sKU2DehVga5JAGAGFEoi3BEojwRiKDHADtqcI8AQiOgJJ1DRNDarrmxjL9EQcv+XYfxiYzmIFcrXamDrmPA0gpRCgfTIyXmqyj-Ek7WXPiA0D4wyYJlGauiBUKVsKRxoqsQ0MWGuwyQ4cMUSWNDGBjqWQpCMbmPzGFjphPTcsQeODFPC0Rx3HMT6D9TcAixKlKAhqQfjCFqgiZWHKBWgoQE6aQEdol0HUAJA0exIjcQ0WxCkZoR7Qp4oSI+GViK6jwEkU2AaKdB5exAkgHpiZhuIIup4AYFTCCaK4Wg2orXKoH0zYwqQP9FQJcOIB3jzqj4n-teNLFhir8xjQviGJeGVDLxWI2iQ+KfHoTiACojxOD1RAm1jaRlVEDoXTK31yWx4WXDfTYHQS1xoEvUTAFUBMZbwl-YxsWOeFCDyCPI2yLxN3qYTOgsoppoMIGAbhqgIOPcGmzqAakyajQGFgsJUkqBOYDY45quIUCv8shZ4hFsb17A8SKI9E7STeM4k08XkweVRp5jDxBYLspZOtB7wfyBVksufD6PFLAwYkKoryVnvBJmQy0qqEyF4LqJrFxgkelGdyUQFwwgQNSxwlHJFwZga9Bgxg6mBmThBBNz48BBXIRF8lECjRMIyRIABwCWUDa1dCHBAAuATVgv4LAZySVKbDktGMaEoMLhjaBqBLM6IFURCwnFKFgW4SSmKZVAq0klCLEbStRIABU50i6adIYnii9xnEy1LWxmHhjlEIcYrmMXOLRjyKIQE8pgGK78joAgo2vg30Ym3TPBQAsXvMOrFd8-EXMf5CpRJAuRaSNA1rnuAGCCBqgBdQicUEowYDck2uAgQ9PPF19Lx7E3SdyN7AhwtibSMsgmNmxfMdcKpFQmr0i5IgqYadKjMxn8Qo5dwLRVyQSBGAzSu+ZgCwFBOtIwSCZfkxsdUQ8SERoCwCXoCoSpiWZ6SUSNSRyRpgsQMcGpGpiBJfrgyqR64lSdqPHh8DiwJMssUTPgYZN-QsoeqmQnqo9SiRT03sNbNtn2yQpTE-cX60UkISHgSE6HKRhNlLTd4QENmHbkPjYIgmpHFmcMGAAQ9CMQCO8O4hVL5khxakG+MEhWkqjfEChOQnQGMr1E2gudeXNpDxiAyVueswSN5NiECymhFINQJpndkgyLxls5RC7LtkOyPhWItuW7LCmN0MsKjFNOoxil+84pmUiPuFLTBJTY+1UBIhXASzTzWoqU1LBlPD43JWeZs5iUQD9kqAPEi03WZSMEjUB8pFAYAEVLplk0uZhw8Hoxypg+JgmJ8fENJWJDMZtKLGLMVbKdGuyRRy4WzpPPiKJF55VgIEjUJtCGwa5foCkK0JXH9SVMx8wqRUP4lAQgmTMYibiB4EAi3uMZHGLATfF5tDcxpbVrzz2CIYVg78HsPkA0RyBtEiwRwPMmXBlMDRt4ECCiHIlBMPQDCxhcUB0wgReB89PWDIDkDq54giQdgDalyncsr8fIdgELNSAihiAj3GRYGGWmCAiMm4UCNzDVKkcFZ14ICdUGpB504yCIdorSBIUMpaA5ChQN23qowAJaiYhgJlRFA7QrFNiuxeETsARR5FbtaXLzFkmUxjhriBoBSC2HQNgWZINQATGNJ8woRdgZabGUIj3h9aSZAYPpRVGNdQImZSJEdNzJeJFgj9dyo7IeAABHTwOynGAKA1ycAUEKQBfCpIhwFwJIIosTAlooA2xBQPkMpSnyJ8+43+f3Kj66MgSKYSUH4H8A1LiSI+NZLDRNg7p8mCAURTujGBSLkgws+ZfYArlKSfI6QRwCADEL9D0lKQltqUwCCxt+J0Ml9AW3pL51-EZITjhgMPDXw3EPMDxHpkzKWCNlooBRSkGziPAdoQNX4DKm7YYx7WbyjkKQvMUWh8gvyv8P8owB-grgO6N5CtwxiAUH5-GCktLD0b-kKss9CWK5xny0ljSmmCgSBD8VYTqgMPaoEnV6A1NcQ806iZ8G+DzIZMXzFQsyQaK79kBVotXj4hVF5ktcDMX-OiBjk6ZhIMK7YjPjwElsKQp8HTPUFwFyZvuuitSR4kgYgwTwNAF8H+CQ4fwgQS4NZACVEUwBkUwIBgKmGIDsR0A00mAP8XOSeBMgKmc5Pmm0jxZeACgNkUoSCYbhNCKoJgOVxxXnIFA5FG1smj-BkVv4Ck0WesrpBIqAIKK+olbWLDGRyAsym-HpB3TyLYxqwDMpOCSAgQvlog3sDjQeBT8tuAa78OUKZVpri16AGfPCAxCMc9Mei2oIEvDlw59cKogCQjxKAcwjePkfuDsVIKKB0VBfWCQhlqVnB6l0iz5YmGKVCAXgZSkgBUqqUqhmlrSkgO0u+CdLqlQMQZfOsyYjL9x1dZUo4FACGhU4AAPgAAkoObQFeP9HJVM+XYHdD0mPUgBT1-PFYPwiWAvB7QRXekCevRRIBmAUAVOMxBu6IAWQw-NMDevtHOAM+O6OpOisT5vlMSGKzqqOrGVqwfQkyyKNMuEVzLgVZ0SFecQBWwrC1c8UFRYpIBEboVgKsjQ8FwynLXEmbS5YEpuX6U9wN9R5TOxeV0b0gHy5ZQRuIBbKdlwELNbwA+JHKgS0C72e-WBXShJ1FgeFXmt-jIr-hqK+6h+lZ5Yj6VPwCtWhmxWyBzkNajcHbjjK4xauLDF9CqNlx0BNaEgdTCW3B6yTd6ZQSkAgGgKVBXEdNZMi1I4YsQMyB4FiNrg0C4holJWUVRAHFWERJVedaHiUAaDjxdSoI6+Exjal5tIW6qqGJqstw6qRAPofVRwGtX8Q7VMyB1eICNVOhTV5qqAOGSBhOrQALq2TfvM8lNaHgPqugoZukClrnI6kIKKnGmDBAUG9EPNmrG4jRqsYamuNSqETXJr8lhM+sFiLo1VrTVXaZfgADU5Agouja+BWBbcZ8jQclsO10A7hgCuAzXPZs3ADE+hPMaHLoQ6J0a+1LwG0umDTUz1yuWa2yDmsHWab9G4s3qU7I+BfBdNZctngZtxXN5XwJmw+IRHM1XxagVmpXrwAGLBIHNquZzVDFSTjKsN1wLWDuhU0xqgIdGmZfhuBXiLeNTwRZY0sE3CapVomhrQcr3XHKg5w46AmcuY1yErl0OVMuxvuVbhGM3GjAeTs8XFMGlU6gjT8sZXEaYVRsPNSCrMWUbMmkumjaRvhVDqciiYHdcMu-lbAsOSfHDhVSBIrqjQxAfrt+u4CIACxoy5AnUvdAKbZFxYGdaUvKW7aulIicbYBCcmJgZtwvebavDe2ZqUwX2vNYtrzWPaB1ea5bYBSX4dgNtd67bctv22uIoCkSrXBiHoFDBlZPiYBChH1w0wOR92tXZiuk0RqfZRAdrX6q60kByKPWkAH1oG2hBcGw28ZSqHq37LS9qoCLTPl0Io0NASOjSnLO+Eqi4cYBc8C+x8TIJlxsEbLdqqRR6qYqBqorbat7AhEPQFWk1QoGq21aFe2hHEFgO+6E0BgtiMYpxy0oIDtCQCQERpr+0FLEVf4VTb5y0D+cE1H-WbbjreXprZ6H2n0EkFWGVqXd78CHQduT0EhU9p2jPfiCz1XbtRN2-PfmQj3-6o962zbVup3Rh7ntwe4UgzStQ5S1l7e8vZ1rt11a4g9O11SW3dVGZhCwhXeioDJA4gXIaSbGMbQox5zH4juUCjpjGIUh9KT+v7J3oh0SregsWvoPFrlVJbFVqWlVRlpB3yKj5OW2fflvn2Fac0S++1dIFX3ABjVVWi1fmixGndMuOXfLoVx+llcKuWK31Z1ppxU7YImOzDYyh90Sz6w+OibQ-p0BE68NN++sEJvmAiatAYmhnZJtl0U6pgSy+3aTtwOtafIpisheCr8BK66I0uoFcCol1-L4jtGwI8Lv1YhHAjDG1nUxouUc7WN3Ou5Zxv53PLBdqun7UXqDCa6qN+6zib-M959KHOQMR3XOud2VLXdq8MdecFt1ZGmlEWtpR0qTXVLC+3ulNb7qDBoHWQL29-Qnoh1qAu05mQ3LsPlV5l6ST4TRQeHpgtAOipIcQjukj1V6UAdGkPa9ozVf7QacYbQIXrQ2l13d2MFw57uL0Q0IjMAfA65wUAS1qAHUSwIGpB2t7GtxAN1QeAoMaAnjzOkgP4uPDCF6S6bAJr-l1Ly5NAdubAU+A6J8A4DCGaffEFy1z6JESQM0IvoljL6ythqjQ5Vo33aHkoiGPgy9QENSq4tsqxLQqroBKq0tqq3-CDqxH0QQQTEQZuomYRmGOt4Ol6iDFyRBMDBQwRyijVUL4hBAiuJQnnUa4Pgf2FQK3bjix12HxjDhu43foJ2PG3DCQEnW8uSNQrUjpG9I-MHl0xGqNcRkjTLpWU5H5ceRtEAUeuVFGONDy0o5O0F3pH+NoR9-TTrzp079lEmkZVJr6kyaJFby+TSEYqP1hUNo9DXZOCGW1HtdOoXXchqynWAgSrRiYO0aXUY7iSNu0XQJqDBG7BjG64Yx3PQ3IFNT2Gx9W8qcPY9H9WgLkRsuJ0eHnjXLcnbGasOBnvDtO3w-TrDNM6AQ-p7I7Q1yPnLXTQDd0yqJ50lGnlPp15cCqiNgqswEKu0wkfJ2mmpdtG+M8WETNEAW5vYPQyv3AA5DDDN3Yw-d3yRg6jNEOsU4eDtyVApTWICppTGvBjFXTSpu8GoFBz30ujxZ8db0f7NFKSlbRhdS7pQPFgKza6oY50eqMpnd1Ryz2SLJePRm2tD5yvSd2+OfxQ1tMr3S-vsP-aFtmB+Ax0dNWfFjjeanbVRZnwLGzMCIN86DnkxrHoKmxx+ExnaJUqKQD2soU9umN0X-dFxpIHCBuNJndTvYFs7j3bOSziAJ4E8FeA6LXxX2mgNBZUEiYFtcY58CoFSARBaV9j4W9AGKv4PRbBD0qkQ8yY4biHlV6WtVdIexPUBcTCh-EwarX1aGatHoIkyVqiAOrkoAJ3sJJTJo8DQcKNHeppNFHFgajjOos9btAulmAzEF2dfmegsdHYL5ZgYwharNIXCiOFyw2Lv8mfGMuF5i7tedoi3nTDwFus7YYbOpq3lnZj4avCDO7K-Do5iM-qEp1FWmz4RrC5EatPRHNzsRlI-acSMmmDI1G80w6cE0TnHTU550zOZY3znblnpvncuZ41Ka8d9x8o5UduNBggrpB0K2oEc36Zd6kSQQATHHj3KklxpHCCDjzkYCbwDMSJBcO4S0n8g9JoQzKoS3yrbLrJiQw5c5MarZDM+3VW5YWAL7lDxJ1Q+oc0OUnvLyUMY3Np1NBhDjXxMtcGpB1CN-9Nak7fWqowkh2LQSnlW2vlxk1O1YPHtfWCmOcIZjGy04+-tEuB6Xkklk8ywh2urnHg7x-1X0e7MBtC+4iqudNMTA83cLXxn44RZB3I239Gyj-e9pZsYpQ9gl8PX-oYvzGNwzF5Y-plWNeJ1jO+tENxZ2N8XjLGy9GzRZOOYG2bMAeSjtfkxZICYINrVTifkMwr3LHATywjctW+WSTah6k0bqi3Q5LLjJ362IYBv2WOTmWuGdDgsy3hKajQBMr4jxBqlJKKog3Ari7RBNyYXNh4IdZIAhWkeMk1oknXVOcZ6zOOqZU2btu4x8Y8l+sE1ZWV9merGysnekdashnxNSpAIysoyOFWyzbyp02zvyNzmudC54o16Y2u+me7g1jc5jCmtjXdzk17cwebeXHn5sWmhBmXh5M6JVWbETiAYgKtPmWiL5yU2qQ-OynvzCp7SrzBVOAXl12V4gOusKl5XRQ3RidXzaDB5nd1i6l+1uhQta7PZqc1SkiCMpDAwKEOESeeEph6kWZr7LxMBXlx8xTbc8D68OIssMnhDTJv68lrZOSHHLTtuQ+DbduQ2Pb5J9fSQE30+XobfllfYFeINt7Xj4ig4mQjy6eg3+nAKOviaBhi3Pj+F342Go5t6mJt9t2u9NpIvamyLfu844rf-gYGMmS2hA0cfj242NbixliysfYt63OLADI27xb2MCX+1z262ziTftgXm7RAL+wWd-uGqH7T9zdSqFitoW7p8VjU3VYrs4bGr7h5q7BHbvDnQzXd8M4Eabv92W7fVoXTPYV3z2dzgRvc8rpmtrm5rBGwey6eWuj3VrvOrjWUdJxbWq7QjwCCI4ZJYH176usmQoH6YMQ+TJkfe-efMMin8gz5iU2+bPsBIL78p38zfYAtqnC+edoEzR0Lt4x6OOsplqg5IBfWrLWDsOylojtSGCHYNvLcQ5YAeWyHXl729Q99v5plpQTAkCoGAQhKUZB4fYUiE0DMwRxHKmjmnTG15PsYBTx20DBluV25bzN7NQ4LkfKJlH6tl6kxaWOsXdbTMjYzo+2N6Px2CjqiwGtouoGVbRjva1JcmM4XeHktv4+GswtHyCpp8hBReMzPJ9UoOZ7df-bTNAx4Lj9xC5lYBCmOkrLRyC2leIA-2SX0CQ+6KePuNOqYzTmU1+baeKmOnqpoC8VZIRb2KnFrPe4KZquuOJl7jxsx2a8c93fHeyzu4LW7sEbgnyVnyK3ensUabTiu0a9E5WWxPpr41x514o-sD2FrQ92c5zrY3j31rAu7J6veU3V2HbhMRDbLeVd2uyShp5Nd44UsvW9MaJ3GHjCPBql5MPMHWtzHHgJBNeAPIXYk6SNL2UjlBVztwE0jhO1Xw1207G-jeJvAjKrhV91ZCeRHpX7VpUnlwOKmdV7xjwW31YACKIARUIcm4dwvDXDwe5x47NuKPxa7gF4FyjIQv73ne2+Y0ZjVL3xA3lmLEMyUo7ubr4COQd2pmRBI8DHQlum684LUiXpHzzolNcehfs23dVznGPa5FWmXIt5loOxg5+uiGWTUz9kzM6y2g2XbRDgrWaE9sUPtD4gH27DeSgEcagGoTcPUG-NvmSW+IGMu0HMwqFOgTyq6y3voeNbwL6QGw6K513LJ3Diip9Nu5udYogFx3cp7yYFdMJ5BaifKdKnOJqJOH4AB4UKYr192lXNj-d5Wefu0uLHlL7+zBdLvHASzhB5M7ZFTNQfgSjzQvjw4bflyD5ka2-TJeufmaOg-M4iw4Ff0POfIjNuW3Mc+ea3vnGj-Wn84NtbGeLux4F5RZLVKPlbhj4S2cc-2K2Cgxjq987Zcuu273ZJ+G4++8vPu1nr7k5DSf3eB2Yt4z0O2e9wdA2o7-EhHpmUECnx4CGlliHuHc2kYOgPiakCWzhyERJ9jwHpwXbCs0w06PB0lxhtg8SOuzDwBu9TsHPBm-Hsrz0kW7HMeSYFrxs6jm4o-FgdXcb85Am9dJy2In6rqa7V+kD1fyduGL10MFi23hckDQAN0MODdIhc2Jz5jCZz9MGuZFOTlu3a-aA7tTPpHiw83k49YiyEbEMIRaBgBpD77VHnKzR5VCWP0rhZ2s7jhY98euPIg2K8V8L4yHzPrlhZwSatUOfStah8rcs69v5pnPLSg93SfQffXrL2Duyxe-wdfNEQxwupnuCkDmJqYvQb7i0VoF004C2kGoP8Yg-BW+nSX2oDuwws9m63tT3m5x+bMieWYYnsR5J9ItZe+NK7z7X6Dovyf8gXz9Rzrc0eqeuLgLzT-xe0-oAwXc71WzGatsbubbBL2x8S6Y90AzvnHw79S8Y+4v2Pu64r+hZO9l23HFPj1-B6NOU+HgNX9Nw14GvJu57cR1r+18nMKAuvPr3r-65EpBu9wIbkb+G-G892o3A57ZUOZlcM7ivnV0r1Gd7OVfpvzr5D6J-m+C-TzPHdb5t+281PhTj5lglN5HXFhePhPsJ3c-Eco3JHU+6n9-tp87pZPkR+nwoEZ-a22LKn-W2z408m2QXOnr4uC7eW024wC36S6pvtuuvQfmtLtPrnUVqUQC8uLOSZXVzDskedTXJCXfesufD3bnkO6e-+vnu8HwNsz4Q-mdWeX3L3uGxSds9b7iwCXjH9hL6D-wxfHJFX5l7V85fo30UH0R0f9FJvrTKb4gGf79EBj5rpvtqQiFlxEhjhfi7QlpjzaWiamrQd9mphYiRuk3uY6eG+biObmMopJ778eLWv1YLavvja7bWAflaJoqwfpH5key3sAGreYflmBbeertB4gWPRuS7Tq9HlY60elHt97Ue9jor4xWeLuLSikhfFd7Fu3Smqh084UCY74B79pL7EBR3tY6EudjtWbHceXKYB-EEwNIKQAp8lwAvoB9vj7SA+2qZiRI8PsrygQpNMoS5swCPZoswL6O0Q0capi47K+GXqn6a+RPju4iE8ahK4a+avlAFleMARV7BGnHi1Z5ebVmAGC0HvhN4i6rHkk6SEBwt16ogFvv15W+JQDb7DeYbn4gRulpvr5bmabnV4Zu2rjG5mmRvom6HmQYGvZAkm-sdaa2XMMg70awcufBlSGAmiBhIcsoMCICebKiAkgT4EP6qBGIHu7fernsHaYOHnlP5eekdk5bXuFnre6KG97u95r+VDjaow2y-slBGB9tiYFk+Saqr7Z+FFhC76eC7mra9uL1AzBQELkJhDaEmigwL7C5LJTCCA1MI-Cx2RihX7c+JAAGjMQ56DKgW2y7kZ6ruJnigFtCJeq8a8exANvY2KfDlLY1mDfgTpDBx8CMFSezbjJ4TBNfpC4Gesxio4KeajoX6-OJfgC5l++jlz7UW1fo84Z+lxkSj1+kZjcEwBCfsAGpBhdoEhdopgSg6j+v3ke7-eEzp56A2zQbM43uC-h0HWeq-mapPuT3r0E0OAVicjLSr7LeAK4E4nOKkcJPuSwG4GZI1wciEOMXI4hV+MKSCBwgSULgAyKKsjnoUgVH4yB6Abm77A6XtjpjBnjuYGquV-pjC3+zAPf7i6BkFqEX+rgZkZ2BEJgJJP+yOGriUgHMGITng+IF-64wP-jLgF0GoPzBSuDgR3YM6KABAFBOlXpm5hOfvo4Z2uIhCMBIhjwLFaeh6ZpeK8BovhS6pWDHhlZ7+EvikA3eAdmP51BJ7jZY4OJIZe5YmrQfd6L+z3v5aveVIeQ40hiNkyG7wkTJZgo45mGiCASzLjQBDCfMEcI7y4xJrSYs4Hs6rZgnHmiEKhWbkGBNu4rpEZPONPq7DQhPPnT5AhDPop5M+Rfhxb-Ohtuz7l+envO7KQ4wfI4hhUagH4YEnPv0a7eRLrlakBUvjS4JhiVu4HNysvj6CpmkjL4B7+5diqEzeW4ViCZBxYMf7O+Phm74fEsoDeGQBqygJ7t6NgV2HAB1XgZCygGXIgAUA3Lu-pNe1-qBHQA4EUmodeu8DoC8wUgEWzJeDQOqLTiFIFdplAoONAxak2NlPpABimqW5XB+mtIHke6HmXj9cCAGxBSCsoMqCF8R4TL5K+zHqeHneUYQeERhmLvrpDoOLmx6Xhu6gAAaopLoHHAd4Yf64aaobqHRQaiCcRughEeRoahFCvJEbwJvlSRMwB4CeAjuKNA0AF0-iBjjHCVMDCZBaTMB4iQR+rm4Hne9gS775eH4UqQiRJXpYHe+3obYFJh8Abk7Ce2MEU7YErPICD0u9Toy6vmzLtKafmcpj+Ycuypp04WRh4sqx8umHrvZMIu9D4hrGVIFrjg+IBEmzMwj8IrIeIm4KBTDAptGSFtBFIe7adBNnqWGrO9Ies7+2IzsQBjOE-hmFA+M-j54HWaPkdb9OSdBoBDOZevW54WCLgI7XByLnApounwfeG9q-wdMGAhHzlOEghPziz7ghC4ZCFaeBxq26nBa4W85nBCtqu7-wG4UJ6N+LwTy4qI8UTvb8mdeE-yMAIMMMYcO87iR7kRsoTPgNOwUe+YtObLhFHX2UUVy63hB-gYFq+gwW67GmctqAH+OgtI5E-hirr6F-h5Xn9jhBCgHJFnAakTEGyRqkYpHvKxEVV4mhHmlpHw+pIEeD6Rj8kZGoC2kOZhmRsSIkHYGJTshZy+CgI5FUBn9lwHS+8YWJHi+7EZx6cR+3oI5eR4ns-rk+kkUzbwhSQLI4bRS7qtGguunjMHVqqjlrbzRxfto5LRxtlCGTBK4WmCOu0np4bDhmfkSjLhpBM2CixlfutHTRswbNHSxynnOFqeujhz7PhR0T5Ej0m7vzYQcKLifJnyotn1ES2BFoi5EGnYR1E8CQCNTBkwu9LLhp0p4EfCMYOUdiB1SbQDNS4w6du4g7gt4PrjVBZlviHj+9QZP6Zh0ziD45hd3pZ6UhD7pVE9BxWjVGJmOrApYG4ZQAbjKi7MNzCn06zCDjqYrptoK7yRstTYcgdUfpE-cNQFrixydAAwLAsmIDZJZ094GxZlAXMMVF5hlIUv6FhK-iWGUOdDl7G3BfUQ8Fux-DkRb2x+svnh22Y0XzFwh5wSOG6x-PvI5jh4sYbGSxwISbHM+ssfOHqeCsStF-BUwXX5kR1huwFmOCocxFMxu4eQEkA6rLBrR0oMkGKxWtMaxF0AEkb9FSR7rkjH+oKMZf5DWmMPDEKRiEab6aRP7DjGEQeMe+KGRgLCZEkx7mrEiGhlEa6G2RjgSDGekYMZDHQBPvm5EkRGygipIeXMTbHJBqAUt4vUxQNiDyBmgIoEbMKgeszqB-iloGSUMUfmoKAooaQAiB3xGIFShkgTd5i2+CaU4kAwiaIkSh4gdKHMx+-voFOu9dpK6zW6MepECSSCdpENAqCXpHoJNQJgl00pkTgkCJJWLDEHBUCTE4GQcCYjEEafYTGY+hBCe+EFuoMRAH+hrwcI70JWmlpICRNoMJGiRH8aur7hHMUQGxhJASeEEBZ4S-SbxyfrzGgJ-MbvGax+8Qza-BGyrX6TyEsQAZnxSnhfFmxpfjfE7hbyubbHGe0W8b1uiflDEwBGITwKBImKInE-en1n97ueacc1HeeLQdnHtBZUcWErOBcSob9B5YSQJBMKUf+z2h0BLfJk22MGeDPw6IMEjwyzGIdFpetVmolqxPifk4AxmvpspuhBXu77FuP4c4kM2riTJG9gLXjr7QJs9hEFxBlyTolm+PXn65+BgbgEEdAQQaN6hBjvujHkxxTlUb5WFEYAZyBptOwlk0SgXUxcJagepi8JIVjoF6SvYPInih4iRIEg67MZQFAJJwKzHABb8cd5-xl4gwHeqi8RU7LxzwTj7i8mydc5bxySTvHbRI4XvLkWh8UrF8+LbmLEikYAEcEvoJwbCG5+k4ab7Mw1JEsF4QWINfLq46wQMQUYWxjsHswfiZTHjmuYTnF9Jk8SvpveFUbPFOeKYcnFphAPpM5NB2YfxKtE-strwckTGCgKhu1QNDI8qhECeBKEB2lbH1JTFmnRX6c3I9K527Ua8YEcBdFA40CXMHqS7CHDGSqRMEBkiISmBEtzG8GeIfkDtxx9O0AngSOr3FK4fXsxjXg8IHJgjxQoXxpypvSSQ6EmBYUqn9JH3kjYp+6ibInWBbWpNGrhJ8XknGxBSbOFaOV8RbFLhesfsFV+22hrEIh6gJUn-RpKRDLx+hKbybEpHsYElXhgCbikNGU8mh6RJTutwGkBu0M-GEBcFg-bfxB7Fw5wpk2ElTkI+ytOnSJgKawnApAxKCmcJYTNwlQpmgTCkCJM6WsnKh28f750Jeaq+EnJlCRjHNaVgULrAxhXqUykJD-hpE6EyCTpFoJBkSYnGRZidgnmRgAVZHGh3yvYm2J6oTAkqRCMX8A-Jvkb9pOpExuekiul6VSnXpB0bemaJJ-r2CwRM9BBFXJCuvhnwRliSaHIR3XDsI3aVyphGVhR9BZjmYRmC5C3gqMXxraJbia74eJnpF+FORQRoBEKha8WLKC+iGX8nChfTCdGVOgrjKFoBDLuKbPRLLmFGX27Tp9F32MYZOmMxOKU-EJWsSRxEi+XEReFBJcoDeF0xT6S5Eux0gfC7uxg0eSneRiSaMFXp9KZtG5JjFtOGghC0XLHXxQLqUnMp+sVyk02ZaTkkpJNKWknruCZqzyxKeEAeB4SVmBpZ6YvRNAzyY-jBfTQMXBmpJjx8qVml5p3QfZ7VRjniP41BqYce5apxIRnGz+iYHakDO3USsmbhN6RJ72ZGGf5n3xgWXJ48pkJknpHaoBunrnakBjnowGd2piZlJrbqtox6yBpbYMpQWQHqruVQJUk9hFHsclEAdqcgobg4Jp67GcMpvSRI8mnDwJtAYTEnSpKd4DSIVAHRCtnvKGaaVGZZiqaSZ5xqqfllJxrSQSHtJTUeHbA+ZWfdEyZc6UdEIpogZKHIpMAKELvA2XKdGsQ30esnfBGidJFrm0EQb6auaRhAkjWZpgvZ4J8oY+lZBLOotbs6I9ua5rWmTiuYvpeyfZFyugTisrzZsAQ+neJG8ch7SpomQ8w-yzAXlSJ4AyjQFOODugzHHhKiYmEKhqKdWY88dsSTCGasQMIoEmIoMckQxcik76fCFYSorMYBdG8maKuINorJabJAYqWp7MCdnrmCus4q2KIgPYq0gTilADWKWuS5ZuKyGer7gJpmSiG0gouQCCvp-hkTkKWjGktZumaTouYT2VriYri5OrojlyKUOTcn7mKuibkbkA4DxxCA7nE6COAfLCzDGSXMMeDsKt4MeA8CnQIIbz0jjnUY05lhP5ZgEguWeE7QlCloiMRAMPgD4AqcKELTAJxH2rZgnwIrDymS4C3z2iWxLawHEiAHIB7ISHggD1UheVwBKgMAK4DOizwNACUQfIESLTAaiBNj+A+QmELYAMADkKzA74JeZ7AeXBNj5CvgNMCT5OQnlygWOQnsD5CE2J8RUUaiP4CUQE2NGI4AOQvkJ1QNEHBEIAm+XYC75+Qu8ATYq+Z8SugSSNflEAu+bKBH5nxJRAj5j+U5BjEisK-kFClEHvkP5J+fkLic1+VYALIheTcJhA1ed3kSAveY4CwAMEMTBwRrnNAVEAeUOgXnImBYaqkKG3qpA4F0gHgWxmPeQyjIFA+aEB4F76s5CucEAAAD87AK4Bc4H6q5w7ANBVZRMFCBXPyLgHBXYCCw6+T0aMFzBRIC8F38EIUXA-BUQDLAawCIUIF-XCsCrA0hTACB5YQNwXEFeBTMoaFNoBgV2AUgIZpoAf4DoXAguBXYBGARhdQAmFehWnk2OphdID8FUBTAWjc8BSwUyIdBechEiaBboVmFsUdmCaFdgKijIFohUgXQAKhUhJmArgGEB4Fb8OIUiA3ANwWWFpBU5jEFjhdAX4AsBXACuFYhVbheFxgAEV+F7AAUXwefeamDkFpRSoW-wiRRjCkFGavIVuFAvOwXbAyRf7ipFzRfgBOFHeZEU8FVuJIUMAVBbRAwQsoOxAHEvgGQir5LpIAX+A7wGEJ5c7EGoir5nPG5yAFaiCvkn5qxYAUHEi+bKAoAnxLKAH5q+Y3m16LkF9CAF+QsAU-5YBRAW2AnRZkXZFcRdQB9FeRUUU+FJBXYDeCxRUEXQAIRRQVhF7RQsrBGbhbZh9FAxTEUW4DxQkXsASRQIVcFohQ8UqF1+G0XQFnRZ3mMQrgIoVrAAxUSIf57wMflT5zokmqbFwxfMWUQhxZejJogBXsVhClELKA0lj+UBroAV+XsCfE4+Z8Qj5lEPkKP54AC+gMoIAFMXj5tJXiU5Ck9MrBMldgP4C+A7wN-mLFQgX+CAFZCDsUTY7wHlzDFq+RQjm67gEIDyl+xYEAn5FCDazyl5xWoiklepQewUlW+cPlH5q+fkIN6YpUQDb5tJdvnvAnJWAXgaKwN4BnFa2uyW+AE2AsVgFPOC0rxAgBVGITYlEFKVClA2hQUYAgBRW6+AH+d-kmlU+RW6eADKOaXil4+XlzRAnxEmKfE8xYsXgAMgcADjA3klMVJiZCNMBUU4+UmKLF4wP1Sug-ovyWzF+Zecill5ZZWVhC7wJ8TRA0wPkJqI4xSfn+AtZfYrjIwAG8YgA2iIAUAAWh-mTlvgGojsQQpZOVxAeyIbD-EKxZRC+AnoavkAAky0q8AwZb2X9lU+dMDjlzgPyW+AYQigCH5soJ8SUE+Zf2D+6wZYvn+AfpceVnAIgGcWWlxpXSVgFDesGp2lTopuXOl0QCgBH5CpRMWmADgPsTm6LgABUHEaiO8AS0MxZmX5ClpUKVHE5ANsiucIVLaXBlC5dWUn50wP8TjA8pQECdlqpXqXbEEyFqV7AMxZ8TvAnodSXDF0QGQhkVnxKvmfAp8rwD5algM4BUVwAGcUXFoBVPngFDCJAXpFGRS4VvSChUoXPFdhTYWCJ8lb4V8ZohRiWrAoJYEW-FZRYgVaVCJSkWvFaRYXmSVVedJWUQEsESLYFrxXgWNgkRVIBwAyJc4UmVL6E6LCKRIurk2mxBZECPcnlbsmEJ7odqBtF7xYso+Vv4S1ohVllfYUxK+Ra8UdFjlXAXSVXbpJ5Ei2SbZVhAXlQLGTqURXAB2AhxiFX0WRsS8WRVhRUpXSAsVcZXxVzlQ8VqIthkSKdmzogkB2A+Op5UF5dxdJV5MSAKEUoFkQD7mFVrnI1XhGIVd4VFVo0HlXeVMVQCX+FMVS1VSVlVcgCsFHhYVgwQERUqB2VdgDULqQa6cwDvFGxINXRVw1blIhV1+GZUOFgiNNVOVtEO1UPFTxTBBHVEsIQTxMFzL1XnI3VUTyWcgVc5E3BeVc8zOM9lHlXMsfCAFUGV90o-TahOwJEDs8NOLtVPVpVcVXNVcVVkVtVc1bkUwQedodVOYx1WDUfVLxiFW8eeVVuksEeVTB6XpVVbYaY1ilSFXwWo1Wdl4mWaSKBL+IVUQAPuIVRipfed2dDWRAZLuwDahdgDUYhVh3iFVNV41RLns1RAGMaJVSamDUF5XIB9BfFsAD8WlFk+d47EwFukYVt5uQNnB5QKtYyX1UPYNnCDWhBdmBa1GAKUw+5EZrGZP8RtW3nSgyyOijlFlBXyAZCAAD6O1CBZ1UDF9VOcUgF2cLQWC8-uJbX1UPtfQX1UoIAwBP8Fte4W+17AJdUR1rnJ-xwlMdZ4VYAZ6pr7xAgZV3AWBwtYdyNF5yPVTq1KyrLU66KypVLGA-tXnUEaWBa2D+1utUXX4FDKAbXsAltSbXhBP4RNXsAFtcgCq19VNbXfgrtVpUDFTtS7V21-eXyAe1QlYEa2F-tbJDB1wQGHUGglFBLAZCSdVx461FoJ-wrK46Qq4bEgdTnXnUgRkhLb10gPVSNCQTk5gH1XddUjT1odU-xz1WDAvXYAydQaAr1WYJ-x5qa9tgQv10sI-T+18SJfVh1gsFHVI1tmHHU9FtmPfUp1yaCcTp1n6YLC513AIEYF1GZjXXK1HddrVl15dZrUoNGAE-WbyGyvrWV1mDQgBN1ykS3UqV8gpPWD5PdUPVQA-dTADO1vdaUXu1ntSPnj1F3g3UEN59YaC-119RLDz10gIvUP1EsNg1r1BGhvVyaW9Vbhd1fqJm7RqWiEgAr8GMOHW2YjVBjAf1CrqfUSNU9SHWz1PDbfV8NYDY-U9gqjdQnoqRjZZTAloFkaCN1qGZxg26VwNsBcNghaBYANSAFdWgWwDUCWLgTxUvVq+qdZA07JmdY409GsDfA1aVhdeXUBN7DWg1nJbDagDa11deE14NqkFY2m1LDebXkNoQDbX0N9taEAD1WTcPWhAo9V7U111+JPUX1Wjdw2V6x1fw3L1hjevXgx4jeY1BNu9Y3aLKijZ42gWpTOzyAkmTR40SFoFjQ10NvTSIAglI9Uw0TYgRrEVW4xgLI3yNf4G019NPRqUzfVmPICSmNDNuo2NNFwBw0NU5TTfVVN+jYI2GNr9SY3ZwshesAxNndWc07NM9U-xnNXNf6LuNEgGpVgNPjRA1Kw-jcTBnNwTY3YbEXzc03ZuwRrc1KFXdRQ221TzUoUDNg9eC2YlozWPX51oTYg3hNyDbE1YNUTbhkXNcTRaAsNiTYbUENRDTBkkNaTew3d1YLW7UO1tDVC1ktBTWM2pNp9cS1lNNzXs131S9QY2r1dTSfX+4fzQy1X1TLXo0sthzavXHNH6Gs118pTZw27NOjfs38th9Uc0dF6RdLVrpR9a+pP8stZ-zpFQAA diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts new file mode 100644 index 00000000..2e6ee8ef --- /dev/null +++ b/server/api/musicbrainz/index.ts @@ -0,0 +1,569 @@ +import { BaseNodeBrainz } from 'nodebrainz'; +import type { mbArtist, mbRecording, mbReleaseGroup, mbRelease, mbWork} from './interfaces'; +import {mbArtistType, mbReleaseGroupType, mbWorkType} from './interfaces'; + +interface SearchOptions { + query: string; + page?: number; + limit?: number; + keywords?: string; + artistname?: string; + albumname?: string; + recordingname?: string; + tag?: string; +} + +interface ArtistSearchOptions { + query: string; + tag?: string; // (part of) a tag attached to the artist + limit?: number; + offset?: number; +} + +interface RecordingSearchOptions { + query: string; + tag?: string; // (part of) a tag attached to the recording + artistname?: string; // (part of) the name of any of the recording artists + release?: string; // the name of a release that the recording appears on + offset?: number; + limit?: number; +} + +interface ReleaseSearchOptions { + query: string; + artistname?: string; // (part of) the name of any of the release artists + tag?: string; // (part of) a tag attached to the release + limit?: number; + offset?: number; +} + +interface ReleaseGroupSearchOptions { + query: string; + artistname?: string; // (part of) the name of any of the release group artists + tag?: string; // (part of) a tag attached to the release group + limit?: number; + offset?: number; +} + +interface WorkSearchOptions { + query: string; + artist?: string; // (part of) the name of an artist related to the work (e.g. a composer or lyricist) + tag?: string; // (part of) a tag attached to the work + limit?: number; + offset?: number; +} + +interface Tag { + name: string; + count: number; +} + +interface Area { + "sort-name": string + "type-id": string + "iso-3166-1-codes": string[] + type: string + disambiguation: string + name: string + id: string +} + +interface Media { + position: number + "track-count": number + format: string + "format-id": string + title: string +} + +interface ReleaseEvent { + area: Area + date: string +} + +interface RawArtist { + "sort-name": string + disambiguation: string + id: string + name: string + "type-id": string + type: string +} + +interface RawRecording { + length: number + video: boolean + title: string + id: string + disambiguation: string + tags: Tag[] +} + +interface RawReleaseGroup { + tags: Tag[], + "primary-type": string + "secondary-types": string[] + disambiguation: string + "first-release-date": string + "secondary-type-ids": string[] + releases: any[] + "primary-type-id": string + id: string + title: string +} + +interface RawRelease { + barcode: string + tags: Tag[] + disambiguation: string + packaging: string + "packaging-id": string + "release-events": ReleaseEvent[] + title: string + status: string + "text-representation": { + language: string + script: string + } + "status-id": string + "release-group": any + country: string + quality: string + date: string + id: string + media: Media[] +} + +interface RawWork { + disambiguation: string + attributes: any[] + id: string + "type-id": string + languages: string[] + type: string + tags: Tag[] + iswcs: string[] + title: string + language: string +} + +function searchOptionstoArtistSearchOptions(options: SearchOptions): ArtistSearchOptions { + const data : ArtistSearchOptions = { + query: options.query + } + if (options.tag) { + data.tag = options.tag; + } + if (options.limit) { + data.limit = options.limit; + } + else { + data.limit = 25; + } + if (options.page) { + data.offset = (options.page-1)*data.limit; + } + return data; +} + +function searchOptionstoRecordingSearchOptions(options: SearchOptions): RecordingSearchOptions { + const data : RecordingSearchOptions = { + query: options.query + } + if (options.tag) { + data.tag = options.tag; + } + if (options.artistname) { + data.artistname = options.artistname; + } + if (options.albumname) { + data.release = options.albumname; + } + if (options.limit) { + data.limit = options.limit; + } + else { + data.limit = 25; + } + if (options.page) { + data.offset = (options.page-1)*data.limit; + } + return data; +} + +function searchOptionstoReleaseSearchOptions(options: SearchOptions): ReleaseSearchOptions { + const data : ReleaseSearchOptions = { + query: options.query + } + if (options.artistname) { + data.artistname = options.artistname; + } + if (options.tag) { + data.tag = options.tag; + } + if (options.limit) { + data.limit = options.limit; + } + else { + data.limit = 25; + } + if (options.page) { + data.offset = (options.page-1)*data.limit; + } + return data; +} + +function searchOptionstoReleaseGroupSearchOptions(options: SearchOptions): ReleaseGroupSearchOptions { + const data : ReleaseGroupSearchOptions = { + query: options.query + } + if (options.artistname) { + data.artistname = options.artistname; + } + if (options.tag) { + data.tag = options.tag; + } + if (options.limit) { + data.limit = options.limit; + } + else { + data.limit = 25; + } + if (options.page) { + data.offset = (options.page-1)*data.limit; + } + return data; +} + +function searchOptionstoWorkSearchOptions(options: SearchOptions): WorkSearchOptions { + const data : WorkSearchOptions = { + query: options.query + } + if (options.artistname) { + data.artist = options.artistname; + } + if (options.tag) { + data.tag = options.tag; + } + if (options.limit) { + data.limit = options.limit; + } + else { + data.limit = 25; + } + if (options.page) { + data.offset = (options.page-1)*data.limit; + } + return data; +} + +class MusicBrainz extends BaseNodeBrainz { + constructor() { + super({userAgent:'Overseer-with-lidar-support/0.0.1 ( https://github.com/ano0002/overseerr )'}); + } + + public searchMulti = async (search: SearchOptions) => { + try { + const artistSearch = searchOptionstoArtistSearchOptions(search); + const recordingSearch = searchOptionstoRecordingSearchOptions(search); + const releaseGroupSearch = searchOptionstoReleaseGroupSearchOptions(search); + const releaseSearch = searchOptionstoReleaseSearchOptions(search); + const workSearch = searchOptionstoWorkSearchOptions(search); + const artistResults = await this.searchArtists(artistSearch); + const recordingResults = await this.searchRecordings(recordingSearch); + const releaseGroupResults = await this.searchReleaseGroups(releaseGroupSearch); + const releaseResults = await this.searchReleases(releaseSearch); + const workResults = await this.searchWorks(workSearch); + + const combinedResults = { + status: 'ok', + artistResults, + recordingResults, + releaseGroupResults, + releaseResults, + workResults + }; + + return combinedResults; + } catch (e) { + return { + status: 'error', + artistResults: [], + recordingResults: [], + releaseGroupResults: [], + releaseResults: [], + workResults: [] + }; + } + }; + + public searchArtists = async (search: ArtistSearchOptions) => { + try { + const results = await this.search('artist', search); + return results; + } catch (e) { + return []; + } + }; + + public searchRecordings = async (search: RecordingSearchOptions) => { + try { + const results = await this.search('recording', search); + return results; + } catch (e) { + return []; + } + }; + + public searchReleaseGroups = async (search: ReleaseGroupSearchOptions) => { + try { + const results = await this.search('release-group', search); + return results; + } catch (e) { + return []; + } + }; + + public searchReleases = async (search: ReleaseSearchOptions) => { + try { + const results = await this.search('release', search); + return results; + } catch (e) { + return []; + } + }; + + public searchWorks = async (search: WorkSearchOptions) => { + try { + const results = await this.search('work', search); + return results; + } catch (e) { + return []; + } + }; + + public getArtist = async (artistId : string): Promise => { + try { + const rawData = this.artist(artistId, {inc: 'tags+recordings+releases+release-groups+works'}); + const artist : mbArtist = { + id: rawData.id, + name: rawData.name, + sortName: rawData["sort-name"], + type: (rawData.type as mbArtistType) || mbArtistType.OTHER, + recordings: rawData.recordings.map((recording: RawRecording): mbRecording => { + return { + id: recording.id, + artist: [{ + id: rawData.id, + name: rawData.name, + sortName: rawData["sort-name"], + type: (rawData.type as mbArtistType) || mbArtistType.OTHER, + tags: rawData.tags.map((tag: Tag) => tag.name) + }], + title: recording.title, + length: recording.length, + tags: recording.tags.map((tag: Tag) => tag.name), + } + }), + releases: rawData.releases.map((release: RawRelease): mbRelease => { + return { + id: release.id, + artist: [{ + id: rawData.id, + name: rawData.name, + sortName: rawData["sort-name"], + type: (rawData.type as mbArtistType) || mbArtistType.OTHER, + tags: rawData.tags.map((tag: Tag) => tag.name) + }], + title: release.title, + date: new Date(release.date), + tags: release.tags.map((tag: Tag) => tag.name), + } + }), + releaseGroups: rawData["release-groups"].map((releaseGroup: RawReleaseGroup): mbReleaseGroup => { + return { + id: releaseGroup.id, + artist: [{ + id: rawData.id, + name: rawData.name, + sortName: rawData["sort-name"], + type: (rawData.type as mbArtistType) || mbArtistType.OTHER, + tags: rawData.tags.map((tag: Tag) => tag.name) + }], + title: releaseGroup.title, + type: (releaseGroup["primary-type"] as mbReleaseGroupType) || mbReleaseGroupType.OTHER, + firstReleased: new Date(releaseGroup["first-release-date"]), + tags: releaseGroup.tags.map((tag: Tag) => tag.name), + } + }), + works: rawData.works.map((work: RawWork): mbWork => { + return { + id: work.id, + title: work.title, + type: (work.type as mbWorkType) || mbWorkType.OTHER, + artist: [{ + id: rawData.id, + name: rawData.name, + sortName: rawData["sort-name"], + type: (rawData.type as mbArtistType) || mbArtistType.OTHER, + tags: rawData.tags.map((tag: Tag) => tag.name) + }], + tags: work.tags.map((tag: Tag) => tag.name), + } + }), + tags: rawData.tags.map((tag: Tag) => tag.name), + }; + return artist; + } catch (e) { + throw new Error(`[MusicBrainz] Failed to fetch artist details: ${e.message}`); + } + }; + + public getRecording = async (recordingId : string): Promise => { + try { + const rawData = this.recording(recordingId, {inc: 'tags+artists+releases'}); + const recording : mbRecording = { + id: rawData.id, + title: rawData.title, + artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => { + return { + id: artist.artist.id, + name: artist.artist.name, + sortName: artist.artist["sort-name"], + type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER + } + }), + length: rawData.length, + firstReleased: new Date(rawData["first-release-date"]), + tags: rawData.tags.map((tag: Tag) => tag.name), + }; + return recording; + + } catch (e) { + throw new Error(`[MusicBrainz] Failed to fetch recording details: ${e.message}`); + } + }; + + public async getReleaseGroup(releaseGroupId : string): Promise { + try { + const rawData = this.releaseGroup(releaseGroupId, {inc: 'tags+artists+releases'}); + const releaseGroup : mbReleaseGroup = { + id: rawData.id, + title: rawData.title, + artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => { + return { + id: artist.artist.id, + name: artist.artist.name, + sortName: artist.artist["sort-name"], + type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER + } + }), + type: (rawData["primary-type"] as mbReleaseGroupType) || mbReleaseGroupType.OTHER, + firstReleased: new Date(rawData["first-release-date"]), + tags: rawData.tags.map((tag: Tag) => tag.name), + }; + return releaseGroup; + } catch (e) { + throw new Error(`[MusicBrainz] Failed to fetch release group details: ${e.message}`); + } + }; + + public async getRelease(releaseId : string): Promise { + try { + const rawData = this.release(releaseId, {inc: 'tags+artists+recordings'}); + const release : mbRelease = { + id: rawData.id, + title: rawData.title, + artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => { + return { + id: artist.artist.id, + name: artist.artist.name, + sortName: artist.artist["sort-name"], + type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER + } + }), + date: new Date(rawData["release-events"][0].date), + tracks: rawData.media.map((media: { + "track-count": number + title: string + format: string + position: number + "track-offset": number + tracks: { + title: string + position: number + id: string + length: number + recording: { + disambiguation: string + "first-release-date": string + title: string + id: string + length: number + tags: Tag[] + video: boolean + } + number: string + }[]; + "format-id": string + }) => { + return media.tracks.map((track: { + title: string + position: number + id: string + length: number + recording: { + disambiguation: string + "first-release-date": string + title: string + id: string + length: number + tags: Tag[] + video: boolean + } + + number: string + }) => { + return { + id: track.id, + title: track.title, + length: track.recording.length, + tags: track.recording.tags.map((tag: Tag) => tag.name), + } + }) + }).flat(), + tags: rawData.tags.map((tag: Tag) => tag.name), + }; + return release; + } catch (e) { + throw new Error(`[MusicBrainz] Failed to fetch release details: ${e.message}`); + } + }; + + public async getWork(workId : string): Promise { + try { + const rawData = this.work(workId, {inc: 'tags+artist-rels'}); + const work : mbWork = { + id: rawData.id, + title: rawData.title, + type: (rawData.type as mbWorkType) || mbWorkType.OTHER, + artist: rawData.relations.map((relation: {artist: RawArtist}) => { + return { + id: relation.artist.id, + name: relation.artist.name, + sortName: relation.artist["sort-name"], + type: (relation.artist.type as mbArtistType) || mbArtistType.OTHER + } + }), + tags: rawData.tags.map((tag: Tag) => tag.name), + }; + return work; + } catch (e) { + throw new Error(`[MusicBrainz] Failed to fetch work details: ${e.message}`); + } + }; + + +} + +export default MusicBrainz; diff --git a/server/api/musicbrainz/interfaces.ts b/server/api/musicbrainz/interfaces.ts new file mode 100644 index 00000000..fcfc7c3f --- /dev/null +++ b/server/api/musicbrainz/interfaces.ts @@ -0,0 +1,105 @@ +// Purpose: Interfaces for MusicBrainz data. + +export enum mbArtistType { + PERSON = 'Person', + GROUP = 'Group', + ORCHESTRA = 'Orchestra', + CHOIR = 'Choir', + CHARACTER = 'Character', + OTHER = 'Other', +}; + +export interface mbArtist { + id: string; + name: string; + sortName: string; + type: mbArtistType; + recordings?: mbRecording[]; + releases?: mbRelease[]; + releaseGroups?: mbReleaseGroup[]; + works?: mbWork[]; + gender?: string; + area?: string; + beginDate?: string; + endDate?: string; + tags: string[]; +}; + +export interface mbRecording { + id: string; + title: string; + artist: mbArtist[]; + length: number; + firstReleased?: Date; + tags: string[]; +}; + +export interface mbRelease { + id: string; + title: string; + artist: mbArtist[]; + date?: Date; + tracks?: mbRecording[]; + tags: string[]; +}; + + +export enum mbReleaseGroupType { + ALBUM = 'Album', + SINGLE = 'Single', + EP = 'EP', + BROADCAST = 'Broadcast', + OTHER = 'Other', +}; + +export interface mbReleaseGroup { + id: string; + title: string; + artist: mbArtist[]; + type: mbReleaseGroupType; + firstReleased?: Date; + releases?: mbRelease[]; + tags: string[]; +}; + +export enum mbWorkType { + ARIA = 'Aria', + BALLET = 'Ballet', + CANTATA = 'Cantata', + CONCERTO = 'Concerto', + SONATA = 'Sonata', + SUITE = 'Suite', + MADRIGAL = 'Madrigal', + MASS = 'Mass', + MOTET = 'Motet', + OPERA = 'Opera', + ORATORIO = 'Oratorio', + OVERTURE = 'Overture', + PARTITA = 'Partita', + QUARTET = 'Quartet', + SONG_CYCLE = 'Song-cycle', + SYMPHONY = 'Symphony', + SONG = 'Song', + SYMPHONIC_POEM = 'Symphonic poem', + ZARZUELA = 'Zarzuela', + ETUDE = 'Étude', + POEM = 'Poem', + SOUNDTRACK = 'Soundtrack', + PROSE = 'Prose', + OPERETTA = 'Operetta', + AUDIO_DRAMA = 'Audio drama', + BEIJING_OPERA = 'Beijing opera', + PLAY = 'Play', + MUSICAL = 'Musical', + INCIDENTAL_MUSIC = 'Incidental music', + OTHER = 'Other', +}; + + +export interface mbWork { + id: string; + title: string; + type: mbWorkType; + artist: mbArtist[]; + tags: string[]; +}; diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index c004b474..a4dcd910 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -1,7 +1,7 @@ import ExternalAPI from '@server/api/externalapi'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; -import type { DVRSettings } from '@server/lib/settings'; +import type { ArrSettings } from '@server/lib/settings'; export interface SystemStatus { version: string; @@ -79,7 +79,7 @@ interface QueueResponse { } class ServarrBase extends ExternalAPI { - static buildUrl(settings: DVRSettings, path?: string): string { + static buildUrl(settings: ArrSettings, path?: string): string { return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ settings.port }${settings.baseUrl ?? ''}${path}`; diff --git a/server/api/servarr/lidarr.ts b/server/api/servarr/lidarr.ts new file mode 100644 index 00000000..4920671f --- /dev/null +++ b/server/api/servarr/lidarr.ts @@ -0,0 +1,216 @@ +import logger from '@server/logger'; +import ServarrBase from './base'; + +export interface LidarrMusicOptions { + title: string; + qualityProfileId: number; + tags: number[]; + profileId: number; + year: number; + rootFolderPath: string; + mbId: number; + monitored?: boolean; + searchNow?: boolean; +} + +export interface LidarrMusic { + id: number; + title: string; + isAvailable: boolean; + monitored: boolean; + mbId: number; + imdbId: string; + titleSlug: string; + folderName: string; + path: string; + profileId: number; + qualityProfileId: number; + added: string; + hasFile: boolean; +} + + +class LidarrAPI extends ServarrBase<{ musicId: number }> { + constructor({ url, apiKey }: { url: string; apiKey: string }) { + super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' }); + } + + public getMusics = async (): Promise => { + try { + const response = await this.axios.get('/music'); + + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve musics: ${e.message}`); + } + }; + + public getMusic = async ({ id }: { id: number }): Promise => { + try { + const response = await this.axios.get(`/music/${id}`); + + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve music: ${e.message}`); + } + }; + + public async getMusicBymbId(id: number): Promise { + try { + const response = await this.axios.get('/music/lookup', { + params: { + term: `musicbrainz:${id}`, + }, + }); + + if (!response.data[0]) { + throw new Error('Music not found'); + } + + return response.data[0]; + } catch (e) { + logger.error('Error retrieving music by MUSICBRAINZ ID', { + label: 'Lidarr API', + errorMessage: e.message, + mbId: id, + }); + throw new Error('Music not found'); + } + } + + public addMusic = async ( + options: LidarrMusicOptions + ): Promise => { + try { + const music = await this.getMusicBymbId(options.mbId); + + if (music.hasFile) { + logger.info( + 'Title already exists and is available. Skipping add and returning success', + { + label: 'Lidarr', + music, + } + ); + return music; + } + + // music exists in Lidarr but is neither downloaded nor monitored + if (music.id && !music.monitored) { + const response = await this.axios.put(`/music`, { + ...music, + title: options.title, + qualityProfileId: options.qualityProfileId, + profileId: options.profileId, + titleSlug: options.mbId.toString(), + mbId: options.mbId, + year: options.year, + tags: options.tags, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored, + addOptions: { + searchForMusic: options.searchNow, + }, + }); + + if (response.data.monitored) { + logger.info( + 'Found existing title in Lidarr and set it to monitored.', + { + label: 'Lidarr', + musicId: response.data.id, + musicTitle: response.data.title, + } + ); + logger.debug('Lidarr update details', { + label: 'Lidarr', + music: response.data, + }); + + if (options.searchNow) { + this.searchMusic(response.data.id); + } + + return response.data; + } else { + logger.error('Failed to update existing music in Lidarr.', { + label: 'Lidarr', + options, + }); + throw new Error('Failed to update existing music in Lidarr'); + } + } + + if (music.id) { + logger.info( + 'Music is already monitored in Lidarr. Skipping add and returning success', + { label: 'Lidarr' } + ); + return music; + } + + const response = await this.axios.post(`/music`, { + title: options.title, + qualityProfileId: options.qualityProfileId, + profileId: options.profileId, + titleSlug: options.mbId.toString(), + mbId: options.mbId, + year: options.year, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored, + tags: options.tags, + addOptions: { + searchForMusic: options.searchNow, + }, + }); + + if (response.data.id) { + logger.info('Lidarr accepted request', { label: 'Lidarr' }); + logger.debug('Lidarr add details', { + label: 'Lidarr', + music: response.data, + }); + } else { + logger.error('Failed to add music to Lidarr', { + label: 'Lidarr', + options, + }); + throw new Error('Failed to add music to Lidarr'); + } + return response.data; + } catch (e) { + logger.error( + 'Failed to add music to Lidarr. This might happen if the music already exists, in which case you can safely ignore this error.', + { + label: 'Lidarr', + errorMessage: e.message, + options, + response: e?.response?.data, + } + ); + throw new Error('Failed to add music to Lidarr'); + } + }; + + public async searchMusic(musicId: number): Promise { + logger.info('Executing music search command', { + label: 'Lidarr API', + musicId, + }); + + try { + await this.runCommand('MusicsSearch', { musicIds: [musicId] }); + } catch (e) { + logger.error( + 'Something went wrong while executing Lidarr music search.', + { + label: 'Lidarr API', + errorMessage: e.message, + musicId, + } + ); + } + } +} + +export default LidarrAPI; diff --git a/server/constants/media.ts b/server/constants/media.ts index de2bf834..51b47116 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -8,6 +8,7 @@ export enum MediaRequestStatus { export enum MediaType { MOVIE = 'movie', TV = 'tv', + MUSIC = 'music', } export enum MediaStatus { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 2d169172..cf9e2eb5 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -20,6 +20,7 @@ import { import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import Season from './Season'; +import LidarrAPI from '@server/api/servarr/lidarr'; @Entity() class Media { @@ -72,14 +73,22 @@ class Media { @Column({ type: 'varchar' }) public mediaType: MediaType; - @Column() + @Column({ nullable: true }) @Index() public tmdbId: number; - @Column({ unique: true, nullable: true }) + @Column({ nullable: true }) + @Index() + public mbId: number; + + @Column({ nullable: true }) @Index() public tvdbId?: number; + @Column({ nullable: true }) + @Index() + public musicdbId?: number; + @Column({ nullable: true }) @Index() public imdbId?: string; @@ -253,6 +262,21 @@ class Media { } } } + + if (this.mediaType === MediaType.MUSIC) { + if (this.serviceId !== null && this.externalServiceSlug !== null) { + const settings = getSettings(); + const server = settings.lidarr.find( + (lidarr) => lidarr.id === this.serviceId + ); + + if (server) { + this.serviceUrl = server.externalUrl + ? `${server.externalUrl}/movie/${this.externalServiceSlug}` + : LidarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`); + } + } + } } @AfterLoad() @@ -308,6 +332,20 @@ class Media { ); } } + + if (this.mediaType === MediaType.MUSIC) { + if ( + this.externalServiceId !== undefined && + this.externalServiceId !== null && + this.serviceId !== undefined && + this.serviceId !== null + ) { + this.downloadStatus = downloadTracker.getMusicProgress( + this.serviceId, + this.externalServiceId + ); + } + } } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba67ab7b..1bec10b2 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -5,7 +5,10 @@ import type { SonarrSeries, } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; +import type { LidarrMusicOptions } from '@server/api/servarr/lidarr'; +import LidarrAPI from '@server/api/servarr/lidarr'; import TheMovieDb from '@server/api/themoviedb'; +import MusicBrainz from '@server/api/musicbrainz'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { MediaRequestStatus, @@ -53,6 +56,7 @@ export class MediaRequest { options: MediaRequestOptions = {} ): Promise { const tmdb = new TheMovieDb(); + const musicbrainz = new MusicBrainz(); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); @@ -1160,6 +1164,234 @@ export class MediaRequest { } } + public async sendToLidarr(): Promise { + if ( + this.status === MediaRequestStatus.APPROVED && + this.type === MediaType.MUSIC + ) { + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); + if (settings.lidarr.length === 0 && !settings.lidarr[0]) { + logger.info( + 'No Lidarr server configured, skipping request processing', + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + return; + } + + let lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault); + + if ( + this.serverId !== null && + this.serverId >= 0 && + lidarrSettings?.id !== this.serverId + ) { + lidarrSettings = settings.lidarr.find( + (lidarr) => lidarr.id === this.serverId + ); + logger.info( + `Request has an override server: ${lidarrSettings?.name}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } + + if (!lidarrSettings) { + logger.warn( + `There is no default Lidarr server configured. Did you set any of your Lidarr servers as default?`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + return; + } + + let rootFolder = lidarrSettings.activeDirectory; + let qualityProfile = lidarrSettings.activeProfileId; + let tags = lidarrSettings.tags ? [...lidarrSettings.tags] : []; + + if ( + this.rootFolder && + this.rootFolder !== '' && + this.rootFolder !== lidarrSettings.activeDirectory + ) { + rootFolder = this.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + }); + } + + if ( + this.profileId && + this.profileId !== lidarrSettings.activeProfileId + ) { + qualityProfile = this.profileId; + logger.info( + `Request has an override quality profile ID: ${qualityProfile}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } + + if (this.tags && !isEqual(this.tags, lidarrSettings.tags)) { + tags = this.tags; + logger.info(`Request has override tags`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + tagIds: tags, + }); + } + + const musicbrainz = new MusicBrainz(); + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v3'), + }); + const music = await musicbrainz.getMusic({ mbId: this.media.mbId }); + + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + logger.error('Media data not found', { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + }); + return; + } + + if (lidarrSettings.tagRequests) { + let userTag = (await lidarr.getTags()).find((v) => + v.label.startsWith(this.requestedBy.id + ' - ') + ); + if (!userTag) { + logger.info(`Requester has no active tag. Creating new`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + userId: this.requestedBy.id, + newTag: + this.requestedBy.id + ' - ' + this.requestedBy.displayName, + }); + userTag = await lidarr.createTag({ + label: this.requestedBy.id + ' - ' + this.requestedBy.displayName, + }); + } + if (userTag.id) { + if (!tags?.find((v) => v === userTag?.id)) { + tags?.push(userTag.id); + } + } else { + logger.warn(`Requester has no tag and failed to add one`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + userId: this.requestedBy.id, + lidarrServer: lidarrSettings.hostname + ':' + lidarrSettings.port, + }); + } + } + + if ( + media['status'] === MediaStatus.AVAILABLE + ) { + logger.warn('Media already exists, marking request as APPROVED', { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + }); + + const requestRepository = getRepository(MediaRequest); + this.status = MediaRequestStatus.APPROVED; + await requestRepository.save(this); + return; + } + + const lidarrMusicOptions: LidarrMusicOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + title: music.title, + mbId: music.id, + year: Number(music.release_date.slice(0, 4)), + monitored: true, + tags, + searchNow: !lidarrSettings.preventSearch, + }; + + // Run this asynchronously so we don't wait for it on the UI side + lidarr + .addMusic(lidarrMusicOptions) + .then(async (lidarrMusic) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + media['externalServiceId'] = + lidarrMusic.id; + media['externalServiceSlug'] = + lidarrMusic.titleSlug; + media['serviceId'] = lidarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + + logger.warn( + 'Something went wrong sending music request to Lidarr, marking status as FAILED', + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + lidarrMusicOptions, + } + ); + + this.sendNotification(media, Notification.MEDIA_FAILED); + }); + logger.info('Sent request to Lidarr', { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + }); + } catch (e) { + logger.error('Something went wrong sending request to Lidarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: this.id, + mediaId: this.media.id, + }); + throw new Error(e.message); + } + } + } + private async sendNotification(media: Media, type: Notification) { const tmdb = new TheMovieDb(); @@ -1244,6 +1476,25 @@ export class MediaRequest { }, ], }); + } else if (this.type === MediaType.MUSIC) { + const music = await musicbrainz.getMusic({ mbId: media.tmdbId }); + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: `${music.name}${ + music.first_realease_date ? ` (${music.first_realease_date.slice(0, 4)})` : '' + }`, + message: truncate(music.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + image: `http://coverartarchive.org/${music.type}/${music.mbid}/front-250`, //TODO: Add coverartarchive + }); } } catch (e) { logger.error('Something went wrong sending media notification(s)', { diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 011205e7..7637de58 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -2,13 +2,15 @@ import NodeCache from 'node-cache'; export type AvailableCacheIds = | 'tmdb' + | 'musicbrainz' | 'radarr' | 'sonarr' | 'rt' | 'imdb' | 'github' | 'plexguid' - | 'plextv'; + | 'plextv' + | 'lidarr'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -46,8 +48,13 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + musicbrainz: new Cache('musicbrainz', 'MusicBrainz API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), + lidarr: new Cache('lidarr', 'Lidarr API'), rt: new Cache('rt', 'Rotten Tomatoes API', { stdTtl: 43200, checkPeriod: 60 * 30, diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index cf29313e..a45b37da 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -1,5 +1,6 @@ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; +import LidarrAPI from '@server/api/servarr/lidarr'; import { MediaType } from '@server/constants/media'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -26,6 +27,7 @@ export interface DownloadingItem { class DownloadTracker { private radarrServers: Record = {}; private sonarrServers: Record = {}; + private lidarrServers: Record = {}; public getMovieProgress( serverId: number, @@ -53,6 +55,19 @@ class DownloadTracker { ); } + public getMusicProgress( + serverId: number, + externalServiceId: number + ): DownloadingItem[] { + if (!this.lidarrServers[serverId]) { + return []; + } + + return this.lidarrServers[serverId].filter( + (item) => item.externalId === externalServiceId + ); + } + public async resetDownloadTracker() { this.radarrServers = {}; } @@ -60,6 +75,7 @@ class DownloadTracker { public updateDownloads() { this.updateRadarrDownloads(); this.updateSonarrDownloads(); + this.updateLidarrDownloads(); } private async updateRadarrDownloads() { @@ -214,6 +230,83 @@ class DownloadTracker { }) ); } + + private async updateLidarrDownloads() { + const settings = getSettings(); + + // Remove duplicate servers + const filteredServers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => { + return ( + lidarrA.hostname === lidarrB.hostname && + lidarrA.port === lidarrB.port && + lidarrA.baseUrl === lidarrB.baseUrl + ); + }); + + // Load downloads from Lidarr servers + Promise.all( + filteredServers.map(async (server) => { + if (server.syncEnabled) { + const lidarr = new LidarrAPI({ + apiKey: server.apiKey, + url: LidarrAPI.buildUrl(server, '/api/v3'), + }); + + try { + const queueItems = await lidarr.getQueue(); + + this.lidarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.seriesId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.TV, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + episode: item.episode, + })); + + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + } catch { + logger.error( + `Unable to get queue from Sonarr server: ${server.name}`, + { + label: 'Download Tracker', + } + ); + } + + // Duplicate this data to matching servers + const matchingServers = settings.lidarr.filter( + (ss) => + ss.hostname === server.hostname && + ss.port === server.port && + ss.baseUrl === server.baseUrl && + ss.id !== server.id + ); + + if (matchingServers.length > 0) { + logger.debug( + `Matching download data to ${matchingServers.length} other Sonarr server(s)`, + { label: 'Download Tracker' } + ); + } + + matchingServers.forEach((ms) => { + if (ms.syncEnabled) { + this.lidarrServers[ms.id] = this.lidarrServers[server.id]; + } + }); + } + }) + ); + } } const downloadTracker = new DownloadTracker(); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 10213a04..7b33d10a 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -44,7 +44,7 @@ export interface TautulliSettings { externalUrl?: string; } -export interface DVRSettings { +export interface ArrSettings { id: number; name: string; hostname: string; @@ -56,7 +56,6 @@ export interface DVRSettings { activeProfileName: string; activeDirectory: string; tags: number[]; - is4k: boolean; isDefault: boolean; externalUrl?: string; syncEnabled: boolean; @@ -64,6 +63,10 @@ export interface DVRSettings { tagRequests: boolean; } +export interface DVRSettings extends ArrSettings { + is4k: boolean; +} + export interface RadarrSettings extends DVRSettings { minimumAvailability: string; } @@ -80,6 +83,7 @@ export interface SonarrSettings extends DVRSettings { enableSeasonFolders: boolean; } + interface Quota { quotaLimit?: number; quotaDays?: number; @@ -95,6 +99,7 @@ export interface MainSettings { defaultQuotas: { movie: Quota; tv: Quota; + music: Quota; }; hideAvailable: boolean; localLogin: boolean; @@ -250,6 +255,7 @@ export type JobId = | 'plex-watchlist-sync' | 'radarr-scan' | 'sonarr-scan' + | 'lidarr-scan' | 'download-sync' | 'download-sync-reset' | 'image-cache-cleanup' @@ -264,6 +270,7 @@ interface AllSettings { tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; + lidarr: ArrSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; @@ -291,6 +298,7 @@ class Settings { defaultQuotas: { movie: {}, tv: {}, + music: {}, }, hideAvailable: false, localLogin: true, @@ -311,6 +319,7 @@ class Settings { tautulli: {}, radarr: [], sonarr: [], + lidarr: [], public: { initialized: false, }, @@ -415,6 +424,9 @@ class Settings { 'sonarr-scan': { schedule: '0 30 4 * * *', }, + 'lidarr-scan': { + schedule: '0 0 5 * * *', + }, 'availability-sync': { schedule: '0 0 5 * * *', }, @@ -478,6 +490,14 @@ class Settings { this.data.sonarr = data; } + get lidarr(): ArrSettings[] { + return this.data.lidarr; + } + + set lidarr(data: ArrSettings[]) { + this.data.lidarr = data; + } + get public(): PublicSettings { return this.data.public; } diff --git a/server/types/nodebrainz.d.ts b/server/types/nodebrainz.d.ts new file mode 100644 index 00000000..e0593f9b --- /dev/null +++ b/server/types/nodebrainz.d.ts @@ -0,0 +1 @@ +declare module 'nodebrainz';