diff --git a/next.config.js b/next.config.js index deacbda3..702c8716 100644 --- a/next.config.js +++ b/next.config.js @@ -11,6 +11,8 @@ module.exports = { { hostname: 'image.tmdb.org' }, { hostname: 'artworks.thetvdb.com' }, { hostname: 'plex.tv' }, + { hostname: 'archive.org' }, + { hostname: 'r2.theaudiodb.com' }, ], }, webpack(config) { diff --git a/package.json b/package.json index 893a3d15..4719f545 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "date-fns": "2.29.3", "dayjs": "1.11.19", "dns-caching": "^0.2.7", - "dompurify": "^3.2.3", + "dompurify": "^3.2.4", "email-templates": "12.0.1", "express": "4.21.2", "express-openapi-validator": "4.13.8", @@ -69,6 +69,7 @@ "gravatar-url": "3.1.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", + "jsdom": "^26.0.0", "lodash": "4.17.21", "mime": "3", "next": "^14.2.25", @@ -124,13 +125,15 @@ "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", "@types/bcrypt": "5.0.0", - "@types/cookie-parser": "1.4.10", - "@types/country-flag-icons": "1.2.2", - "@types/csurf": "1.11.5", + "@types/cookie-parser": "1.4.3", + "@types/country-flag-icons": "1.2.0", + "@types/csurf": "1.11.2", + "@types/dompurify": "^3.2.0", "@types/email-templates": "8.0.4", "@types/express": "4.17.17", - "@types/express-session": "1.18.2", - "@types/lodash": "4.17.21", + "@types/express-session": "1.17.6", + "@types/jsdom": "^21.1.7", + "@types/lodash": "4.14.191", "@types/mime": "3", "@types/node": "22.10.5", "@types/node-schedule": "2.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3c731a8..4a362baa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,8 +87,8 @@ importers: specifier: ^0.2.7 version: 0.2.7 dompurify: - specifier: ^3.2.3 - version: 3.2.3 + specifier: ^3.2.4 + version: 3.2.4 email-templates: specifier: 12.0.3 version: 12.0.3(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7) @@ -116,6 +116,9 @@ importers: https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 + jsdom: + specifier: ^26.0.0 + version: 26.0.0 lodash: specifier: 4.17.21 version: 4.17.21 @@ -283,8 +286,11 @@ importers: specifier: 1.2.2 version: 1.2.2 '@types/csurf': - specifier: 1.11.5 - version: 1.11.5 + specifier: 1.11.2 + version: 1.11.2 + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 '@types/email-templates': specifier: 8.0.4 version: 8.0.4(encoding@0.1.13) @@ -292,8 +298,11 @@ importers: specifier: 4.17.17 version: 4.17.17 '@types/express-session': - specifier: 1.18.2 - version: 1.18.2 + specifier: 1.17.6 + version: 1.17.6 + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 '@types/lodash': specifier: 4.17.21 version: 4.17.21 @@ -442,6 +451,9 @@ packages: '@apidevtools/json-schema-ref-parser@9.0.9': resolution: {integrity: sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==} + '@asamuzakjp/css-color@2.8.3': + resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -1572,6 +1584,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.0.1': + resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.1': + resolution: {integrity: sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-color-parser@3.0.7': + resolution: {integrity: sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-parser-algorithms@3.0.4': + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-tokenizer@3.0.3': + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + '@cypress/request@3.0.7': resolution: {integrity: sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==} engines: {node: '>= 6'} @@ -3258,6 +3298,10 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/email-templates@8.0.4': resolution: {integrity: sha512-HYvVoyG8qS6PrimZZOS4wMrtQ9MelKEl0sOpi4zVpz2Ds74v+UvWckIFz3NyGyTwAR1okMbwJkApgR2GL/ALjg==} @@ -3297,6 +3341,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3421,6 +3468,9 @@ packages: '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -4544,6 +4594,10 @@ packages: resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} engines: {node: '>=8.0.0'} + cssstyle@4.2.1: + resolution: {integrity: sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==} + engines: {node: '>=18'} + csstype@2.6.21: resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==} @@ -4573,6 +4627,10 @@ packages: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -4803,8 +4861,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.2.3: - resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==} + dompurify@3.2.4: + resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==} domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -5386,8 +5444,12 @@ packages: resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} engines: {node: '>= 0.12'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + + form-data@4.0.3: + resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} engines: {node: '>= 6'} form-data@4.0.5: @@ -5703,6 +5765,10 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -5755,6 +5821,14 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@1.1.1: resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} engines: {node: '>=8.12.0'} @@ -6037,6 +6111,9 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} @@ -6239,6 +6316,15 @@ packages: peerDependencies: '@babel/preset-env': ^7.1.6 + jsdom@26.0.0: + resolution: {integrity: sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -6555,6 +6641,9 @@ packages: resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==} engines: {node: 20 || >=22} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -7167,6 +7256,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + nwsapi@2.2.16: + resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} + oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} @@ -7362,6 +7454,9 @@ packages: parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + parse5@7.2.1: + resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -8203,6 +8298,9 @@ packages: rndm@1.2.0: resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@3.2.0: resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==} engines: {node: '>=4'} @@ -8263,6 +8361,10 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.21.0: resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==} @@ -8718,6 +8820,9 @@ packages: tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@3.2.7: resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} engines: {node: '>=12.13.0'} @@ -8806,6 +8911,13 @@ packages: resolution: {integrity: sha512-fSgYrW0ITH0SR/CqKMXIruYIPpNu5aDgUp22UhYoSrnUQwc7SBqifEBFNce7AAcygUPBo6a/gbtcguWdmko4RQ==} hasBin: true + tldts-core@6.1.75: + resolution: {integrity: sha512-AOvV5YYIAFFBfransBzSTyztkc3IMfz5Eq3YluaRiEu55nn43Fzaufx70UqEKYr8BoLCach4q8g/bg6e5+/aFw==} + + tldts@6.1.75: + resolution: {integrity: sha512-+lFzEXhpl7JXgWYaXcB6DqTYXbUArvrWAE/5ioq/X3CdWLbDjpPP4XTrQBmEJ91y3xbe4Fkw7Lxv4P3GWeJaNg==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -8850,9 +8962,17 @@ packages: resolution: {integrity: sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==} engines: {node: '>=16'} + tough-cookie@5.1.0: + resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + traverse@0.6.9: + resolution: {integrity: sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==} + engines: {node: '>= 0.4'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -9278,6 +9398,10 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -9303,9 +9427,25 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.1.0: + resolution: {integrity: sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -9424,6 +9564,22 @@ packages: utf-8-validate: optional: true + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.4.16: resolution: {integrity: sha512-9rH7UTUNphxeDRCeJBi4Fxp/z0fd92WeXNQ1dtUYMpqO3PaK59hVDCuUmOGHRZvufJDzcX8TG+Kdty7ylM0t2w==} @@ -9446,6 +9602,9 @@ packages: resolution: {integrity: sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9549,6 +9708,14 @@ snapshots: call-me-maybe: 1.0.2 js-yaml: 4.1.0 + '@asamuzakjp/css-color@2.8.3': + dependencies: + '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-color-parser': 3.0.7(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + lru-cache: 10.4.3 + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -11151,6 +11318,26 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.0.1': {} + + '@csstools/css-calc@2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-color-parser@3.0.7(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/color-helpers': 5.0.1 + '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-tokenizer@3.0.3': {} + '@cypress/request@3.0.7': dependencies: aws-sign2: 0.7.0 @@ -13441,6 +13628,10 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.2.4 + '@types/email-templates@8.0.4(encoding@0.1.13)': dependencies: '@types/html-to-text': 9.0.4 @@ -13497,6 +13688,12 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 22.10.5 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + '@types/json-schema@7.0.15': {} '@types/json-stable-stringify@1.0.36': {} @@ -13617,6 +13814,8 @@ snapshots: '@types/express': 4.17.17 '@types/serve-static': 1.15.7 + '@types/tough-cookie@4.0.5': {} + '@types/triple-beam@1.3.5': {} '@types/ua-parser-js@0.7.39': {} @@ -14930,6 +15129,11 @@ snapshots: dependencies: css-tree: 1.1.3 + cssstyle@4.2.1: + dependencies: + '@asamuzakjp/css-color': 2.8.3 + rrweb-cssom: 0.8.0 + csstype@2.6.21: {} csstype@3.1.3: {} @@ -15004,6 +15208,11 @@ snapshots: dependencies: assert-plus: 1.0.0 + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.1.0 + data-view-buffer@1.0.1: dependencies: call-bind: 1.0.7 @@ -15207,7 +15416,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.2.3: + dompurify@3.2.4: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16149,11 +16358,10 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 - form-data@4.0.2: + form-data@4.0.1: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 mime-types: 2.1.35 form-data@4.0.5: @@ -16520,6 +16728,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -16611,6 +16823,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + human-signals@1.1.1: {} human-signals@2.1.0: {} @@ -16881,6 +17100,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-plain-object@5.0.0: {} + is-promise@2.2.2: {} is-regex@1.1.4: @@ -17119,6 +17340,34 @@ snapshots: transitivePeerDependencies: - supports-color + jsdom@26.0.0: + dependencies: + cssstyle: 4.2.1 + data-urls: 5.0.0 + decimal.js: 10.5.0 + form-data: 4.0.1 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.16 + parse5: 7.2.1 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.1.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@0.5.0: {} jsesc@2.5.2: {} @@ -17442,9 +17691,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@10.4.3: {} - - lru-cache@11.2.1: {} + lru-cache@10.2.2: {} lru-cache@5.1.1: dependencies: @@ -18302,6 +18549,8 @@ snapshots: nullthrows@1.1.1: {} + nwsapi@2.2.16: {} + oauth-sign@0.9.0: {} ob1@0.80.12: @@ -18522,6 +18771,10 @@ snapshots: dependencies: entities: 4.5.0 + parse5@7.2.1: + dependencies: + entities: 4.5.0 + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -19515,6 +19768,8 @@ snapshots: rndm@1.2.0: {} + rrweb-cssom@0.8.0: {} + run-applescript@3.2.0: dependencies: execa: 0.10.0 @@ -19582,6 +19837,10 @@ snapshots: sax@1.4.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.21.0: dependencies: loose-envify: 1.4.0 @@ -20223,6 +20482,12 @@ snapshots: dependencies: tldts-core: 6.1.78 + tldts-core@6.1.75: {} + + tldts@6.1.75: + dependencies: + tldts-core: 6.1.75 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -20256,8 +20521,18 @@ snapshots: dependencies: tldts: 6.1.78 + tough-cookie@5.1.0: + dependencies: + tldts: 6.1.75 + tr46@0.0.3: {} + traverse@0.6.9: + dependencies: + gopd: 1.0.1 + typedarray.prototype.slice: 1.0.3 + which-typed-array: 1.1.15 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -20672,6 +20947,10 @@ snapshots: void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -20718,8 +20997,21 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.1.0: + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -20891,6 +21183,10 @@ snapshots: ws@7.5.10: {} + ws@8.18.0: {} + + xml-name-validator@5.0.0: {} + xml2js@0.4.16: dependencies: sax: 1.4.1 @@ -20914,6 +21210,8 @@ snapshots: xmlbuilder@9.0.7: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@4.0.3: {} diff --git a/public/images/jellyseerr_poster_not_found_square.png b/public/images/jellyseerr_poster_not_found_square.png new file mode 100644 index 00000000..c116bd1c Binary files /dev/null and b/public/images/jellyseerr_poster_not_found_square.png differ diff --git a/public/images/overseerr_poster_not_found_square.png b/public/images/overseerr_poster_not_found_square.png new file mode 100644 index 00000000..c116bd1c Binary files /dev/null and b/public/images/overseerr_poster_not_found_square.png differ diff --git a/public/os_logo_filled.png b/public/os_logo_filled.png deleted file mode 100644 index 37ad421d..00000000 Binary files a/public/os_logo_filled.png and /dev/null differ diff --git a/public/preview.jpg b/public/preview.jpg deleted file mode 100644 index a21f1b97..00000000 Binary files a/public/preview.jpg and /dev/null differ diff --git a/seerr-api.yml b/seerr-api.yml index fe5f3dc9..dc87ea15 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6274,6 +6274,91 @@ paths: type: string example: Genre Name /discover/music: + get: + summary: Discover fresh music releases + description: Returns a list of recent music releases from ListenBrainz in a JSON object + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: days + schema: + type: number + example: 30 + default: 30 + description: Number of days to look back for fresh releases + - in: query + name: genre + schema: + type: string + example: 'Album,EP,Single' + description: Comma-separated list of music genres/types to filter by + - in: query + name: onlyWithCoverArt + schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + example: 'false' + description: Whether to hide releases without cover art (default is false) + - in: query + name: releaseDateGte + schema: + type: string + format: date + example: '2023-01-01' + description: Filter for releases on or after this date + - in: query + name: releaseDateLte + schema: + type: string + format: date + example: '2023-12-31' + description: Filter for releases on or before this date + - in: query + name: sortBy + schema: + type: string + enum: + - release_date.desc + - release_date.asc + - title.asc + - title.desc + - artist.asc + - artist.desc + default: release_date.desc + example: release_date.desc + description: Field and direction to sort results + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/AlbumResult' + /discover/music/albums: get: summary: Discover trending music albums description: Returns a list of trending albums from ListenBrainz in a JSON object @@ -6326,6 +6411,57 @@ paths: type: array items: $ref: '#/components/schemas/AlbumResult' + /discover/music/artists: + get: + summary: Discover trending music artists + description: Returns a list of trending artists from ListenBrainz in a JSON object + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: range + schema: + type: string + enum: [week, month, year, all] + default: month + - in: query + name: sortBy + schema: + type: string + enum: + - listen_count.desc + - listen_count.asc + - name.asc + - name.desc + default: listen_count.desc + example: listen_count.desc + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/ArtistResult' /discover/watchlist: get: summary: Get the Plex watchlist. @@ -6458,7 +6594,46 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/MainSettings' + type: object + properties: + mediaType: + type: string + enum: [movie, tv, music] + example: movie + mediaId: + oneOf: + - type: number + - type: string + description: TMDB ID for movies/tv, MusicBrainz ID for music + example: 123 + tvdbId: + type: number + example: 123 + seasons: + oneOf: + - type: array + items: + type: number + minimum: 0 + - type: string + enum: [all] + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string + languageProfileId: + type: number + userId: + type: number + nullable: true + required: + - mediaType + - mediaId responses: '201': description: Succesfully created the request @@ -7056,6 +7231,26 @@ paths: schema: type: string example: en + - in: query + name: page + description: Page number for paginated results + schema: + type: integer + default: 1 + example: 1 + - in: query + name: pageSize + description: Number of items per page + schema: + type: integer + default: 20 + example: 20 + - in: query + name: albumType + description: Filter results by album type (Album, EP, Single, etc.) + schema: + type: string + example: 'Album' responses: '200': description: Returned person @@ -7099,10 +7294,37 @@ paths: $ref: '#/components/schemas/CreditCrew' id: type: number - /group/{artistId}: + /coverart/batch/{ids}: + get: + summary: Batch fetch cover art + description: Fetches cover art for multiple albums by their MusicBrainz IDs in a single request. Designed for progressive loading of cover images. + tags: + - music + parameters: + - in: path + name: ids + required: true + schema: + type: string + example: '87f17f8a-c0e2-406c-a149-8c8e311bf330,b5b4bb4b-8ba5-3acf-88cb-4cae2699d8da' + description: Comma-separated list of MusicBrainz album IDs + responses: + '200': + description: Cover art URLs mapped to their MusicBrainz IDs + content: + application/json: + schema: + type: object + additionalProperties: + type: string + nullable: true + example: + '87f17f8a-c0e2-406c-a149-8c8e311bf330': 'https://archive.org/download/mbid-87f17f8a-c0e2-406c-a149-8c8e311bf330/mbid-87f17f8a-c0e2-406c-a149-8c8e311bf330-23795455019.jpg' + 'b5b4bb4b-8ba5-3acf-88cb-4cae2699d8da': null + /artist/{artistId}: get: summary: Get artist details - description: Returns artist details in a JSON object. + description: Returns full artist details including discography and metadata tags: - music parameters: @@ -7111,188 +7333,149 @@ paths: required: true schema: type: string - example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 - - in: query - name: full - schema: - type: boolean - example: false - default: false - - in: query - name: maxElements - schema: - type: number - example: 50 - default: 25 - - in: query - name: offset - schema: - type: number - example: 25 - default: 0 - responses: - '200': - description: Artist details - content: - application/json: - schema: - $ref: '#/components/schemas/ArtistResult' - /group/{artistId}/discography: - get: - summary: Get artist discography - description: Returns the artist's discography with pagination and optional type filtering - tags: - - music - parameters: - - in: path - name: artistId - required: true - schema: - type: string - example: f59c5520-5f46-4d2c-b2c4-822eabf53419 + example: 'f27ec8db-af05-4f36-916e-3d57f91ecf5e' - in: query name: page + description: Page number for paginated results schema: - type: number + type: integer default: 1 example: 1 - - in: query - name: type - schema: - type: string - enum: [Album, Single, EP, Other] - description: Filter releases by type - in: query name: pageSize + description: Number of items per page schema: - type: number + type: integer default: 20 example: 20 + - in: query + name: albumType + description: Filter results by album type (Album, EP, Single, etc.) + schema: + type: string + example: 'Album' responses: '200': - description: Paginated artist discography + description: Artist details returned successfully content: application/json: schema: type: object properties: - page: + name: + type: string + example: 'Artist Name' + type: + type: string + example: 'Person' + area: + type: string + example: 'United States' + beginYear: type: number - pageInfo: + example: 1990 + endYear: + type: number + nullable: true + wikipedia: type: object + nullable: true properties: - total: - type: number - totalPages: - type: number - results: + content: + type: string + artistThumb: + type: string + nullable: true + artistBackdrop: + type: string + nullable: true + releaseGroups: type: array items: type: object properties: id: type: string + mediaType: + type: string + enum: ['album'] title: type: string - type: + first-release-date: type: string - enum: [Album, Single, EP, Other] - releasedate: - type: string - images: + format: date + artist-credit: type: array items: type: object properties: - CoverType: - type: string - Url: + name: type: string + primary-type: + type: string + enum: ['Album', 'Single', 'EP', 'Other'] + secondary_types: + type: array + items: + type: string + posterPath: + type: string + nullable: true + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + pagination: + type: object + properties: + page: + type: integer + example: 1 + pageSize: + type: integer + example: 20 + totalItems: + type: integer + example: 100 + totalPages: + type: integer + example: 5 + albumType: + type: string + nullable: true + example: 'Album' + typeCounts: + type: object + additionalProperties: + type: integer + example: + Album: 20 + Single: 15 + EP: 5 '404': description: Artist not found - '500': - description: Internal server error - /person/{personId}/discography: - get: - summary: Get person's musical discography - description: Returns the person's discography with pagination and optional type filtering - tags: - - music - parameters: - - in: path - name: personId - required: true - schema: - type: number - example: 287 - - in: query - name: artistId - required: true - schema: - type: string - example: 'b2fbd053-4380-412c-95d2-35c6da8f1011' - - in: query - name: type - schema: - type: string - enum: [Album, Single, EP, Other] - description: Filter releases by type - - in: query - name: page - schema: - type: number - default: 1 - example: 1 - - in: query - name: pageSize - schema: - type: number - default: 20 - example: 20 - responses: - '200': - description: Paginated person discography content: application/json: schema: type: object properties: - page: + status: type: number - pageInfo: - type: object - properties: - total: - type: number - totalPages: - type: number - results: - type: array - items: - type: object - properties: - id: - type: string - title: - type: string - type: - type: string - enum: [Album, Single, EP, Other] - releasedate: - type: string - images: - type: array - items: - type: object - properties: - CoverType: - type: string - Url: - type: string - '404': - description: No music artist found for this person + example: 404 + message: + type: string + example: 'Artist not found' '500': - description: Unable to retrieve artist discography + description: Internal server error + content: + application/json: + schema: + type: object + properties: + status: + type: number + example: 500 + message: + type: string + example: 'Unable to retrieve artist.' /music/{mbId}: get: summary: Get album details @@ -7331,10 +7514,10 @@ paths: application/json: schema: $ref: '#/components/schemas/AlbumResult' - /music/{mbId}/wikipedia-extract: + /music/{mbId}/artist: get: - summary: Get artist Wikipedia extract - description: Returns Wikipedia extract for the artist of the specified album + summary: Get artist details + description: Returns details about the artist of the specified album tags: - music parameters: @@ -7343,221 +7526,57 @@ paths: required: true schema: type: string - example: da04991d-8e8a-43be-9892-1e6342a77410 - - in: query - name: language - schema: - type: string - example: en - default: en - responses: - '200': - description: Artist Wikipedia extract - content: - application/json: - schema: - type: object - properties: - artistId: - type: string - artistName: - type: string - overview: - type: string - url: - type: string - '404': - description: Artist not found - content: - application/json: - schema: - type: object - properties: - message: - type: string - '500': - description: Internal server error - content: - application/json: - schema: - type: object - properties: - message: - type: string - /music/{mbId}/tracks: - get: - summary: Get album tracks - description: Returns tracks listing for the specified album - tags: - - music - parameters: - - in: path - name: mbId - required: true - schema: - type: string - example: d9390962-495f-466e-8674-e705e660ed8b - responses: - '200': - description: Album tracks - content: - application/json: - schema: - type: object - properties: - media: - type: array - items: - type: object - properties: - format: - type: string - track-count: - type: integer - position: - type: integer - tracks: - type: array - items: - type: object - properties: - id: - type: string - number: - type: string - title: - type: string - length: - type: integer - position: - type: integer - recording: - type: object - properties: - id: - type: string - title: - type: string - length: - type: integer - first-release-date: - type: string - video: - type: boolean - disambiguation: - type: string - '404': - description: Album not found - content: - application/json: - schema: - type: object - properties: - message: - type: string - '500': - description: Internal server error - content: - application/json: - schema: - type: object - properties: - message: - type: string - /music/{mbId}/discography: - get: - summary: Get artist discography - description: Returns a list of albums by the artist of the specified discography - tags: - - music - parameters: - - in: path - name: mbId - required: true - schema: - type: string - example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + example: b5b4bb4b-8ba5-3acf-88cb-4cae2699d8da - in: query name: page schema: - type: integer - minimum: 1 + type: number default: 1 - in: query - name: language + name: pageSize schema: - type: string - example: en + type: number + default: 20 responses: '200': - description: List of artist albums + description: Artist details content: application/json: schema: type: object properties: - page: - type: integer - totalPages: - type: integer - totalResults: - type: integer - results: - type: array - items: - type: object - properties: - id: - type: integer - mediaType: - type: string - enum: [music] - title: - type: string - artistName: - type: string - posterPath: - type: string - mediaInfo: - $ref: '#/components/schemas/MediaInfo' - '404': - description: Artist not found - content: - application/json: - schema: - type: object - properties: - message: - type: string - '500': - description: Internal server error - content: - application/json: - schema: - type: object - properties: - message: - type: string - /music/{mbId}/similar: - get: - tags: - - music - summary: Get similar artists for a music album - parameters: - - name: mbId - in: path - required: true - schema: - type: string - description: MusicBrainz ID of the album - - name: page - in: query - required: false - schema: - type: integer - default: 1 - description: Page number for pagination - responses: + artist: + type: object + properties: + area: + type: string + artist_mbid: + type: string + begin_year: + type: integer + mbid: + type: string + name: + type: string + rels: + type: object + properties: + free streaming: + type: string + purchase for download: + type: string + social network: + type: string + streaming: + type: string + wikidata: + type: string + youtube: + type: string + tag: + type: object + type: + type: string '404': description: Artist not found content: @@ -7565,6 +7584,8 @@ paths: schema: type: object properties: + status: + type: integer message: type: string '500': @@ -7574,6 +7595,8 @@ paths: schema: type: object properties: + status: + type: integer message: type: string /media: diff --git a/server/api/coverartarchive/index.ts b/server/api/coverartarchive/index.ts index 4ced8c83..6422d8be 100644 --- a/server/api/coverartarchive/index.ts +++ b/server/api/coverartarchive/index.ts @@ -1,9 +1,24 @@ import ExternalAPI from '@server/api/externalapi'; +import { getRepository } from '@server/datasource'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; +import { In } from 'typeorm'; import type { CoverArtResponse } from './interfaces'; class CoverArtArchive extends ExternalAPI { - constructor() { + private static instance: CoverArtArchive; + private readonly CACHE_TTL = 43200; + private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; + + public static getInstance(): CoverArtArchive { + if (!CoverArtArchive.instance) { + CoverArtArchive.instance = new CoverArtArchive(); + } + return CoverArtArchive.instance; + } + + private constructor() { super( 'https://coverartarchive.org', {}, @@ -17,20 +32,164 @@ class CoverArtArchive extends ExternalAPI { ); } + private isMetadataStale(metadata: MetadataAlbum | null): boolean { + if (!metadata) return true; + return Date.now() - metadata.updatedAt.getTime() > this.STALE_THRESHOLD; + } + + private createEmptyResponse(id: string): CoverArtResponse { + return { images: [], release: `/release/${id}` }; + } + + private createCachedResponse(url: string, id: string): CoverArtResponse { + return { + images: [ + { + approved: true, + front: true, + id: 0, + thumbnails: { 250: url }, + }, + ], + release: `/release/${id}`, + }; + } + + public async getCoverArtFromCache( + id: string + ): Promise { + try { + const metadata = await getRepository(MetadataAlbum).findOne({ + where: { mbAlbumId: id }, + select: ['caaUrl'], + }); + return metadata?.caaUrl; + } catch (error) { + logger.error('Failed to fetch cover art from cache', { + label: 'CoverArtArchive', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } + } + public async getCoverArt(id: string): Promise { + try { + const metadata = await getRepository(MetadataAlbum).findOne({ + where: { mbAlbumId: id }, + select: ['caaUrl', 'updatedAt'], + }); + + if (metadata?.caaUrl) { + return this.createCachedResponse(metadata.caaUrl, id); + } + + if (metadata && !this.isMetadataStale(metadata)) { + return this.createEmptyResponse(id); + } + + return await this.fetchCoverArt(id); + } catch (error) { + logger.error('Failed to get cover art', { + label: 'CoverArtArchive', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return this.createEmptyResponse(id); + } + } + + private async fetchCoverArt(id: string): Promise { try { const data = await this.get( `/release-group/${id}`, undefined, - 43200 + this.CACHE_TTL ); + + const releaseMBID = data.release.split('/').pop(); + + data.images = data.images.map((image) => { + const fullUrl = `https://archive.org/download/mbid-${releaseMBID}/mbid-${releaseMBID}-${image.id}_thumb250.jpg`; + + if (image.front) { + getRepository(MetadataAlbum) + .upsert( + { mbAlbumId: id, caaUrl: fullUrl }, + { conflictPaths: ['mbAlbumId'] } + ) + .catch((e) => { + logger.error('Failed to save album metadata', { + label: 'CoverArtArchive', + error: e instanceof Error ? e.message : 'Unknown error', + }); + }); + } + + return { + approved: image.approved, + front: image.front, + id: image.id, + thumbnails: { 250: fullUrl }, + }; + }); + return data; - } catch (e) { - throw new Error( - `[CoverArtArchive] Failed to fetch cover art: ${e.message}` + } catch (error) { + await getRepository(MetadataAlbum).upsert( + { mbAlbumId: id, caaUrl: null }, + { conflictPaths: ['mbAlbumId'] } ); + return this.createEmptyResponse(id); } } + + public async batchGetCoverArt( + ids: string[] + ): Promise> { + if (!ids.length) return {}; + + const metadataRepository = getRepository(MetadataAlbum); + const existingMetadata = await metadataRepository.find({ + where: { mbAlbumId: In(ids) }, + select: ['mbAlbumId', 'caaUrl', 'updatedAt'], + }); + + const results: Record = {}; + const idsToFetch: string[] = []; + + ids.forEach((id) => { + const metadata = existingMetadata.find((m) => m.mbAlbumId === id); + + if (metadata?.caaUrl) { + results[id] = metadata.caaUrl; + } else if (metadata && !this.isMetadataStale(metadata)) { + results[id] = null; + } else { + idsToFetch.push(id); + } + }); + + if (idsToFetch.length > 0) { + const batchPromises = idsToFetch.map((id) => + this.fetchCoverArt(id) + .then((response) => { + const frontImage = response.images.find((img) => img.front); + results[id] = frontImage?.thumbnails?.[250] || null; + return true; + }) + .catch(() => { + results[id] = null; + return false; + }) + ); + + await Promise.all(batchPromises); + } + + return results; + } } export default CoverArtArchive; diff --git a/server/api/coverartarchive/interfaces.ts b/server/api/coverartarchive/interfaces.ts index 133a948b..28a11a22 100644 --- a/server/api/coverartarchive/interfaces.ts +++ b/server/api/coverartarchive/interfaces.ts @@ -1,21 +1,12 @@ interface CoverArtThumbnails { - 1200: string; 250: string; - 500: string; - large: string; - small: string; } interface CoverArtImage { approved: boolean; - back: boolean; - comment: string; - edit: number; front: boolean; id: number; - image: string; thumbnails: CoverArtThumbnails; - types: string[]; } export interface CoverArtResponse { diff --git a/server/api/listenbrainz/index.ts b/server/api/listenbrainz/index.ts index 34d475ab..af1a0383 100644 --- a/server/api/listenbrainz/index.ts +++ b/server/api/listenbrainz/index.ts @@ -1,8 +1,11 @@ import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import type { - LbSimilarArtistResponse, + LbAlbumDetails, + LbArtistDetails, + LbFreshReleasesResponse, LbTopAlbumsResponse, + LbTopArtistsResponse, } from './interfaces'; class ListenBrainzAPI extends ExternalAPI { @@ -13,48 +16,60 @@ class ListenBrainzAPI extends ExternalAPI { { nodeCache: cacheManager.getCache('listenbrainz').data, rateLimit: { - maxRPS: 50, + maxRPS: 25, id: 'listenbrainz', }, } ); } - public async getSimilarArtists( - artistMbid: string, - options: { - days?: number; - session?: number; - contribution?: number; - threshold?: number; - limit?: number; - skip?: number; - } = {} - ): Promise { - const { - days = 9000, - session = 300, - contribution = 5, - threshold = 15, - limit = 50, - skip = 30, - } = options; + public async getAlbum(mbid: string): Promise { + try { + return await this.getRolling( + `/album/${mbid}`, + {}, + 43200, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + 'https://listenbrainz.org' + ); + } catch (e) { + throw new Error( + `[ListenBrainz] Failed to fetch album details: ${e.message}` + ); + } + } - return this.getRolling( - '/similar-artists/json', - { - artist_mbids: artistMbid, - algorithm: `session_based_days_${days}_session_${session}_contribution_${contribution}_threshold_${threshold}_limit_${limit}_skip_${skip}`, - }, - 43200, - undefined, - 'https://labs.api.listenbrainz.org' - ); + public async getArtist(mbid: string): Promise { + try { + return await this.getRolling( + `/artist/${mbid}`, + {}, + 43200, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + 'https://listenbrainz.org' + ); + } catch (e) { + throw new Error( + `[ListenBrainz] Failed to fetch artist details: ${e.message}` + ); + } } public async getTopAlbums({ offset = 0, - range = 'week', + range = 'month', count = 20, }: { offset?: number; @@ -71,6 +86,49 @@ class ListenBrainzAPI extends ExternalAPI { 43200 ); } + + public async getTopArtists({ + offset = 0, + range = 'month', + count = 20, + }: { + offset?: number; + range?: string; + count?: number; + }): Promise { + return this.get( + '/stats/sitewide/artists', + { + offset: offset.toString(), + range, + count: count.toString(), + }, + 43200 + ); + } + + public async getFreshReleases({ + days = 7, + sort = 'release_date', + offset = 0, + count = 20, + }: { + days?: number; + sort?: string; + offset?: number; + count?: number; + } = {}): Promise { + return this.get( + '/explore/fresh-releases', + { + days: days.toString(), + sort, + offset: offset.toString(), + count: count.toString(), + }, + 43200 + ); + } } export default ListenBrainzAPI; diff --git a/server/api/listenbrainz/interfaces.ts b/server/api/listenbrainz/interfaces.ts index 607ddb41..9928ad9e 100644 --- a/server/api/listenbrainz/interfaces.ts +++ b/server/api/listenbrainz/interfaces.ts @@ -29,3 +29,215 @@ export interface LbTopAlbumsResponse { to_ts: number; }; } + +export interface LbArtist { + artist_credit_name: string; + artist_mbid: string; + join_phrase: string; +} + +export interface LbTrack { + artist_mbids: string[]; + artists: LbArtist[]; + length: number; + name: string; + position: number; + recording_mbid: string; + total_listen_count: number; + total_user_count: number; +} + +export interface LbMedium { + format: string; + name: string; + position: number; + tracks: LbTrack[]; +} + +export interface LbListener { + listen_count: number; + user_name: string; +} + +export interface LbListeningStats { + artist_mbids: string[]; + artist_name: string; + caa_id: number; + caa_release_mbid: string; + from_ts: number; + last_updated: number; + listeners: LbListener[]; + release_group_mbid: string; + release_group_name: string; + stats_range: string; + to_ts: number; + total_listen_count: number; + total_user_count: number; +} + +export interface LbAlbumDetails { + caa_id: number; + caa_release_mbid: string; + listening_stats: LbListeningStats; + mediums: LbMedium[]; + recordings_release_mbid: string; + release_group_mbid: string; + release_group_metadata: { + artist: { + artist_credit_id: number; + artists: { + area: string; + artist_mbid: string; + begin_year: number; + join_phrase: string; + name: string; + rels: { [key: string]: string }; + type: string; + }[]; + name: string; + }; + release: { + caa_id: number; + caa_release_mbid: string; + date: string; + name: string; + rels: any[]; + type: string; + }; + release_group: { + caa_id: number; + caa_release_mbid: string; + date: string; + name: string; + rels: any[]; + type: string; + }; + tag: { + artist: { + artist_mbid: string; + count: number; + tag: string; + }[]; + release_group: { + count: number; + genre_mbid: string; + tag: string; + }[]; + }; + }; + type: string; +} + +export interface LbArtistRels { + [key: string]: string; +} + +export interface LbArtistTag { + artist_mbid: string; + count: number; + tag: string; +} + +export interface LbArtistMetadata { + area: string; + artist_mbid: string; + begin_year: number; + mbid: string; + name: string; + rels: LbArtistRels; + tag: { + artist: LbArtistTag[]; + }; + type: string; +} + +export interface LbPopularRecording { + artist_mbids: string[]; + artist_name: string; + artists: LbArtist[]; + caa_id: number; + caa_release_mbid: string; + length: number; + recording_mbid: string; + recording_name: string; + release_color?: { + blue: number; + green: number; + red: number; + }; + release_mbid: string; + release_name: string; + total_listen_count: number; + total_user_count: number; +} + +export interface LbReleaseGroupExtended extends LbReleaseGroup { + artist_credit_name: string; + artists: LbArtist[]; + date: string; + mbid: string; + type: string; + name: string; + secondary_types?: string[]; + total_listen_count: number; +} + +export interface LbArtistDetails { + artist: LbArtistMetadata; + coverArt: string; + listeningStats: LbListeningStats; + popularRecordings: LbPopularRecording[]; + releaseGroups: LbReleaseGroupExtended[]; + similarArtists: { + artists: LbSimilarArtistResponse[]; + topRecordingColor: { + blue: number; + green: number; + red: number; + }; + topReleaseGroupColor: { + blue: number; + green: number; + red: number; + }; + }; +} + +export interface LbArtist { + artist_mbid: string; + artist_name: string; + listen_count: number; +} + +export interface LbTopArtistsResponse { + payload: { + count: number; + from_ts: number; + last_updated: number; + offset: number; + range: string; + artists: LbArtist[]; + to_ts: number; + }; +} + +export interface LbRelease { + artist_credit_name: string; + artist_mbids: string[]; + caa_id: number; + caa_release_mbid: string; + listen_count: number; + release_date: string; + release_group_mbid: string; + release_group_primary_type: string; + release_group_secondary_type: string; + release_mbid: string; + release_name: string; + release_tags: string[]; +} + +export interface LbFreshReleasesResponse { + payload: { + releases: LbRelease[]; + }; +} diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts index 75f1e5fd..6271383c 100644 --- a/server/api/musicbrainz/index.ts +++ b/server/api/musicbrainz/index.ts @@ -1,274 +1,164 @@ import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import DOMPurify from 'dompurify'; -import type { - MbAlbumDetails, - MbArtistDetails, - MbLink, - MbSearchMultiResponse, -} from './interfaces'; +import { JSDOM } from 'jsdom'; +import type { MbAlbumDetails, MbArtistDetails } from './interfaces'; + +const window = new JSDOM('').window; +const purify = DOMPurify(window); class MusicBrainz extends ExternalAPI { constructor() { super( - 'https://api.lidarr.audio/api/v0.4', + 'https://musicbrainz.org/ws/2', {}, { + headers: { + 'User-Agent': + 'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr)', + Accept: 'application/json', + }, nodeCache: cacheManager.getCache('musicbrainz').data, rateLimit: { - maxRPS: 50, + maxRPS: 25, id: 'musicbrainz', }, } ); } - public searchMulti = async ({ + public async searchAlbum({ query, + limit = 30, + offset = 0, }: { query: string; - }): Promise => { + limit?: number; + offset?: number; + }): Promise { try { - const data = await this.get('/search', { - type: 'all', - query, - }); - - return data.filter( - (result) => !result.artist || result.artist.type === 'Group' - ); - } catch (e) { - return []; - } - }; - - public async searchArtist({ - query, - }: { - query: string; - }): Promise { - try { - const data = await this.get( - '/search', + const data = await this.get<{ + created: string; + count: number; + offset: number; + 'release-groups': MbAlbumDetails[]; + }>( + '/release-group', { - type: 'artist', query, + fmt: 'json', + limit: limit.toString(), + offset: offset.toString(), }, 43200 ); - return data; + return data['release-groups']; + } catch (e) { + throw new Error(`[MusicBrainz] Failed to search albums: ${e.message}`); + } + } + + public async searchArtist({ + query, + limit = 50, + offset = 0, + }: { + query: string; + limit?: number; + offset?: number; + }): Promise { + try { + const data = await this.get<{ + created: string; + count: number; + offset: number; + artists: MbArtistDetails[]; + }>( + '/artist', + { + query, + fmt: 'json', + limit: limit.toString(), + offset: offset.toString(), + }, + 43200 + ); + + return data.artists; } catch (e) { throw new Error(`[MusicBrainz] Failed to search artists: ${e.message}`); } } - public async getAlbum({ - albumId, + public async getArtistWikipediaExtract({ + artistMbid, + language = 'en', }: { - albumId?: string; - }): Promise { + artistMbid: string; + language?: string; + }): Promise<{ title: string; url: string; content: string } | null> { try { - const data = await this.get( - `/album/${albumId}`, - {}, - 43200 + const response = await fetch( + `https://musicbrainz.org/artist/${artistMbid}/wikipedia-extract`, + { + headers: { + Accept: 'application/json', + 'Accept-Language': language, + }, + } ); - return data; - } catch (e) { - throw new Error( - `[MusicBrainz] Failed to fetch album details: ${e.message}` - ); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + if (!data.wikipediaExtract || !data.wikipediaExtract.content) { + throw new Error('No Wikipedia extract found'); + } + + const cleanContent = purify.sanitize(data.wikipediaExtract.content, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + }); + + return { + title: data.wikipediaExtract.title, + url: data.wikipediaExtract.url, + content: cleanContent.trim(), + }; + } catch (error) { + throw new Error(`Error fetching Wikipedia extract: ${error.message}`); } } - public async getArtist({ - artistId, - }: { - artistId: string; - }): Promise { - try { - const artistData = await this.get( - `/artist/${artistId}`, - {}, - 43200 - ); - - return artistData; - } catch (e) { - throw new Error( - `[MusicBrainz] Failed to fetch artist details: ${e.message}` - ); - } - } - - private static requestQueue: Promise = Promise.resolve(); - private lastRequestTime = 0; - private readonly RATE_LIMIT_DELAY = 1100; - public async getReleaseGroup({ releaseId, }: { releaseId: string; }): Promise { try { - await MusicBrainz.requestQueue; - - MusicBrainz.requestQueue = (async () => { - const now = Date.now(); - const timeSinceLastRequest = now - this.lastRequestTime; - if (timeSinceLastRequest < this.RATE_LIMIT_DELAY) { - await new Promise((resolve) => - setTimeout(resolve, this.RATE_LIMIT_DELAY - timeSinceLastRequest) - ); - } - this.lastRequestTime = Date.now(); - })(); - - await MusicBrainz.requestQueue; - - const data = await this.getRolling( + const data = await this.get<{ + 'release-group': { + id: string; + }; + }>( `/release/${releaseId}`, { inc: 'release-groups', fmt: 'json', }, - 43200, - { - headers: { - 'User-Agent': - 'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr; hello@jellyseerr.com)', - Accept: 'application/json', - }, - }, - 'https://musicbrainz.org/ws/2' + 43200 ); - return data['release-group']?.id || null; + return data['release-group']?.id ?? null; } catch (e) { - if (e.message.includes('503')) { - await new Promise((resolve) => - setTimeout(resolve, this.RATE_LIMIT_DELAY * 2) - ); - - await MusicBrainz.requestQueue; - MusicBrainz.requestQueue = Promise.resolve(); - this.lastRequestTime = Date.now(); - - try { - return await this.getReleaseGroup({ releaseId }); - } catch (retryError) { - throw new Error( - `[MusicBrainz] Failed to fetch release group after retry: ${retryError.message}` - ); - } - } throw new Error( `[MusicBrainz] Failed to fetch release group: ${e.message}` ); } } - - public async getWikipediaExtract( - id: string, - language = 'en', - type: 'artist' | 'album' = 'album' - ): Promise { - try { - const data = - type === 'album' - ? await this.get(`/album/${id}`, { language }, 43200) - : await this.get( - `/artist/${id}`, - { language }, - 43200 - ); - - let targetLinks: MbLink[] | undefined; - if (type === 'album') { - const albumData = data as MbAlbumDetails; - const artistId = albumData.artists?.[0]?.id; - if (!artistId) return null; - - const artistData = await this.get( - `/artist/${artistId}`, - { language }, - 43200 - ); - targetLinks = artistData.links; - } else { - const artistData = data as MbArtistDetails; - targetLinks = artistData.links; - } - - const wikiLink = targetLinks?.find( - (l: MbLink) => l.type.toLowerCase() === 'wikidata' - )?.target; - - if (!wikiLink) return null; - - const wikiId = wikiLink.split('/').pop(); - if (!wikiId) return null; - - interface WikidataResponse { - entities: { - [key: string]: { - sitelinks?: { - [key: string]: { - title: string; - }; - }; - }; - }; - } - - interface WikipediaResponse { - query: { - pages: { - [key: string]: { - extract: string; - }; - }; - }; - } - - const wikiResponse = await fetch( - `https://www.wikidata.org/w/api.php?action=wbgetentities&props=sitelinks&ids=${wikiId}&format=json` - ); - const wikiData = (await wikiResponse.json()) as WikidataResponse; - - const wikipediaTitle = - wikiData.entities[wikiId]?.sitelinks?.[`${language}wiki`]?.title; - if (!wikipediaTitle) return null; - - const extractResponse = await fetch( - `https://${language}.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&titles=${encodeURIComponent( - wikipediaTitle - )}&format=json&origin=*` - ); - const extractData = (await extractResponse.json()) as WikipediaResponse; - const extract = Object.values(extractData.query.pages)[0]?.extract; - - if (!extract) return null; - - const decoded = DOMPurify.sanitize(extract, { - ALLOWED_TAGS: [], // Strip all HTML tags - ALLOWED_ATTR: [], // Strip all attributes - }) - .trim() - .replace(/\s+/g, ' ') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - - return decoded; - } catch (e) { - return null; - } - } } export default MusicBrainz; diff --git a/server/api/musicbrainz/interfaces.ts b/server/api/musicbrainz/interfaces.ts index 18049c96..aab0af2a 100644 --- a/server/api/musicbrainz/interfaces.ts +++ b/server/api/musicbrainz/interfaces.ts @@ -1,126 +1,119 @@ -interface MbMediaResult { +interface MbResult { id: string; score: number; } -export interface MbArtistResult extends MbMediaResult { - media_type: 'artist'; - artistname: string; - overview: string; - disambiguation: string; - type: 'Group' | 'Person'; - status: string; - sortname: string; - genres: string[]; - images: MbImage[]; - links: MbLink[]; - rating?: { - Count: number; - Value: number | null; - }; -} - -export interface MbAlbumResult extends MbMediaResult { - media_type: 'album'; - title: string; - artistid: string; - artists: MbArtistResult[]; - type: string; - releasedate: string; - disambiguation: string; - genres: string[]; - images: MbImage[]; - secondarytypes: string[]; - overview?: string; - releases?: { - id: string; - track_count?: number; - title?: string; - releasedate?: string; - }[]; -} - -export interface MbImage { - CoverType: 'Fanart' | 'Logo' | 'Poster' | 'Cover'; - Url: string; -} - export interface MbLink { - target: string; type: string; + target: string; } -export interface MbSearchMultiResponse { - artist: MbArtistResult | null; - album: MbAlbumResult | null; - score: number; -} - -export interface MbArtistDetails extends MbArtistResult { - artistaliases: string[]; - oldids: string[]; - links: MbLink[]; - images: MbImage[]; - rating: { - Count: number; - Value: number | null; - }; - Albums?: Album[]; +export interface MbAlbumResult extends MbResult { + media_type: 'album'; + title: string; + 'primary-type': 'Album' | 'Single' | 'EP'; + 'first-release-date': string; + 'artist-credit': { + name: string; + artist: { + id: string; + name: string; + 'sort-name': string; + overview?: string; + }; + }[]; + posterPath: string | undefined; } export interface MbAlbumDetails extends MbAlbumResult { - aliases: string[]; - artists: MbArtistResult[]; - releases: MbRelease[]; - rating: { - Count: number; - Value: number | null; - }; - overview: string; - secondaryTypes: string[]; -} - -interface MbRelease { - id: string; - title: string; - status: string; - releasedate: string; - country: string[]; - label: string[]; - track_count: number; - media: MbMedium[]; - tracks: MbTrack[]; - disambiguation: string; -} - -interface MbMedium { - Format: string; - Name: string; - Position: number; -} - -interface MbTrack { - id: string; - artistid: string; - trackname: string; - tracknumber: string; - trackposition: number; - mediumnumber: number; - durationms: number; - recordingid: string; - oldids: string[]; - oldrecordingids: string[]; -} - -interface Album { - Id: string; - OldIds: string[]; - ReleaseStatuses: string[]; - SecondaryTypes: string[]; - Title: string; - Type: string; - images?: { - CoverType: string; - Url: string; + 'type-id': string; + 'primary-type-id': string; + count: number; + 'secondary-types'?: string[]; + 'secondary-type-ids'?: string[]; + releases: { + id: string; + title: string; + status: string; + 'status-id': string; }[]; + releasedate: string; + tags?: { + count: number; + name: string; + }[]; + artists?: { + id: string; + name: string; + overview?: string; + }[]; + links?: MbLink[]; + poster_path?: string; +} + +export interface MbArtistResult extends MbResult { + media_type: 'artist'; + name: string; + type: 'Group' | 'Person'; + 'sort-name': string; + country?: string; + disambiguation?: string; + artistThumb?: string | null; + artistBackdrop?: string | null; +} + +export interface MbArtistDetails extends MbArtistResult { + 'type-id': string; + area?: { + id: string; + name: string; + type: string; + 'type-id': string; + 'sort-name': string; + }; + 'begin-area'?: { + id: string; + name: string; + type: string; + 'sort-name': string; + }; + 'life-span'?: { + begin?: string; + ended: boolean; + }; + gender?: string; + 'gender-id'?: string; + isnis?: string[]; + aliases?: { + name: string; + 'sort-name': string; + type?: string; + 'type-id'?: string; + }[]; + tags?: { + count: number; + name: string; + }[]; + links?: MbLink[]; +} + +export interface MbSearchAlbumResponse { + created: string; + count: number; + offset: number; + 'release-groups': MbAlbumDetails[]; +} + +export interface MbSearchArtistResponse { + created: string; + count: number; + offset: number; + artists: MbArtistDetails[]; +} + +export interface MbSearchMultiResponse { + created: string; + count: number; + offset: number; + results: (MbArtistResult | MbAlbumResult)[]; } diff --git a/server/api/servarr/lidarr.ts b/server/api/servarr/lidarr.ts index 76f97849..a9fc2510 100644 --- a/server/api/servarr/lidarr.ts +++ b/server/api/servarr/lidarr.ts @@ -20,6 +20,11 @@ export interface LidarrArtistResult extends LidarrMediaResult { export interface LidarrAlbumResult extends LidarrMediaResult { album: { + disambiguation: string; + duration: number; + mediumCount: number; + ratings: LidarrRating | undefined; + links: never[]; media_type: 'music'; title: string; foreignAlbumId: string; @@ -29,6 +34,22 @@ export interface LidarrAlbumResult extends LidarrMediaResult { genres: string[]; images: LidarrImage[]; artist: { + id: number; + status: string; + ended: boolean; + foreignArtistId: string; + tadbId: number; + discogsId: number; + artistType: string; + disambiguation: string | undefined; + links: never[]; + images: never[]; + genres: never[]; + cleanName: string | undefined; + sortName: string | undefined; + tags: never[]; + added: string; + ratings: LidarrRating | undefined; artistName: string; overview: string; }; @@ -60,6 +81,8 @@ export interface LidarrArtistDetails { added: string; ratings: LidarrRating; remotePoster?: string; + cleanName?: string; + sortName?: string; } export interface LidarrAlbumDetails { @@ -165,24 +188,53 @@ export interface LidarrSearchResponse { export interface LidarrAlbumOptions { [key: string]: unknown; - profileId: number; - mbId: string; - qualityProfileId: number; - rootFolderPath: string; title: string; - monitored: boolean; - tags: string[]; - searchNow: boolean; + disambiguation?: string; + overview?: string; artistId: number; + foreignAlbumId: string; + monitored: boolean; + anyReleaseOk: boolean; + profileId: number; + duration?: number; + albumType: string; + secondaryTypes: string[]; + mediumCount?: number; + ratings?: LidarrRating; + releaseDate?: string; + releases: any[]; + genres: string[]; + media: any[]; artist: { - id: number; - foreignArtistId: string; + status: string; + ended: boolean; artistName: string; + foreignArtistId: string; + tadbId?: number; + discogsId?: number; + overview?: string; + artistType: string; + disambiguation?: string; + links: LidarrLink[]; + images: LidarrImage[]; + path: string; qualityProfileId: number; metadataProfileId: number; - rootFolderPath: string; monitored: boolean; monitorNewItems: string; + rootFolderPath: string; + genres: string[]; + cleanName?: string; + sortName?: string; + tags: number[]; + added?: string; + ratings?: LidarrRating; + id: number; + }; + images: LidarrImage[]; + links: LidarrLink[]; + addOptions: { + searchForNewAlbum: boolean; }; } @@ -221,6 +273,11 @@ export interface LidarrAlbum { }; } +export interface SearchCommand extends Record { + name: 'AlbumSearch'; + albumIds: number[]; +} + class LidarrAPI extends ServarrBase<{ albumId: number }> { protected apiKey: string; constructor({ url, apiKey }: { url: string; apiKey: string }) { @@ -237,15 +294,6 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> { } } - public async getArtist({ id }: { id: number }): Promise { - try { - const data = await this.get(`/artist/${id}`); - return data; - } catch (e) { - throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`); - } - } - public async getAlbum({ id }: { id: number }): Promise { try { const data = await this.get(`/album/${id}`); @@ -255,29 +303,6 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> { } } - public async getAlbumByMusicBrainzId( - mbId: string - ): Promise { - try { - const data = await this.get('/album/lookup', { - term: `lidarr:${mbId}`, - }); - - if (!data[0]) { - throw new Error('Album not found'); - } - - return data[0]; - } catch (e) { - logger.error('Error retrieving album by foreign ID', { - label: 'Lidarr API', - errorMessage: e.message, - mbId: mbId, - }); - throw new Error('Album not found'); - } - } - public async removeAlbum(albumId: number): Promise { try { await this.delete(`/album/${albumId}`, { @@ -290,215 +315,60 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> { } } - public async getArtistByMusicBrainzId( - mbId: string - ): Promise { + public async searchAlbum(mbid: string): Promise { try { - const data = await this.get('/artist/lookup', { - term: `lidarr:${mbId}`, + const data = await this.get(`/search`, { + term: `lidarr:${mbid}`, }); - - if (!data[0]) { - throw new Error('Artist not found'); - } - - return data[0]; + return data; } catch (e) { - logger.error('Error retrieving artist by foreign ID', { - label: 'Lidarr API', - errorMessage: e.message, - mbId: mbId, - }); - throw new Error('Artist not found'); + throw new Error(`[Lidarr] Failed to search album: ${e.message}`); } } - public async addAlbum( - options: LidarrAlbumOptions - ): Promise { + public async addAlbum(options: LidarrAlbumOptions): Promise { try { - const data = await this.post('/album', options); - return data; - } catch (e) { - if (e.message.includes('This album has already been added')) { - logger.info('Album already exists in Lidarr, monitoring it in Lidarr', { - label: 'Lidarr', - albumTitle: options.title, - mbId: options.mbId, - }); - throw e; + const existingAlbums = await this.get('/album', { + foreignAlbumId: options.foreignAlbumId, + includeAllArtistAlbums: 'true', + }); + + if (existingAlbums.length > 0 && existingAlbums[0].monitored) { + logger.info( + 'Album is already monitored in Lidarr. Skipping add and returning success', + { + label: 'Lidarr', + } + ); + return existingAlbums[0]; } - logger.error('Failed to add album to Lidarr', { - label: 'Lidarr', - options, - errorMessage: e.message, + const data = await this.post('/album', { + ...options, + monitored: true, }); + return data; + } catch (e) { throw new Error(`[Lidarr] Failed to add album: ${e.message}`); } } - public async addArtist( - options: LidarrArtistOptions - ): Promise { + public async searchAlbumByMusicBrainzId( + mbid: string + ): Promise { try { - const data = await this.post('/artist', options); + const data = await this.get('/search', { + term: `lidarr:${mbid}`, + }); return data; } catch (e) { - logger.error('Failed to add artist to Lidarr', { - label: 'Lidarr', - options, - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to add artist: ${e.message}`); - } - } - - public async searchMulti(searchTerm: string): Promise { - try { - const data = await this.get< - { - foreignId: string; - artist?: { - artistName: string; - overview?: string; - remotePoster?: string; - artistType?: string; - genres: string[]; - foreignArtistId: string; - }; - album?: { - title: string; - foreignAlbumId: string; - overview?: string; - releaseDate?: string; - albumType: string; - genres: string[]; - images: LidarrImage[]; - artist: { - artistName: string; - overview?: string; - }; - remoteCover?: string; - }; - id: number; - }[] - >( - '/search', - { - term: searchTerm, - }, - undefined, - { - headers: { - 'X-Api-Key': this.apiKey, - }, - } + throw new Error( + `[Lidarr] Failed to search album by MusicBrainz ID: ${e.message}` ); - - if (!data) { - throw new Error('No data received from Lidarr'); - } - - const results = data.map((item) => { - if (item.album) { - return { - id: item.id, - mbId: item.album.foreignAlbumId, - media_type: 'music' as const, - album: { - media_type: 'music' as const, - title: item.album.title, - foreignAlbumId: item.album.foreignAlbumId, - overview: item.album.overview || '', - releaseDate: item.album.releaseDate || '', - albumType: item.album.albumType, - genres: item.album.genres, - images: item.album.remoteCover - ? [ - { - url: item.album.remoteCover, - coverType: 'cover', - }, - ] - : item.album.images, - artist: { - artistName: item.album.artist.artistName, - overview: item.album.artist.overview || '', - }, - }, - } satisfies LidarrAlbumResult; - } - - if (item.artist) { - return { - id: item.id, - mbId: item.artist.foreignArtistId, - media_type: 'artist' as const, - artist: { - media_type: 'artist' as const, - artistName: item.artist.artistName, - overview: item.artist.overview || '', - remotePoster: item.artist.remotePoster, - artistType: item.artist.artistType || '', - genres: item.artist.genres, - }, - } satisfies LidarrArtistResult; - } - - throw new Error('Invalid search result type'); - }); - - return { - page: 1, - total_pages: 1, - total_results: results.length, - results, - }; - } catch (e) { - logger.error('Failed to search Lidarr', { - label: 'Lidarr API', - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to search: ${e.message}`); } } - public async updateArtist( - artist: LidarrArtistDetails - ): Promise { - try { - const data = await this.put(`/artist/${artist.id}`, { - ...artist, - } as Record); - return data; - } catch (e) { - logger.error('Failed to update artist in Lidarr', { - label: 'Lidarr', - artistId: artist.id, - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to update artist: ${e.message}`); - } - } - - public async updateAlbum(album: LidarrAlbum): Promise { - try { - const data = await this.put(`/album/${album.id}`, { - ...album, - } as Record); - return data; - } catch (e) { - logger.error('Failed to update album in Lidarr', { - label: 'Lidarr', - albumId: album.id, - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to update album: ${e.message}`); - } - } - - public async searchAlbum(albumId: number): Promise { + public async searchOnAdd(albumId: number): Promise { logger.info('Executing album search command', { label: 'Lidarr API', albumId, diff --git a/server/api/theaudiodb/index.ts b/server/api/theaudiodb/index.ts new file mode 100644 index 00000000..6ae9f1d9 --- /dev/null +++ b/server/api/theaudiodb/index.ts @@ -0,0 +1,227 @@ +import ExternalAPI from '@server/api/externalapi'; +import { getRepository } from '@server/datasource'; +import MetadataArtist from '@server/entity/MetadataArtist'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; +import { In } from 'typeorm'; +import type { TadbArtistResponse } from './interfaces'; + +class TheAudioDb extends ExternalAPI { + private static instance: TheAudioDb; + private readonly apiKey = '195003'; + private readonly CACHE_TTL = 43200; + private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; + + constructor() { + super( + 'https://www.theaudiodb.com/api/v1/json', + {}, + { + nodeCache: cacheManager.getCache('tadb').data, + rateLimit: { + maxRPS: 25, + id: 'tadb', + }, + } + ); + } + + public static getInstance(): TheAudioDb { + if (!TheAudioDb.instance) { + TheAudioDb.instance = new TheAudioDb(); + } + return TheAudioDb.instance; + } + + private isMetadataStale(metadata: MetadataArtist | null): boolean { + if (!metadata || !metadata.tadbUpdatedAt) return true; + return Date.now() - metadata.tadbUpdatedAt.getTime() > this.STALE_THRESHOLD; + } + + private createEmptyResponse() { + return { artistThumb: null, artistBackground: null }; + } + + public async getArtistImagesFromCache(id: string): Promise< + | { + artistThumb: string | null; + artistBackground: string | null; + } + | null + | undefined + > { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: id }, + select: ['tadbThumb', 'tadbCover', 'tadbUpdatedAt'], + }); + + if (metadata) { + return { + artistThumb: metadata.tadbThumb, + artistBackground: metadata.tadbCover, + }; + } + return undefined; + } catch (error) { + logger.error('Failed to fetch artist images from cache', { + label: 'TheAudioDb', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } + } + + public async getArtistImages( + id: string + ): Promise<{ artistThumb: string | null; artistBackground: string | null }> { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: id }, + select: ['tadbThumb', 'tadbCover', 'tadbUpdatedAt'], + }); + + if (metadata?.tadbThumb || metadata?.tadbCover) { + return { + artistThumb: metadata.tadbThumb, + artistBackground: metadata.tadbCover, + }; + } + + if (metadata && !this.isMetadataStale(metadata)) { + return this.createEmptyResponse(); + } + + return await this.fetchArtistImages(id); + } catch (error) { + logger.error('Failed to get artist images', { + label: 'TheAudioDb', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return this.createEmptyResponse(); + } + } + + private async fetchArtistImages(id: string): Promise<{ + artistThumb: string | null; + artistBackground: string | null; + }> { + try { + const data = await this.get( + `/${this.apiKey}/artist-mb.php`, + { i: id }, + this.CACHE_TTL + ); + + const result = { + artistThumb: data.artists?.[0]?.strArtistThumb || null, + artistBackground: data.artists?.[0]?.strArtistFanart || null, + }; + + const metadataRepository = getRepository(MetadataArtist); + await metadataRepository + .upsert( + { + mbArtistId: id, + tadbThumb: result.artistThumb, + tadbCover: result.artistBackground, + tadbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ) + .catch((e) => { + logger.error('Failed to save artist metadata', { + label: 'TheAudioDb', + error: e instanceof Error ? e.message : 'Unknown error', + }); + }); + + return result; + } catch (error) { + await getRepository(MetadataArtist).upsert( + { + mbArtistId: id, + tadbThumb: null, + tadbCover: null, + tadbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ); + return this.createEmptyResponse(); + } + } + + public async batchGetArtistImages(ids: string[]): Promise< + Record< + string, + { + artistThumb: string | null; + artistBackground: string | null; + } + > + > { + if (!ids.length) return {}; + + const metadataRepository = getRepository(MetadataArtist); + const existingMetadata = await metadataRepository.find({ + where: { mbArtistId: In(ids) }, + select: ['mbArtistId', 'tadbThumb', 'tadbCover', 'tadbUpdatedAt'], + }); + + const results: Record< + string, + { + artistThumb: string | null; + artistBackground: string | null; + } + > = {}; + const idsToFetch: string[] = []; + + ids.forEach((id) => { + const metadata = existingMetadata.find((m) => m.mbArtistId === id); + + if (metadata?.tadbThumb || metadata?.tadbCover) { + results[id] = { + artistThumb: metadata.tadbThumb, + artistBackground: metadata.tadbCover, + }; + } else if (metadata && !this.isMetadataStale(metadata)) { + results[id] = { + artistThumb: null, + artistBackground: null, + }; + } else { + idsToFetch.push(id); + } + }); + + if (idsToFetch.length > 0) { + const batchPromises = idsToFetch.map((id) => + this.fetchArtistImages(id) + .then((response) => { + results[id] = response; + return true; + }) + .catch(() => { + results[id] = { + artistThumb: null, + artistBackground: null, + }; + return false; + }) + ); + + await Promise.all(batchPromises); + } + + return results; + } +} + +export default TheAudioDb; diff --git a/server/api/theaudiodb/interfaces.ts b/server/api/theaudiodb/interfaces.ts new file mode 100644 index 00000000..e32c1694 --- /dev/null +++ b/server/api/theaudiodb/interfaces.ts @@ -0,0 +1,8 @@ +interface TadbArtist { + strArtistThumb: string | null; + strArtistFanart: string | null; +} + +export interface TadbArtistResponse { + artists?: TadbArtist[]; +} diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 0509ea44..74ec5841 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -16,7 +16,6 @@ import type { TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetails, - TmdbPersonSearchResponse, TmdbProductionCompany, TmdbRegion, TmdbSearchMovieResponse, @@ -231,31 +230,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } }; - public async searchPerson({ - query, - page = 1, - includeAdult = false, - language = 'en', - }: SearchOptions): Promise { - try { - const data = await this.get('/search/person', { - query, - page: page.toString(), - include_adult: includeAdult ? 'true' : 'false', - language, - }); - - return data; - } catch (e) { - return { - page: 1, - results: [], - total_pages: 1, - total_results: 0, - }; - } - } - public getPerson = async ({ personId, language = this.locale, diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index a39ed379..628adead 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -42,6 +42,7 @@ export interface TmdbCollectionResult { export interface TmdbPersonResult { id: number; + known_for_department: string; name: string; popularity: number; profile_path?: string; @@ -464,20 +465,12 @@ export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse { results: TmdbCompany[]; } +export interface TmdbSearchPersonResponse extends TmdbPaginatedResponse { + results: TmdbPersonResult[]; +} + export interface TmdbWatchProviderRegion { iso_3166_1: string; english_name: string; native_name: string; } - -export interface TmdbPersonSearchResponse extends TmdbPaginatedResponse { - results: TmdbPersonSearchResult[]; -} - -export interface TmdbPersonSearchResult - extends Omit { - gender: number; - known_for_department: string; - original_name: string; - known_for: (TmdbMovieResult | TmdbTvResult)[]; -} diff --git a/server/api/themoviedb/personMapper.ts b/server/api/themoviedb/personMapper.ts new file mode 100644 index 00000000..d0438e6c --- /dev/null +++ b/server/api/themoviedb/personMapper.ts @@ -0,0 +1,348 @@ +import ExternalAPI from '@server/api/externalapi'; +import TheMovieDb from '@server/api/themoviedb'; +import { getRepository } from '@server/datasource'; +import MetadataArtist from '@server/entity/MetadataArtist'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; +import { In } from 'typeorm'; +import type { TmdbSearchPersonResponse } from './interfaces'; + +interface SearchPersonOptions { + query: string; + page?: number; + includeAdult?: boolean; + language?: string; +} + +class TmdbPersonMapper extends ExternalAPI { + private static instance: TmdbPersonMapper; + private readonly CACHE_TTL = 43200; + private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; + private tmdb: TheMovieDb; + + private constructor() { + super( + 'https://api.themoviedb.org/3', + { + api_key: '431a8708161bcd1f1fbe7536137e61ed', + }, + { + nodeCache: cacheManager.getCache('tmdb').data, + rateLimit: { + maxRPS: 50, + id: 'tmdb', + }, + } + ); + this.tmdb = new TheMovieDb(); + } + + public static getInstance(): TmdbPersonMapper { + if (!TmdbPersonMapper.instance) { + TmdbPersonMapper.instance = new TmdbPersonMapper(); + } + return TmdbPersonMapper.instance; + } + + private isMetadataStale(metadata: MetadataArtist | null): boolean { + if (!metadata || !metadata.tmdbUpdatedAt) return true; + return Date.now() - metadata.tmdbUpdatedAt.getTime() > this.STALE_THRESHOLD; + } + + private createEmptyResponse() { + return { personId: null, profilePath: null }; + } + + public async getMappingFromCache( + artistId: string + ): Promise<{ personId: number | null; profilePath: string | null } | null> { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + if (!metadata) { + return null; + } + + if (this.isMetadataStale(metadata)) { + return null; + } + + return { + personId: metadata.tmdbPersonId ? Number(metadata.tmdbPersonId) : null, + profilePath: metadata.tmdbThumb, + }; + } catch (error) { + logger.error('Failed to get person mapping from cache', { + label: 'TmdbPersonMapper', + artistId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } + } + + public async getMapping( + artistId: string, + artistName: string + ): Promise<{ personId: number | null; profilePath: string | null }> { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + if (metadata?.tmdbPersonId || metadata?.tmdbThumb) { + return { + personId: metadata.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + profilePath: metadata.tmdbThumb, + }; + } + + if (metadata && !this.isMetadataStale(metadata)) { + return this.createEmptyResponse(); + } + + return await this.fetchMapping(artistId, artistName); + } catch (error) { + logger.error('Failed to get person mapping', { + label: 'TmdbPersonMapper', + artistId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return this.createEmptyResponse(); + } + } + + private async fetchMapping( + artistId: string, + artistName: string + ): Promise<{ personId: number | null; profilePath: string | null }> { + try { + const existingMetadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + if (existingMetadata?.tmdbPersonId) { + return { + personId: Number(existingMetadata.tmdbPersonId), + profilePath: existingMetadata.tmdbThumb, + }; + } + + const cleanArtistName = artistName + .split(/(?:(?:feat|ft)\.?\s+|&\s*|,\s+)/i)[0] + .trim() + .replace(/['']/g, "'"); + + const searchResults = await this.get( + '/search/person', + { + query: cleanArtistName, + page: '1', + include_adult: 'false', + language: 'en', + }, + this.CACHE_TTL + ); + + const exactMatches = searchResults.results.filter((person) => { + const normalizedPersonName = person.name + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/['']/g, "'") + .replace(/[^a-z0-9\s]/g, '') + .trim(); + + const normalizedArtistName = cleanArtistName + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/['']/g, "'") + .replace(/[^a-z0-9\s]/g, '') + .trim(); + + return normalizedPersonName === normalizedArtistName; + }); + + if (exactMatches.length > 0) { + const tmdbPersonIds = exactMatches.map((match) => match.id.toString()); + const existingMappings = await getRepository(MetadataArtist).find({ + where: { tmdbPersonId: In(tmdbPersonIds) }, + select: ['mbArtistId', 'tmdbPersonId'], + }); + + const availableMatches = exactMatches.filter( + (match) => + !existingMappings.some( + (mapping) => + mapping.tmdbPersonId === match.id.toString() && + mapping.mbArtistId !== artistId + ) + ); + + const soundMatches = availableMatches.filter( + (person) => person.known_for_department === 'Sound' + ); + + const exactMatch = + soundMatches.length > 0 + ? soundMatches.reduce((prev, current) => + current.popularity > prev.popularity ? current : prev + ) + : availableMatches.length > 0 + ? availableMatches.reduce((prev, current) => + current.popularity > prev.popularity ? current : prev + ) + : null; + + const mapping = { + personId: exactMatch?.id ?? null, + profilePath: exactMatch?.profile_path + ? `https://image.tmdb.org/t/p/w500${exactMatch.profile_path}` + : null, + }; + + await getRepository(MetadataArtist) + .upsert( + { + mbArtistId: artistId, + tmdbPersonId: mapping.personId?.toString() ?? null, + tmdbThumb: mapping.profilePath, + tmdbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ) + .catch((e) => { + logger.error('Failed to save artist metadata', { + label: 'TmdbPersonMapper', + error: e instanceof Error ? e.message : 'Unknown error', + }); + }); + + return mapping; + } else { + await getRepository(MetadataArtist).upsert( + { + mbArtistId: artistId, + tmdbPersonId: null, + tmdbThumb: null, + tmdbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ); + return this.createEmptyResponse(); + } + } catch (error) { + await getRepository(MetadataArtist).upsert( + { + mbArtistId: artistId, + tmdbPersonId: null, + tmdbThumb: null, + tmdbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ); + return this.createEmptyResponse(); + } + } + + public async batchGetMappings( + artists: { artistId: string; artistName: string }[] + ): Promise< + Record + > { + if (!artists.length) return {}; + + const metadataRepository = getRepository(MetadataArtist); + const artistIds = artists.map((a) => a.artistId); + + const existingMetadata = await metadataRepository.find({ + where: { mbArtistId: In(artistIds) }, + select: ['mbArtistId', 'tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + const results: Record< + string, + { personId: number | null; profilePath: string | null } + > = {}; + const artistsToFetch: { artistId: string; artistName: string }[] = []; + + artists.forEach(({ artistId, artistName }) => { + const metadata = existingMetadata.find((m) => m.mbArtistId === artistId); + + if (metadata?.tmdbPersonId || metadata?.tmdbThumb) { + results[artistId] = { + personId: metadata.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + profilePath: metadata.tmdbThumb, + }; + } else if (metadata && !this.isMetadataStale(metadata)) { + results[artistId] = this.createEmptyResponse(); + } else { + artistsToFetch.push({ artistId, artistName }); + } + }); + + if (artistsToFetch.length > 0) { + const batchSize = 5; + for (let i = 0; i < artistsToFetch.length; i += batchSize) { + const batch = artistsToFetch.slice(i, i + batchSize); + const batchPromises = batch.map(({ artistId, artistName }) => + this.fetchMapping(artistId, artistName) + .then((mapping) => { + results[artistId] = mapping; + return true; + }) + .catch(() => { + results[artistId] = this.createEmptyResponse(); + return false; + }) + ); + + await Promise.all(batchPromises); + } + } + + return results; + } + + public async searchPerson( + options: SearchPersonOptions + ): Promise { + try { + return await this.get( + '/search/person', + { + query: options.query, + page: options.page?.toString() ?? '1', + include_adult: options.includeAdult ? 'true' : 'false', + language: options.language ?? 'en', + }, + this.CACHE_TTL + ); + } catch (e) { + return { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }; + } + } +} + +export default TmdbPersonMapper; diff --git a/server/constants/discover.ts b/server/constants/discover.ts index 15e8088e..69710d2a 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -4,7 +4,6 @@ export enum DiscoverSliderType { RECENTLY_ADDED = 1, RECENT_REQUESTS, PLEX_WATCHLIST, - POPULAR_ALBUMS, TRENDING, POPULAR_MOVIES, MOVIE_GENRES, @@ -23,6 +22,8 @@ export enum DiscoverSliderType { TMDB_NETWORK, TMDB_MOVIE_STREAMING_SERVICES, TMDB_TV_STREAMING_SERVICES, + POPULAR_ALBUMS, + POPULAR_ARTISTS, } export const defaultSliders: Partial[] = [ @@ -51,57 +52,63 @@ export const defaultSliders: Partial[] = [ order: 3, }, { - type: DiscoverSliderType.POPULAR_ALBUMS, + type: DiscoverSliderType.POPULAR_MOVIES, enabled: true, isBuiltIn: true, order: 4, }, { - type: DiscoverSliderType.POPULAR_MOVIES, + type: DiscoverSliderType.MOVIE_GENRES, enabled: true, isBuiltIn: true, order: 5, }, { - type: DiscoverSliderType.MOVIE_GENRES, + type: DiscoverSliderType.UPCOMING_MOVIES, enabled: true, isBuiltIn: true, order: 6, }, { - type: DiscoverSliderType.UPCOMING_MOVIES, + type: DiscoverSliderType.STUDIOS, enabled: true, isBuiltIn: true, order: 7, }, { - type: DiscoverSliderType.STUDIOS, + type: DiscoverSliderType.POPULAR_TV, enabled: true, isBuiltIn: true, order: 8, }, { - type: DiscoverSliderType.POPULAR_TV, + type: DiscoverSliderType.TV_GENRES, enabled: true, isBuiltIn: true, order: 9, }, { - type: DiscoverSliderType.TV_GENRES, + type: DiscoverSliderType.UPCOMING_TV, enabled: true, isBuiltIn: true, order: 10, }, { - type: DiscoverSliderType.UPCOMING_TV, + type: DiscoverSliderType.NETWORKS, enabled: true, isBuiltIn: true, order: 11, }, { - type: DiscoverSliderType.NETWORKS, + type: DiscoverSliderType.POPULAR_ALBUMS, enabled: true, isBuiltIn: true, order: 12, }, + { + type: DiscoverSliderType.POPULAR_ARTISTS, + enabled: true, + isBuiltIn: true, + order: 13, + }, ]; diff --git a/server/constants/issue.ts b/server/constants/issue.ts index 9e320866..90739951 100644 --- a/server/constants/issue.ts +++ b/server/constants/issue.ts @@ -2,8 +2,8 @@ export enum IssueType { VIDEO = 1, AUDIO = 2, SUBTITLES = 3, - LYRICS = 4, - OTHER = 5, + OTHER = 4, + LYRICS = 5, } export enum IssueStatus { @@ -15,6 +15,6 @@ export const IssueTypeName = { [IssueType.AUDIO]: 'Audio', [IssueType.VIDEO]: 'Video', [IssueType.SUBTITLES]: 'Subtitle', - [IssueType.LYRICS]: 'Lyrics', [IssueType.OTHER]: 'Other', + [IssueType.LYRICS]: 'Lyrics', }; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index e9bed8b5..ca8162c4 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -30,39 +30,38 @@ import Season from './Season'; class Media { public static async getRelatedMedia( user: User | undefined, - ids: (number | string)[] + ids: number | number[] | string | string[] ): Promise { const mediaRepository = getRepository(Media); try { - if (ids.length === 0) { + let finalIds: (number | string)[]; + if (!Array.isArray(ids)) { + finalIds = [ids]; + } else { + finalIds = ids; + } + + if (finalIds.length === 0) { return []; } - const tmdbIds = ids.filter((id): id is number => typeof id === 'number'); - const mbIds = ids.filter((id): id is string => typeof id === 'string'); - - const queryBuilder = mediaRepository + const media = await mediaRepository .createQueryBuilder('media') .leftJoinAndSelect( 'media.watchlists', 'watchlist', 'media.id = watchlist.media and watchlist.requestedBy = :userId', { userId: user?.id } - ); + ) + .where( + typeof finalIds[0] === 'string' + ? 'media.mbId IN (:...finalIds)' + : 'media.tmdbId IN (:...finalIds)', + { finalIds } + ) + .getMany(); - if (tmdbIds.length > 0 && mbIds.length > 0) { - queryBuilder.where( - '(media.tmdbId IN (:...tmdbIds) OR media.mbId IN (:...mbIds))', - { tmdbIds, mbIds } - ); - } else if (tmdbIds.length > 0) { - queryBuilder.where('media.tmdbId IN (:...tmdbIds)', { tmdbIds }); - } else if (mbIds.length > 0) { - queryBuilder.where('media.mbId IN (:...mbIds)', { mbIds }); - } - - const media = await queryBuilder.getMany(); return media; } catch (e) { logger.error(e.message); @@ -77,13 +76,11 @@ class Media { const mediaRepository = getRepository(Media); try { - const whereClause = - typeof id === 'string' - ? { mbId: id, mediaType } - : { tmdbId: id, mediaType }; - const media = await mediaRepository.findOne({ - where: whereClause, + where: + typeof id === 'string' + ? { mbId: id, mediaType } + : { tmdbId: id, mediaType }, relations: { requests: true, issues: true }, }); diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 7b22b623..4a63d634 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,15 +1,11 @@ -import type { LidarrAlbumDetails } from '@server/api/servarr/lidarr'; -import LidarrAPI from '@server/api/servarr/lidarr'; -import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; -import RadarrAPI from '@server/api/servarr/radarr'; -import type { - AddSeriesOptions, - SonarrSeries, -} from '@server/api/servarr/sonarr'; -import SonarrAPI from '@server/api/servarr/sonarr'; +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; +import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces'; +import MusicBrainz from '@server/api/musicbrainz'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { + TmdbKeyword, TmdbMovieDetails, TmdbTvDetails, } from '@server/api/themoviedb/interfaces'; @@ -58,12 +54,8 @@ export class MediaRequest { requestBody: MediaRequestBody, user: User, options: MediaRequestOptions = {} - ): Promise { + ): Promise { const tmdb = new TheMovieDb(); - const lidarr = new LidarrAPI({ - apiKey: getSettings().lidarr[0].apiKey, - url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'), - }); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); @@ -125,20 +117,6 @@ export class MediaRequest { ); } - if ( - requestBody.mediaType === MediaType.MUSIC && - !requestUser.hasPermission( - [Permission.REQUEST, Permission.REQUEST_MUSIC], - { - type: 'or', - } - ) - ) { - throw new RequestPermissionError( - 'You do not have permission to make music requests.' - ); - } - const quotas = await requestUser.getQuota(); if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { @@ -152,51 +130,55 @@ export class MediaRequest { throw new QuotaRestrictedError('Music Quota exceeded.'); } - const tmdbMedia = + const requestedMedia = requestBody.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: requestBody.mediaId }) : requestBody.mediaType === MediaType.TV ? await tmdb.getTvShow({ tvId: requestBody.mediaId }) - : await lidarr.getAlbumByMusicBrainzId(requestBody.mediaId.toString()); + : await new ListenBrainzAPI().getAlbum(requestBody.mediaId.toString()); let media = await mediaRepository.findOne({ - where: { - mbId: - requestBody.mediaType === MediaType.MUSIC - ? requestBody.mediaId.toString() - : undefined, - tmdbId: - requestBody.mediaType !== MediaType.MUSIC - ? requestBody.mediaId - : undefined, - mediaType: requestBody.mediaType, - }, + where: + requestBody.mediaType === MediaType.MUSIC + ? { + mbId: requestBody.mediaId.toString(), + mediaType: requestBody.mediaType, + } + : { tmdbId: requestBody.mediaId, mediaType: requestBody.mediaType }, relations: ['requests'], }); + const isTmdbMedia = ( + media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails + ): media is TmdbMovieDetails | TmdbTvDetails => { + return 'id' in media; + }; + + const isLbAlbum = ( + media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails + ): media is LbAlbumDetails => { + return 'release_group_mbid' in media; + }; + if (!media) { media = new Media({ - mbId: - requestBody.mediaType === MediaType.MUSIC - ? requestBody.mediaId.toString() - : undefined, - tmdbId: - requestBody.mediaType !== MediaType.MUSIC - ? requestBody.mediaId - : undefined, + tmdbId: isTmdbMedia(requestedMedia) ? requestedMedia.id : undefined, + mbId: isLbAlbum(requestedMedia) + ? requestedMedia.release_group_mbid + : undefined, + tvdbId: isTmdbMedia(requestedMedia) + ? requestBody.tvdbId ?? requestedMedia.external_ids?.tvdb_id + : undefined, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: requestBody.mediaType, }); } else { if (media.status === MediaStatus.BLACKLISTED) { logger.warn('Request for media blocked due to being blacklisted', { - mbId: - requestBody.mediaType === MediaType.MUSIC - ? requestBody.mediaId - : undefined, - tmdbId: - requestBody.mediaType !== MediaType.MUSIC - ? tmdbMedia.id - : undefined, + id: isLbAlbum(requestedMedia) + ? requestedMedia.release_group_mbid + : requestedMedia.id, mediaType: requestBody.mediaType, label: 'Media Request', }); @@ -207,19 +189,31 @@ export class MediaRequest { if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { media.status = MediaStatus.PENDING; } + + if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { + media.status4k = MediaStatus.PENDING; + } } const existing = await requestRepository .createQueryBuilder('request') .leftJoin('request.media', 'media') .leftJoinAndSelect('request.requestedBy', 'user') - .where( - requestBody.mediaType === MediaType.MUSIC + .where('request.is4k = :is4k', { is4k: requestBody.is4k }) + .andWhere( + requestBody.mediaType === 'music' ? 'media.mbId = :mbId' : 'media.tmdbId = :tmdbId', - requestBody.mediaType === MediaType.MUSIC - ? { mbId: requestBody.mediaId } - : { tmdbId: tmdbMedia.id } + requestBody.mediaType === 'music' + ? { + mbId: (requestedMedia as { release_group_mbid: string }) + .release_group_mbid, + } + : { + tmdbId: isTmdbMedia(requestedMedia) + ? requestedMedia.id + : undefined, + } ) .andWhere('media.mediaType = :mediaType', { mediaType: requestBody.mediaType, @@ -229,12 +223,17 @@ export class MediaRequest { if (existing && existing.length > 0) { // If there is an existing movie request that isn't declined, don't allow a new one. if ( - requestBody.mediaType === MediaType.MUSIC && + (requestBody.mediaType === MediaType.MOVIE || + requestBody.mediaType === MediaType.MUSIC) && existing[0].status !== MediaRequestStatus.DECLINED ) { logger.warn('Duplicate request for media blocked', { - mbId: requestBody.mediaId, + id: + requestBody.mediaType === MediaType.MUSIC + ? media.mbId + : (requestedMedia as TmdbMovieDetails | TmdbTvDetails).id, mediaType: requestBody.mediaType, + is4k: requestBody.is4k, label: 'Media Request', }); @@ -256,116 +255,140 @@ export class MediaRequest { } } - const isTmdbMedia = ( - media: LidarrAlbumDetails | TmdbMovieDetails | TmdbTvDetails - ): media is TmdbMovieDetails | TmdbTvDetails => { - return 'original_language' in media && 'keywords' in media; - }; - - let prioritizedRule: OverrideRule | undefined; - + // Apply overrides if the user is not an admin or has the "advanced request" permission const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], { type: 'or', }); + let rootFolder = requestBody.rootFolder; + let profileId = requestBody.profileId; + let tags = requestBody.tags; + if (useOverrides) { - if (requestBody.mediaType !== MediaType.MUSIC) { - const defaultRadarrId = requestBody.is4k - ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) - : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); - const defaultSonarrId = requestBody.is4k - ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) - : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); + const defaultRadarrId = requestBody.is4k + ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) + : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); + const defaultSonarrId = requestBody.is4k + ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) + : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); + const defaultLidarrId = settings.lidarr.findIndex((l) => l.isDefault); - const overrideRuleRepository = getRepository(OverrideRule); - const overrideRules = await overrideRuleRepository.find({ - where: - requestBody.mediaType === MediaType.MOVIE - ? { radarrServiceId: defaultRadarrId } - : { sonarrServiceId: defaultSonarrId }, - }); + const overrideRuleRepository = getRepository(OverrideRule); + const overrideRules = await overrideRuleRepository.find({ + where: + requestBody.mediaType === MediaType.MOVIE + ? { radarrServiceId: defaultRadarrId } + : requestBody.mediaType === MediaType.TV + ? { sonarrServiceId: defaultSonarrId } + : { lidarrServiceId: defaultLidarrId }, + }); - const appliedOverrideRules = overrideRules.filter((rule) => { - if (isTmdbMedia(tmdbMedia)) { - if ( - rule.language && - !rule.language - .split('|') - .some( - (languageId) => languageId === tmdbMedia.original_language - ) - ) { - return false; - } - - if (rule.keywords) { - const keywordList = - 'results' in tmdbMedia.keywords - ? tmdbMedia.keywords.results - : 'keywords' in tmdbMedia.keywords - ? tmdbMedia.keywords.keywords - : []; - - if ( - !rule.keywords - .split(',') - .some((keywordId) => - keywordList.map((k) => k.id).includes(Number(keywordId)) - ) - ) { - return false; - } - } - - const hasAnimeKeyword = - 'results' in tmdbMedia.keywords && - tmdbMedia.keywords.results.some( - (keyword) => keyword.id === ANIME_KEYWORD_ID - ); - - if ( - requestBody.mediaType === MediaType.TV && - hasAnimeKeyword && - (!rule.keywords || - !rule.keywords - .split(',') - .map(Number) - .includes(ANIME_KEYWORD_ID)) - ) { - return false; - } - } + const appliedOverrideRules = overrideRules.filter((rule) => { + // Only apply keyword/genre rules for TMDB media + if (isTmdbMedia(requestedMedia)) { + const hasAnimeKeyword = + 'results' in requestedMedia.keywords && + requestedMedia.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ); if ( - rule.users && - !rule.users - .split(',') - .some((userId) => Number(userId) === requestUser.id) + requestBody.mediaType === MediaType.TV && + hasAnimeKeyword && + (!rule.keywords || + !rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID)) ) { return false; } - return true; - }); + if ( + rule.genre && + !rule.genre + .split(',') + .some((genreId) => + requestedMedia.genres + .map((genre) => genre.id) + .includes(Number(genreId)) + ) + ) { + return false; + } - prioritizedRule = appliedOverrideRules.sort((a, b) => { - const keys: (keyof OverrideRule)[] = [ - 'genre', - 'language', - 'keywords', - ]; - return ( - keys.filter((key) => b[key] !== null).length - - keys.filter((key) => a[key] !== null).length - ); - })[0]; + if ( + rule.language && + !rule.language + .split('|') + .some( + (languageId) => languageId === requestedMedia.original_language + ) + ) { + return false; + } - if (prioritizedRule) { - logger.debug('Override rule applied.', { - label: 'Media Request', - overrides: prioritizedRule, - }); + if ( + rule.keywords && + !rule.keywords.split(',').some((keywordId) => { + let keywordList: TmdbKeyword[] = []; + + if ('keywords' in requestedMedia.keywords) { + keywordList = requestedMedia.keywords.keywords; + } else if ('results' in requestedMedia.keywords) { + keywordList = requestedMedia.keywords.results; + } + + return keywordList + .map((keyword: TmdbKeyword) => keyword.id) + .includes(Number(keywordId)); + }) + ) { + return false; + } } + + if ( + rule.users && + !rule.users + .split(',') + .some((userId) => Number(userId) === requestUser.id) + ) { + return false; + } + + return true; + }); + + // hacky way to prioritize rules + // TODO: make this better + const prioritizedRule = appliedOverrideRules.sort((a, b) => { + const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; + + const aSpecificity = keys.filter((key) => a[key] !== null).length; + const bSpecificity = keys.filter((key) => b[key] !== null).length; + + // Take the rule with the most specific condition first + return bSpecificity - aSpecificity; + })[0]; + + if (prioritizedRule) { + if (prioritizedRule.rootFolder) { + rootFolder = prioritizedRule.rootFolder; + } + if (prioritizedRule.profileId) { + profileId = prioritizedRule.profileId; + } + if (prioritizedRule.tags) { + tags = [ + ...new Set([ + ...(tags || []), + ...prioritizedRule.tags.split(',').map((tag) => Number(tag)), + ]), + ]; + } + + logger.debug('Override rule applied.', { + label: 'Media Request', + overrides: prioritizedRule, + }); } } @@ -407,31 +430,65 @@ export class MediaRequest { : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, - profileId: prioritizedRule?.profileId ?? requestBody.profileId, - rootFolder: prioritizedRule?.rootFolder ?? requestBody.rootFolder, - tags: prioritizedRule?.tags - ? [ - ...new Set([ - ...(requestBody.tags || []), - ...prioritizedRule.tags.split(',').map(Number), - ]), - ] - : requestBody.tags, + profileId: profileId, + rootFolder: rootFolder, + tags: tags, isAutoRequest: options.isAutoRequest ?? false, }); await requestRepository.save(request); return request; - } else if (requestBody.mediaType === MediaType.TV) { - const tmdbMediaShow = tmdbMedia as Awaited< + } else if (requestBody.mediaType === MediaType.MUSIC) { + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.MUSIC, + media, + requestedBy: requestUser, + // If the user is an admin or has the music auto approve permission, automatically approve the request + status: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + serverId: requestBody.serverId, + profileId: profileId, + rootFolder: rootFolder, + tags: tags, + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } else { + const requestedMediaShow = requestedMedia as Awaited< ReturnType >; - const requestedSeasons = + let requestedSeasons = requestBody.seasons === 'all' ? tmdbMediaShow.seasons .filter((season) => season.season_number !== 0) .map((season) => season.season_number) : (requestBody.seasons as number[]); + if (!settings.main.enableSpecialEpisodes) { + requestedSeasons = requestedSeasons.filter((sn) => sn > 0); + } + let existingSeasons: number[] = []; // We need to check existing requests on this title to make sure we don't double up on seasons that were @@ -520,10 +577,10 @@ export class MediaRequest { : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, - profileId: requestBody.profileId, - rootFolder: requestBody.rootFolder, + profileId: profileId, + rootFolder: rootFolder, languageProfileId: requestBody.languageProfileId, - tags: requestBody.tags, + tags: tags, seasons: finalSeasons.map( (sn) => new SeasonRequest({ @@ -547,42 +604,6 @@ export class MediaRequest { isAutoRequest: options.isAutoRequest ?? false, }); - await requestRepository.save(request); - return request; - } else { - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.MUSIC, - media, - requestedBy: requestUser, - status: user.hasPermission( - [ - Permission.AUTO_APPROVE, - Permission.AUTO_APPROVE_MUSIC, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: user.hasPermission( - [ - Permission.AUTO_APPROVE, - Permission.AUTO_APPROVE_MUSIC, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? user - : undefined, - serverId: requestBody.serverId, - profileId: requestBody.profileId, - rootFolder: requestBody.rootFolder, - tags: requestBody.tags, - isAutoRequest: options.isAutoRequest ?? false, - }); - await requestRepository.save(request); return request; } @@ -794,13 +815,17 @@ export class MediaRequest { type: Notification ) { const tmdb = new TheMovieDb(); - const lidarr = new LidarrAPI({ - apiKey: getSettings().lidarr[0].apiKey, - url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'), - }); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); + const musicbrainz = new MusicBrainz(); try { - const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; + const mediaType = + this.type === MediaType.MOVIE + ? 'Movie' + : this.type === MediaType.TV + ? 'Series' + : 'Album'; let event: string | undefined; let notifyAdmin = true; let notifySystem = true; @@ -880,16 +905,18 @@ export class MediaRequest { }, ], }); - } else if (this.type === MediaType.MUSIC) { - if (!media.mbId) { - throw new Error('MusicBrainz ID not found for media'); - } - - const album = await lidarr.getAlbumByMusicBrainzId(media.mbId); - - const coverUrl = album.images?.find( - (img) => img.coverType === 'Cover' - )?.url; + } else if (this.type === MediaType.MUSIC && media.mbId) { + const album = await listenbrainz.getAlbum(media.mbId); + const coverArtResponse = await coverArt.getCoverArt(media.mbId); + const coverArtUrl = + coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; + const artistId = + album.release_group_metadata?.artist?.artists[0]?.artist_mbid; + const artistWiki = artistId + ? await musicbrainz.getArtistWikipediaExtract({ + artistMbid: artistId, + }) + : null; notificationManager.sendNotification(type, { media, @@ -898,15 +925,13 @@ export class MediaRequest { notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, - subject: `${album.title}${ - album.releaseDate ? ` (${album.releaseDate.slice(0, 4)})` : '' - }`, - message: truncate(album.overview || '', { + subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`, + message: truncate(artistWiki?.content ?? '', { length: 500, separator: /\s/, omission: '…', }), - image: coverUrl, + image: coverArtUrl, }); } } catch (e) { diff --git a/server/entity/MetadataAlbum.ts b/server/entity/MetadataAlbum.ts new file mode 100644 index 00000000..6365eea4 --- /dev/null +++ b/server/entity/MetadataAlbum.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +class MetadataAlbum { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ unique: true }) + public mbAlbumId: string; + + @Column({ nullable: true, type: 'varchar' }) + public caaUrl: string | null; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default MetadataAlbum; diff --git a/server/entity/MetadataArtist.ts b/server/entity/MetadataArtist.ts new file mode 100644 index 00000000..91373499 --- /dev/null +++ b/server/entity/MetadataArtist.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +class MetadataArtist { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ unique: true }) + public mbArtistId: string; + + @Column({ nullable: true, type: 'varchar' }) + public tmdbPersonId: string | null; + + @Column({ nullable: true, type: 'varchar' }) + public tmdbThumb: string | null; + + @Column({ nullable: true, type: 'datetime' }) + public tmdbUpdatedAt: Date | null; + + @Column({ nullable: true, type: 'varchar' }) + public tadbThumb: string | null; + + @Column({ nullable: true, type: 'varchar' }) + public tadbCover: string | null; + + @Column({ nullable: true, type: 'datetime' }) + public tadbUpdatedAt: Date | null; + + @CreateDateColumn() + public createdAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default MetadataArtist; diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts index aab72006..229ee8cb 100644 --- a/server/entity/OverrideRule.ts +++ b/server/entity/OverrideRule.ts @@ -12,6 +12,9 @@ class OverrideRule { @Column({ type: 'int', nullable: true }) public sonarrServiceId?: number; + @Column({ type: 'int', nullable: true }) + public lidarrServiceId?: number; + @Column({ nullable: true }) public users?: string; diff --git a/server/index.ts b/server/index.ts index 0b2c7770..152fa2d1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,8 +23,7 @@ import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; import avatarproxy from '@server/routes/avatarproxy'; import caaproxy from '@server/routes/caaproxy'; -import fanartproxy from '@server/routes/fanartproxy'; -import lidarrproxy from '@server/routes/lidarrproxy'; +import tadbproxy from '@server/routes/tadbproxy'; import tmdbproxy from '@server/routes/tmdbproxy'; import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; @@ -241,8 +240,7 @@ app server.use('/tmdbproxy', clearCookies, tmdbproxy); server.use('/avatarproxy', clearCookies, avatarproxy); server.use('/caaproxy', clearCookies, caaproxy); - server.use('/lidarrproxy', clearCookies, lidarrproxy); - server.use('/fanartproxy', clearCookies, fanartproxy); + server.use('/tadbproxy', clearCookies, tadbproxy); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index bca3597b..4beebeb9 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -64,7 +64,10 @@ export interface CacheItem { export interface CacheResponse { apiCaches: CacheItem[]; - imageCache: Record<'tmdb' | 'avatar' | 'caa' | 'lidarr' | 'fanart', { size: number; imageCount: number }>; + imageCache: Record< + 'tmdb' | 'avatar' | 'caa' | 'tadb', + { size: number; imageCount: number } + >; dnsCache: { stats: DnsStats | undefined; entries: DnsEntries | undefined; diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 3a9c6684..cfd5838f 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -5,6 +5,7 @@ export type AvailableCacheIds = | 'musicbrainz' | 'listenbrainz' | 'covertartarchive' + | 'tadb' | 'radarr' | 'sonarr' | 'lidarr' @@ -64,6 +65,10 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + tadb: new Cache('tadb', 'The Audio Database API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), lidarr: new Cache('lidarr', 'Lidarr API'), diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 0c5b4977..39b3e287 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -329,7 +329,7 @@ class ImageProxy { }); await promises.mkdir(dir, { recursive: true }); - await promises.writeFile(filename, new Uint8Array(buffer)); + await promises.writeFile(filename, buffer); } private getCacheKey(path: string) { @@ -340,9 +340,7 @@ class ImageProxy { const hash = createHash('sha256'); for (const item of items) { if (typeof item === 'number') hash.update(String(item)); - else if (Buffer.isBuffer(item)) { - hash.update(item.toString()); - } else { + else { hash.update(item); } } diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 7b7a679f..21a129f3 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -71,7 +71,9 @@ class EmailAgent const mediaType = payload.media ? payload.media.mediaType === MediaType.MOVIE ? 'movie' - : 'series' + : payload.media.mediaType === MediaType.TV + ? 'series' + : 'album' : undefined; const is4k = payload.request?.is4k; diff --git a/server/lib/search.ts b/server/lib/search.ts index 3be208aa..26f2a605 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -239,28 +239,17 @@ searchProviders.push({ const musicbrainz = new MusicBrainz(); try { - const response = await musicbrainz.searchMulti({ + const albumResults = await musicbrainz.searchAlbum({ query: query || '', + limit: 20, }); - const results: CombinedSearchResponse['results'] = response.map( - (result) => { - if (result.artist) { - return { - ...result.artist, - media_type: 'artist', - } as MbArtistResult; - } - - if (result.album) { - return { - ...result.album, - media_type: 'album', - } as MbAlbumResult; - } - - throw new Error('Invalid search result type'); - } + const results: CombinedSearchResponse['results'] = albumResults.map( + (album) => + ({ + ...album, + media_type: 'album', + } as MbAlbumResult) ); return { diff --git a/server/migration/postgres/1714310036946-AddMusicSupport.ts b/server/migration/postgres/1714310036946-AddMusicSupport.ts new file mode 100644 index 00000000..cc1e1343 --- /dev/null +++ b/server/migration/postgres/1714310036946-AddMusicSupport.ts @@ -0,0 +1,125 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMusicSupport1714310036946 implements MigrationInterface { + name = 'AddMusicSupport1714310036946'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaLimit" integer`); + await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaDays" integer`); + + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_7ff2d11f6a83cb52386eaebe74"` + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_41a289eb1fa489c1bc6f38d9c3"` + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_7157aad07c73f6a6ae3bbd5ef5"` + ); + + await queryRunner.query( + `ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" DROP NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD "mbId" character varying` + ); + await queryRunner.query(`DROP INDEX IF EXISTS "unique_user_db"`); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_watchlist_mbid" ON "watchlist" ("mbId")` + ); + + await queryRunner.query( + `ALTER TABLE "media" ALTER COLUMN "tmdbId" DROP NOT NULL` + ); + await queryRunner.query(`ALTER TABLE "media" ADD "mbId" character varying`); + await queryRunner.query( + `ALTER TABLE "media" ADD CONSTRAINT "CHK_media_type" CHECK ("mediaType" IN ('movie', 'tv', 'music'))` + ); + + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_media_mbid" ON "media" ("mbId")` + ); + + await queryRunner.query( + `CREATE TABLE "metadata_album" ( + "id" SERIAL NOT NULL, + "mbAlbumId" character varying NOT NULL, + "caaUrl" character varying NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_metadata_album" PRIMARY KEY ("id") + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_album_mbAlbumId" ON "metadata_album" ("mbAlbumId")` + ); + + await queryRunner.query( + `CREATE TABLE "metadata_artist" ( + "id" SERIAL NOT NULL, + "mbArtistId" character varying NOT NULL, + "tmdbPersonId" character varying, + "tmdbThumb" character varying, + "tmdbUpdatedAt" TIMESTAMP, + "tadbThumb" character varying, + "tadbCover" character varying, + "tadbUpdatedAt" TIMESTAMP, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_metadata_artist" PRIMARY KEY ("id") + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_artist_mbArtistId" ON "metadata_artist" ("mbArtistId")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_metadata_album_mbAlbumId"` + ); + await queryRunner.query(`DROP TABLE IF EXISTS "metadata_album"`); + + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_watchlist_mbid"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_media_mbid"`); + + await queryRunner.query( + `ALTER TABLE "watchlist" DROP CONSTRAINT IF EXISTS "UNIQUE_USER_FOREIGN"` + ); + await queryRunner.query( + `ALTER TABLE "media" DROP CONSTRAINT IF EXISTS "CHK_media_type"` + ); + + await queryRunner.query( + `DELETE FROM "watchlist" WHERE "mediaType" = 'music'` + ); + + await queryRunner.query(`ALTER TABLE "media" DROP COLUMN "mbId"`); + await queryRunner.query(`ALTER TABLE "watchlist" DROP COLUMN "mbId"`); + await queryRunner.query( + `ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" SET NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "media" ALTER COLUMN "tmdbId" SET NOT NULL` + ); + + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaDays"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaLimit"`); + } +} diff --git a/server/migration/postgres/1734805738349-AddOverrideRules.ts b/server/migration/postgres/1734805738349-AddOverrideRules.ts index b9cc4721..62c65675 100644 --- a/server/migration/postgres/1734805738349-AddOverrideRules.ts +++ b/server/migration/postgres/1734805738349-AddOverrideRules.ts @@ -5,7 +5,7 @@ export class AddOverrideRules1734805738349 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))` + `CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "lidarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))` ); } diff --git a/server/migration/sqlite/1714310036946-AddMusicSupport.ts b/server/migration/sqlite/1714310036946-AddMusicSupport.ts index 811ff99a..d9681cc0 100644 --- a/server/migration/sqlite/1714310036946-AddMusicSupport.ts +++ b/server/migration/sqlite/1714310036946-AddMusicSupport.ts @@ -25,7 +25,9 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), - CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById") + CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById"), + CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION )` ); @@ -71,7 +73,8 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, - "jellyfinMediaId4k" varchar + "jellyfinMediaId4k" varchar, + CONSTRAINT "CHK_media_type" CHECK ("mediaType" IN ('movie', 'tv', 'music')) )` ); @@ -108,9 +111,47 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { await queryRunner.query( `CREATE INDEX "IDX_media_mbid" ON "media" ("mbId")` ); + + await queryRunner.query( + `CREATE TABLE "metadata_album" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "mbAlbumId" varchar NOT NULL, + "caaUrl" varchar NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_album_mbAlbumId" ON "metadata_album" ("mbAlbumId")` + ); + + await queryRunner.query( + `CREATE TABLE "metadata_artist" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "mbArtistId" varchar NOT NULL, + "tmdbPersonId" varchar, + "tmdbThumb" varchar, + "tmdbUpdatedAt" datetime, + "tadbThumb" varchar, + "tadbCover" varchar, + "tadbUpdatedAt" datetime, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')) + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_artist_mbArtistId" ON "metadata_artist" ("mbArtistId")` + ); } public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_metadata_album_mbAlbumId"`); + await queryRunner.query(`DROP TABLE "metadata_album"`); + + await queryRunner.query(`DROP INDEX "IDX_metadata_artist_mbArtistId"`); + await queryRunner.query(`DROP TABLE "metadata_artist"`); + await queryRunner.query(`DROP INDEX "IDX_watchlist_mbid"`); await queryRunner.query(`DROP INDEX "IDX_media_mbid"`); @@ -125,7 +166,9 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, - CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById") + CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), + CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION )` ); diff --git a/server/migration/sqlite/1734805733535-AddOverrideRules.ts b/server/migration/sqlite/1734805733535-AddOverrideRules.ts index 692dc875..ae132542 100644 --- a/server/migration/sqlite/1734805733535-AddOverrideRules.ts +++ b/server/migration/sqlite/1734805733535-AddOverrideRules.ts @@ -5,7 +5,7 @@ export class AddOverrideRules1734805733535 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` + `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "lidarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` ); } diff --git a/server/models/Artist.ts b/server/models/Artist.ts index 2abc0677..38b19231 100644 --- a/server/models/Artist.ts +++ b/server/models/Artist.ts @@ -1,53 +1,34 @@ -import type { MbArtistDetails } from '@server/api/musicbrainz/interfaces'; import type Media from '@server/entity/Media'; -export interface ArtistDetailsType { - id: string; +export interface ArtistDetails { name: string; - type: string; - overview: string; - disambiguation: string; - status: string; - genres: string[]; - images: { - CoverType: string; - Url: string; - }[]; - links: { - target: string; - type: string; - }[]; - Albums?: { + area?: string; + artist: { + name: string; + artist_mbid: string; + begin_year?: number; + end_year?: number; + area?: string; + }; + alsoKnownAs?: string[]; + biography?: string; + wikipedia?: { + content: string; + }; + artistThumb?: string | null; + artistBackdrop?: string | null; + profilePath?: string; + releaseGroups?: { id: string; title: string; - type: string; - releasedate: string; - images?: { - CoverType: string; - Url: string; + 'first-release-date': string; + 'artist-credit': { + name: string; }[]; + 'primary-type': string; + secondary_types?: string[]; + total_listen_count?: number; + posterPath?: string; mediaInfo?: Media; - onUserWatchlist?: boolean; }[]; } - -export const mapArtistDetails = ( - artist: MbArtistDetails -): ArtistDetailsType => ({ - id: artist.id, - name: artist.artistname, - type: artist.type, - overview: artist.overview, - disambiguation: artist.disambiguation, - status: artist.status, - genres: artist.genres, - images: artist.images, - links: artist.links, - Albums: artist.Albums?.map((album) => ({ - id: album.Id.toLowerCase(), - title: album.Title, - type: album.Type, - releasedate: '', - images: [], - })), -}); diff --git a/server/models/Music.ts b/server/models/Music.ts index 58e73e56..80625ad8 100644 --- a/server/models/Music.ts +++ b/server/models/Music.ts @@ -1,8 +1,4 @@ -import type { - MbAlbumDetails, - MbImage, - MbLink, -} from '@server/api/musicbrainz/interfaces'; +import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces'; import type Media from '@server/entity/Media'; export interface MusicDetails { @@ -10,121 +6,120 @@ export interface MusicDetails { mbId: string; title: string; titleSlug?: string; - overview: string; - artistId: string; + mediaType: 'album'; type: string; releaseDate: string; - disambiguation: string; - genres: string[]; - secondaryTypes: string[]; - releases: { - id: string; - title: string; - status: string; - releaseDate: string; - trackCount: number; - country: string[]; - label: string[]; - media: { - format: string; - name: string; - position: number; - }[]; - tracks: { - id: string; - artistId: string; - trackName: string; - trackNumber: string; - trackPosition: number; - mediumNumber: number; - durationMs: number; - recordingId: string; - }[]; - disambiguation: string; - }[]; artist: { id: string; - artistName: string; - sortName: string; - type: 'Group' | 'Person'; - disambiguation: string; - overview: string; - genres: string[]; - status: string; - images: MbImage[]; - links: MbLink[]; - rating?: { - count: number; - value: number | null; - }; + name: string; + area?: string; + beginYear?: number; + type?: string; + }; + tracks: { + name: string; + position: number; + length: number; + recordingMbid: string; + totalListenCount: number; + totalUserCount: number; + artists: { + name: string; + mbid: string; + tmdbMapping?: { + personId: number; + profilePath: string; + }; + }[]; + }[]; + tags?: { + artist: { + artistMbid: string; + count: number; + tag: string; + }[]; + releaseGroup: { + count: number; + genreMbid: string; + tag: string; + }[]; + }; + stats?: { + totalListenCount: number; + totalUserCount: number; + listeners: { + userName: string; + listenCount: number; + }[]; }; - images: MbImage[]; - links: MbLink[]; mediaInfo?: Media; onUserWatchlist?: boolean; + posterPath?: string; + artistWikipedia?: { + content: string; + title: string; + url: string; + }; + tmdbPersonId?: number; + artistBackdrop?: string; + artistThumb?: string; } export const mapMusicDetails = ( - album: MbAlbumDetails, + album: LbAlbumDetails, media?: Media, userWatchlist?: boolean ): MusicDetails => ({ - id: album.id, - mbId: album.id, - title: album.title, - titleSlug: album.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'), - overview: album.overview, - artistId: album.artistid, + id: album.release_group_mbid, + mbId: album.release_group_mbid, + title: album.release_group_metadata.release_group.name, + titleSlug: album.release_group_metadata.release_group.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-'), + mediaType: 'album', type: album.type, - releaseDate: album.releasedate, - disambiguation: album.disambiguation, - genres: album.genres, - secondaryTypes: album.secondaryTypes ?? [], - releases: album.releases.map((release) => ({ - id: release.id, - title: release.title, - status: release.status, - releaseDate: release.releasedate, - trackCount: release.track_count, - country: release.country, - label: release.label, - media: release.media.map((medium) => ({ - format: medium.Format, - name: medium.Name, - position: medium.Position, - })), - tracks: release.tracks.map((track) => ({ - id: track.id, - artistId: track.artistid, - trackName: track.trackname, - trackNumber: track.tracknumber, - trackPosition: track.trackposition, - mediumNumber: track.mediumnumber, - durationMs: track.durationms, - recordingId: track.recordingid, - })), - disambiguation: release.disambiguation, - })), + releaseDate: album.release_group_metadata.release_group.date, artist: { - id: album.artists[0].id, - artistName: album.artists[0].artistname, - sortName: album.artists[0].sortname, - type: album.artists[0].type, - disambiguation: album.artists[0].disambiguation, - overview: album.artists[0].overview, - genres: album.artists[0].genres, - status: album.artists[0].status, - images: album.artists[0].images, - links: album.artists[0].links, - rating: album.artists[0].rating - ? { - count: album.artists[0].rating.Count, - value: album.artists[0].rating.Value, - } - : undefined, + id: album.release_group_metadata.artist.artists[0].artist_mbid, + name: album.release_group_metadata.artist.name, + area: album.release_group_metadata.artist.artists[0].area, + beginYear: album.release_group_metadata.artist.artists[0].begin_year, + type: album.release_group_metadata.artist.artists[0].type, + }, + tracks: album.mediums.flatMap((medium) => + medium.tracks.map((track) => ({ + name: track.name, + position: track.position, + length: track.length, + recordingMbid: track.recording_mbid, + totalListenCount: track.total_listen_count, + totalUserCount: track.total_user_count, + artists: track.artists.map((artist) => ({ + name: artist.artist_credit_name, + mbid: artist.artist_mbid, + })), + })) + ), + tags: { + artist: album.release_group_metadata.tag.artist.map((tag) => ({ + artistMbid: tag.artist_mbid, + count: tag.count, + tag: tag.tag, + })), + releaseGroup: album.release_group_metadata.tag.release_group.map((tag) => ({ + count: tag.count, + genreMbid: tag.genre_mbid, + tag: tag.tag, + })), + }, + stats: { + totalListenCount: album.listening_stats.total_listen_count, + totalUserCount: album.listening_stats.total_user_count, + listeners: album.listening_stats.listeners.map((listener) => ({ + userName: listener.user_name, + listenCount: listener.listen_count, + })), }, - images: album.images, - links: album.artists[0].links, mediaInfo: media, onUserWatchlist: userWatchlist, }); diff --git a/server/models/Person.ts b/server/models/Person.ts index b2aeacf2..ca208033 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -20,7 +20,21 @@ export interface PersonDetails { adult: boolean; imdbId?: string; homepage?: string; - mbArtistId?: string; + artist?: { + artistBackdrop: string | null; + artistThumb?: string; + releaseGroups?: { + secondary_types?: string[]; + id: string; + title: string; + 'first-release-date': string; + 'artist-credit': { + name: string; + }[]; + 'primary-type': string; + posterPath?: string; + }[]; + }; } export interface PersonCredit { diff --git a/server/models/Search.ts b/server/models/Search.ts index b2cf171f..16eabd1d 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -1,9 +1,6 @@ import type { - MbAlbumDetails, MbAlbumResult, - MbArtistDetails, MbArtistResult, - MbImage, } from '@server/api/musicbrainz/interfaces'; import type { TmdbCollectionResult, @@ -87,35 +84,34 @@ export interface PersonResult { export interface ArtistResult extends MbSearchResult { mediaType: 'artist'; - artistname: string; - overview: string; - disambiguation: string; + tmdbPersonId?: number; + name: string; type: 'Group' | 'Person'; - status: string; - sortname: string; - genres: string[]; - images: MbImage[]; - artistimage?: string; - rating?: { - Count: number; - Value: number | null; - }; + 'sort-name': string; + country?: string; + disambiguation?: string; + artistThumb?: string | null; + artistBackdrop?: string | null; mediaInfo?: Media; } export interface AlbumResult extends MbSearchResult { mediaType: 'album'; title: string; - artistid: string; - artistname?: string; - type: string; - releasedate: string; - disambiguation: string; - genres: string[]; - images: MbImage[]; - secondarytypes: string[]; + 'primary-type': 'Album' | 'Single' | 'EP'; + 'first-release-date': string; + releaseDate?: string; + 'artist-credit': { + name: string; + artist: { + id: string; + name: string; + 'sort-name': string; + }; + }[]; + posterPath?: string; + needsCoverArt?: boolean; mediaInfo?: Media; - overview?: string; } export type Results = @@ -203,22 +199,18 @@ export const mapPersonResult = ( }); export const mapArtistResult = ( - artistResult: MbArtistResult, - media?: Media + artistResult: MbArtistResult ): ArtistResult => ({ id: artistResult.id, score: artistResult.score, mediaType: 'artist', - artistname: artistResult.artistname, - overview: artistResult.overview, - disambiguation: artistResult.disambiguation, + name: artistResult.name, type: artistResult.type, - status: artistResult.status, - sortname: artistResult.sortname, - genres: artistResult.genres, - images: artistResult.images, - rating: artistResult.rating, - mediaInfo: media, + 'sort-name': artistResult['sort-name'], + country: artistResult.country, + disambiguation: artistResult.disambiguation, + artistThumb: artistResult.artistThumb, + artistBackdrop: artistResult.artistBackdrop, }); export const mapAlbumResult = ( @@ -229,16 +221,12 @@ export const mapAlbumResult = ( score: albumResult.score, mediaType: 'album', title: albumResult.title, - artistid: albumResult.artistid, - artistname: albumResult.artists?.[0]?.artistname, - type: albumResult.type, - releasedate: albumResult.releasedate, - disambiguation: albumResult.disambiguation, - genres: albumResult.genres, - images: albumResult.images, - secondarytypes: albumResult.secondarytypes, + 'primary-type': albumResult['primary-type'], + 'first-release-date': albumResult['first-release-date'], + 'artist-credit': albumResult['artist-credit'], + posterPath: albumResult.posterPath, + needsCoverArt: !albumResult.posterPath, mediaInfo: media, - overview: albumResult.artists?.[0]?.overview, }); const isTmdbMovie = ( @@ -289,7 +277,7 @@ const isTmdbCollection = ( return result.media_type === 'collection'; }; -const isLidarrArtist = ( +const isMbArtist = ( result: | TmdbMovieResult | TmdbTvResult @@ -301,7 +289,7 @@ const isLidarrArtist = ( return result.media_type === 'artist'; }; -const isLidarrAlbum = ( +const isMbAlbum = ( result: | TmdbMovieResult | TmdbTvResult @@ -346,9 +334,9 @@ export const mapSearchResults = async ( return mapPersonResult(result); } else if (isTmdbCollection(result)) { return mapCollectionResult(result); - } else if (isLidarrArtist(result)) { + } else if (isMbArtist(result)) { return mapArtistResult(result); - } else if (isLidarrAlbum(result)) { + } else if (isMbAlbum(result)) { return mapAlbumResult( result, media?.find( @@ -405,6 +393,7 @@ export const mapPersonDetailsToResult = ( personDetails: TmdbPersonDetails ): TmdbPersonResult => ({ id: personDetails.id, + known_for_department: personDetails.known_for_department, media_type: 'person', name: personDetails.name, popularity: personDetails.popularity, @@ -412,39 +401,3 @@ export const mapPersonDetailsToResult = ( profile_path: personDetails.profile_path, known_for: [], }); - -export const mapArtistDetailsToResult = ( - artistDetails: MbArtistDetails -): MbArtistResult => ({ - id: artistDetails.id, - score: 100, // Default score since we're mapping details - media_type: 'artist', - artistname: artistDetails.artistname, - overview: artistDetails.overview, - disambiguation: artistDetails.disambiguation, - type: artistDetails.type, - status: artistDetails.status, - sortname: artistDetails.sortname, - genres: artistDetails.genres, - images: artistDetails.images, - links: artistDetails.links, - rating: artistDetails.rating, -}); - -export const mapAlbumDetailsToResult = ( - albumDetails: MbAlbumDetails -): MbAlbumResult => ({ - id: albumDetails.id, - score: 100, - media_type: 'album', - title: albumDetails.title, - artistid: albumDetails.artistid, - artists: albumDetails.artists, - type: albumDetails.type, - releasedate: albumDetails.releasedate, - disambiguation: albumDetails.disambiguation, - genres: albumDetails.genres, - images: albumDetails.images, - secondarytypes: albumDetails.secondarytypes, - overview: albumDetails.overview || albumDetails.artists?.[0]?.overview || '', -}); diff --git a/server/routes/artist.ts b/server/routes/artist.ts new file mode 100644 index 00000000..3af60a56 --- /dev/null +++ b/server/routes/artist.ts @@ -0,0 +1,169 @@ +import ListenBrainzAPI from '@server/api/listenbrainz'; +import type { LbReleaseGroupExtended } from '@server/api/listenbrainz/interfaces'; +import MusicBrainz from '@server/api/musicbrainz'; +import TheAudioDb from '@server/api/theaudiodb'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; +import logger from '@server/logger'; +import { Router } from 'express'; +import { In } from 'typeorm'; + +const artistRoutes = Router(); + +artistRoutes.get('/:id', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); + const musicbrainz = new MusicBrainz(); + const theAudioDb = TheAudioDb.getInstance(); + + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 20; + const initialItemsPerType = 20; + const albumType = req.query.albumType as string | undefined; + + try { + const [artistData, metadataArtist] = await Promise.all([ + listenbrainz.getArtist(req.params.id), + getRepository(MetadataArtist).findOne({ + where: { mbArtistId: req.params.id }, + select: ['mbArtistId', 'tadbThumb', 'tadbCover', 'tmdbThumb'], + }), + ]); + + if (!artistData) { + throw new Error('Artist not found'); + } + + const groupedReleaseGroups = artistData.releaseGroups.reduce((acc, rg) => { + const type = rg.secondary_types?.length + ? rg.secondary_types[0] + : rg.type || 'Other'; + + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(rg); + return acc; + }, {} as Record); + + Object.keys(groupedReleaseGroups).forEach((type) => { + groupedReleaseGroups[type].sort((a, b) => { + const dateA = a.date ? new Date(a.date).getTime() : 0; + const dateB = b.date ? new Date(b.date).getTime() : 0; + return dateB - dateA; + }); + }); + + let releaseGroupsToProcess: LbReleaseGroupExtended[]; + let totalCount; + let totalPages; + + if (albumType) { + const filteredReleaseGroups = groupedReleaseGroups[albumType] || []; + totalCount = filteredReleaseGroups.length; + totalPages = Math.ceil(totalCount / pageSize); + + releaseGroupsToProcess = filteredReleaseGroups.slice( + (page - 1) * pageSize, + page * pageSize + ); + } else { + releaseGroupsToProcess = []; + Object.entries(groupedReleaseGroups).forEach(([, releases]) => { + releaseGroupsToProcess.push(...releases.slice(0, initialItemsPerType)); + }); + + totalCount = Object.values(groupedReleaseGroups).reduce( + (sum, releases) => sum + releases.length, + 0 + ); + totalPages = 1; + } + + const mbIds = releaseGroupsToProcess.map((rg) => rg.mbid); + + const [artistWikipedia, artistImages, relatedMedia, albumMetadata] = + await Promise.all([ + musicbrainz + .getArtistWikipediaExtract({ + artistMbid: req.params.id, + language: req.locale, + }) + .catch(() => null), + !metadataArtist?.tadbThumb && !metadataArtist?.tadbCover + ? theAudioDb.getArtistImages(req.params.id) + : theAudioDb.getArtistImagesFromCache(req.params.id), + Media.getRelatedMedia(req.user, mbIds), + getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(mbIds) }, + cache: true, + }), + ]); + + const metadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) + ); + + const mediaMap = new Map(relatedMedia.map((media) => [media.mbId, media])); + + const mappedReleaseGroups = releaseGroupsToProcess.map((releaseGroup) => { + const metadata = metadataMap.get(releaseGroup.mbid); + const coverArtUrl = metadata?.caaUrl || null; + + return { + id: releaseGroup.mbid, + mediaType: 'album', + title: releaseGroup.name, + 'first-release-date': releaseGroup.date, + 'artist-credit': [{ name: releaseGroup.artist_credit_name }], + 'primary-type': releaseGroup.type || 'Other', + secondary_types: releaseGroup.secondary_types || [], + total_listen_count: releaseGroup.total_listen_count || 0, + posterPath: coverArtUrl, + needsCoverArt: !coverArtUrl, + mediaInfo: mediaMap.get(releaseGroup.mbid), + }; + }); + + const typeCounts = Object.fromEntries( + Object.entries(groupedReleaseGroups).map(([type, releases]) => [ + type, + releases.length, + ]) + ); + + return res.status(200).json({ + ...artistData, + wikipedia: artistWikipedia, + artistThumb: + metadataArtist?.tmdbThumb ?? + metadataArtist?.tadbThumb ?? + artistImages?.artistThumb ?? + null, + artistBackdrop: + metadataArtist?.tadbCover ?? artistImages?.artistBackground ?? null, + releaseGroups: mappedReleaseGroups, + pagination: { + page, + pageSize, + totalItems: totalCount, + totalPages, + albumType, + }, + typeCounts, + }); + } catch (e) { + logger.error('Something went wrong retrieving artist details', { + label: 'Artist API', + errorMessage: e.message, + artistId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve artist.', + }); + } +}); + +export default artistRoutes; diff --git a/server/routes/caaproxy.ts b/server/routes/caaproxy.ts index 4a1ecd9c..18306eca 100644 --- a/server/routes/caaproxy.ts +++ b/server/routes/caaproxy.ts @@ -3,14 +3,17 @@ import logger from '@server/logger'; import { Router } from 'express'; const router = Router(); -const caaImageProxy = new ImageProxy('caa', 'http://coverartarchive.org', { +const caaImageProxy = new ImageProxy('caa', 'https://archive.org/download', { rateLimitOptions: { maxRPS: 50, }, }); +/** + * Image Proxy + */ router.get('/*', async (req, res) => { - const imagePath = req.path; + const imagePath = req.path.replace('/download', ''); try { const imageData = await caaImageProxy.getImage(imagePath); @@ -28,7 +31,7 @@ router.get('/*', async (req, res) => { imagePath, errorMessage: e.message, }); - res.status(500).end(); + res.status(500).send(); } }); diff --git a/server/routes/coverart.ts b/server/routes/coverart.ts new file mode 100644 index 00000000..610a4406 --- /dev/null +++ b/server/routes/coverart.ts @@ -0,0 +1,28 @@ +import CoverArtArchive from '@server/api/coverartarchive'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const coverArtRoutes = Router(); + +coverArtRoutes.get('/batch/:ids', async (req, res) => { + const coverArtArchive = CoverArtArchive.getInstance(); + const ids = (req.params.ids || '').split(',').filter(Boolean); + + if (!ids.length) { + return res.status(200).json({}); + } + + try { + const coverResults = await coverArtArchive.batchGetCoverArt(ids); + return res.status(200).json(coverResults); + } catch (e) { + logger.error('Error fetching batch cover art', { + label: 'CoverArtArchive', + errorMessage: e instanceof Error ? e.message : 'Unknown error', + count: ids.length, + }); + return res.status(200).json({}); + } +}); + +export default coverArtRoutes; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e4e893a0..8ffe7b33 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,12 +1,15 @@ import ListenBrainzAPI from '@server/api/listenbrainz'; -import MusicBrainz from '@server/api/musicbrainz'; import PlexTvAPI from '@server/api/plextv'; +import TheAudioDb from '@server/api/theaudiodb'; import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import TmdbPersonMapper from '@server/api/themoviedb/personMapper'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { @@ -26,6 +29,7 @@ import { mapNetwork } from '@server/models/Tv'; import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; +import { In } from 'typeorm'; import { z } from 'zod'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { @@ -858,7 +862,246 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( discoverRoutes.get('/music', async (req, res, next) => { const listenbrainz = new ListenBrainzAPI(); - const musicbrainz = new MusicBrainz(); + + try { + const page = Number(req.query.page) || 1; + const pageSize = 20; + const sortBy = (req.query.sortBy as string) || 'release_date.desc'; + const days = Number(req.query.days) || 30; + const genreFilter = req.query.genre as string | undefined; + const showOnlyWithCovers = req.query.onlyWithCoverArt === 'true'; + const releaseDateGte = req.query.releaseDateGte as string | undefined; + const releaseDateLte = req.query.releaseDateLte as string | undefined; + + const [field, direction] = sortBy.split('.'); + let apiSortField = 'release_date'; + + if (field === 'title') { + apiSortField = 'release_name'; + } else if (field === 'artist') { + apiSortField = 'artist_credit_name'; + } + + const freshReleasesData = await listenbrainz.getFreshReleases({ + offset: 0, + count: 20, + days, + sort: apiSortField, + }); + + let filteredReleases = freshReleasesData.payload.releases; + + if (genreFilter) { + const genres = genreFilter.split(','); + filteredReleases = freshReleasesData.payload.releases.filter( + (release) => { + let releaseType; + + if (release.release_group_secondary_type) { + releaseType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + releaseType = release.release_tags[0]; + } else { + releaseType = release.release_group_primary_type || 'Album'; + } + + return genres.includes(releaseType); + } + ); + } + + if (releaseDateGte || releaseDateLte) { + filteredReleases = filteredReleases.filter((release) => { + if (!release.release_date) { + return false; + } + + const releaseDate = new Date(release.release_date); + + if (releaseDateGte) { + const gteDate = new Date(releaseDateGte); + if (releaseDate < gteDate) { + return false; + } + } + + if (releaseDateLte) { + const lteDate = new Date(releaseDateLte); + if (releaseDate > lteDate) { + return false; + } + } + + return true; + }); + } + + filteredReleases.sort((a, b) => { + const multiplier = direction === 'asc' ? 1 : -1; + + switch (field) { + case 'release_date': { + const dateA = a.release_date ? new Date(a.release_date).getTime() : 0; + const dateB = b.release_date ? new Date(b.release_date).getTime() : 0; + return (dateA - dateB) * multiplier; + } + case 'title': { + return ( + (a.release_name ?? '').localeCompare(b.release_name ?? '') * + multiplier + ); + } + case 'artist': { + return ( + (a.artist_credit_name ?? '').localeCompare( + b.artist_credit_name ?? '' + ) * multiplier + ); + } + default: + return 0; + } + }); + + const mbIds = filteredReleases + .map((release) => release.release_group_mbid) + .filter(Boolean); + + const existingMetadata = + mbIds.length > 0 + ? await getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(mbIds) }, + select: ['mbAlbumId', 'caaUrl'], + cache: true, + }) + : []; + + const metadataMap = new Map( + existingMetadata.map((meta) => [meta.mbAlbumId, meta]) + ); + + if (showOnlyWithCovers) { + filteredReleases = filteredReleases.filter((release) => { + if (!release.release_group_mbid) { + return false; + } + const metadata = metadataMap.get(release.release_group_mbid); + return !!metadata?.caaUrl; + }); + } + + const totalResults = filteredReleases.length; + const totalPages = Math.ceil(totalResults / pageSize); + + const offset = (page - 1) * pageSize; + const paginatedReleases = filteredReleases.slice(offset, offset + pageSize); + + const paginatedMbIds = paginatedReleases + .map((release) => release.release_group_mbid) + .filter(Boolean); + + if (paginatedMbIds.length === 0) { + const results = paginatedReleases.map((release) => { + let secondaryType; + if (release.release_group_secondary_type) { + secondaryType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + secondaryType = release.release_tags[0]; + } + + return { + id: null, + mediaType: 'album', + 'primary-type': release.release_group_primary_type || 'Album', + secondaryType, + title: release.release_name, + 'artist-credit': [{ name: release.artist_credit_name }], + releaseDate: release.release_date, + posterPath: undefined, + }; + }); + + return res.json({ + page, + totalPages, + totalResults, + results, + }); + } + + const media = await Media.getRelatedMedia(req.user, paginatedMbIds); + + const mediaMap = new Map( + media.map((mediaItem) => [mediaItem.mbId, mediaItem]) + ); + + const results = paginatedReleases.map((release) => { + if (!release.release_group_mbid) { + let secondaryType; + if (release.release_group_secondary_type) { + secondaryType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + secondaryType = release.release_tags[0]; + } + + return { + id: null, + mediaType: 'album', + 'primary-type': release.release_group_primary_type || 'Album', + secondaryType, + title: release.release_name, + 'artist-credit': [{ name: release.artist_credit_name }], + releaseDate: release.release_date, + posterPath: undefined, + }; + } + + const metadata = metadataMap.get(release.release_group_mbid); + const hasCoverArt = !!metadata?.caaUrl; + + let secondaryType; + if (release.release_group_secondary_type) { + secondaryType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + secondaryType = release.release_tags[0]; + } + + return { + id: release.release_group_mbid, + mediaType: 'album', + 'primary-type': release.release_group_primary_type || 'Album', + secondaryType, + title: release.release_name, + 'artist-credit': [{ name: release.artist_credit_name }], + artistId: release.artist_mbids?.[0], + mediaInfo: mediaMap.get(release.release_group_mbid), + releaseDate: release.release_date, + posterPath: metadata?.caaUrl || null, + needsCoverArt: !hasCoverArt, + }; + }); + + return res.json({ + page, + totalPages, + totalResults, + results, + }); + } catch (e) { + logger.error('Failed to retrieve fresh music releases', { + label: 'API', + error: e instanceof Error ? e.message : 'Unknown error', + stack: e instanceof Error ? e.stack : undefined, + }); + return next({ + status: 500, + message: 'Unable to retrieve fresh music releases.', + }); + } +}); + +discoverRoutes.get('/music/albums', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); try { const page = Number(req.query.page) || 1; @@ -866,113 +1109,110 @@ discoverRoutes.get('/music', async (req, res, next) => { const offset = (page - 1) * pageSize; const sortBy = (req.query.sortBy as string) || 'listen_count.desc'; - const data = await listenbrainz.getTopAlbums({ + const topAlbumsData = await listenbrainz.getTopAlbums({ offset, count: pageSize, - range: 'week', + range: 'month', }); - const media = await Media.getRelatedMedia( - req.user, - data.payload.release_groups.map((album) => album.release_group_mbid) - ); + const mbIds = topAlbumsData.payload.release_groups + .map((album) => album.release_group_mbid) + .filter((id): id is string => !!id); - const albumDetailsPromises = data.payload.release_groups.map( - async (album) => { - try { - const details = await musicbrainz.getAlbum({ - albumId: album.release_group_mbid, - }); + if (mbIds.length === 0) { + const results = topAlbumsData.payload.release_groups.map((album) => ({ + id: null, + mediaType: 'album', + 'primary-type': 'Album', + title: album.release_group_name, + 'artist-credit': [{ name: album.artist_name }], + listenCount: album.listen_count, + posterPath: undefined, + })); - const images = - details.images?.length > 0 - ? details.images.filter((img) => img.CoverType === 'Cover') - : album.caa_id - ? [ - { - CoverType: 'Cover', - Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`, - }, - ] - : []; - - return { - id: album.release_group_mbid, - mediaType: 'album', - type: 'Album', - title: album.release_group_name, - artistname: album.artist_name, - artistId: album.artist_mbids[0], - releasedate: details.releasedate || '', - images, - mediaInfo: media?.find( - (med) => med.mbId === album.release_group_mbid - ), - listenCount: album.listen_count, - }; - } catch (e) { - return { - id: album.release_group_mbid, - mediaType: 'album', - type: 'Album', - title: album.release_group_name, - artistname: album.artist_name, - artistId: album.artist_mbids[0], - releasedate: '', - images: album.caa_id - ? [ - { - CoverType: 'Cover', - Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`, - }, - ] - : [], - mediaInfo: media?.find( - (med) => med.mbId === album.release_group_mbid - ), - listenCount: album.listen_count, - }; - } - } - ); - - const results = await Promise.all(albumDetailsPromises); - - switch (sortBy) { - case 'listen_count.asc': - results.sort((a, b) => a.listenCount - b.listenCount); - break; - case 'listen_count.desc': - results.sort((a, b) => b.listenCount - a.listenCount); - break; - case 'title.asc': - results.sort((a, b) => a.title.localeCompare(b.title)); - break; - case 'title.desc': - results.sort((a, b) => b.title.localeCompare(a.title)); - break; - case 'release_date.asc': - results.sort((a, b) => - (a.releasedate || '').localeCompare(b.releasedate || '') - ); - break; - case 'release_date.desc': - results.sort((a, b) => - (b.releasedate || '').localeCompare(a.releasedate || '') - ); - break; + return res.json({ + page, + totalPages: Math.ceil(topAlbumsData.payload.count / pageSize), + totalResults: topAlbumsData.payload.count, + results, + }); } - return res.status(200).json({ + const [existingMetadata, media] = await Promise.all([ + getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(mbIds) }, + select: ['mbAlbumId', 'caaUrl'], + cache: true, + }), + Media.getRelatedMedia(req.user, mbIds), + ]); + + const metadataMap = new Map( + existingMetadata.map((meta) => [meta.mbAlbumId, meta]) + ); + + const mediaMap = new Map( + media.map((mediaItem) => [mediaItem.mbId, mediaItem]) + ); + + const results = topAlbumsData.payload.release_groups.map((album) => { + if (!album.release_group_mbid) { + return { + id: null, + mediaType: 'album', + 'primary-type': 'Album', + title: album.release_group_name, + 'artist-credit': [{ name: album.artist_name }], + listenCount: album.listen_count, + posterPath: undefined, + }; + } + + const metadata = metadataMap.get(album.release_group_mbid); + const hasCoverArt = !!metadata?.caaUrl; + + return { + id: album.release_group_mbid, + mediaType: 'album', + 'primary-type': 'Album', + title: album.release_group_name, + 'artist-credit': [{ name: album.artist_name }], + artistId: album.artist_mbids[0], + mediaInfo: mediaMap.get(album.release_group_mbid), + listenCount: album.listen_count, + posterPath: metadata?.caaUrl || null, + needsCoverArt: !hasCoverArt, + }; + }); + + if (sortBy) { + const [field, direction] = sortBy.split('.'); + const multiplier = direction === 'asc' ? 1 : -1; + + results.sort((a, b) => { + switch (field) { + case 'listen_count': { + return (a.listenCount - b.listenCount) * multiplier; + } + case 'title': { + return (a.title ?? '').localeCompare(b.title ?? '') * multiplier; + } + default: + return 0; + } + }); + } + + return res.json({ page, - totalPages: Math.ceil(data.payload.count / pageSize), - totalResults: data.payload.count, + totalPages: Math.ceil(topAlbumsData.payload.count / pageSize), + totalResults: topAlbumsData.payload.count, results, }); } catch (e) { - logger.debug('Something went wrong retrieving popular music', { + logger.error('Failed to retrieve popular music', { label: 'API', - errorMessage: e.message, + error: e instanceof Error ? e.message : 'Unknown error', }); return next({ status: 500, @@ -981,6 +1221,167 @@ discoverRoutes.get('/music', async (req, res, next) => { } }); +discoverRoutes.get('/music/artists', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); + const personMapper = TmdbPersonMapper.getInstance(); + const theAudioDb = TheAudioDb.getInstance(); + + try { + const page = Number(req.query.page) || 1; + const pageSize = 20; + const offset = (page - 1) * pageSize; + const sortBy = (req.query.sortBy as string) || 'listen_count.desc'; + + const topArtistsData = await listenbrainz.getTopArtists({ + offset, + count: pageSize, + range: 'month', + }); + + const mbIds = topArtistsData.payload.artists + .map((artist) => artist.artist_mbid) + .filter(Boolean); + + if (mbIds.length === 0) { + return res.status(200).json({ + page, + totalPages: Math.ceil(topArtistsData.payload.count / pageSize), + totalResults: topArtistsData.payload.count, + results: topArtistsData.payload.artists.map((artist) => ({ + id: null, + mediaType: 'artist', + name: artist.artist_name, + listenCount: artist.listen_count, + })), + }); + } + + const [media, artistMetadata] = await Promise.all([ + Media.getRelatedMedia(req.user, mbIds), + getRepository(MetadataArtist).find({ + where: { mbArtistId: In(mbIds) }, + }), + ]); + + const mediaMap = new Map( + media.map((mediaItem) => [mediaItem.mbId, mediaItem]) + ); + + const metadataMap = new Map( + artistMetadata.map((metadata) => [metadata.mbArtistId, metadata]) + ); + + const artistsNeedingImages = mbIds.filter((id) => { + const metadata = metadataMap.get(id); + return !metadata?.tadbThumb && !metadata?.tadbCover; + }); + + const artistsForPersonMapping = topArtistsData.payload.artists + .filter((artist) => artist.artist_mbid) + .filter((artist) => { + const metadata = metadataMap.get(artist.artist_mbid); + return !metadata?.tmdbPersonId; + }) + .map((artist) => ({ + artistId: artist.artist_mbid, + artistName: artist.artist_name, + })); + + interface ArtistImageResults { + [key: string]: { + artistThumb?: string; + artistBackground?: string; + }; + } + + const [artistImageResults] = await Promise.all([ + artistsNeedingImages.length > 0 + ? (theAudioDb.batchGetArtistImages( + artistsNeedingImages + ) as Promise) + : ({} as ArtistImageResults), + artistsForPersonMapping.length > 0 + ? personMapper.batchGetMappings(artistsForPersonMapping) + : {}, + ]); + + let updatedArtistMetadata = artistMetadata; + if (artistsForPersonMapping.length > 0 || artistsNeedingImages.length > 0) { + updatedArtistMetadata = await getRepository(MetadataArtist).find({ + where: { mbArtistId: In(mbIds) }, + }); + } + + const updatedMetadataMap = new Map( + updatedArtistMetadata.map((metadata) => [metadata.mbArtistId, metadata]) + ); + + const results = topArtistsData.payload.artists.map((artist) => { + if (!artist.artist_mbid) { + return { + id: null, + mediaType: 'artist', + name: artist.artist_name, + listenCount: artist.listen_count, + }; + } + + const metadata = updatedMetadataMap.get(artist.artist_mbid); + const imageResult = artistImageResults[artist.artist_mbid]; + + return { + id: artist.artist_mbid, + mediaType: 'artist', + name: artist.artist_name, + mediaInfo: mediaMap.get(artist.artist_mbid), + listenCount: artist.listen_count, + artistThumb: + metadata?.tmdbThumb ?? + metadata?.tadbThumb ?? + imageResult?.artistThumb ?? + null, + artistBackdrop: + metadata?.tadbCover ?? imageResult?.artistBackground ?? null, + tmdbPersonId: metadata?.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + }; + }); + + if (sortBy) { + const [field, direction] = sortBy.split('.'); + const multiplier = direction === 'asc' ? 1 : -1; + + results.sort((a, b) => { + switch (field) { + case 'listen_count': + return (a.listenCount - b.listenCount) * multiplier; + case 'name': + return (a.name ?? '').localeCompare(b.name ?? '') * multiplier; + default: + return 0; + } + }); + } + + return res.status(200).json({ + page, + totalPages: Math.ceil(topArtistsData.payload.count / pageSize), + totalResults: topArtistsData.payload.count, + results, + }); + } catch (e) { + logger.error('Failed to retrieve popular artists', { + label: 'API', + error: e instanceof Error ? e.message : 'Unknown error', + }); + return next({ + status: 500, + message: 'Unable to retrieve popular artists.', + }); + } +}); + discoverRoutes.get, WatchlistResponse>( '/watchlist', async (req, res) => { diff --git a/server/routes/fanartproxy.ts b/server/routes/fanartproxy.ts deleted file mode 100644 index 4b6dd809..00000000 --- a/server/routes/fanartproxy.ts +++ /dev/null @@ -1,35 +0,0 @@ -import ImageProxy from '@server/lib/imageproxy'; -import logger from '@server/logger'; -import { Router } from 'express'; - -const router = Router(); -const fanartImageProxy = new ImageProxy('fanart', 'http://assets.fanart.tv/', { - rateLimitOptions: { - maxRPS: 50, - }, -}); - -router.get('/*', async (req, res) => { - const imagePath = req.path; - try { - const imageData = await fanartImageProxy.getImage(imagePath); - - res.writeHead(200, { - 'Content-Type': `image/${imageData.meta.extension}`, - 'Content-Length': imageData.imageBuffer.length, - 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, - 'OS-Cache-Key': imageData.meta.cacheKey, - 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', - }); - - res.end(imageData.imageBuffer); - } catch (e) { - logger.error('Failed to proxy image', { - imagePath, - errorMessage: e.message, - }); - res.status(500).end(); - } -}); - -export default router; diff --git a/server/routes/group.ts b/server/routes/group.ts deleted file mode 100644 index a5f2c1f5..00000000 --- a/server/routes/group.ts +++ /dev/null @@ -1,165 +0,0 @@ -import CoverArtArchive from '@server/api/coverartarchive'; -import MusicBrainz from '@server/api/musicbrainz'; -import Media from '@server/entity/Media'; -import logger from '@server/logger'; -import { mapArtistDetails } from '@server/models/Artist'; -import { Router } from 'express'; - -const groupRoutes = Router(); - -groupRoutes.get('/:id', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const locale = req.locale || 'en'; - - try { - const [artistDetails, wikipediaExtract] = await Promise.all([ - musicbrainz.getArtist({ - artistId: req.params.id, - }), - musicbrainz.getWikipediaExtract(req.params.id, locale, 'artist'), - ]); - - const mappedDetails = await mapArtistDetails(artistDetails); - - if (wikipediaExtract) { - mappedDetails.overview = wikipediaExtract; - } - - return res.status(200).json(mappedDetails); - } catch (e) { - logger.error('Something went wrong retrieving artist details', { - label: 'Group API', - errorMessage: e.message, - artistId: req.params.id, - }); - return next({ - status: 500, - message: 'Unable to retrieve artist details.', - }); - } -}); - -groupRoutes.get('/:id/discography', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const coverArtArchive = new CoverArtArchive(); - - try { - const type = req.query.type as string; - const page = Number(req.query.page) || 1; - const pageSize = 20; - - const artistDetails = await musicbrainz.getArtist({ - artistId: req.params.id, - }); - - const mappedDetails = await mapArtistDetails(artistDetails); - - if (!mappedDetails.Albums?.length) { - return res.status(200).json({ - page: 1, - pageInfo: { - total: 0, - totalPages: 0, - }, - results: [], - }); - } - - let filteredAlbums = mappedDetails.Albums; - if (type) { - if (type === 'Other') { - filteredAlbums = mappedDetails.Albums.filter( - (album) => !['Album', 'Single', 'EP'].includes(album.type) - ); - } else { - filteredAlbums = mappedDetails.Albums.filter( - (album) => album.type === type - ); - } - } - - const albumsWithDetails = await Promise.all( - filteredAlbums.map(async (album) => { - try { - const albumDetails = await musicbrainz.getAlbum({ - albumId: album.id, - }); - - let images = albumDetails.images; - - if (!images?.length) { - try { - const coverArtData = await coverArtArchive.getCoverArt(album.id); - if (coverArtData.images?.length) { - images = coverArtData.images.map((img) => ({ - CoverType: img.front ? 'Cover' : 'Poster', - Url: img.image, - })); - } - } catch (e) { - // Handle cover art errors silently - } - } - - return { - ...album, - images: images || [], - releasedate: albumDetails.releasedate || '', - }; - } catch (e) { - return { - ...album, - images: [], - releasedate: '', - }; - } - }) - ); - - const sortedAlbums = albumsWithDetails.sort((a, b) => { - if (!a.releasedate && !b.releasedate) return 0; - if (!a.releasedate) return 1; - if (!b.releasedate) return -1; - return ( - new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime() - ); - }); - - const totalResults = sortedAlbums.length; - const totalPages = Math.ceil(totalResults / pageSize); - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedAlbums = sortedAlbums.slice(start, end); - - const media = await Media.getRelatedMedia( - req.user, - paginatedAlbums.map((album) => album.id) - ); - - const results = paginatedAlbums.map((album) => ({ - ...album, - mediaInfo: media?.find((med) => med.mbId === album.id), - })); - - return res.status(200).json({ - page, - pageInfo: { - total: totalResults, - totalPages, - }, - results, - }); - } catch (e) { - logger.error('Something went wrong retrieving artist discography', { - label: 'Group API', - errorMessage: e.message, - artistId: req.params.id, - }); - return next({ - status: 500, - message: 'Unable to retrieve artist discography.', - }); - } -}); - -export default groupRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index 93d62177..fb71f527 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -27,11 +27,12 @@ import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; +import artistRoutes from './artist'; import authRoutes from './auth'; import blacklistRoutes from './blacklist'; import collectionRoutes from './collection'; +import coverArtRoutes from './coverart'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; -import groupRoutes from './group'; import issueRoutes from './issue'; import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; @@ -159,7 +160,7 @@ router.use('/tv', isAuthenticated(), tvRoutes); router.use('/music', isAuthenticated(), musicRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); -router.use('/group', isAuthenticated(), groupRoutes); +router.use('/artist', isAuthenticated(), artistRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); @@ -170,7 +171,7 @@ router.use( isAuthenticated(Permission.ADMIN), overrideRuleRoutes ); - +router.use('/coverart', isAuthenticated(), coverArtRoutes); router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/issue.ts b/server/routes/issue.ts index cc543342..153e4e3f 100644 --- a/server/routes/issue.ts +++ b/server/routes/issue.ts @@ -173,18 +173,18 @@ issueRoutes.get('/count', async (req, res, next) => { }) .getCount(); - const lyricsCount = await query - .where('issue.issueType = :issueType', { - issueType: IssueType.LYRICS, - }) - .getCount(); - const othersCount = await query .where('issue.issueType = :issueType', { issueType: IssueType.OTHER, }) .getCount(); + const lyricsCount = await query + .where('issue.issueType = :issueType', { + issueType: IssueType.LYRICS, + }) + .getCount(); + const openCount = await query .where('issue.status = :issueStatus', { issueStatus: IssueStatus.OPEN, @@ -202,8 +202,8 @@ issueRoutes.get('/count', async (req, res, next) => { video: videoCount, audio: audioCount, subtitles: subtitlesCount, - lyrics: lyricsCount, others: othersCount, + lyrics: lyricsCount, open: openCount, closed: closedCount, }); diff --git a/server/routes/lidarrproxy.ts b/server/routes/lidarrproxy.ts deleted file mode 100644 index f44966d0..00000000 --- a/server/routes/lidarrproxy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import ImageProxy from '@server/lib/imageproxy'; -import logger from '@server/logger'; -import { Router } from 'express'; - -const router = Router(); -const lidarrImageProxy = new ImageProxy( - 'lidarr', - 'https://imagecache.lidarr.audio', - { - rateLimitOptions: { - maxRPS: 50, - }, - } -); - -router.get('/*', async (req, res) => { - const imagePath = req.path; - try { - const imageData = await lidarrImageProxy.getImage(imagePath); - - res.writeHead(200, { - 'Content-Type': `image/${imageData.meta.extension}`, - 'Content-Length': imageData.imageBuffer.length, - 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, - 'OS-Cache-Key': imageData.meta.cacheKey, - 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', - }); - - res.end(imageData.imageBuffer); - } catch (e) { - logger.error('Failed to proxy image', { - imagePath, - errorMessage: e.message, - }); - res.status(500).end(); - } -}); - -export default router; diff --git a/server/routes/media.ts b/server/routes/media.ts index 0f1716cc..266453f1 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -6,6 +6,7 @@ import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { @@ -24,6 +25,7 @@ const mediaRoutes = Router(); mediaRoutes.get('/', async (req, res, next) => { const mediaRepository = getRepository(Media); + const metadataAlbumRepository = getRepository(MetadataAlbum); const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; @@ -78,6 +80,37 @@ mediaRoutes.get('/', async (req, res, next) => { take: pageSize, skip, }); + + const musicMediaItems = media.filter( + (item) => item.mediaType === 'music' && item.mbId + ); + + const mbIds = musicMediaItems.map((item) => item.mbId as string); + + const albumMetadata = + mbIds.length > 0 + ? await metadataAlbumRepository.find({ + where: { mbAlbumId: In(mbIds) }, + select: ['mbAlbumId', 'caaUrl'], + }) + : []; + + const albumMetadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) + ); + + const mediaWithCoverArt = media.map((item) => { + if (item.mediaType === 'music' && item.mbId) { + const metadata = albumMetadataMap.get(item.mbId); + return { + ...item, + posterPath: metadata?.caaUrl || null, + needsCoverArt: !metadata?.caaUrl, + }; + } + return item; + }); + return res.status(200).json({ pageInfo: { pages: Math.ceil(mediaCount / pageSize), @@ -85,10 +118,14 @@ mediaRoutes.get('/', async (req, res, next) => { results: mediaCount, page: Math.ceil(skip / pageSize) + 1, }, - results: media, + results: mediaWithCoverArt, } as MediaResultsResponse); } catch (e) { - next({ status: 500, message: e.message }); + logger.error('Something went wrong retrieving media', { + label: 'Media', + error: e instanceof Error ? e.message : 'Unknown error', + }); + next({ status: 500, message: 'Unable to retrieve media' }); } }); @@ -206,38 +243,36 @@ mediaRoutes.delete( serviceSettings = settings.radarr.find( (radarr) => radarr.isDefault && radarr.is4k === is4k ); - } else if(media.mediaType === MediaType.TV) { + } else if (media.mediaType === MediaType.TV) { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.isDefault && sonarr.is4k === is4k ); } else { - serviceSettings = settings.lidarr.find( - (lidarr) => lidarr.isDefault); + serviceSettings = settings.lidarr.find((lidarr) => lidarr.isDefault); } - - const specificServiceId = is4k ? media.serviceId4k : media.serviceId; - if ( - specificServiceId && - specificServiceId >= 0 && - serviceSettings?.id !== specificServiceId - ) { - if (media.mediaType === MediaType.MOVIE) { - serviceSettings = settings.radarr.find( - (radarr) => radarr.id === specificServiceId - ); - } else if (media.mediaType === MediaType.TV) { - serviceSettings = settings.sonarr.find( - (sonarr) => sonarr.id === specificServiceId - ); - } else { - serviceSettings = settings.lidarr.find( - (lidarr) => lidarr.id === media.serviceId - ) - } + const specificServiceId = is4k ? media.serviceId4k : media.serviceId; + if ( + specificServiceId && + specificServiceId >= 0 && + serviceSettings?.id !== specificServiceId + ) { + if (media.mediaType === MediaType.MOVIE) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.id === specificServiceId + ); + } else if (media.mediaType === MediaType.TV) { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.id === specificServiceId + ); + } else { + serviceSettings = settings.lidarr.find( + (lidarr) => lidarr.id === media.serviceId + ); } - - if (!serviceSettings) { + } + + if (!serviceSettings) { logger.warn( `There is no default ${ media.mediaType === MediaType.MOVIE @@ -260,7 +295,7 @@ mediaRoutes.delete( apiKey: serviceSettings.apiKey, url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), }); - + await (service as RadarrAPI).removeMovie(media.tmdbId); } else if (media.mediaType === MediaType.TV) { service = new SonarrAPI({ @@ -276,8 +311,7 @@ mediaRoutes.delete( throw new Error('TVDB ID not found'); } await (service as SonarrAPI).removeSeries(tvdbId); - } else if (media.mediaType == MediaType.MUSIC) - { + } else if (media.mediaType == MediaType.MUSIC) { service = new LidarrAPI({ apiKey: serviceSettings.apiKey, url: LidarrAPI.buildUrl(serviceSettings, '/api/v1'), @@ -291,7 +325,6 @@ mediaRoutes.delete( } return res.status(204).send(); - } catch (e) { logger.error('Something went wrong fetching media in delete request', { label: 'Media', diff --git a/server/routes/music.ts b/server/routes/music.ts index 12778f2c..f8e5db55 100644 --- a/server/routes/music.ts +++ b/server/routes/music.ts @@ -1,30 +1,29 @@ -import CoverArtArchive from '@server/api/coverartarchive'; import ListenBrainzAPI from '@server/api/listenbrainz'; import MusicBrainz from '@server/api/musicbrainz'; -import TheMovieDb from '@server/api/themoviedb'; +import TheAudioDb from '@server/api/theaudiodb'; +import TmdbPersonMapper from '@server/api/themoviedb/personMapper'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapMusicDetails } from '@server/models/Music'; import { Router } from 'express'; +import { In } from 'typeorm'; const musicRoutes = Router(); musicRoutes.get('/:id', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); const musicbrainz = new MusicBrainz(); - const locale = req.locale || 'en'; + const personMapper = TmdbPersonMapper.getInstance(); + const theAudioDb = TheAudioDb.getInstance(); try { - const [albumDetails, wikipediaExtract] = await Promise.all([ - musicbrainz.getAlbum({ - albumId: req.params.id, - }), - musicbrainz.getWikipediaExtract(req.params.id, locale), - ]); - - const [media, onUserWatchlist] = await Promise.all([ + const [albumDetails, media, onUserWatchlist] = await Promise.all([ + listenbrainz.getAlbum(req.params.id), getRepository(Media) .createQueryBuilder('media') .leftJoinAndSelect('media.requests', 'requests') @@ -36,31 +35,143 @@ musicRoutes.get('/:id', async (req, res, next) => { }) .getOne() .then((media) => media ?? undefined), - getRepository(Watchlist).exist({ where: { mbId: req.params.id, - requestedBy: { - id: req.user?.id, - }, + requestedBy: { id: req.user?.id }, }, }), ]); + const artistId = + albumDetails.release_group_metadata?.artist?.artists[0]?.artist_mbid; + const isPerson = + albumDetails.release_group_metadata?.artist?.artists[0]?.type === + 'Person'; + const trackArtistIds = albumDetails.mediums + .flatMap((medium) => medium.tracks) + .flatMap((track) => track.artists) + .filter((artist) => artist.artist_mbid) + .map((artist) => artist.artist_mbid); + + const [ + metadataAlbum, + metadataArtist, + trackArtistMetadata, + artistWikipedia, + ] = await Promise.all([ + getRepository(MetadataAlbum).findOne({ + where: { mbAlbumId: req.params.id }, + }), + artistId + ? getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + }) + : Promise.resolve(undefined), + getRepository(MetadataArtist).find({ + where: { mbArtistId: In(trackArtistIds) }, + }), + artistId && isPerson + ? musicbrainz + .getArtistWikipediaExtract({ + artistMbid: artistId, + language: req.locale, + }) + .catch(() => null) + : Promise.resolve(null), + ]); + + const trackArtistsToMap = albumDetails.mediums + .flatMap((medium) => medium.tracks) + .flatMap((track) => + track.artists + .filter((artist) => artist.artist_mbid) + .filter( + (artist) => + !trackArtistMetadata.some( + (m) => m.mbArtistId === artist.artist_mbid && m.tmdbPersonId + ) + ) + .map((artist) => ({ + artistId: artist.artist_mbid, + artistName: artist.artist_credit_name, + })) + ); + + const [artistImages, personMappingResult, updatedArtistMetadata] = + await Promise.all([ + artistId && !metadataArtist?.tadbThumb && !metadataArtist?.tadbCover + ? theAudioDb.getArtistImages(artistId) + : Promise.resolve(null), + artistId && isPerson && !metadataArtist?.tmdbPersonId + ? personMapper + .getMapping( + artistId, + albumDetails.release_group_metadata.artist.artists[0].name + ) + .catch(() => null) + : Promise.resolve(null), + trackArtistsToMap.length > 0 + ? personMapper.batchGetMappings(trackArtistsToMap).then(() => + getRepository(MetadataArtist).find({ + where: { mbArtistId: In(trackArtistIds) }, + }) + ) + : Promise.resolve(trackArtistMetadata), + ]); + + const updatedMetadataArtist = + personMappingResult && artistId + ? await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + }) + : metadataArtist; + const mappedDetails = mapMusicDetails(albumDetails, media, onUserWatchlist); + const finalTrackArtistMetadata = + updatedArtistMetadata || trackArtistMetadata; - if (wikipediaExtract) { - mappedDetails.artist.overview = wikipediaExtract; - } - - return res.status(200).json(mappedDetails); + return res.status(200).json({ + ...mappedDetails, + posterPath: metadataAlbum?.caaUrl ?? null, + needsCoverArt: !metadataAlbum?.caaUrl, + artistWikipedia, + artistThumb: + updatedMetadataArtist?.tmdbThumb ?? + updatedMetadataArtist?.tadbThumb ?? + artistImages?.artistThumb ?? + null, + artistBackdrop: + updatedMetadataArtist?.tadbCover ?? + artistImages?.artistBackground ?? + null, + tmdbPersonId: updatedMetadataArtist?.tmdbPersonId + ? Number(updatedMetadataArtist.tmdbPersonId) + : null, + tracks: mappedDetails.tracks.map((track) => ({ + ...track, + artists: track.artists.map((artist) => { + const metadata = finalTrackArtistMetadata.find( + (m) => m.mbArtistId === artist.mbid + ); + return { + ...artist, + tmdbMapping: metadata?.tmdbPersonId + ? { + personId: Number(metadata.tmdbPersonId), + profilePath: metadata.tmdbThumb, + } + : null, + }; + }), + })), + }); } catch (e) { logger.error('Something went wrong retrieving album details', { label: 'Music API', errorMessage: e.message, mbId: req.params.id, }); - return next({ status: 500, message: 'Unable to retrieve album details.', @@ -68,186 +179,203 @@ musicRoutes.get('/:id', async (req, res, next) => { } }); -musicRoutes.get('/:id/discography', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const coverArtArchive = new CoverArtArchive(); - +musicRoutes.get('/:id/artist', async (req, res, next) => { try { - const albumDetails = await musicbrainz.getAlbum({ - albumId: req.params.id, - }); - - if (!albumDetails.artists?.[0]?.id) { - throw new Error('No artist found for album'); - } + const listenbrainzApi = new ListenBrainzAPI(); + const personMapper = TmdbPersonMapper.getInstance(); + const theAudioDb = TheAudioDb.getInstance(); + const metadataAlbumRepository = getRepository(MetadataAlbum); + const metadataArtistRepository = getRepository(MetadataArtist); const page = Number(req.query.page) || 1; - const pageSize = 20; + const pageSize = Number(req.query.pageSize) || 20; + const isSlider = req.query.slider === 'true'; - const artistData = await musicbrainz.getArtist({ - artistId: albumDetails.artists[0].id, - }); + const albumData = await listenbrainzApi.getAlbum(req.params.id); + const artistData = albumData?.release_group_metadata?.artist?.artists?.[0]; + const artistType = artistData?.type; - const albums = - artistData.Albums?.map((album) => ({ - id: album.Id.toLowerCase(), - title: album.Title, - type: album.Type, - mediaType: 'album', - })) ?? []; - - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedAlbums = albums.slice(start, end); - - const albumDetailsPromises = paginatedAlbums.map(async (album) => { - try { - const details = await musicbrainz.getAlbum({ - albumId: album.id, - }); - - let images = details.images; - - // Try to get cover art if no images found - if (!images || images.length === 0) { - try { - const coverArtData = await coverArtArchive.getCoverArt(album.id); - if (coverArtData.images?.length > 0) { - images = coverArtData.images.map((img) => ({ - CoverType: img.front ? 'Cover' : 'Poster', - Url: img.image, - })); - } - } catch (coverArtError) { - // Fallback silently - } - } - - return { - ...album, - images, - releasedate: details.releasedate, - }; - } catch (e) { - return album; - } - }); - - const albumsWithDetails = await Promise.all(albumDetailsPromises); - - const media = await Media.getRelatedMedia( - req.user, - albumsWithDetails.map((album) => album.id) - ); - - const resultsWithMedia = albumsWithDetails.map((album) => ({ - ...album, - mediaInfo: media?.find((med) => med.mbId === album.id), - })); - - return res.status(200).json({ - page, - totalPages: Math.ceil(albums.length / pageSize), - totalResults: albums.length, - results: resultsWithMedia, - }); - } catch (e) { - logger.error('Something went wrong retrieving artist discography', { - label: 'Music API', - errorMessage: e.message, - albumId: req.params.id, - }); - - return next({ - status: 500, - message: 'Unable to retrieve artist discography.', - }); - } -}); - -musicRoutes.get('/:id/similar', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const listenbrainz = new ListenBrainzAPI(); - const tmdb = new TheMovieDb(); - - try { - const albumDetails = await musicbrainz.getAlbum({ - albumId: req.params.id, - }); - - if (!albumDetails.artists?.[0]?.id) { - throw new Error('No artist found for album'); + if (!artistData?.artist_mbid || artistType === 'Other') { + return res.status(404).json({ + status: 404, + message: 'Artist details not available for this type', + }); } - const page = Number(req.query.page) || 1; - const pageSize = 20; - - const similarArtists = await listenbrainz.getSimilarArtists( - albumDetails.artists[0].id + const [artistDetails, cachedTheAudioDb, metadataArtist] = await Promise.all( + [ + listenbrainzApi.getArtist(artistData.artist_mbid), + theAudioDb.getArtistImagesFromCache(artistData.artist_mbid), + metadataArtistRepository.findOne({ + where: { mbArtistId: artistData.artist_mbid }, + }), + ] ); - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedArtists = similarArtists.slice(start, end); + if (!artistDetails) { + return res.status(404).json({ status: 404, message: 'Artist not found' }); + } - const artistDetailsPromises = paginatedArtists.map(async (artist) => { - try { - let tmdbId = null; - if (artist.type === 'Person') { - const searchResults = await tmdb.searchPerson({ - query: artist.name, - page: 1, - }); - - const match = searchResults.results.find( - (result) => result.name.toLowerCase() === artist.name.toLowerCase() + const totalReleaseGroups = artistDetails.releaseGroups.length; + const paginatedReleaseGroups = + isSlider || page === 1 + ? artistDetails.releaseGroups.slice(0, pageSize) + : artistDetails.releaseGroups.slice( + (page - 1) * pageSize, + page * pageSize ); - if (match) { - tmdbId = match.id; - } - } - const details = await musicbrainz.getArtist({ - artistId: artist.artist_mbid, - }); + const releaseGroupIds = paginatedReleaseGroups.map((rg) => rg.mbid); + const similarArtistIds = + artistDetails.similarArtists?.artists?.map((a) => a.artist_mbid) ?? []; - return { - id: tmdbId || artist.artist_mbid, - mediaType: 'artist' as const, - artistname: artist.name, - type: artist.type || 'Person', - overview: artist.comment, - score: artist.score, - images: details.images || [], - artistimage: details.images?.find((img) => img.CoverType === 'Poster') - ?.Url, - }; - } catch (e) { - return null; - } - }); + const [relatedMedia, albumMetadata, similarArtistMetadata] = + await Promise.all([ + Media.getRelatedMedia(req.user, releaseGroupIds), + metadataAlbumRepository.find({ + where: { mbAlbumId: In(releaseGroupIds) }, + }), + similarArtistIds.length > 0 + ? metadataArtistRepository.find({ + where: { mbArtistId: In(similarArtistIds) }, + }) + : Promise.resolve([]), + ]); - const artistDetails = (await Promise.all(artistDetailsPromises)).filter( - (artist): artist is NonNullable => artist !== null + const albumMetadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) ); - return res.status(200).json({ - page, - totalPages: Math.ceil(similarArtists.length / pageSize), - totalResults: similarArtists.length, - results: artistDetails, - }); - } catch (e) { - logger.error('Something went wrong retrieving similar artists', { - label: 'Music API', - errorMessage: e.message, - albumId: req.params.id, + const similarArtistMetadataMap = new Map( + similarArtistMetadata.map((metadata) => [metadata.mbArtistId, metadata]) + ); + + const artistsNeedingImages = similarArtistIds.filter((id) => { + const metadata = similarArtistMetadataMap.get(id); + return !metadata?.tadbThumb && !metadata?.tadbCover; }); - return next({ - status: 500, - message: 'Unable to retrieve similar artists.', + const personArtists = + artistDetails.similarArtists?.artists + ?.filter((artist) => artist.type === 'Person') + .filter((artist) => { + const metadata = similarArtistMetadataMap.get(artist.artist_mbid); + return !metadata?.tmdbPersonId; + }) + .map((artist) => ({ + artistId: artist.artist_mbid, + artistName: artist.name, + })) ?? []; + + type ArtistImageResults = Record< + string, + { artistThumb: string | null; artistBackground: string | null } + >; + + const [artistImageResults, updatedArtistMetadata, artistImagesResult] = + await Promise.all([ + artistsNeedingImages.length > 0 + ? theAudioDb.batchGetArtistImages(artistsNeedingImages) + : ({} as ArtistImageResults), + personArtists.length > 0 + ? personMapper.batchGetMappings(personArtists).then(() => + metadataArtistRepository.find({ + where: { mbArtistId: In(similarArtistIds) }, + }) + ) + : Promise.resolve(similarArtistMetadata), + !cachedTheAudioDb && + !metadataArtist?.tadbThumb && + !metadataArtist?.tadbCover + ? theAudioDb.getArtistImages(artistData.artist_mbid) + : Promise.resolve(null), + ]); + + const relatedMediaMap = new Map( + relatedMedia.map((media) => [media.mbId, media]) + ); + + const finalArtistMetadataMap = new Map( + (updatedArtistMetadata || similarArtistMetadata).map((metadata) => [ + metadata.mbArtistId, + metadata, + ]) + ); + + const transformedReleaseGroups = paginatedReleaseGroups.map( + (releaseGroup) => { + const metadata = albumMetadataMap.get(releaseGroup.mbid); + return { + id: releaseGroup.mbid, + mediaType: 'album', + title: releaseGroup.name, + 'first-release-date': releaseGroup.date, + 'artist-credit': [{ name: releaseGroup.artist_credit_name }], + 'primary-type': releaseGroup.type || 'Other', + posterPath: metadata?.caaUrl ?? null, + needsCoverArt: !metadata?.caaUrl, + mediaInfo: relatedMediaMap.get(releaseGroup.mbid), + }; + } + ); + + const transformedSimilarArtists = + artistDetails.similarArtists?.artists?.map((artist) => { + const metadata = finalArtistMetadataMap.get(artist.artist_mbid); + const artistImageResult = + artistImageResults[ + artist.artist_mbid as keyof typeof artistImageResults + ]; + + const artistThumb = + metadata?.tadbThumb || (artistImageResult?.artistThumb ?? null); + + const artistBackground = + metadata?.tadbCover || (artistImageResult?.artistBackground ?? null); + + return { + ...artist, + artistThumb: metadata?.tmdbThumb ?? artistThumb, + artistBackground: artistBackground, + tmdbPersonId: metadata?.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + }; + }) ?? []; + + return res.status(200).json({ + artist: { + ...artistDetails, + artistThumb: + cachedTheAudioDb?.artistThumb ?? + metadataArtist?.tadbThumb ?? + artistImagesResult?.artistThumb ?? + null, + artistBackdrop: + cachedTheAudioDb?.artistBackground ?? + metadataArtist?.tadbCover ?? + artistImagesResult?.artistBackground ?? + null, + similarArtists: { + ...artistDetails.similarArtists, + artists: transformedSimilarArtists, + }, + releaseGroups: transformedReleaseGroups, + pagination: { + page, + pageSize, + totalItems: totalReleaseGroups, + totalPages: Math.ceil(totalReleaseGroups / pageSize), + }, + }, }); + } catch (error) { + logger.error('Something went wrong retrieving artist details', { + label: 'Music API', + errorMessage: error.message, + artistId: req.params.id, + }); + return next({ status: 500, message: 'Unable to retrieve artist details.' }); } }); diff --git a/server/routes/person.ts b/server/routes/person.ts index 6594481f..ebf4eca1 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,7 +1,10 @@ -import CoverArtArchive from '@server/api/coverartarchive'; -import MusicBrainz from '@server/api/musicbrainz'; +import ListenBrainzAPI from '@server/api/listenbrainz'; +import TheAudioDb from '@server/api/theaudiodb'; import TheMovieDb from '@server/api/themoviedb'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import logger from '@server/logger'; import { mapCastCredits, @@ -9,53 +12,176 @@ import { mapPersonDetails, } from '@server/models/Person'; import { Router } from 'express'; +import { In } from 'typeorm'; const personRoutes = Router(); personRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); - const musicBrainz = new MusicBrainz(); + const listenbrainz = new ListenBrainzAPI(); + const theAudioDb = TheAudioDb.getInstance(); + + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 20; + const initialItemsPerType = 20; + const albumType = req.query.albumType as string | undefined; try { - const person = await tmdb.getPerson({ - personId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, - }); + const [person, existingMetadata] = await Promise.all([ + tmdb.getPerson({ + personId: Number(req.params.id), + language: (req.query.language as string) ?? req.locale, + }), + getRepository(MetadataArtist).findOne({ + where: { tmdbPersonId: req.params.id }, + select: ['mbArtistId', 'tmdbThumb', 'tadbThumb', 'tadbCover'], + }), + ]); - let mbArtistId = null; - try { - const artists = await musicBrainz.searchArtist({ - query: person.name, - }); + let artistData = null; - const matchedArtist = artists.find((artist) => { - if (artist.type !== 'Person') { - return false; + if (existingMetadata?.mbArtistId) { + artistData = await listenbrainz.getArtist(existingMetadata.mbArtistId); + + if (artistData?.releaseGroups) { + const groupedReleaseGroups = artistData.releaseGroups.reduce( + (acc, rg) => { + const type = rg.secondary_types?.length + ? rg.secondary_types[0] + : rg.type || 'Other'; + + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(rg); + return acc; + }, + {} as Record + ); + + Object.keys(groupedReleaseGroups).forEach((type) => { + groupedReleaseGroups[type].sort((a, b) => { + const dateA = a.date ? new Date(a.date).getTime() : 0; + const dateB = b.date ? new Date(b.date).getTime() : 0; + return dateB - dateA; + }); + }); + + let releaseGroupsToProcess: typeof artistData.releaseGroups = []; + let totalCount: number; + let totalPages: number; + + if (albumType) { + const filteredReleaseGroups = groupedReleaseGroups[albumType] || []; + totalCount = filteredReleaseGroups.length; + totalPages = Math.ceil(totalCount / pageSize); + + releaseGroupsToProcess = filteredReleaseGroups.slice( + (page - 1) * pageSize, + page * pageSize + ); + } else { + Object.entries(groupedReleaseGroups).forEach(([, releases]) => { + releaseGroupsToProcess.push( + ...releases.slice(0, initialItemsPerType) + ); + }); + + totalCount = Object.values(groupedReleaseGroups).reduce( + (sum, releases) => sum + releases.length, + 0 + ); + totalPages = 1; } - const nameMatches = - artist.artistname.toLowerCase() === person.name.toLowerCase(); - const aliasMatches = artist.artistaliases?.some( - (alias) => alias.toLowerCase() === person.name.toLowerCase() - ); - return nameMatches || aliasMatches; - }); + const allReleaseGroupIds = releaseGroupsToProcess.map((rg) => rg.mbid); - if (matchedArtist) { - mbArtistId = matchedArtist.id; + const [artistImagesPromise, relatedMedia, albumMetadata] = + await Promise.all([ + !existingMetadata.tadbThumb && !existingMetadata.tadbCover + ? theAudioDb.getArtistImages(existingMetadata.mbArtistId) + : Promise.resolve(null), + Media.getRelatedMedia(req.user, allReleaseGroupIds), + getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(allReleaseGroupIds) }, + select: ['mbAlbumId', 'caaUrl'], + cache: true, + }), + ]); + + if (artistImagesPromise) { + existingMetadata.tadbThumb = artistImagesPromise.artistThumb; + existingMetadata.tadbCover = artistImagesPromise.artistBackground; + } + + const mediaMap = new Map( + relatedMedia.map((media) => [media.mbId, media]) + ); + + const metadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) + ); + + const transformedReleaseGroups = releaseGroupsToProcess.map( + (releaseGroup) => { + const metadata = metadataMap.get(releaseGroup.mbid); + const coverArtUrl = metadata?.caaUrl || null; + + return { + id: releaseGroup.mbid, + mediaType: 'album', + title: releaseGroup.name, + 'first-release-date': releaseGroup.date, + 'artist-credit': [{ name: releaseGroup.artist_credit_name }], + 'primary-type': releaseGroup.type || 'Other', + secondary_types: releaseGroup.secondary_types || [], + total_listen_count: releaseGroup.total_listen_count || 0, + posterPath: coverArtUrl, + needsCoverArt: !coverArtUrl, + mediaInfo: mediaMap.get(releaseGroup.mbid), + }; + } + ); + + const typeCounts = Object.fromEntries( + Object.entries(groupedReleaseGroups).map(([type, releases]) => [ + type, + releases.length, + ]) + ); + + artistData = { + ...artistData, + releaseGroups: transformedReleaseGroups, + typeCounts, + pagination: { + page, + pageSize, + totalItems: totalCount, + totalPages, + albumType, + }, + }; } - } catch (e) { - logger.debug('Failed to fetch music artist data', { - label: 'API', - errorMessage: e.message, - personName: person.name, - }); } - return res.status(200).json({ + const mappedDetails = { ...mapPersonDetails(person), - mbArtistId, - }); + artist: + artistData && existingMetadata?.mbArtistId + ? { + mbid: existingMetadata.mbArtistId, + profilePath: person.profile_path + ? `https://image.tmdb.org/t/p/w500${person.profile_path}` + : existingMetadata.tadbThumb ?? null, + artistThumb: existingMetadata.tadbThumb ?? null, + artistBackdrop: existingMetadata.tadbCover ?? null, + ...artistData, + } + : null, + }; + + return res.status(200).json(mappedDetails); } catch (e) { logger.debug('Something went wrong retrieving person', { label: 'API', @@ -69,148 +195,6 @@ personRoutes.get('/:id', async (req, res, next) => { } }); -personRoutes.get('/:id/discography', async (req, res, next) => { - const musicBrainz = new MusicBrainz(); - const tmdb = new TheMovieDb(); - const coverArtArchive = new CoverArtArchive(); - const artistId = req.query.artistId as string; - const type = req.query.type as string; - const page = Number(req.query.page) || 1; - const pageSize = 20; - - if (!artistId) { - return next({ - status: 400, - message: 'Artist ID is required', - }); - } - - const person = await tmdb.getPerson({ - personId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, - }); - - if (!person.birthday) { - return res.status(200).json({ - page: 1, - pageInfo: { total: 0, totalPages: 0 }, - results: [], - }); - } - - try { - const artistDetails = await musicBrainz.getArtist({ - artistId: artistId, - }); - - const { mapArtistDetails } = await import('@server/models/Artist'); - const mappedDetails = await mapArtistDetails(artistDetails); - - if (!mappedDetails.Albums?.length) { - return res.status(200).json({ - page: 1, - pageInfo: { - total: 0, - totalPages: 0, - }, - results: [], - }); - } - - let filteredAlbums = mappedDetails.Albums; - if (type) { - if (type === 'Other') { - filteredAlbums = mappedDetails.Albums.filter( - (album) => !['Album', 'Single', 'EP'].includes(album.type) - ); - } else { - filteredAlbums = mappedDetails.Albums.filter( - (album) => album.type === type - ); - } - } - - const albumPromises = filteredAlbums.map(async (album) => { - try { - const albumDetails = await musicBrainz.getAlbum({ - albumId: album.id, - }); - - let images = albumDetails.images; - - if (!images || images.length === 0) { - try { - const coverArtData = await coverArtArchive.getCoverArt(album.id); - if (coverArtData.images?.length > 0) { - images = coverArtData.images.map((img) => ({ - CoverType: img.front ? 'Cover' : 'Poster', - Url: img.image, - })); - } - } catch (coverArtError) { - // Silently handle cover art fetch errors - } - } - - return { - ...album, - images: images || [], - releasedate: albumDetails.releasedate || '', - }; - } catch (e) { - return album; - } - }); - - const albumsWithDetails = await Promise.all(albumPromises); - - const sortedAlbums = albumsWithDetails.sort((a, b) => { - if (!a.releasedate && !b.releasedate) return 0; - if (!a.releasedate) return 1; - if (!b.releasedate) return -1; - return ( - new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime() - ); - }); - - const totalResults = sortedAlbums.length; - const totalPages = Math.ceil(totalResults / pageSize); - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedAlbums = sortedAlbums.slice(start, end); - - const media = await Media.getRelatedMedia( - req.user, - paginatedAlbums.map((album) => album.id) - ); - - const results = paginatedAlbums.map((album) => ({ - ...album, - mediaInfo: media?.find((med) => med.mbId === album.id), - })); - - return res.status(200).json({ - page, - pageInfo: { - total: totalResults, - totalPages, - }, - results, - }); - } catch (e) { - logger.error('Something went wrong retrieving discography', { - label: 'Person API', - errorMessage: e.message, - personId: req.params.id, - artistId, - }); - return next({ - status: 500, - message: 'Unable to retrieve discography.', - }); - } -}); - personRoutes.get('/:id/combined_credits', async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/search.ts b/server/routes/search.ts index 9e530406..638cb774 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,10 +1,11 @@ import MusicBrainz from '@server/api/musicbrainz'; -import type { - MbAlbumResult, - MbArtistResult, -} from '@server/api/musicbrainz/interfaces'; +import TheAudioDb from '@server/api/theaudiodb'; import TheMovieDb from '@server/api/themoviedb'; +import TmdbPersonMapper from '@server/api/themoviedb/personMapper'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import { findSearchProvider, type CombinedSearchResponse, @@ -12,69 +13,272 @@ import { import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; +import { In } from 'typeorm'; const searchRoutes = Router(); +const ITEMS_PER_PAGE = 20; searchRoutes.get('/', async (req, res, next) => { const queryString = req.query.query as string; - const searchProvider = findSearchProvider(queryString.toLowerCase()); - let results: CombinedSearchResponse; - let combinedResults: CombinedSearchResponse['results'] = []; + const page = Number(req.query.page) || 1; + const language = (req.query.language as string) ?? req.locale; try { + const searchProvider = findSearchProvider(queryString.toLowerCase()); + let results: CombinedSearchResponse; + if (searchProvider) { const [id] = queryString .toLowerCase() .match(searchProvider.pattern) as RegExpMatchArray; results = await searchProvider.search({ id, - language: (req.query.language as string) ?? req.locale, + language, query: queryString, }); } else { const tmdb = new TheMovieDb(); - const tmdbResults = await tmdb.searchMulti({ - query: queryString, - page: Number(req.query.page), + const musicbrainz = new MusicBrainz(); + const theAudioDb = TheAudioDb.getInstance(); + const personMapper = TmdbPersonMapper.getInstance(); + + const [tmdbResults, albumResults, artistResults] = await Promise.all([ + tmdb.searchMulti({ + query: queryString, + page, + language, + }), + musicbrainz.searchAlbum({ + query: queryString, + limit: ITEMS_PER_PAGE, + }), + musicbrainz.searchArtist({ + query: queryString, + limit: ITEMS_PER_PAGE, + }), + ]); + + const personIds = tmdbResults.results + .filter( + (result) => result.media_type === 'person' && !result.profile_path + ) + .map((p) => p.id.toString()); + + const albumIds = albumResults.map((album) => album.id); + const artistIds = artistResults.map((artist) => artist.id); + const tmdbPersonIds = tmdbResults.results + .filter((result) => result.media_type === 'person') + .map((person) => person.id.toString()); + + const [artistMetadata, albumMetadata, artistsMetadata, existingMappings] = + await Promise.all([ + personIds.length > 0 + ? getRepository(MetadataArtist).find({ + where: { tmdbPersonId: In(personIds) }, + cache: true, + select: ['tmdbPersonId', 'tadbThumb', 'tadbCover'], + }) + : [], + albumIds.length > 0 + ? getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(albumIds) }, + cache: true, + select: ['mbAlbumId', 'caaUrl'], + }) + : [], + artistIds.length > 0 + ? getRepository(MetadataArtist).find({ + where: { mbArtistId: In(artistIds) }, + cache: true, + select: [ + 'mbArtistId', + 'tmdbPersonId', + 'tadbThumb', + 'tadbCover', + ], + }) + : [], + tmdbPersonIds.length > 0 + ? getRepository(MetadataArtist).find({ + where: { tmdbPersonId: In(tmdbPersonIds) }, + cache: true, + select: ['mbArtistId', 'tmdbPersonId'], + }) + : [], + ]); + + const artistMetadataMap = new Map( + artistMetadata.map((m) => [m.tmdbPersonId, m]) + ); + + const albumMetadataMap = new Map( + albumMetadata.map((m) => [m.mbAlbumId, m]) + ); + + const artistsMetadataMap = new Map( + artistsMetadata.map((m) => [m.mbArtistId, m]) + ); + + const existingMappingsMap = new Map( + existingMappings.map((m) => [m.mbArtistId, m.tmdbPersonId]) + ); + + const personsWithoutImages = tmdbResults.results.filter( + (result) => result.media_type === 'person' && !result.profile_path + ); + + personsWithoutImages.forEach((person) => { + const metadata = artistMetadataMap.get(person.id.toString()); + if (metadata?.tadbThumb) { + Object.assign(person, { + profile_path: metadata.tadbThumb, + artist_backdrop: metadata.tadbCover, + }); + } }); - combinedResults = [...tmdbResults.results]; + const artistsNeedingMapping = artistResults + .filter( + (artist) => + artist.type === 'Person' && + !artistsMetadataMap.get(artist.id)?.tmdbPersonId + ) + .map((artist) => ({ + artistId: artist.id, + artistName: artist.name, + })); - const musicbrainz = new MusicBrainz(); - const mbResults = await musicbrainz.searchMulti({ query: queryString }); + const artistsNeedingImages = artistIds.filter((id) => { + const metadata = artistsMetadataMap.get(id); + return !metadata?.tadbThumb && !metadata?.tadbCover; + }); - if (mbResults.length > 0) { - const mbMappedResults = mbResults.map((result) => { - if (result.artist) { - return { - ...result.artist, - media_type: 'artist', - } as MbArtistResult; + type PersonMappingResult = Record< + string, + { personId: number | null; profilePath: string | null } + >; + type ArtistImageResult = Record< + string, + { artistThumb: string | null; artistBackground: string | null } + >; + + const [personMappingResults, artistImageResults] = await Promise.all([ + artistsNeedingMapping.length > 0 + ? personMapper.batchGetMappings(artistsNeedingMapping) + : ({} as PersonMappingResult), + artistsNeedingImages.length > 0 + ? theAudioDb.batchGetArtistImages(artistsNeedingImages) + : ({} as ArtistImageResult), + ]); + + let updatedArtistsMetadataMap = artistsMetadataMap; + if ( + (artistsNeedingMapping.length > 0 || artistsNeedingImages.length > 0) && + artistIds.length > 0 + ) { + const updatedArtistsMetadata = await getRepository(MetadataArtist).find( + { + where: { mbArtistId: In(artistIds) }, + cache: true, + select: ['mbArtistId', 'tmdbPersonId', 'tadbThumb', 'tadbCover'], } - if (result.album) { - return { - ...result.album, - media_type: 'album', - } as MbAlbumResult; - } - throw new Error('Invalid search result type'); - }); + ); - combinedResults = [...combinedResults, ...mbMappedResults]; + updatedArtistsMetadataMap = new Map( + updatedArtistsMetadata.map((m) => [m.mbArtistId, m]) + ); } + const albumsWithArt = albumResults.map((album) => { + const metadata = albumMetadataMap.get(album.id); + + return { + ...album, + media_type: 'album' as const, + posterPath: metadata?.caaUrl ?? undefined, + needsCoverArt: !metadata?.caaUrl, + score: album.score || 0, + }; + }); + + const artistsWithArt = artistResults + .map((artist) => { + const metadata = updatedArtistsMetadataMap.get(artist.id); + const personMapping = personMappingResults[artist.id]; + const hasTmdbPersonId = + metadata?.tmdbPersonId || personMapping?.personId !== null; + + if (artist.type === 'Person' && hasTmdbPersonId) { + return null; + } + + const artistThumb = + metadata?.tadbThumb || + (artistImageResults[artist.id]?.artistThumb ?? null); + + const artistBackdrop = + metadata?.tadbCover || + (artistImageResults[artist.id]?.artistBackground ?? null); + + return { + ...artist, + media_type: 'artist' as const, + artistThumb, + artistBackdrop, + score: artist.score || 0, + }; + }) + .filter( + (artist): artist is NonNullable => artist !== null + ); + + const filteredArtists = artistsWithArt.filter((artist) => { + const tmdbPersonId = existingMappingsMap.get(artist.id); + return !tmdbPersonId || !tmdbPersonIds.includes(tmdbPersonId); + }); + + const musicResults = [...albumsWithArt, ...filteredArtists].sort( + (a, b) => (b.score || 0) - (a.score || 0) + ); + + const totalItems = tmdbResults.total_results + musicResults.length; + const totalPages = Math.max( + tmdbResults.total_pages, + Math.ceil(totalItems / ITEMS_PER_PAGE) + ); + + const combinedResults = + page === 1 + ? [...tmdbResults.results, ...musicResults] + : tmdbResults.results; + results = { page: tmdbResults.page, - total_pages: tmdbResults.total_pages, - total_results: tmdbResults.total_results + mbResults.length, + total_pages: totalPages, + total_results: totalItems, results: combinedResults, }; } - const media = await Media.getRelatedMedia( - req.user, - results.results.map((result) => ('id' in result ? result.id : 0)) - ); + const movieTvIds = results.results + .filter( + (result) => result.media_type === 'movie' || result.media_type === 'tv' + ) + .map((result) => Number(result.id)); + + const musicIds = results.results + .filter( + (result) => + result.media_type === 'album' || result.media_type === 'artist' + ) + .map((result) => result.id.toString()); + + const [movieTvMedia, musicMedia] = await Promise.all([ + movieTvIds.length > 0 ? Media.getRelatedMedia(req.user, movieTvIds) : [], + musicIds.length > 0 ? Media.getRelatedMedia(req.user, musicIds) : [], + ]); + + const media = [...movieTvMedia, ...musicMedia]; const mappedResults = await mapSearchResults(results.results, media); @@ -87,8 +291,8 @@ searchRoutes.get('/', async (req, res, next) => { } catch (e) { logger.debug('Something went wrong retrieving search results', { label: 'API', - errorMessage: e.message, - query: req.query.query, + errorMessage: e instanceof Error ? e.message : 'Unknown error', + query: queryString, }); return next({ status: 500, diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 64b905f2..32af9b59 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -761,8 +761,7 @@ settingsRoutes.get('/cache', async (_req, res) => { const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const avatarImageCache = await ImageProxy.getImageStats('avatar'); const caaImageCache = await ImageProxy.getImageStats('caa'); - const lidarrImageCache = await ImageProxy.getImageStats('lidarr'); - const fanartImageCache = await ImageProxy.getImageStats('fanart'); + const tadbImageCache = await ImageProxy.getImageStats('tadb'); const stats: DnsStats | undefined = dnsCache?.getStats(); const entries: DnsEntries | undefined = dnsCache?.getCacheEntries(); @@ -773,8 +772,7 @@ settingsRoutes.get('/cache', async (_req, res) => { tmdb: tmdbImageCache, avatar: avatarImageCache, caa: caaImageCache, - lidarr: lidarrImageCache, - fanart: fanartImageCache, + tadb: tadbImageCache, }, dnsCache: { stats, diff --git a/server/routes/imageproxy.ts b/server/routes/tadbproxy.ts similarity index 88% rename from server/routes/imageproxy.ts rename to server/routes/tadbproxy.ts index ac2fbe08..af99941a 100644 --- a/server/routes/imageproxy.ts +++ b/server/routes/tadbproxy.ts @@ -30,6 +30,13 @@ function initTvdbImageProxy() { return _tvdbImageProxy; } +const tadbImageProxy = new ImageProxy('tadb', 'https://r2.theaudiodb.com', { + rateLimitOptions: { + maxRequests: 20, + maxRPS: 50, + }, +}); + router.get('/:type/*', async (req, res) => { const imagePath = req.path.replace(/^\/\w+/, ''); try { @@ -38,6 +45,8 @@ router.get('/:type/*', async (req, res) => { imageData = await initTmdbImageProxy().getImage(imagePath); } else if (req.params.type === 'tvdb') { imageData = await initTvdbImageProxy().getImage(imagePath); + } else if (req.params.type === 'tabd') { + imageData = await tadbImageProxy.getImage(imagePath); } else { logger.error('Unsupported image type', { imagePath, diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 9a73275a..f875c8af 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -1,4 +1,5 @@ -import LidarrAPI from '@server/api/servarr/lidarr'; +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; import TheMovieDb from '@server/api/themoviedb'; import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; @@ -8,7 +9,6 @@ import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; -import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; @@ -26,6 +26,8 @@ export class IssueCommentSubscriber let title = ''; let image = ''; const tmdb = new TheMovieDb(); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); try { const issue = ( @@ -57,26 +59,12 @@ export class IssueCommentSubscriber tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; - } else if (media.mediaType === MediaType.MUSIC) { - const settings = getSettings(); - if (!settings.lidarr[0]) { - throw new Error('No Lidarr server configured'); - } + } else if (media.mediaType === MediaType.MUSIC && media.mbId) { + const album = await listenbrainz.getAlbum(media.mbId); + const coverArtResponse = await coverArt.getCoverArt(media.mbId); - const lidarr = new LidarrAPI({ - apiKey: settings.lidarr[0].apiKey, - url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'), - }); - - if (!media.mbId) { - throw new Error('MusicBrainz ID is undefined'); - } - - const album = await lidarr.getAlbumByMusicBrainzId(media.mbId); - const artist = await lidarr.getArtist({ id: album.artistId }); - - title = `${artist.artistName} - ${album.title}`; - image = album.images?.[0]?.url ?? ''; + title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`; + image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; } const [firstComment] = sortBy(issue.comments, 'id'); diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index b117b4db..8f7a756d 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -1,11 +1,11 @@ -import LidarrAPI from '@server/api/servarr/lidarr'; +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; import TheMovieDb from '@server/api/themoviedb'; import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import Issue from '@server/entity/Issue'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; -import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { @@ -25,6 +25,8 @@ export class IssueSubscriber implements EntitySubscriberInterface { let title = ''; let image = ''; const tmdb = new TheMovieDb(); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); try { if (entity.media.mediaType === MediaType.MOVIE) { @@ -41,26 +43,15 @@ export class IssueSubscriber implements EntitySubscriberInterface { tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; - } else if (entity.media.mediaType === MediaType.MUSIC) { - const settings = getSettings(); - if (!settings.lidarr[0]) { - throw new Error('No Lidarr server configured'); - } + } else if ( + entity.media.mediaType === MediaType.MUSIC && + entity.media.mbId + ) { + const album = await listenbrainz.getAlbum(entity.media.mbId); + const coverArtResponse = await coverArt.getCoverArt(entity.media.mbId); - const lidarr = new LidarrAPI({ - apiKey: settings.lidarr[0].apiKey, - url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'), - }); - - if (!entity.media.mbId) { - throw new Error('MusicBrainz ID is undefined'); - } - const album = await lidarr.getAlbumByMusicBrainzId(entity.media.mbId); - - const artist = await lidarr.getArtist({ id: album.artistId }); - - title = `${artist.artistName} - ${album.title}`; - image = album.images?.[0]?.url ?? ''; + title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`; + image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; } const [firstComment] = sortBy(entity.comments, 'id'); diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 2c6985c8..aa260a78 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -1,11 +1,13 @@ +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; +import MusicBrainz from '@server/api/musicbrainz'; +import LidarrAPI from '@server/api/servarr/lidarr'; import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr'; import type { AddSeriesOptions, SonarrSeries, } from '@server/api/servarr/sonarr'; -import LidarrAPI from '@server/api/servarr/lidarr'; -import MusicBrainz from '@server/api/musicbrainz' import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; @@ -28,7 +30,6 @@ import type { InsertEvent, RemoveEvent, UpdateEvent, - Not } from 'typeorm'; import { EventSubscriber } from 'typeorm'; @@ -178,7 +179,6 @@ export class MediaRequestSubscriber entity: MediaRequest, event?: UpdateEvent ) { - // Get fresh media state using event manager let latestMedia: Media | null = null; if (event?.manager) { @@ -192,44 +192,55 @@ export class MediaRequestSubscriber where: { id: entity.media.id }, }); - if(!latestMedia - || latestMedia.mediaType !== MediaType.MUSIC - || latestMedia['status'] != MediaStatus.AVAILABLE) - { - return - } + if ( + !latestMedia || + latestMedia.mediaType !== MediaType.MUSIC || + latestMedia['status'] != MediaStatus.AVAILABLE + ) { + return; + } - try { - const musicbrainz = new MusicBrainz(); - const albumDetails = await musicbrainz.getAlbum({ - albumId: latestMedia.mbId, - }); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); + const musicbrainz = new MusicBrainz(); - const coverImage = albumDetails.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url; + try { + const album = await listenbrainz.getAlbum(latestMedia.mbId ?? ''); + const coverArtResponse = await coverArt.getCoverArt( + latestMedia.mbId ?? '' + ); + const coverArtUrl = + coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; + const artistId = + album.release_group_metadata?.artist?.artists[0]?.artist_mbid; + const artistWiki = artistId + ? await musicbrainz.getArtistWikipediaExtract({ + artistMbid: artistId, + }) + : null; - notificationManager.sendNotification( - Notification.MEDIA_AVAILABLE, - { - event: `Album Request Now Available`, - notifyAdmin: false, - notifySystem: true, - notifyUser: entity.requestedBy, - subject: albumDetails.title ?? latestMedia.mbId ?? 'Unknown Album', - message: albumDetails.overview || 'Album is now available.', - media: latestMedia, - request: entity, - image: coverImage, - } - ); - } catch (e) { - logger.error('Something went wrong sending media notification(s)', { - label: 'Notifications', - errorMessage: e.message, - mediaId: entity.id, - }); - } + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + event: 'Album Request Now Available', + notifyAdmin: false, + notifySystem: true, + notifyUser: entity.requestedBy, + subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`, + message: truncate(artistWiki?.content ?? '', { + length: 500, + separator: /\s/, + omission: '…', + }), + media: latestMedia, + image: coverArtUrl, + request: entity, + }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); + } } } @@ -802,224 +813,216 @@ export class MediaRequestSubscriber public async sendToLidarr(entity: MediaRequest): Promise { if ( - entity.status !== MediaRequestStatus.APPROVED || - entity.type !== MediaType.MUSIC + entity.status === MediaRequestStatus.APPROVED && + entity.type === MediaType.MUSIC ) { - return; - } + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); - try { - const mediaRepository = getRepository(Media); - const settings = getSettings(); - const media = await mediaRepository.findOne({ - where: { id: entity.media.id }, - relations: { requests: true }, - }); + if (settings.lidarr.length === 0 && !settings.lidarr[0]) { + logger.info( + 'No Lidarr server configured, skipping request processing', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } - if (!media?.mbId) { - throw new Error('Media data or MusicBrainz ID not found'); - } + let lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault); - const lidarrSettings = - entity.serverId !== null && entity.serverId >= 0 - ? settings.lidarr.find((l) => l.id === entity.serverId) - : settings.lidarr.find((l) => l.isDefault); + if ( + entity.serverId !== null && + entity.serverId >= 0 && + lidarrSettings?.id !== entity.serverId + ) { + lidarrSettings = settings.lidarr.find( + (lidarr) => lidarr.id === entity.serverId + ); + logger.info( + `Request has an override server: ${lidarrSettings?.name}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } - if (!lidarrSettings) { - logger.warn('No valid Lidarr server configured', { + if (!lidarrSettings) { + logger.warn('There is no default Lidarr server configured.', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + return; + } + + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + if (media.status === MediaStatus.AVAILABLE) { + logger.warn('Media already exists, marking request as APPROVED', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.APPROVED; + await requestRepository.save(entity); + return; + } + + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), + }); + + if (!media.mbId) { + throw new Error('media.mbId is required but is undefined'); + } + const searchResults = await lidarr.searchAlbumByMusicBrainzId( + media.mbId + ); + + if (!searchResults?.length) { + throw new Error('Album not found in Lidarr search'); + } + + const albumInfo = searchResults[0].album; + + let rootFolder = lidarrSettings.activeDirectory; + + if ( + entity.rootFolder && + entity.rootFolder !== '' && + entity.rootFolder !== rootFolder + ) { + rootFolder = entity.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } + + const artistPath = `${rootFolder}/${albumInfo.artist.artistName}`; + + const addAlbumPayload: LidarrAlbumOptions = { + title: albumInfo.title, + disambiguation: albumInfo.disambiguation || '', + overview: albumInfo.overview, + artistId: albumInfo.artist.id, + foreignAlbumId: albumInfo.foreignAlbumId, + monitored: true, + anyReleaseOk: true, + profileId: 1, + duration: albumInfo.duration || 0, + albumType: albumInfo.albumType, + secondaryTypes: [], + mediumCount: albumInfo.mediumCount || 0, + ratings: albumInfo.ratings, + releaseDate: albumInfo.releaseDate, + releases: [], + genres: albumInfo.genres, + media: [], + artist: { + status: albumInfo.artist.status, + ended: albumInfo.artist.ended, + artistName: albumInfo.artist.artistName, + foreignArtistId: albumInfo.artist.foreignArtistId, + tadbId: albumInfo.artist.tadbId || 0, + discogsId: albumInfo.artist.discogsId || 0, + overview: albumInfo.artist.overview, + artistType: albumInfo.artist.artistType, + disambiguation: albumInfo.artist.disambiguation, + links: albumInfo.artist.links || [], + images: albumInfo.artist.images || [], + path: artistPath, + qualityProfileId: 1, + metadataProfileId: 2, + monitored: true, + monitorNewItems: 'none', + rootFolderPath: rootFolder, + genres: albumInfo.artist.genres || [], + cleanName: albumInfo.artist.cleanName, + sortName: albumInfo.artist.sortName, + tags: albumInfo.artist.tags || [], + added: albumInfo.artist.added || new Date().toISOString(), + ratings: albumInfo.artist.ratings, + id: albumInfo.artist.id, + }, + images: albumInfo.images || [], + links: albumInfo.links || [], + addOptions: { + searchForNewAlbum: true, + }, + }; + + lidarr + .addAlbum(addAlbumPayload) + .then(async (result) => { + const updateFields = { + externalServiceId: result.id, + externalServiceSlug: result.titleSlug, + serviceId: lidarrSettings?.id, + }; + + await mediaRepository.update({ id: entity.media.id }, updateFields); + + if (addAlbumPayload.addOptions.searchForNewAlbum) { + await lidarr.searchOnAdd(result.id); + } + }) + .catch(async (error) => { + const requestRepository = getRepository(MediaRequest); + + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + + logger.warn( + 'Something went wrong sending album request to Lidarr, marking status as FAILED', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + error: error.message, + } + ); + + MediaRequest.sendNotification( + entity, + media, + Notification.MEDIA_FAILED + ); + }); + + logger.info('Sent request to Lidarr', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); - return; - } - - const rootFolder = entity.rootFolder || lidarrSettings.activeDirectory; - const qualityProfile = entity.profileId || lidarrSettings.activeProfileId; - const tags = lidarrSettings.tags?.map((t) => t.toString()) || []; - - const lidarr = new LidarrAPI({ - apiKey: lidarrSettings.apiKey, - url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), - }); - - const lidarrAlbum = await lidarr.getAlbumByMusicBrainzId(media.mbId); - - let artistId: number; - try { - const existingArtist = await lidarr.getArtistByMusicBrainzId( - lidarrAlbum.artist.foreignArtistId - ); - artistId = existingArtist.id; - } catch { - const addedArtist = await lidarr.addArtist({ - artistName: lidarrAlbum.artist.artistName, - foreignArtistId: lidarrAlbum.artist.foreignArtistId, - qualityProfileId: qualityProfile, - profileId: qualityProfile, - metadataProfileId: qualityProfile, - rootFolderPath: rootFolder, - monitored: false, - tags: tags.map((t) => Number(t)), - searchNow: !lidarrSettings.preventSearch, - monitorNewItems: 'none', - monitor: 'none', - searchForMissingAlbums: false, - addOptions: { - monitor: 'none', - monitored: false, - searchForMissingAlbums: false, - }, + } catch (e) { + logger.error('Something went wrong sending request to Lidarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: entity.id, + mediaId: entity.media.id, }); - - await new Promise((resolve) => setTimeout(resolve, 60000)); - artistId = addedArtist.id; + throw new Error(e.message); } - - try { - const album = await lidarr.addAlbum({ - mbId: media.mbId, - foreignAlbumId: media.mbId, - title: lidarrAlbum.title, - qualityProfileId: qualityProfile, - profileId: qualityProfile, - metadataProfileId: qualityProfile, - rootFolderPath: rootFolder, - monitored: false, - tags, - searchNow: false, - artistId, - images: lidarrAlbum.images?.length - ? lidarrAlbum.images - : [ - { - url: '', - coverType: 'cover', - }, - ], - addOptions: { - monitor: 'none', - monitored: false, - searchForMissingAlbums: false, - }, - artist: { - id: artistId, - foreignArtistId: lidarrAlbum.artist.foreignArtistId, - artistName: lidarrAlbum.artist.artistName, - qualityProfileId: qualityProfile, - metadataProfileId: qualityProfile, - rootFolderPath: rootFolder, - monitored: false, - monitorNewItems: 'none', - }, - }); - - media.externalServiceId = album.id; - (media.externalServiceSlug = media.mbId), - (media.serviceId = lidarrSettings.id); - media.status = MediaStatus.PROCESSING; - await mediaRepository.save(media); - setTimeout(async () => { - try { - const albumDetails = await lidarr.getAlbum({ id: album.id }); - albumDetails.monitored = true; - await lidarr.updateAlbum(albumDetails); - - if (!lidarrSettings.preventSearch) { - await lidarr.searchAlbum(album.id); - } - - setTimeout(async () => { - try { - const finalAlbumDetails = await lidarr.getAlbum({ - id: album.id, - }); - if (!finalAlbumDetails.monitored) { - finalAlbumDetails.monitored = true; - await lidarr.updateAlbum(finalAlbumDetails); - await lidarr.searchAlbum(album.id); - } - } catch (err) { - logger.error('Failed final album monitoring check', { - label: 'Media Request', - error: err.message, - requestId: entity.id, - mediaId: entity.media.id, - albumId: entity.id, - }); - } - }, 20000); - - logger.info('Completed album monitoring setup', { - label: 'Media Request', - requestId: entity.id, - mediaId: entity.media.id, - albumId: entity.id, - }); - } catch (err) { - logger.error('Failed to process album monitoring', { - label: 'Media Request', - error: err.message, - requestId: entity.id, - mediaId: entity.media.id, - albumId: entity.id, - }); - } - }, 60000); - } catch (error) { - if (error.message.includes('This album has already been added')) { - const existingAlbums = await lidarr.getAlbums(); - const existingAlbum = existingAlbums.find( - (a) => a.foreignAlbumId === media.mbId - ); - - if (existingAlbum) { - media.externalServiceId = existingAlbum.id; - media.externalServiceSlug = media.mbId; - media.serviceId = lidarrSettings.id; - media.status = MediaStatus.PROCESSING; - await mediaRepository.save(media); - - setTimeout(async () => { - try { - await new Promise((resolve) => setTimeout(resolve, 20000)); - const albumDetails = await lidarr.getAlbum({ - id: existingAlbum.id, - }); - albumDetails.monitored = true; - await lidarr.updateAlbum(albumDetails); - - if (!lidarrSettings.preventSearch) { - await lidarr.searchAlbum(existingAlbum.id); - } - } catch (err) { - logger.error('Failed to process existing album', { - label: 'Media Request', - error: err.message, - requestId: entity.id, - mediaId: entity.media.id, - albumId: existingAlbum.id, - }); - } - }, 0); - } - } else { - const requestRepository = getRepository(MediaRequest); - entity.status = MediaRequestStatus.FAILED; - await requestRepository.save(this); - MediaRequest.sendNotification(entity, media, Notification.MEDIA_FAILED); - throw error; - } - } - } catch (e) { - logger.error('Failed to process Lidarr request', { - label: 'Media Request', - error: e.message, - requestId: entity.id, - mediaId: entity.media.id, - }); } } + public async updateParentStatus(entity: MediaRequest): Promise { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 1f860940..2b7def18 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -109,9 +109,11 @@ export class MediaSubscriber implements EntitySubscriberInterface { const allSeasonsReady = allSeasonResults.every((result) => result); shouldComplete = allSeasonsReady; - } else if (event.mediaType === MediaType.MUSIC) - { - if(event['status'] == MediaStatus.AVAILABLE || event['status'] === MediaStatus.DELETED) { + } else if (event.mediaType === MediaType.MUSIC) { + if ( + event['status'] == MediaStatus.AVAILABLE || + event['status'] === MediaStatus.DELETED + ) { shouldComplete = true; } } diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index f3789339..548378ff 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -1,9 +1,3 @@ -import type { - LidarrAlbumDetails, - LidarrAlbumResult, - LidarrArtistDetails, - LidarrArtistResult, -} from '@server/api/servarr/lidarr'; import type { TmdbCollectionResult, TmdbMovieDetails, @@ -44,18 +38,6 @@ export const isCollection = ( return (collection as TmdbCollectionResult).media_type === 'collection'; }; -export const isAlbum = ( - media: LidarrAlbumResult | LidarrArtistResult -): media is LidarrAlbumResult => { - return (media as LidarrAlbumResult).album?.albumType !== undefined; -}; - -export const isArtist = ( - media: LidarrAlbumResult | LidarrArtistResult -): media is LidarrArtistResult => { - return (media as LidarrArtistResult).artist?.artistType !== undefined; -}; - export const isMovieDetails = ( movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails ): movie is TmdbMovieDetails => { @@ -67,15 +49,3 @@ export const isTvDetails = ( ): tv is TmdbTvDetails => { return (tv as TmdbTvDetails).number_of_seasons !== undefined; }; - -export const isAlbumDetails = ( - details: LidarrAlbumDetails | LidarrArtistDetails -): details is LidarrAlbumDetails => { - return (details as LidarrAlbumDetails).albumType !== undefined; -}; - -export const isArtistDetails = ( - details: LidarrAlbumDetails | LidarrArtistDetails -): details is LidarrArtistDetails => { - return (details as LidarrArtistDetails).artistType !== undefined; -}; diff --git a/src/components/AddedCard/index.tsx b/src/components/AddedCard/index.tsx index a083dbe5..b35d0166 100644 --- a/src/components/AddedCard/index.tsx +++ b/src/components/AddedCard/index.tsx @@ -1,4 +1,5 @@ import TitleCard from '@app/components/TitleCard'; +import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers'; import { Permission, useUser } from '@app/hooks/useUser'; import type { MovieDetails } from '@server/models/Movie'; import type { MusicDetails } from '@server/models/Music'; @@ -15,6 +16,8 @@ export interface AddedCardProps { canExpand?: boolean; isAddedToWatchlist?: boolean; mutateParent?: () => void; + posterPath?: string | null; + needsCoverArt?: boolean; } const isMovie = ( @@ -26,7 +29,7 @@ const isMovie = ( const isMusic = ( media: MovieDetails | TvDetails | MusicDetails ): media is MusicDetails => { - return (media as MusicDetails).artistId !== undefined; + return (media as MusicDetails).artist !== undefined; }; const AddedCard = ({ @@ -38,6 +41,8 @@ const AddedCard = ({ canExpand, isAddedToWatchlist = false, mutateParent, + posterPath: initialPosterPath, + needsCoverArt: initialNeedsCoverArt, }: AddedCardProps) => { const { hasPermission } = useUser(); @@ -52,10 +57,31 @@ const AddedCard = ({ ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`; - const { data: title, error } = useSWR< + const { data: titleData, error } = useSWR< MovieDetails | TvDetails | MusicDetails >(inView ? url : null); + const title = + useProgressiveCovers( + type === 'music' && + titleData && + isMusic(titleData) && + (initialPosterPath || initialNeedsCoverArt) + ? [ + { + ...titleData, + posterPath: initialPosterPath || titleData.posterPath, + needsCoverArt: + initialNeedsCoverArt ?? + (titleData as MusicDetails & { needsCoverArt?: boolean }) + .needsCoverArt, + } as MusicDetails, + ] + : titleData + ? [titleData] + : [] + )[0] ?? titleData; + if (!title && !error) { return (
@@ -84,10 +110,10 @@ const AddedCard = ({ isAddedToWatchlist={ title.mediaInfo?.watchlists?.length || isAddedToWatchlist } - image={title.images?.find((image) => image.CoverType === 'Cover')?.Url} + image={title.posterPath} status={title.mediaInfo?.status} title={title.title} - artist={title.artist.artistName} + artist={title.artist.name} type={title.type} year={title.releaseDate} mediaType={'album'} diff --git a/src/components/GroupCard/index.tsx b/src/components/ArtistCard/index.tsx similarity index 84% rename from src/components/GroupCard/index.tsx rename to src/components/ArtistCard/index.tsx index 5c4541d3..4371cbfd 100644 --- a/src/components/GroupCard/index.tsx +++ b/src/components/ArtistCard/index.tsx @@ -3,26 +3,30 @@ import { UserCircleIcon } from '@heroicons/react/24/solid'; import Link from 'next/link'; import { useState } from 'react'; -interface GroupCardProps { - groupId: string; +interface ArtistCardProps { + artistId: string; name: string; subName?: string; - image?: string; + profilePath?: string | null; + artistThumb?: string | null; + type?: string; canExpand?: boolean; } -const GroupCard = ({ - groupId, +const ArtistCard = ({ + artistId, name, subName, - image, + profilePath, + artistThumb, + type, canExpand = false, -}: GroupCardProps) => { +}: ArtistCardProps) => { const [isHovered, setHovered] = useState(false); return ( { setHovered(true); @@ -48,11 +52,11 @@ const GroupCard = ({
- {image ? ( + {artistThumb || profilePath ? (
{name}
- {subName && ( + {(subName || type) && (
- {subName} + {subName || type}
)}
; +} + +interface AlbumTypeState { + albums: Album[]; + isExpanded: boolean; + isLoading: boolean; + isHovered: boolean; + isCollapsing: boolean; +} + +const albumTypeMessages: Record = { + Album: 'album', + EP: 'ep', + Single: 'single', + Live: 'live', + Compilation: 'compilation', + Remix: 'remix', + Soundtrack: 'soundtrack', + Broadcast: 'broadcast', + Demo: 'demo', + Other: 'other', +}; + +const Biography = ({ + content, + showBio, + onClick, +}: { + content: string; + showBio: boolean; + onClick: () => void; +}) => { + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + role="button" + tabIndex={0} + > + + } + > +

{content}

+
+
+
+ ); +}; + +const AlbumSection = ({ + type, + state, + totalCount, + artistName, + onToggleExpand, + onHover, +}: { + type: string; + state: AlbumTypeState; + totalCount: number; + artistName: string; + onToggleExpand: (type: string) => void; + onHover: (type: string, isHovered: boolean) => void; +}) => { + const intl = useIntl(); + const { albums, isExpanded, isLoading, isHovered, isCollapsing } = state; + + const displayAlbums = isExpanded ? albums : albums.slice(0, 20); + + const shouldShowExpandButton = totalCount > 20; + + const remainingItems = totalCount - albums.length; + const placeholdersToShow = Math.min(remainingItems, 20); + + const messageKey = albumTypeMessages[type] || 'other'; + const title = intl.formatMessage(messages[messageKey]); + + return ( +
+
+
+ {title} + {totalCount > 0 && ( + ({totalCount}) + )} +
+
+
    + {displayAlbums + .filter((media) => media && media.id) + .map((media) => ( +
  • + +
  • + ))} + + {shouldShowExpandButton && !isLoading && ( +
  • +
    +
    onToggleExpand(type)} + onMouseEnter={() => onHover(type, true)} + onMouseLeave={() => onHover(type, false)} + onBlur={() => onHover(type, false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleExpand(type); + } + }} + role="button" + tabIndex={0} + aria-label={intl.formatMessage( + isExpanded ? messages.showless : messages.showall + )} + > +
    +
    + {isExpanded ? ( + + ) : ( + + )} +
    + {intl.formatMessage( + isExpanded ? messages.showless : messages.showall + )} +
    + {!isExpanded && totalCount > 20 && ( +
    + {`${totalCount} total`} +
    + )} +
    +
    +
    +
    +
  • + )} + + {isLoading && + placeholdersToShow > 0 && + [...Array(placeholdersToShow)].map((_, i) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +const ArtistDetails = () => { + const intl = useIntl(); + const router = useRouter(); + const artistId = router.query.artistId as string; + + const { data, error } = useSWR( + artistId ? `/api/v1/artist/${artistId}` : null, + { + revalidateOnFocus: false, + revalidateIfStale: false, + dedupingInterval: 30000, + } + ); + + const [albumTypes, setAlbumTypes] = useState>( + {} + ); + const [showBio, setShowBio] = useState(false); + + useEffect(() => { + if (data?.typeCounts) { + const initialAlbumTypes: Record = {}; + + data.releaseGroups.forEach((album) => { + if (album && album.id) { + const type = album.secondary_types?.length + ? album.secondary_types[0] + : album['primary-type'] || 'Other'; + + if (!initialAlbumTypes[type]) { + initialAlbumTypes[type] = { + albums: [], + isExpanded: false, + isLoading: false, + isHovered: false, + isCollapsing: false, + }; + } + initialAlbumTypes[type].albums.push(album); + } + }); + + setAlbumTypes(initialAlbumTypes); + } + }, [data]); + + const artistName = useMemo(() => { + return data?.artist?.name || data?.name || ''; + }, [data]); + + const personAttributes = useMemo(() => { + if (!data) return []; + + const attributes: string[] = []; + + if (data.birthday) { + if (data.deathday) { + attributes.push( + intl.formatMessage(messages.lifespan, { + birthdate: intl.formatDate(data.birthday, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }), + deathdate: intl.formatDate(data.deathday, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }), + }) + ); + } else { + attributes.push( + intl.formatMessage(messages.birthdate, { + birthdate: intl.formatDate(data.birthday, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }), + }) + ); + } + } + + if (data.artist?.area) { + attributes.push(data.artist.area); + } + + return attributes; + }, [data, intl]); + + const biographyContent = useMemo(() => { + return data?.biography || data?.wikipedia?.content || ''; + }, [data]); + + const handleHover = useCallback((albumType: string, isHovered: boolean) => { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isHovered, + }, + })); + }, []); + + const loadAllAlbumsOfType = useCallback( + async (albumType: string): Promise => { + if (!artistId) return; + + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isLoading: true, + }, + })); + + try { + const response = await fetch( + `/api/v1/artist/${artistId}?albumType=${albumType}&pageSize=${ + data?.typeCounts?.[albumType] || 100 + }` + ); + + if (response.ok) { + const responseData = await response.json(); + const validAlbums = responseData.releaseGroups + .filter((album: Album) => album && album.id) + .map((album: Album) => ({ + ...album, + needsCoverArt: album.posterPath ? false : true, + })); + + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + albums: validAlbums, + isExpanded: true, + isLoading: false, + }, + })); + } + } catch (error) { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isLoading: false, + }, + })); + } + }, + [artistId, data] + ); + + const toggleExpandType = useCallback( + (albumType: string): void => { + const currentState = albumTypes[albumType]; + + if (currentState?.isExpanded) { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isCollapsing: true, + isHovered: false, + }, + })); + + setTimeout(() => { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isExpanded: false, + isCollapsing: false, + }, + })); + }, 300); + } else { + const albums = albumTypes[albumType]?.albums || []; + const typeCount = data?.typeCounts?.[albumType] || 0; + + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isHovered: false, + }, + })); + + if (albums.length < typeCount) { + loadAllAlbumsOfType(albumType); + } else { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isExpanded: true, + }, + })); + } + } + }, + [albumTypes, data, loadAllAlbumsOfType] + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const albumTypeOrder = [ + 'Album', + 'EP', + 'Single', + 'Live', + 'Compilation', + 'Remix', + 'Soundtrack', + 'Broadcast', + 'Demo', + 'Other', + ]; + + return ( + <> + +
+ +
+
+ {data.artistThumb && ( +
+ +
+ )} +
+

{artistName}

+
+
{personAttributes.join(' | ')}
+
+ {biographyContent && ( + setShowBio((show) => !show)} + /> + )} +
+
+ +
+ {albumTypeOrder + .filter((type) => (albumTypes[type]?.albums.length ?? 0) > 0) + .map((type) => ( + + ))} +
+ + ); +}; + +export default ArtistDetails; diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx index 615f5d02..0735234d 100644 --- a/src/components/Blacklist/index.tsx +++ b/src/components/Blacklist/index.tsx @@ -63,7 +63,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMusic = ( media: MovieDetails | TvDetails | MusicDetails ): media is MusicDetails => { - return (media as MusicDetails).artistId !== undefined; + return (media as MusicDetails).artist.id !== undefined; }; const Blacklist = () => { @@ -341,13 +341,9 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { type={isMusic(title) ? 'music' : 'tmdb'} src={ isMusic(title) - ? title.artist.images?.find((img) => img.CoverType === 'Fanart') - ?.Url || - title.artist.images?.find((img) => img.CoverType === 'Poster') - ?.Url || - title.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url || + ? title.artistBackdrop || + title.artistThumb || + title.posterPath || '' : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ title.backdropPath ?? '' @@ -383,12 +379,12 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { src={ title ? isMusic(title) - ? title.images?.find((image) => image.CoverType === 'Cover') - ?.Url ?? '/images/seerr_poster_not_found.png' + ? title.posterPath || + '/images/jellyseerr_poster_not_found_square.png' : title.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` - : '/images/seerr_poster_not_found.png' - : '/images/seerr_poster_not_found.png' + : '/images/jellyseerr_poster_not_found.png' + : '/images/jellyseerr_poster_not_found.png' } alt="" sizes="100vw" @@ -418,7 +414,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { {title && (isMusic(title) - ? `${title.artist.artistName} - ${title.title}` + ? `${title.artist.name} - ${title.title}` : isMovie(title) ? title.title : title.name)} @@ -513,7 +509,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { item.mbId, title && (isMusic(title) - ? `${title.artist.artistName} - ${title.title}` + ? `${title.artist.name} - ${title.title}` : isMovie(title) ? title.title : title.name) diff --git a/src/components/BlacklistModal/index.tsx b/src/components/BlacklistModal/index.tsx index 0342c959..dba2c7ec 100644 --- a/src/components/BlacklistModal/index.tsx +++ b/src/components/BlacklistModal/index.tsx @@ -27,16 +27,14 @@ const isMovie = ( media: MovieDetails | TvDetails | MusicDetails | null ): media is MovieDetails => { if (!media) return false; - return ( - (media as MovieDetails).title !== undefined && !('artistName' in media) - ); + return 'title' in media && !('artist' in media); }; const isMusic = ( media: MovieDetails | TvDetails | MusicDetails | null ): media is MusicDetails => { if (!media) return false; - return (media as MusicDetails).artistId !== undefined; + return 'artist' in media && typeof media.artist?.name === 'string'; }; const BlacklistModal = ({ @@ -69,7 +67,7 @@ const BlacklistModal = ({ const getTitle = () => { if (isMusic(data)) { - return `${data.artist.artistName} - ${data.title}`; + return `${data.artist.name} - ${data.title}`; } return isMovie(data) ? data.title : data?.name; }; @@ -85,7 +83,7 @@ const BlacklistModal = ({ const getBackdrop = () => { if (isMusic(data)) { - return data.artist.images?.find((img) => img.CoverType === 'Fanart')?.Url; + return data.artistBackdrop; } return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`; }; diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index 1eeca02e..e4e2b562 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -1,6 +1,7 @@ import useSettings from '@app/hooks/useSettings'; import type { ImageLoader, ImageProps } from 'next/image'; import Image from 'next/image'; +import { useState } from 'react'; const imageLoader: ImageLoader = ({ src }) => src; @@ -15,8 +16,11 @@ export type CachedImageProps = ImageProps & { **/ const CachedImage = ({ src, type, ...props }: CachedImageProps) => { const { currentSettings } = useSettings(); + const [, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); let imageUrl: string; + let fallbackImage = ''; if (type === 'tmdb') { // tmdb stuff @@ -32,23 +36,39 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => { '/imageproxy/tvdb/' ) : src; - } else if (type === 'avatar') { - // jellyfin avatar (if any) - imageUrl = src; + fallbackImage = '/images/jellyseerr_poster_not_found.png'; } else if (type === 'music') { - // Handle CAA, Fanart and Lidarr images - imageUrl = /^https?:\/\/coverartarchive\.org\//.test(src) - ? src.replace(/^https?:\/\/coverartarchive\.org\//, '/caaproxy/') - : /^https?:\/\/assets\.fanart\.tv\//.test(src) - ? src.replace(/^https?:\/\/assets\.fanart\.tv\//, '/fanartproxy/') - : currentSettings.cacheImages - ? src.replace(/^https:\/\/imagecache\.lidarr\.audio\//, '/lidarrproxy/') + // Cover Art Archive and TheAudioDB images + imageUrl = src.startsWith('https://archive.org/') + ? src.replace(/^https:\/\/archive\.org\//, '/caaproxy/') + : currentSettings.cacheImages && + !src.startsWith('/') && + src.startsWith('https://r2.theaudiodb.com/') + ? src.replace(/^https:\/\/r2\.theaudiodb\.com\//, '/tadbproxy/') : src; + fallbackImage = '/images/jellyseerr_poster_not_found_square.png'; + } else if (type === 'avatar') { + imageUrl = src; + fallbackImage = '/images/user_placeholder.png'; } else { return null; } - return ; + const displaySrc = isError ? fallbackImage : imageUrl; + + return ( + setIsLoading(false)} + onError={() => { + setIsError(true); + setIsLoading(false); + }} + /> + ); }; export default CachedImage; diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 220d7366..146132ef 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -1,5 +1,5 @@ import AddedCard from '@app/components/AddedCard'; -import GroupCard from '@app/components/GroupCard'; +import ArtistCard from '@app/components/ArtistCard'; import PersonCard from '@app/components/PersonCard'; import TitleCard from '@app/components/TitleCard'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -162,42 +162,40 @@ const ListView = ({ isAddedToWatchlist={ title.mediaInfo?.watchlists?.length ?? 0 } - image={ - title.images?.find((image) => image.CoverType === 'Cover') - ?.Url - } + image={title.posterPath} status={title.mediaInfo?.status} title={title.title} - artist={title.artistname} - type={title.type} - year={title.releasedate} + artist={title['artist-credit']?.[0]?.name} + type={title['primary-type']} + year={ + title.releaseDate + ? title.releaseDate.split('-')[0] + : title['first-release-date']?.split('-')[0] + } mediaType={title.mediaType} inProgress={ (title.mediaInfo?.downloadStatus ?? []).length > 0 } + needsCoverArt={title.needsCoverArt} canExpand /> ); break; case 'artist': - return title.type === 'Group' ? ( - image.CoverType === 'Poster') - ?.Url ?? title.artistimage - } + personId={title.tmdbPersonId} + name={title.name} + profilePath={title.artistThumb ?? undefined} canExpand /> ) : ( - ); diff --git a/src/components/Common/Toggle/index.tsx b/src/components/Common/Toggle/index.tsx new file mode 100644 index 00000000..d0d9e837 --- /dev/null +++ b/src/components/Common/Toggle/index.tsx @@ -0,0 +1,29 @@ +import { Switch } from '@headlessui/react'; + +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +const Toggle = ({ checked, onChange, disabled = false }: ToggleProps) => { + return ( + + Toggle + + + ); +}; + +export default Toggle; diff --git a/src/components/Discover/DiscoverMusic/index.tsx b/src/components/Discover/DiscoverMusic/index.tsx index 2cd71d60..8d7da87e 100644 --- a/src/components/Discover/DiscoverMusic/index.tsx +++ b/src/components/Discover/DiscoverMusic/index.tsx @@ -1,40 +1,49 @@ +import Button from '@app/components/Common/Button'; import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; -import type { FilterOptions } from '@app/components/Discover/constants'; -import { prepareFilterValues } from '@app/components/Discover/constants'; +import { + countActiveFilters, + prepareFilterValues, +} from '@app/components/Discover/constants'; +import FilterSlideover from '@app/components/Discover/FilterSlideover'; import useDiscover from '@app/hooks/useDiscover'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; -import { BarsArrowDownIcon } from '@heroicons/react/24/solid'; +import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import type { AlbumResult } from '@server/models/Search'; import { useRouter } from 'next/router'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Discover.DiscoverMusic', { - discovermusics: 'Music', - sortPopularityDesc: 'Most Listened', - sortPopularityAsc: 'Least Listened', - sortReleaseDateDesc: 'Newest First', - sortReleaseDateAsc: 'Oldest First', - sortTitleAsc: 'Title (A-Z)', - sortTitleDesc: 'Title (Z-A)', + discovermusic: 'Music', + sortReleaseDateAsc: 'Release Date Ascending', + sortReleaseDateDesc: 'Release Date Descending', + sortTitleAsc: 'Title (A-Z) Ascending', + sortTitleDesc: 'Title (Z-A) Descending', + sortArtistAsc: 'Artist Name (A-Z) Ascending', + sortArtistDesc: 'Artist Name (Z-A) Descending', + activefilters: + '{count, plural, one {# Active Filter} other {# Active Filters}}', + filters: 'Filters', }); const SortOptions = { - PopularityDesc: 'listen_count.desc', - PopularityAsc: 'listen_count.asc', ReleaseDateDesc: 'release_date.desc', ReleaseDateAsc: 'release_date.asc', TitleAsc: 'title.asc', TitleDesc: 'title.desc', + ArtistAsc: 'artist.asc', + ArtistDesc: 'artist.desc', } as const; const DiscoverMusic = () => { const intl = useIntl(); const router = useRouter(); const updateQueryParams = useUpdateQueryParams({}); + const [showFilters, setShowFilters] = useState(false); const preparedFilters = prepareFilterValues(router.query); @@ -46,16 +55,16 @@ const DiscoverMusic = () => { titles, fetchMore, error, - } = useDiscover( // Add intersection type to ensure id is number - '/api/v1/discover/music', - preparedFilters - ); + } = useDiscover('/api/v1/discover/music', { + ...preparedFilters, + days: preparedFilters.days ?? '7', + }); if (error) { return ; } - const title = intl.formatMessage(messages.discovermusics); + const title = intl.formatMessage(messages.discovermusic); return ( <> @@ -74,12 +83,6 @@ const DiscoverMusic = () => { value={preparedFilters.sortBy} onChange={(e) => updateQueryParams('sortBy', e.target.value)} > - - @@ -92,8 +95,30 @@ const DiscoverMusic = () => { + +
+ setShowFilters(false)} + show={showFilters} + /> +
+ +
{ + const intl = useIntl(); + const router = useRouter(); + const updateQueryParams = useUpdateQueryParams({}); + + const preparedFilters = prepareFilterValues(router.query); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + '/api/v1/discover/music/albums', + preparedFilters + ); + + if (error) { + return ; + } + + const title = intl.formatMessage(messages.discoveralbums); + + return ( + <> + +
+
{title}
+
+
+ + + + +
+
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMusicAlbums; diff --git a/src/components/Discover/DiscoverMusicArtists/index.tsx b/src/components/Discover/DiscoverMusicArtists/index.tsx new file mode 100644 index 00000000..57525b8c --- /dev/null +++ b/src/components/Discover/DiscoverMusicArtists/index.tsx @@ -0,0 +1,101 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import { prepareFilterValues } from '@app/components/Discover/constants'; +import useDiscover from '@app/hooks/useDiscover'; +import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; +import Error from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import { BarsArrowDownIcon } from '@heroicons/react/24/solid'; +import type { ArtistResult } from '@server/models/Search'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Discover.DiscoverMusicArtists', { + discoverartists: 'Artists', + sortPopularityDesc: 'Most Listened', + sortPopularityAsc: 'Least Listened', + sortNameAsc: 'Name (A-Z) Ascending', + sortNameDesc: 'Name (Z-A) Descending', +}); + +const SortOptions = { + PopularityDesc: 'listen_count.desc', + PopularityAsc: 'listen_count.asc', + NameAsc: 'name.asc', + NameDesc: 'name.desc', +} as const; + +const DiscoverMusicArtists = () => { + const intl = useIntl(); + const router = useRouter(); + const updateQueryParams = useUpdateQueryParams({}); + + const preparedFilters = prepareFilterValues(router.query); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + '/api/v1/discover/music/artists', + preparedFilters + ); + + if (error) { + return ; + } + + const title = intl.formatMessage(messages.discoverartists); + + return ( + <> + +
+
{title}
+
+
+ + + + +
+
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMusicArtists; diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index f026eab7..f9e0e500 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -135,8 +135,6 @@ const DiscoverSliderEdit = ({ return intl.formatMessage(sliderTitles.plexwatchlist); case DiscoverSliderType.TRENDING: return intl.formatMessage(sliderTitles.trending); - case DiscoverSliderType.POPULAR_ALBUMS: - return intl.formatMessage(sliderTitles.popularalbums); case DiscoverSliderType.POPULAR_MOVIES: return intl.formatMessage(sliderTitles.popularmovies); case DiscoverSliderType.MOVIE_GENRES: @@ -171,6 +169,10 @@ const DiscoverSliderEdit = ({ return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices); case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES: return intl.formatMessage(sliderTitles.tmdbtvstreamingservices); + case DiscoverSliderType.POPULAR_ALBUMS: + return intl.formatMessage(sliderTitles.popularalbums); + case DiscoverSliderType.POPULAR_ARTISTS: + return intl.formatMessage(sliderTitles.popularartists); default: return 'Unknown Slider'; } diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index abdd0105..3bdb2e38 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -1,6 +1,7 @@ import Button from '@app/components/Common/Button'; import MultiRangeSlider from '@app/components/Common/MultiRangeSlider'; import SlideOver from '@app/components/Common/SlideOver'; +import Toggle from '@app/components/Common/Toggle'; import type { FilterOptions } from '@app/components/Discover/constants'; import { countActiveFilters } from '@app/components/Discover/constants'; import LanguageSelector from '@app/components/LanguageSelector'; @@ -19,7 +20,10 @@ import { } from '@app/hooks/useUpdateQueryParams'; import defineMessages from '@app/utils/defineMessages'; import { XCircleIcon } from '@heroicons/react/24/outline'; +import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; +import type { MultiValue } from 'react-select'; +import AsyncSelect from 'react-select/async'; import Datepicker from 'react-tailwindcss-datepicker-sct'; const messages = defineMessages('components.Discover.FilterSlideover', { @@ -45,12 +49,13 @@ const messages = defineMessages('components.Discover.FilterSlideover', { voteCount: 'Number of votes between {minValue} and {maxValue}', status: 'Status', certification: 'Content Rating', + onlyWithCoverArt: 'Only show releases with cover art', }); type FilterSlideoverProps = { show: boolean; onClose: () => void; - type: 'movie' | 'tv'; + type: 'movie' | 'tv' | 'music'; currentFilters: FilterOptions; }; @@ -64,12 +69,188 @@ const FilterSlideover = ({ const { currentSettings } = useSettings(); const updateQueryParams = useUpdateQueryParams({}); const batchUpdateQueryParams = useBatchUpdateQueryParams({}); + const [defaultSelectedGenres, setDefaultSelectedGenres] = useState< + { label: string; value: string }[] | null + >(null); const dateGte = type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte'; const dateLte = type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte'; + useEffect(() => { + if (type === 'music' && currentFilters.genre) { + const genres = currentFilters.genre.split(','); + + setDefaultSelectedGenres( + genres.map((genre) => ({ + label: genre, + value: genre, + })) + ); + } else { + setDefaultSelectedGenres(null); + } + }, [type, currentFilters.genre]); + + const musicGenreOptions = [ + { label: 'Album', value: 'Album' }, + { label: 'EP', value: 'EP' }, + { label: 'Single', value: 'Single' }, + { label: 'Soundtrack', value: 'Soundtrack' }, + { label: 'Remix', value: 'Remix' }, + { label: 'Live', value: 'Live' }, + { label: 'Demo', value: 'Demo' }, + { label: 'DJ-mix', value: 'DJ-mix' }, + { label: 'Compilation', value: 'Compilation' }, + { label: 'Audio drama', value: 'Audio drama' }, + { label: 'Mixtape/Street', value: 'Mixtape/Street' }, + { label: 'Field recording', value: 'Field recording' }, + { label: 'Other', value: 'Other' }, + ]; + + const loadMusicGenreOptions = async (inputValue: string) => { + return musicGenreOptions.filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ); + }; + + if (type === 'music') { + return ( + onClose()} + > +
+
+ {intl.formatMessage(messages.releaseDate)} +
+
+
+
{intl.formatMessage(messages.from)}
+ { + let formattedDate: string | undefined = undefined; + if (value?.startDate) { + try { + const date = new Date(value.startDate as string); + if (!isNaN(date.getTime())) { + formattedDate = date.toISOString().split('T')[0]; + } + } catch (e) { + // Invalid date, use undefined + } + } + updateQueryParams('releaseDateGte', formattedDate); + }} + inputName="fromdate" + useRange={false} + asSingle + containerClassName="datepicker-wrapper" + inputClassName="pr-1 sm:pr-4 text-base leading-5" + displayFormat="YYYY-MM-DD" + /> +
+
+
{intl.formatMessage(messages.to)}
+ { + let formattedDate: string | undefined = undefined; + if (value?.startDate) { + try { + const date = new Date(value.startDate as string); + if (!isNaN(date.getTime())) { + formattedDate = date.toISOString().split('T')[0]; + } + } catch (e) { + // Invalid date, use undefined + } + } + updateQueryParams('releaseDateLte', formattedDate); + }} + inputName="todate" + useRange={false} + asSingle + containerClassName="datepicker-wrapper" + inputClassName="pr-1 sm:pr-4 text-base leading-5" + displayFormat="YYYY-MM-DD" + /> +
+
+
+ +
+ + {intl.formatMessage(messages.genres)} + + ) => { + updateQueryParams( + 'genre', + value?.length ? value.map((v) => v.value).join(',') : undefined + ); + }} + /> + +
+ + {intl.formatMessage(messages.onlyWithCoverArt)} + + { + const newValue = checked ? 'true' : undefined; + updateQueryParams('onlyWithCoverArt', newValue); + }} + /> +
+
+ +
+
+
+ ); + } + return ( { - updateQueryParams( - dateGte, - value?.startDate ? (value.startDate as string) : undefined - ); + // Format the date as YYYY-MM-DD before setting it + let formattedDate: string | undefined = undefined; + if (value?.startDate) { + try { + const date = new Date(value.startDate as string); + if (!isNaN(date.getTime())) { + formattedDate = date.toISOString().split('T')[0]; + } + } catch (e) { + // Invalid date, use undefined + } + } + updateQueryParams(dateGte, formattedDate); }} inputName="fromdate" useRange={false} asSingle containerClassName="datepicker-wrapper" inputClassName="pr-1 sm:pr-4 text-base leading-5" + displayFormat="YYYY-MM-DD" // Add this to enforce the correct format />
@@ -117,16 +308,25 @@ const FilterSlideover = ({ endDate: currentFilters[dateLte] ?? null, }} onChange={(value) => { - updateQueryParams( - dateLte, - value?.startDate ? (value.startDate as string) : undefined - ); + let formattedDate: string | undefined = undefined; + if (value?.startDate) { + try { + const date = new Date(value.startDate as string); + if (!isNaN(date.getTime())) { + formattedDate = date.toISOString().split('T')[0]; + } + } catch (e) { + // Invalid date, use undefined + } + } + updateQueryParams(dateLte, formattedDate); }} inputName="todate" useRange={false} asSingle containerClassName="datepicker-wrapper" inputClassName="pr-1 sm:pr-4 text-base leading-5" + displayFormat="YYYY-MM-DD" // Add this to enforce the correct format />
diff --git a/src/components/Discover/RecentlyAddedSlider/index.tsx b/src/components/Discover/RecentlyAddedSlider/index.tsx index 28ec9092..295624d5 100644 --- a/src/components/Discover/RecentlyAddedSlider/index.tsx +++ b/src/components/Discover/RecentlyAddedSlider/index.tsx @@ -37,7 +37,7 @@ const RecentlyAddedSlider = () => { ( + items={media?.results.map((item) => ( = { export const sliderTitles = defineMessages('components.Discover', { recentrequests: 'Recent Requests', - popularalbums: 'Popular Albums', popularmovies: 'Popular Movies', populartv: 'Popular Series', upcomingtv: 'Upcoming Series', @@ -89,6 +88,8 @@ export const sliderTitles = defineMessages('components.Discover', { tmdbsearch: 'TMDB Search', tmdbmoviestreamingservices: 'TMDB Movie Streaming Services', tmdbtvstreamingservices: 'TMDB TV Streaming Services', + popularalbums: 'Popular Albums', + popularartists: 'Popular Artists', }); export const QueryFilterOptions = z.object({ @@ -116,6 +117,10 @@ export const QueryFilterOptions = z.object({ certificationLte: z.string().optional(), certificationCountry: z.string().optional(), certificationMode: z.enum(['exact', 'range']).optional(), + onlyWithCoverArt: z.string().optional(), + releaseDateGte: z.string().optional(), + releaseDateLte: z.string().optional(), + days: z.string().optional(), }); export type FilterOptions = z.infer; @@ -227,6 +232,18 @@ export const prepareFilterValues = ( filterValues.certificationMode = 'range'; } + if (values.onlyWithCoverArt === 'true') { + filterValues.onlyWithCoverArt = values.onlyWithCoverArt; + } + + if (values.releaseDateGte) { + filterValues.releaseDateGte = values.releaseDateGte; + } + + if (values.releaseDateLte) { + filterValues.releaseDateLte = values.releaseDateLte; + } + return filterValues; }; @@ -272,6 +289,17 @@ export const countActiveFilters = (filterValues: FilterOptions): number => { } delete clonedFilters.certificationMode; + if (clonedFilters.onlyWithCoverArt === 'true') { + totalCount += 1; + delete clonedFilters.onlyWithCoverArt; + } + + if (clonedFilters.releaseDateGte || filterValues.releaseDateLte) { + totalCount += 1; + delete clonedFilters.releaseDateGte; + delete clonedFilters.releaseDateLte; + } + totalCount += Object.keys(clonedFilters).length; return totalCount; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 8727761e..7068f42b 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -219,16 +219,6 @@ const Discover = () => { case DiscoverSliderType.PLEX_WATCHLIST: sliderComponent = ; break; - case DiscoverSliderType.POPULAR_ALBUMS: - sliderComponent = ( - - ); - break; case DiscoverSliderType.TRENDING: sliderComponent = ( { /> ); break; + case DiscoverSliderType.POPULAR_ALBUMS: + sliderComponent = ( + + ); + break; + case DiscoverSliderType.POPULAR_ARTISTS: + sliderComponent = ( + + ); + break; } if (isEditing) { diff --git a/src/components/GroupDetails/index.tsx b/src/components/GroupDetails/index.tsx deleted file mode 100644 index 2b758b7e..00000000 --- a/src/components/GroupDetails/index.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import Ellipsis from '@app/assets/ellipsis.svg'; -import Button from '@app/components/Common/Button'; -import CachedImage from '@app/components/Common/CachedImage'; -import ImageFader from '@app/components/Common/ImageFader'; -import LoadingSpinner from '@app/components/Common/LoadingSpinner'; -import PageTitle from '@app/components/Common/PageTitle'; -import TitleCard from '@app/components/TitleCard'; -import Error from '@app/pages/_error'; -import defineMessages from '@app/utils/defineMessages'; -import type { ArtistDetailsType } from '@server/models/Artist'; -import { useRouter } from 'next/router'; -import { useState } from 'react'; -import { useIntl } from 'react-intl'; -import TruncateMarkup from 'react-truncate-markup'; -import useSWR from 'swr'; -import useSWRInfinite from 'swr/infinite'; - -interface Album { - id: string; - title: string; - type: string; - releasedate: string; - images: { Url: string }[]; - mediaInfo?: { - status?: number; - downloadStatus?: unknown[]; - watchlists?: unknown[]; - }; -} - -interface DiscographyResponse { - page: number; - pageInfo: { - total: number; - totalPages: number; - }; - results: Album[]; -} - -const messages = defineMessages('components.GroupDetails', { - type: 'Type: {type}', - genres: 'Genres: {genres}', - albums: 'Albums', - singles: 'Singles', - eps: 'EPs', - other: 'Other', - overview: 'Overview', - status: 'Status: {status}', - loadmore: 'Load More', -}); - -const GroupDetails = () => { - const intl = useIntl(); - const router = useRouter(); - const [showBio, setShowBio] = useState(false); - - const { - data: albumData, - size: albumSize, - setSize: setAlbumSize, - isValidating: isLoadingAlbums, - } = useSWRInfinite( - (index) => - `/api/v1/group/${router.query.groupId}/discography?page=${ - index + 1 - }&type=Album`, - { revalidateFirstPage: false } - ); - - const { - data: singlesData, - size: singlesSize, - setSize: setSinglesSize, - isValidating: isLoadingSingles, - } = useSWRInfinite( - (index) => - `/api/v1/group/${router.query.groupId}/discography?page=${ - index + 1 - }&type=Single`, - { revalidateFirstPage: false } - ); - - const { - data: epsData, - size: epsSize, - setSize: setEpsSize, - isValidating: isLoadingEps, - } = useSWRInfinite( - (index) => - `/api/v1/group/${router.query.groupId}/discography?page=${ - index + 1 - }&type=EP`, - { revalidateFirstPage: false } - ); - - const { - data: otherData, - size: otherSize, - setSize: setOtherSize, - isValidating: isLoadingOther, - } = useSWRInfinite( - (index) => - `/api/v1/group/${router.query.groupId}/discography?page=${ - index + 1 - }&type=Other`, - { revalidateFirstPage: false } - ); - - const { data, error } = useSWR( - `/api/v1/group/${router.query.groupId}` - ); - - if (!data && !error) { - return ; - } - - if (!data) { - return ; - } - - const groupAttributes: string[] = []; - - if (data.type) { - groupAttributes.push( - intl.formatMessage(messages.type, { - type: data.type, - }) - ); - } - - if (data.genres?.length > 0) { - groupAttributes.push( - intl.formatMessage(messages.genres, { - genres: data.genres.join(', '), - }) - ); - } - - if (data.status) { - const capitalizeFirstLetter = (str: string) => - str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); - - groupAttributes.push( - intl.formatMessage(messages.status, { - status: capitalizeFirstLetter(data.status), - }) - ); - } - - const renderAlbumSection = ( - title: string, - albums: Album[], - isLoading: boolean, - isReachingEnd: boolean, - onLoadMore: () => void - ) => { - if (!albums?.length && !isLoading) return null; - - return ( - <> -
-
- {title} -
-
-
    - {albums?.map((album) => ( -
  • - 0} - canExpand - /> -
  • - ))} - {isLoading && - [...Array(20)].map((_, index) => ( -
  • - -
  • - ))} -
- {!isReachingEnd && ( -
- -
- )} - - ); - }; - - return ( - <> - -
- page.results) ?? []), - ...(singlesData?.flatMap((page) => page.results) ?? []), - ...(epsData?.flatMap((page) => page.results) ?? []), - ...(otherData?.flatMap((page) => page.results) ?? []), - ] - .filter((album) => album.images?.[0]?.Url) - .map((album) => album.images[0].Url) - .slice(0, 6)} - /> -
-
- {data.images?.[0]?.Url && ( -
- img.CoverType === 'Poster')?.Url ?? - data.images[0]?.Url - } - alt="" - style={{ width: '100%', height: '100%', objectFit: 'cover' }} - fill - /> -
- )} -
-

{data.name}

-
-
{groupAttributes.join(' | ')}
-
- {data.overview && ( -
-
setShowBio((show) => !show)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { - setShowBio((show) => !show); - } - }} - role="button" - tabIndex={0} - > - - } - > -

{data.overview}

-
-
-
- )} -
-
- - {renderAlbumSection( - intl.formatMessage(messages.albums), - albumData ? albumData.flatMap((page) => page.results) : [], - isLoadingAlbums ?? false, - (albumData?.[0]?.results.length === 0 || - (albumData && - albumData[albumData.length - 1]?.results.length < 20)) ?? - false, - () => setAlbumSize(albumSize + 1) - )} - {renderAlbumSection( - intl.formatMessage(messages.singles), - singlesData ? singlesData.flatMap((page) => page.results) : [], - isLoadingSingles ?? false, - (singlesData?.[0]?.results.length === 0 || - (singlesData && - singlesData[singlesData.length - 1]?.results.length < 20)) ?? - false, - () => setSinglesSize(singlesSize + 1) - )} - {renderAlbumSection( - intl.formatMessage(messages.eps), - epsData ? epsData.flatMap((page) => page.results) : [], - isLoadingEps, - (epsData?.[0]?.results.length === 0 || - (epsData && epsData[epsData.length - 1]?.results.length < 20)) ?? - false, - () => setEpsSize(epsSize + 1) - )} - {renderAlbumSection( - intl.formatMessage(messages.other), - otherData ? otherData.flatMap((page) => page.results) : [], - isLoadingOther, - (otherData?.[0]?.results.length === 0 || - (otherData && - otherData[otherData.length - 1]?.results.length < 20)) ?? - false, - () => setOtherSize(otherSize + 1) - )} - - ); -}; - -export default GroupDetails; diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx index 8ff59668..c2cceeb7 100644 --- a/src/components/IssueDetails/index.tsx +++ b/src/components/IssueDetails/index.tsx @@ -192,7 +192,7 @@ const IssueDetails = () => { }; const title = isMusic(data) - ? `${data.artist.artistName} - ${data.title}` + ? `${data.artist.name} - ${data.title}` : isMovie(data) ? data.title : data.name; @@ -238,14 +238,7 @@ const IssueDetails = () => { alt="" src={ isMusic(data) - ? data.artist.images?.find((img) => img.CoverType === 'Fanart') - ?.Url || - data.artist.images?.find((img) => img.CoverType === 'Poster') - ?.Url || - data.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url || - '/images/overseerr_poster_not_found.png' + ? data?.artistBackdrop || data?.artistThumb || '' : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}` } style={{ width: '100%', height: '100%', objectFit: 'cover' }} @@ -267,9 +260,8 @@ const IssueDetails = () => { type={isMusic(data) ? 'music' : 'tmdb'} src={ isMusic(data) - ? data.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url || '/images/overseerr_poster_not_found.png' + ? data.posterPath || + '/images/jellyseerr_poster_not_found_square.png' : data.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}` : '/images/seerr_poster_not_found.png' diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx index 7f0a5851..f576e6d3 100644 --- a/src/components/IssueList/IssueItem/index.tsx +++ b/src/components/IssueList/IssueItem/index.tsx @@ -139,8 +139,7 @@ const IssueItem = ({ issue }: IssueItemProps) => { type={isMusic(title) ? 'music' : 'tmdb'} src={ isMusic(title) - ? title.artist.images?.find((img) => img.CoverType === 'Fanart') - ?.Url ?? '/images/overseerr_poster_not_found.png' + ? title.artistBackdrop ?? title.artistThumb ?? '' : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ title.backdropPath ?? '' }` @@ -174,8 +173,8 @@ const IssueItem = ({ issue }: IssueItemProps) => { type={isMusic(title) ? 'music' : 'tmdb'} src={ isMusic(title) - ? title.images?.find((image) => image.CoverType === 'Cover') - ?.Url ?? '/images/overseerr_poster_not_found.png' + ? title.posterPath ?? + '/images/jellyseerr_poster_not_found_square.png' : title.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` : '/images/seerr_poster_not_found.png' @@ -207,7 +206,7 @@ const IssueItem = ({ issue }: IssueItemProps) => { className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl" > {isMusic(title) - ? `${title.artist.artistName} - ${title.title}` + ? `${title.artist.name} - ${title.title}` : isMovie(title) ? title.title : title.name} diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index 51236eaa..1db8e878 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -137,7 +137,7 @@ const CreateIssueModal = ({
{intl.formatMessage(messages.toastSuccessCreate, { title: isMusic(data) - ? `${data.artist.artistName} - ${data.title}` + ? `${data.artist.name} - ${data.title}` : isMovie(data) ? data.title : data.name, @@ -180,7 +180,7 @@ const CreateIssueModal = ({ subTitle={ data && (isMusic(data) - ? `${data.artist.artistName} - ${data.title}` + ? `${data.artist.name} - ${data.title}` : isMovie(data) ? data.title : data.name) @@ -192,11 +192,11 @@ const CreateIssueModal = ({ backdrop={ data ? isMusic(data) - ? data.images?.find((image) => image.CoverType === 'Cover') - ?.Url ?? '/images/overseerr_poster_not_found.png' + ? data.posterPath || + '/images/jellyseerr_poster_not_found_square.png' : data.backdropPath ? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}` - : '/images/overseerr_poster_not_found.png' + : '/images/jellyseerr_poster_not_found.png' : undefined } > diff --git a/src/components/IssueModal/constants.ts b/src/components/IssueModal/constants.ts index 676f4849..7d6ae2fa 100644 --- a/src/components/IssueModal/constants.ts +++ b/src/components/IssueModal/constants.ts @@ -6,8 +6,8 @@ const messages = defineMessages('components.IssueModal', { issueAudio: 'Audio', issueVideo: 'Video', issueSubtitles: 'Subtitle', - issueLyrics: 'Lyrics', issueOther: 'Other', + issueLyrics: 'Lyrics', }); interface IssueOption { @@ -29,14 +29,14 @@ export const issueOptions: IssueOption[] = [ name: messages.issueSubtitles, issueType: IssueType.SUBTITLES, }, - { - name: messages.issueLyrics, - issueType: IssueType.LYRICS, - }, { name: messages.issueOther, issueType: IssueType.OTHER, }, + { + name: messages.issueLyrics, + issueType: IssueType.LYRICS, + }, ]; export const getIssueOptionsForMediaType = ( diff --git a/src/components/Layout/SearchInput/index.tsx b/src/components/Layout/SearchInput/index.tsx index ac5b7b2f..bd3e2341 100644 --- a/src/components/Layout/SearchInput/index.tsx +++ b/src/components/Layout/SearchInput/index.tsx @@ -23,8 +23,9 @@ const SearchInput = () => {
0 ? '1.75rem' : '' }} - className="block w-full rounded-full border border-gray-600 bg-gray-900 bg-opacity-80 py-2 pl-10 text-white placeholder-gray-300 hover:border-gray-500 focus:border-gray-500 focus:bg-opacity-100 focus:placeholder-gray-400 focus:outline-none focus:ring-0 sm:text-base" + className={`block w-full rounded-full border border-gray-600 bg-gray-900 bg-opacity-80 py-2 pl-10 text-white placeholder-gray-300 hover:border-gray-500 focus:border-gray-500 focus:bg-opacity-100 focus:placeholder-gray-400 focus:outline-none focus:ring-0 sm:text-base ${ + searchValue.length > 0 ? 'pr-7' : '' + }`} placeholder={intl.formatMessage(messages.searchPlaceholder)} type="search" autoComplete="off" diff --git a/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx b/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx index 54f80c7e..d4f747b6 100644 --- a/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx +++ b/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx @@ -27,7 +27,7 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => { return null; } - if (data === undefined && !error) { + if (!data && !error) { return ; } diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 9459a759..f36a9bac 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -262,7 +262,7 @@ const ManageSlideOver = ({ isMovie(data) ? data.title : isMusic(data) - ? `${data.title} - ${data.artist.artistName}` + ? `${data.title} - ${data.artist.name}` : data.name } > diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index f0737f51..e6e37052 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,4 +1,4 @@ -import GroupCard from '@app/components/GroupCard'; +import ArtistCard from '@app/components/ArtistCard'; import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard'; import PersonCard from '@app/components/PersonCard'; import Slider from '@app/components/Slider'; @@ -16,7 +16,7 @@ import type { TvResult, } from '@server/models/Search'; import Link from 'next/link'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import useSWRInfinite from 'swr/infinite'; interface MixedResult { @@ -34,12 +34,20 @@ interface MixedResult { interface MediaSliderProps { title: string; - url: string; + url?: string; linkUrl?: string; sliderKey: string; hideWhenEmpty?: boolean; extraParams?: string; onNewTitles?: (titleCount: number) => void; + items?: ( + | MovieResult + | TvResult + | PersonResult + | AlbumResult + | ArtistResult + )[]; + totalItems?: number; } const MediaSlider = ({ @@ -50,12 +58,20 @@ const MediaSlider = ({ sliderKey, hideWhenEmpty = false, onNewTitles, + items: passedItems, + totalItems, }: MediaSliderProps) => { const settings = useSettings(); const { hasPermission } = useUser(); + const [titles, setTitles] = useState< + (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[] + >([]); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + if ( + !url || + (previousPageData && pageIndex + 1 > previousPageData.totalPages) + ) { return null; } @@ -69,19 +85,33 @@ const MediaSlider = ({ } ); - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[] - ); + useEffect(() => { + const newTitles = + passedItems ?? + (data ?? []).reduce( + (a, v) => [...a, ...v.results], + [] as ( + | MovieResult + | TvResult + | PersonResult + | AlbumResult + | ArtistResult + )[] + ); - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } + if (settings.currentSettings.hideAvailable) { + setTitles( + newTitles.filter( + (i) => + (i.mediaType === 'movie' || i.mediaType === 'tv') && + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ) + ); + } else { + setTitles(newTitles); + } + }, [data, passedItems, settings.currentSettings.hideAvailable]); if (settings.currentSettings.hideBlacklisted) { titles = titles.filter( @@ -93,6 +123,7 @@ const MediaSlider = ({ useEffect(() => { if ( + !passedItems && titles.length < 24 && size < 5 && (data?.[0]?.totalResults ?? 0) > size * 20 @@ -105,9 +136,14 @@ const MediaSlider = ({ // at all for our purposes. onNewTitles(titles.length); } - }, [titles, setSize, size, data, onNewTitles]); + }, [titles, setSize, size, data, onNewTitles, passedItems]); - if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) { + if ( + hideWhenEmpty && + (!passedItems + ? (data?.[0].results ?? []).length === 0 + : titles.length === 0) + ) { return null; } @@ -174,46 +210,44 @@ const MediaSlider = ({ key={title.id} id={title.id} isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} - image={ - title.images?.find((image) => image.CoverType === 'Cover')?.Url - } + image={title.posterPath} status={title.mediaInfo?.status} title={title.title} - year={title.releasedate} + year={title['first-release-date']?.split('-')[0]} mediaType={title.mediaType} - artist={title.artistname} - type={title.type} + artist={title['artist-credit']?.[0]?.name} + type={title['primary-type']} inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} + needsCoverArt={title.needsCoverArt} /> ); case 'artist': - return title.type === 'Group' ? ( - - ) : ( + return title.tmdbPersonId ? ( + ) : ( + ); } }); - if (linkUrl && titles.length > 20) { + if (linkUrl && (totalItems ? totalItems > 20 : titles.length > 20)) { finalTitles.push( - title.mediaType !== 'person' + title.mediaType !== 'person' && title.mediaType !== 'album' ? (title as MovieResult | TvResult).posterPath : undefined )} @@ -237,7 +271,7 @@ const MediaSlider = ({
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index b842b46f..85574fe9 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -1060,14 +1060,26 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { )} {!!streamingProviders.length && ( -
+
{intl.formatMessage(messages.streamingproviders)} - + {streamingProviders.map((p) => { return ( - - {p.name} - + + + + + ); })} diff --git a/src/components/MusicDetails/MusicArtistDiscography.tsx b/src/components/MusicDetails/MusicArtistDiscography.tsx index 74d9874a..87fda498 100644 --- a/src/components/MusicDetails/MusicArtistDiscography.tsx +++ b/src/components/MusicDetails/MusicArtistDiscography.tsx @@ -1,49 +1,131 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; -import useDiscover from '@app/hooks/useDiscover'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; +import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import type { MusicDetails } from '@server/models/Music'; import type { AlbumResult } from '@server/models/Search'; import Link from 'next/link'; import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; const messages = defineMessages('components.MusicDetails', { - artistalbums: "Artist's Discography", + discography: "{artistName}'s discography", + byartist: 'by', }); +interface ReleaseGroup { + id: string; + title: string; + 'first-release-date'?: string; + 'primary-type'?: string; + artistThumb?: string; + posterPath?: string; + mediaType: 'album'; + 'artist-credit'?: { name: string }[]; + score?: number; + mediaInfo?: { + status?: number; + downloadStatus?: unknown[]; + watchlists?: unknown[]; + }; +} + +interface Pagination { + page: number; + pageSize: number; + totalPages: number; + totalResults: number; +} + +interface ArtistResponse { + artist: { + releaseGroups: ReleaseGroup[]; + pagination: Pagination; + }; +} + const MusicArtistDiscography = () => { const intl = useIntl(); const router = useRouter(); - const { data: musicData } = useSWR( - `/api/v1/music/${router.query.musicId}` + const musicId = router.query.musicId as string; + const [page, setPage] = useState(1); + const [allReleaseGroups, setAllReleaseGroups] = useState([]); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const { data: musicData, error: musicError } = useSWR( + musicId ? `/api/v1/music/${musicId}` : null ); - const { - isLoadingInitialData, - isEmpty, - isLoadingMore, - isReachingEnd, - titles, - fetchMore, - error, - } = useDiscover( - `/api/v1/music/${router.query.musicId}/discography` + const refreshInterval = musicData + ? refreshIntervalHelper( + { + downloadStatus: musicData.mediaInfo?.downloadStatus, + downloadStatus4k: undefined, + }, + 15000 + ) + : 0; + + useSWR(musicId ? `/api/v1/music/${musicId}` : null, { + refreshInterval, + dedupingInterval: 0, + }); + + const { data: artistData, error: artistError } = useSWR( + musicId ? `/api/v1/music/${musicId}/artist?page=${page}&pageSize=20` : null ); - if (error) { - return ; + useEffect(() => { + if ( + artistData?.artist?.releaseGroups && + artistData.artist.releaseGroups.length > 0 + ) { + setAllReleaseGroups((prev) => { + const uniqueIds = new Set(prev.map((item) => item.id)); + const newItems = artistData.artist.releaseGroups.filter( + (item: ReleaseGroup) => !uniqueIds.has(item.id) + ); + return [...prev, ...newItems]; + }); + + const { pagination } = artistData.artist; + setHasMore(pagination.page < pagination.totalPages); + setIsLoadingMore(false); + } + }, [artistData]); + + const mainArtistName = + musicData?.artist.name.split(/[&,]|\sfeat\./)[0].trim() ?? ''; + + const loadMore = () => { + if (!isLoadingMore && hasMore) { + setIsLoadingMore(true); + setPage((prevPage) => prevPage + 1); + } + }; + + if (!musicData && !musicError) { + return ; + } + + if (musicError || artistError) { + return ; } return ( <>
@@ -53,21 +135,22 @@ const MusicArtistDiscography = () => { href={`/music/${musicData?.mbId}`} className="hover:underline" > - {musicData?.artist.artistName} + {`${musicData?.title} ${intl.formatMessage( + messages.byartist + )} ${mainArtistName}`} } > - {intl.formatMessage(messages.artistalbums)} + {intl.formatMessage(messages.discography, { + artistName: mainArtistName, + })}
0) - } - onScrollBottom={fetchMore} + items={allReleaseGroups as unknown as AlbumResult[]} + isEmpty={allReleaseGroups.length === 0} + isLoading={!artistData} + onScrollBottom={loadMore} /> ); diff --git a/src/components/MusicDetails/MusicArtistSimilar.tsx b/src/components/MusicDetails/MusicArtistSimilar.tsx deleted file mode 100644 index 933e2d32..00000000 --- a/src/components/MusicDetails/MusicArtistSimilar.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import Header from '@app/components/Common/Header'; -import ListView from '@app/components/Common/ListView'; -import PageTitle from '@app/components/Common/PageTitle'; -import useDiscover from '@app/hooks/useDiscover'; -import Error from '@app/pages/_error'; -import defineMessages from '@app/utils/defineMessages'; -import type { MusicDetails } from '@server/models/Music'; -import type { ArtistResult } from '@server/models/Search'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; -import { useIntl } from 'react-intl'; -import useSWR from 'swr'; - -const messages = defineMessages('components.MusicDetails', { - similarArtists: 'Similar Artists', -}); - -const MusicArtistSimilar = () => { - const intl = useIntl(); - const router = useRouter(); - const { data: musicData } = useSWR( - `/api/v1/music/${router.query.musicId}` - ); - - const { - isLoadingInitialData, - isEmpty, - isLoadingMore, - isReachingEnd, - titles, - fetchMore, - error, - } = useDiscover( - `/api/v1/music/${router.query.musicId}/similar` - ); - - if (error) { - return ; - } - - return ( - <> - -
-
- {musicData?.artist.artistName} - - } - > - {intl.formatMessage(messages.similarArtists)} -
-
- 0) - } - onScrollBottom={fetchMore} - /> - - ); -}; - -export default MusicArtistSimilar; diff --git a/src/components/MusicDetails/index.tsx b/src/components/MusicDetails/index.tsx index ef23f3ef..0be1362d 100644 --- a/src/components/MusicDetails/index.tsx +++ b/src/components/MusicDetails/index.tsx @@ -1,3 +1,4 @@ +import Ellipsis from '@app/assets/ellipsis.svg'; import Spinner from '@app/assets/spinner.svg'; import BlacklistModal from '@app/components/BlacklistModal'; import Button from '@app/components/Common/Button'; @@ -6,6 +7,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import type { PlayButtonLink } from '@app/components/Common/PlayButton'; import PlayButton from '@app/components/Common/PlayButton'; +import Tag from '@app/components/Common/Tag'; import Tooltip from '@app/components/Common/Tooltip'; import IssueModal from '@app/components/IssueModal'; import ManageSlideOver from '@app/components/ManageSlideOver'; @@ -30,10 +32,14 @@ import { IssueStatus } from '@server/constants/issue'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import type { MusicDetails as MusicDetailsType } from '@server/models/Music'; +import type { AlbumResult, ArtistResult } from '@server/models/Search'; +import 'country-flag-icons/3x2/flags.css'; +import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; +import TruncateMarkup from 'react-truncate-markup'; import useSWR from 'swr'; const messages = defineMessages('components.MusicDetails', { @@ -53,18 +59,74 @@ const messages = defineMessages('components.MusicDetails', { watchlistError: 'Something went wrong try again.', removefromwatchlist: 'Remove From Watchlist', addtowatchlist: 'Add To Watchlist', - status: 'Status', - label: 'Label', artisttype: 'Artist Type', artiststatus: 'Artist Status', - discography: "{artistName}'s discography", + discography: "{artist.name}'s discography", similarArtists: 'Similar Artists', + byartist: 'by', + country: 'Country', + beginYear: 'Started in', }); interface MusicDetailsProps { music?: MusicDetailsType; } +interface ArtistDetails { + artist: { + releaseGroups: AlbumResult[]; + similarArtists: { + artists: { + tmdbPersonId: number; + artist_mbid: string; + name: string; + type: string; + artistThumb: string; + score: number; + }[]; + }; + pagination?: { + totalItems: number; + }; + }; +} + +const Biography = ({ + content, + showBio, + onClick, +}: { + content: string; + showBio: boolean; + onClick: () => void; +}) => { + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + role="button" + tabIndex={0} + > + + } + > +

{content}

+
+
+
+ ); +}; + const MusicDetails = ({ music }: MusicDetailsProps) => { const settings = useSettings(); const { user, hasPermission } = useUser(); @@ -80,6 +142,7 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { useState(false); const [showBlacklistModal, setShowBlacklistModal] = useState(false); const { addToast } = useToasts(); + const [showBio, setShowBio] = useState(false); const { data, @@ -96,6 +159,10 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { ), }); + const { data: artistData } = useSWR( + data ? `/api/v1/music/${data.id}/artist?page=1&pageSize=20` : null + ); + useEffect(() => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); @@ -135,14 +202,6 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { }); } - const formatDuration = (milliseconds: number): string => { - if (!milliseconds) return ''; - - const totalMinutes = Math.floor(milliseconds / 1000 / 60); - - return `${totalMinutes} Minute${totalMinutes > 1 ? 's' : ''}`; - }; - function getAvalaibleMediaServerName() { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); @@ -283,17 +342,36 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { type: 'or', }); - const totalDurationMs = data.releases?.[0]?.tracks?.reduce( - (sum, track) => sum + (track.durationMs || 0), - 0 - ); - - const truncateOverview = (text: string): string => { - const maxLength = 800; - if (!text || text.length <= maxLength) return text; - - const truncated = text.substring(0, maxLength); - return truncated.substring(0, truncated.lastIndexOf('.') + 1); + const getCountryCode = (countryName: string): string => { + const countryMap: Record = { + Argentina: 'AR', + Australia: 'AU', + Austria: 'AT', + Belgium: 'BE', + Brazil: 'BR', + Canada: 'CA', + China: 'CN', + Denmark: 'DK', + France: 'FR', + England: 'GB', + Germany: 'DE', + India: 'IN', + Ireland: 'IE', + Italy: 'IT', + Japan: 'JP', + Mexico: 'MX', + Netherlands: 'NL', + 'New Zealand': 'NZ', + Norway: 'NO', + 'South Korea': 'KR', + Spain: 'ES', + Sweden: 'SE', + Switzerland: 'CH', + 'United Kingdom': 'GB', + 'United States': 'US', + 'United States of America': 'US', + }; + return countryMap[countryName] || countryName; }; return ( @@ -308,13 +386,10 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { type="music" alt="" src={ - data.artist.images?.find((img) => img.CoverType === 'Fanart') - ?.Url || - data.artist.images?.find((img) => img.CoverType === 'Poster') - ?.Url || - data.images?.find((img) => img.CoverType.toLowerCase() === 'cover') - ?.Url || - '/images/overseerr_poster_not_found.png' + data.artistBackdrop || + data.artistThumb || + data.posterPath || + '/images/jellyseerr_poster_not_found_square.png' } style={{ width: '100%', height: '100%', objectFit: 'cover' }} fill @@ -328,7 +403,11 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { }} />
- + setShowIssueModal(false)} show={showIssueModal} @@ -361,9 +440,8 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { img.CoverType.toLowerCase() === 'cover' - )?.Url || '/images/overseerr_poster_not_found.png' + data?.posterPath || + '/images/jellyseerr_poster_not_found_square.png' } alt="" sizes="100vw" @@ -386,7 +464,9 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { />

- {data.title} - {data.artist.artistName}{' '} + {`${data.title} ${intl.formatMessage(messages.byartist)} ${ + data.artist.name + }`}{' '} {data.releaseDate && ( ({new Date(data.releaseDate).getFullYear()}) @@ -396,8 +476,17 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { {[ {data.type}, - totalDurationMs ? formatDuration(totalDurationMs) : null, - data.genres.length > 0 ? data.genres.join(', ') : null, + + {(() => { + const totalSeconds = + data.tracks?.reduce( + (acc, track) => acc + track.length / 1000, + 0 + ) ?? 0; + const minutes = Math.ceil(totalSeconds / 60); + return intl.formatMessage(messages.runtime, { minutes }); + })()} + , ] .filter(Boolean) .map((t, k) => {t}) @@ -524,29 +613,79 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {

{intl.formatMessage(messages.biography)}

-

- {data.artist.overview - ? truncateOverview(data.artist.overview) - : intl.formatMessage(messages.biographyunavailable)} -

+ setShowBio(!showBio)} + /> + {/* We don't know we we will do with this tags maybe use it to show related content */} + {((data?.tags?.artist?.length ?? 0) > 0 || + (data?.tags?.releaseGroup?.length ?? 0) > 0) && ( +
+ {data?.tags?.artist?.map((tag) => ( + + {tag.tag} + + ))} + {data?.tags?.releaseGroup?.map((tag) => ( + + {tag.tag} + + ))} +
+ )}

{intl.formatMessage(messages.trackstitle)}

- {data.releases?.[0]?.tracks?.length > 0 ? ( + {data.tracks?.length > 0 ? (
- {data.releases[0].tracks.map((track, index) => ( + {data.tracks.map((track) => (
- {index + 1} - - {track.trackName} + + {track.position} +
+
{track.name}
+
+ {track.artists.map((artist, index) => ( + + {index === 0 ? '' : index === 1 ? ' feat. ' : ', '} + + {artist.name} + + + ))} +
+
- {Math.floor((track.durationMs ?? 0) / 1000 / 60)}: - {String( - Math.floor(((track.durationMs ?? 0) / 1000) % 60) - ).padStart(2, '0')} + {Math.floor(track.length / 1000 / 60)}: + {String(Math.floor((track.length / 1000) % 60)).padStart( + 2, + '0' + )}
@@ -559,39 +698,86 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { )}
+ {data.artist.name !== 'Various Artists' && ( +
+ +
+
+ +
+
+
+
+ {data.artist.name.split(/[&,]|\sfeat\./)[0].trim()} +
+ +
+
+ +
+ )}
- {data.releases?.[0]?.status && ( -
- {intl.formatMessage(globalMessages.status)} - - {data.releases[0].status} - -
- )} - {data.releases?.[0]?.label?.length > 0 && ( -
- {intl.formatMessage(messages.label)} - - {data.releases[0].label.map((label) => ( - - {label} - - ))} - -
- )} {data.artist.type && (
{intl.formatMessage(messages.artisttype)} {data.artist.type}
)} - {data.artist.status && ( + {data.artist.area && (
- {intl.formatMessage(messages.artiststatus)} + {intl.formatMessage(messages.country)} - {data.artist.status.charAt(0).toUpperCase() + - data.artist.status.slice(1)} + + {data.artist.area && ( + <> + + {data.artist.area} + + )} + + +
+ )} + {data.artist.beginYear && ( +
+ {intl.formatMessage(messages.beginYear)} + + {data.artist.beginYear}
)} @@ -601,16 +787,31 @@ const MusicDetails = ({ music }: MusicDetailsProps) => { + ({ + id: artist.artist_mbid, + mediaType: 'artist', + name: artist.name, + type: artist.type as 'Group' | 'Person', + artistThumb: artist.artistThumb, + score: artist.score, + tmdbPersonId: artist.tmdbPersonId, + 'sort-name': artist.name, + }) + ) ?? [] + } linkUrl={`/music/${data.id}/similar`} hideWhenEmpty /> diff --git a/src/components/PersonCard/index.tsx b/src/components/PersonCard/index.tsx index 458438a6..59fb93a4 100644 --- a/src/components/PersonCard/index.tsx +++ b/src/components/PersonCard/index.tsx @@ -4,12 +4,11 @@ import Link from 'next/link'; import { useState } from 'react'; interface PersonCardProps { - personId: number | string; + personId: number; name: string; subName?: string; profilePath?: string; canExpand?: boolean; - mediaType?: 'person' | 'artist'; } const PersonCard = ({ @@ -18,7 +17,6 @@ const PersonCard = ({ subName, profilePath, canExpand = false, - mediaType = 'person', }: PersonCardProps) => { const [isHovered, setHovered] = useState(false); @@ -53,11 +51,12 @@ const PersonCard = ({ {profilePath ? (
= { + Album: 'album', + EP: 'ep', + Single: 'single', + Live: 'live', + Compilation: 'compilation', + Remix: 'remix', + Soundtrack: 'soundtrack', + Broadcast: 'broadcast', + Demo: 'demo', + Other: 'other', +}; + +interface Album { + id: string; + title?: string; + 'first-release-date'?: string; + posterPath?: string | null; + needsCoverArt?: boolean; + 'primary-type'?: string; + secondary_types?: string[]; + 'artist-credit'?: { name: string }[]; + mediaInfo?: { + status: MediaStatus; + }; +} + +interface ArtistWithTypeCounts { + name?: string; + artistBackdrop: string | null; + artistThumb?: string; + releaseGroups?: Album[]; + typeCounts?: Record; + area?: string; + artist_mbid?: string; +} + +interface AlbumTypeState { + albums: Album[]; + isExpanded: boolean; + isLoading: boolean; + isHovered: boolean; + isCollapsing: boolean; +} + +interface EnhancedPersonDetails extends Omit { + artist?: ArtistWithTypeCounts; +} + +interface MediaItem { + id: number; + title?: string; + name?: string; + posterPath?: string; + releaseDate?: string; + firstAirDate?: string; + mediaType: 'movie' | 'tv'; + mediaInfo?: { + status?: MediaStatus; + }; + character?: string; + job?: string; + backdropPath?: string; + popularity?: number; +} + +const Biography = ({ + content, + showBio, + onClick, +}: { + content: string; + showBio: boolean; + onClick: () => void; +}) => { + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + role="button" + tabIndex={0} + > + + } + > +

{content}

+
+
+
+ ); +}; + +const AlbumSection = ({ + type, + state, + totalCount, + artistName, + onToggleExpand, + onHover, +}: { + type: string; + state: AlbumTypeState; + totalCount: number; + artistName?: string; + onToggleExpand: (type: string) => void; + onHover: (type: string, isHovered: boolean) => void; +}) => { + const intl = useIntl(); + const { albums, isExpanded, isLoading, isHovered, isCollapsing } = state; + + const displayAlbums = isExpanded ? albums : albums.slice(0, 20); + + const shouldShowExpandButton = totalCount > 20; + + const remainingItems = totalCount - albums.length; + const placeholdersToShow = isExpanded + ? Math.min(remainingItems, 20) + : Math.min(remainingItems, 20); + + const messageKey = albumTypeMessages[type] || 'other'; + const title = intl.formatMessage(messages[messageKey]); + + return ( +
+
+
+ {title} + {totalCount > 0 && ( + ({totalCount}) + )} +
+
+
    + {displayAlbums + .filter((media) => media && media.id) + .map((media) => ( +
  • + +
  • + ))} + + {shouldShowExpandButton && !isLoading && ( +
  • +
    +
    onToggleExpand(type)} + onMouseEnter={() => onHover(type, true)} + onMouseLeave={() => onHover(type, false)} + onBlur={() => onHover(type, false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleExpand(type); + } + }} + role="button" + tabIndex={0} + aria-label={intl.formatMessage( + isExpanded ? messages.showless : messages.showall + )} + > +
    +
    + {isExpanded ? ( + + ) : ( + + )} +
    + {intl.formatMessage( + isExpanded ? messages.showless : messages.showall + )} +
    + {!isExpanded && totalCount > 20 && ( +
    + {`${totalCount} total`} +
    + )} +
    +
    +
    +
    +
  • + )} + + {isLoading && + placeholdersToShow > 0 && + [...Array(placeholdersToShow)].map((_, i) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +const MediaSection = ({ + title, + mediaItems, +}: { + title: React.ReactNode; + mediaItems: MediaItem[]; +}) => { + if (!mediaItems.length) { + return null; + } + + return ( +
+
+
+ {title} +
+
+
    + {mediaItems.map((media) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +const sortCredits = (credits: MediaItem[]): MediaItem[] => { + return orderBy( + credits.filter((credit) => credit.releaseDate || credit.firstAirDate), + [ + (credit) => credit.releaseDate || credit.firstAirDate, + (credit) => credit.popularity, + ], + ['desc', 'desc'] + ); +}; const PersonDetails = () => { const intl = useIntl(); const router = useRouter(); - const [currentMediaType, setCurrentMediaType] = useState('all'); - const { data, error } = useSWR( - `/api/v1/person/${router.query.personId}` + const personId = router.query.personId as string; + const [isFullyLoaded, setIsFullyLoaded] = useState(false); + + const { data, error } = useSWR( + personId ? `/api/v1/person/${personId}` : null, + { + revalidateOnFocus: false, + revalidateIfStale: false, + dedupingInterval: 30000, + } ); - const [showBio, setShowBio] = useState(false); const { data: combinedCredits, error: errorCombinedCredits } = useSWR( - `/api/v1/person/${router.query.personId}/combined_credits` - ); - - const { - data: albumData, - size: albumSize, - setSize: setAlbumSize, - isValidating: isLoadingAlbums, - } = useSWRInfinite( - (index) => - data?.mbArtistId - ? `/api/v1/person/${router.query.personId}/discography?page=${ - index + 1 - }&type=Album&artistId=${data.mbArtistId}` - : null, - { revalidateFirstPage: false } - ); - - const { - data: singlesData, - size: singlesSize, - setSize: setSinglesSize, - isValidating: isLoadingSingles, - } = useSWRInfinite( - (index) => - data?.mbArtistId - ? `/api/v1/person/${router.query.personId}/discography?page=${ - index + 1 - }&type=Single&artistId=${data.mbArtistId}` - : null, - { revalidateFirstPage: false } - ); - - const { - data: epsData, - size: epsSize, - setSize: setEpsSize, - isValidating: isLoadingEps, - } = useSWRInfinite( - (index) => - data?.mbArtistId - ? `/api/v1/person/${router.query.personId}/discography?page=${ - index + 1 - }&type=EP&artistId=${data.mbArtistId}` - : null, - { revalidateFirstPage: false } - ); - - const { - data: otherData, - size: otherSize, - setSize: setOtherSize, - isValidating: isLoadingOther, - } = useSWRInfinite( - (index) => - data?.mbArtistId - ? `/api/v1/person/${router.query.personId}/discography?page=${ - index + 1 - }&type=Other&artistId=${data.mbArtistId}` - : null, - { revalidateFirstPage: false } - ); - - const sortedCast = useMemo(() => { - const filtered = (combinedCredits?.cast ?? []).filter( - (media) => - currentMediaType === 'all' || media.mediaType === currentMediaType - ); - const grouped = groupBy(filtered, 'id'); - - const reduced = Object.values(grouped).map((objs) => ({ - ...objs[0], - character: objs.map((pos) => pos.character).join(', '), - })); - - return reduced.sort((a, b) => { - const aVotes = a.voteCount ?? 0; - const bVotes = b.voteCount ?? 0; - if (aVotes > bVotes) { - return -1; + personId ? `/api/v1/person/${personId}/combined_credits` : null, + { + revalidateOnFocus: false, + revalidateIfStale: false, + dedupingInterval: 30000, } - return 1; - }); - }, [combinedCredits, currentMediaType]); - - const sortedCrew = useMemo(() => { - const filtered = (combinedCredits?.crew ?? []).filter( - (media) => - currentMediaType === 'all' || media.mediaType === currentMediaType ); - const grouped = groupBy(filtered, 'id'); - const reduced = Object.values(grouped).map((objs) => ({ - ...objs[0], - job: objs.map((pos) => pos.job).join(', '), - })); + useEffect(() => { + if ((data && combinedCredits) || (data && !data.knownForDepartment)) { + setIsFullyLoaded(true); + } + }, [data, combinedCredits]); - return reduced.sort((a, b) => { - const aVotes = a.voteCount ?? 0; - const bVotes = b.voteCount ?? 0; - if (aVotes > bVotes) { - return -1; + const [showBio, setShowBio] = useState(false); + const [albumTypes, setAlbumTypes] = useState>( + {} + ); + + useEffect(() => { + if (data?.artist?.typeCounts && data.artist.releaseGroups?.length) { + const initialAlbumTypes: Record = {}; + + data.artist.releaseGroups.forEach((album) => { + if (album && album.id) { + const type = album.secondary_types?.length + ? album.secondary_types[0] + : album['primary-type'] || 'Other'; + + if (!initialAlbumTypes[type]) { + initialAlbumTypes[type] = { + albums: [], + isExpanded: false, + isLoading: false, + isHovered: false, + isCollapsing: false, + }; + } + initialAlbumTypes[type].albums.push({ + ...album, + needsCoverArt: !album.posterPath, + }); + } + }); + + setAlbumTypes(initialAlbumTypes); + } + }, [data?.artist?.typeCounts, data?.artist?.releaseGroups]); + + const loadAllAlbumsOfType = useCallback( + async (albumType: string): Promise => { + if (!personId) return; + + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isLoading: true, + }, + })); + + try { + const pageSize = data?.artist?.typeCounts?.[albumType] || 100; + const response = await fetch( + `/api/v1/person/${personId}?albumType=${albumType}&pageSize=${pageSize}` + ); + + if (response.ok) { + const responseData = await response.json(); + const validAlbums = + responseData.artist?.releaseGroups + ?.filter((album: Album) => album && album.id) + .map((album: Album) => ({ + ...album, + needsCoverArt: !album.posterPath, + })) || []; + + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + albums: validAlbums, + isExpanded: true, + isLoading: false, + }, + })); + } else { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isLoading: false, + }, + })); + } + } catch { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isLoading: false, + }, + })); } - return 1; - }); - }, [combinedCredits, currentMediaType]); + }, + [personId, data?.artist?.typeCounts] + ); + + const handleHover = useCallback((albumType: string, isHovered: boolean) => { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isHovered, + }, + })); + }, []); + + const toggleExpandType = useCallback( + (albumType: string): void => { + const currentState = albumTypes[albumType]; + + if (currentState?.isExpanded) { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isCollapsing: true, + isHovered: false, + }, + })); + + setTimeout(() => { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isExpanded: false, + isCollapsing: false, + }, + })); + }, 300); + } else { + const albums = albumTypes[albumType]?.albums || []; + const typeCount = data?.artist?.typeCounts?.[albumType] || 0; + + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isHovered: false, + }, + })); + + if (albums.length < typeCount) { + loadAllAlbumsOfType(albumType); + } else { + setAlbumTypes((prev) => ({ + ...prev, + [albumType]: { + ...prev[albumType], + isExpanded: true, + }, + })); + } + } + }, + [albumTypes, data?.artist?.typeCounts, loadAllAlbumsOfType] + ); + + const sortedCredits = useMemo(() => { + const cast = combinedCredits?.cast ?? []; + const crew = combinedCredits?.crew ?? []; + + return { + cast: sortCredits( + Object.values(groupBy(cast, 'id')).map((group) => ({ + ...group[0], + character: group.map((g) => g.character).join(', '), + mediaType: group[0].mediaType === 'movie' ? 'movie' : 'tv', + })) + ), + crew: sortCredits( + Object.values(groupBy(crew, 'id')).map((group) => ({ + ...group[0], + job: group.map((g) => g.job).join(', '), + mediaType: group[0].mediaType === 'movie' ? 'movie' : 'tv', + })) + ), + }; + }, [combinedCredits]); + + const personAttributes = useMemo(() => { + if (!data) return []; + + const attributes: string[] = []; + + if (data.birthday) { + if (data.deathday) { + attributes.push( + intl.formatMessage(messages.lifespan, { + birthdate: intl.formatDate(data.birthday, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }), + deathdate: intl.formatDate(data.deathday, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }), + }) + ); + } else { + attributes.push( + intl.formatMessage(messages.birthdate, { + birthdate: intl.formatDate(data.birthday, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }), + }) + ); + } + } + + if (data.placeOfBirth) { + attributes.push(data.placeOfBirth); + } + + return attributes; + }, [data, intl]); + + const albumTypeOrder = [ + 'Album', + 'EP', + 'Single', + 'Live', + 'Compilation', + 'Remix', + 'Soundtrack', + 'Broadcast', + 'Demo', + 'Other', + ]; if (!data && !error) { return ; @@ -186,247 +596,32 @@ const PersonDetails = () => { return ; } - const personAttributes: string[] = []; - - if (data.birthday) { - if (data.deathday) { - personAttributes.push( - intl.formatMessage(messages.lifespan, { - birthdate: intl.formatDate(data.birthday, { - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: 'UTC', - }), - deathdate: intl.formatDate(data.deathday, { - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: 'UTC', - }), - }) - ); - } else { - personAttributes.push( - intl.formatMessage(messages.birthdate, { - birthdate: intl.formatDate(data.birthday, { - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: 'UTC', - }), - }) - ); - } + if (!isFullyLoaded && data.knownForDepartment) { + return ; } - if (data.placeOfBirth) { - personAttributes.push(data.placeOfBirth); - } + const backgroundImages = [ + ...(sortedCredits.cast ?? []), + ...(sortedCredits.crew ?? []), + ] + .filter((media) => media.backdropPath) + .map( + (media) => + `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}` + ) + .slice(0, 6); + const hasCredits = Boolean( + sortedCredits.cast?.length || sortedCredits.crew?.length + ); const isLoading = !combinedCredits && !errorCombinedCredits; - const mediaTypePicker = ( -
- - - - -
- ); - - const cast = (sortedCast ?? []).length > 0 && ( - <> -
-
- {intl.formatMessage(messages.appearsin)} -
-
-
    - {sortedCast?.map((media, index) => { - return ( -
  • - - {media.character && ( -
    - {intl.formatMessage(messages.ascharacter, { - character: media.character, - })} -
    - )} -
  • - ); - })} -
- - ); - - const crew = (sortedCrew ?? []).length > 0 && ( - <> -
-
- {intl.formatMessage(messages.crewmember)} -
-
-
    - {sortedCrew?.map((media, index) => { - return ( -
  • - - {media.job && ( -
    - {media.job} -
    - )} -
  • - ); - })} -
- - ); - - const albumsList = albumData ? albumData.flatMap((page) => page.results) : []; - const isReachingEndAlbums = - albumData?.[0]?.results.length === 0 || - (albumData && albumData[albumData.length - 1]?.results.length < 20); - - const singlesList = singlesData - ? singlesData.flatMap((page) => page.results) - : []; - const isReachingEndSingles = - singlesData?.[0]?.results.length === 0 || - (singlesData && singlesData[singlesData.length - 1]?.results.length < 20); - - const epsList = epsData ? epsData.flatMap((page) => page.results) : []; - const isReachingEndEps = - epsData?.[0]?.results.length === 0 || - (epsData && epsData[epsData.length - 1]?.results.length < 20); - - const otherList = otherData ? otherData.flatMap((page) => page.results) : []; - const isReachingEndOther = - otherData?.[0]?.results.length === 0 || - (otherData && otherData[otherData.length - 1]?.results.length < 20); - - const renderAlbumSection = ( - title: string, - albums: Album[], - isLoading: boolean, - isReachingEnd: boolean, - onLoadMore: () => void - ) => { - if (!albums?.length && !isLoading) return null; - - return ( - <> -
-
- {title} -
-
-
    - {albums?.map((album) => ( -
  • - 0} - canExpand - /> -
  • - ))} - {isLoading && - [...Array(20)].map((_, index) => ( -
  • - -
  • - ))} -
- {!isReachingEnd && ( -
- -
- )} - - ); - }; - return ( <> - {(sortedCrew || sortedCast) && ( + {hasCredits && (
- media.backdropPath) - .map( - (media) => - `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}` - ) - .slice(0, 6)} - /> +
)}
{ data.biography ? 'lg:items-start' : '' }`} > - {data.profilePath && ( + {(data.profilePath || data.artist?.artistThumb) && (
{

{data.name}

- {mediaTypePicker} + {/* This is where the mediaTypePicker was, but it's removed in 60c900bd */}
@@ -467,62 +666,65 @@ const PersonDetails = () => {
)}
-
{mediaTypePicker}
+
+ {/* This is where the mediaTypePicker was, but it's removed in 60c900bd */} +
{data.biography && ( -
- {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} -
setShowBio((show) => !show)} - role="button" - tabIndex={-1} - > - - } - > -

{data.biography}

-
-
-
+ setShowBio((show) => !show)} + /> )}
- {data.mbArtistId && ( + + {data.artist?.typeCounts && ( +
+ {albumTypeOrder + .filter((type) => (albumTypes[type]?.albums.length ?? 0) > 0) + .map((type) => ( + + ))} +
+ )} + + {data.knownForDepartment && ( <> - {renderAlbumSection( - intl.formatMessage(messages.albums), - albumsList, - isLoadingAlbums ?? false, - isReachingEndAlbums ?? false, - () => setAlbumSize(albumSize + 1) - )} - {renderAlbumSection( - intl.formatMessage(messages.singles), - singlesList, - isLoadingSingles ?? false, - isReachingEndSingles ?? false, - () => setSinglesSize(singlesSize + 1) - )} - {renderAlbumSection( - intl.formatMessage(messages.eps), - epsList, - isLoadingEps ?? false, - isReachingEndEps ?? false, - () => setEpsSize(epsSize + 1) - )} - {renderAlbumSection( - intl.formatMessage(messages.otherReleases), - otherList, - isLoadingOther ?? false, - isReachingEndOther ?? false, - () => setOtherSize(otherSize + 1) + {data.knownForDepartment === 'Acting' ? ( + <> + + + + ) : ( + <> + + + )} )} - {data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]} + {isLoading && } ); diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 105f2f25..390785a3 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -71,15 +71,22 @@ const RequestButton = ({ const [editRequest, setEditRequest] = useState(false); // All pending requests - const activeRequests = - media?.requests?.filter( - (request) => - request.status === MediaRequestStatus.PENDING && !request.is4k - ) ?? []; - const active4kRequests = - media?.requests?.filter( - (request) => request.status === MediaRequestStatus.PENDING && request.is4k - ) ?? []; + const activeRequests = useMemo( + () => + media?.requests?.filter( + (request) => + request.status === MediaRequestStatus.PENDING && !request.is4k + ) ?? [], + [media?.requests] + ); + const active4kRequests = useMemo( + () => + media?.requests?.filter( + (request) => + request.status === MediaRequestStatus.PENDING && request.is4k + ) ?? [], + [media?.requests] + ); // Current user's pending request, or the first pending request const activeRequest = useMemo(() => { diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index b4e4949c..1e39f16c 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -5,6 +5,7 @@ import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; import StatusBadge from '@app/components/StatusBadge'; import useDeepLinks from '@app/hooks/useDeepLinks'; +import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -55,7 +56,19 @@ const isMovie = ( const isAlbum = ( media: MovieDetails | TvDetails | MusicDetails ): media is MusicDetails => { - return (media as MusicDetails).artistId !== undefined; + return (media as MusicDetails).artist?.id !== undefined; +}; + +const hasBackdropPath = ( + media: MovieDetails | TvDetails | MusicDetails +): media is MovieDetails | TvDetails => { + return 'backdropPath' in media; +}; + +const hasSeasons = ( + media: MovieDetails | TvDetails | MusicDetails +): media is TvDetails => { + return 'seasons' in media && Array.isArray((media as TvDetails).seasons); }; const RequestCardPlaceholder = () => { @@ -224,7 +237,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { interface RequestCardProps { request: NonFunctionProperties; - onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; + onTitleData?: ( + requestId: number, + title: MovieDetails | TvDetails | MusicDetails + ) => void; } const RequestCard = ({ request, onTitleData }: RequestCardProps) => { @@ -244,9 +260,10 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { ? `/api/v1/tv/${request.media.tmdbId}` : `/api/v1/music/${request.media.mbId}`; - const { data: title, error } = useSWR( - inView ? `${url}` : null - ); + const { data: titleData, error } = useSWR< + MovieDetails | TvDetails | MusicDetails + >(inView ? url : null); + const { data: requestData, error: requestError, @@ -307,12 +324,39 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { }; useEffect(() => { - if (title && onTitleData) { - onTitleData(request.id, title); + if (titleData && onTitleData) { + onTitleData(request.id, titleData); } - }, [title, onTitleData, request]); + }, [titleData, onTitleData, request]); - if (!title && !error) { + interface ExtendedMedia { + posterPath?: string; + needsCoverArt?: boolean; + } + + const title = + useProgressiveCovers( + requestData?.type === 'music' && titleData && isAlbum(titleData) + ? [ + { + ...titleData, + posterPath: + (requestData.media as ExtendedMedia)?.posterPath || + titleData.posterPath, + needsCoverArt: + (requestData.media as ExtendedMedia)?.needsCoverArt !== + undefined + ? (requestData.media as ExtendedMedia).needsCoverArt + : (titleData as MusicDetails & { needsCoverArt?: boolean }) + .needsCoverArt, + } as MusicDetails, + ] + : titleData + ? [titleData] + : [] + )[0] ?? titleData; + + if (!titleData && !error) { return (
@@ -352,17 +396,16 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { alt="" src={ request.type === 'music' && isAlbum(title) - ? title.artist.images?.find((img) => img.CoverType === 'Fanart') - ?.Url || - title.artist.images?.find((img) => img.CoverType === 'Poster') - ?.Url || - title.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url || - '' - : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ - title.backdropPath ?? '' - }` + ? title.artistBackdrop + ? title.artistBackdrop + : title.artistThumb + ? title.artistThumb + : title.posterPath + ? title.posterPath + : '/images/jellyseerr_poster_not_found_square.png' + : hasBackdropPath(title) && title.backdropPath + ? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}` + : '/images/jellyseerr_poster_not_found.png' } style={{ width: '100%', height: '100%', objectFit: 'cover' }} fill @@ -389,7 +432,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { {isAlbum(title) && ( <> - - {title.artist.artistName} + {title.artist.name} )}
@@ -434,33 +477,35 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
)} - {!isMovie(title) && request.seasons.length > 0 && ( -
- - {intl.formatMessage(messages.seasons, { - seasonCount: - (settings.currentSettings.enableSpecialEpisodes - ? title.seasons.length - : title.seasons.filter( - (season) => season.seasonNumber !== 0 - ).length) === request.seasons.length - ? 0 - : request.seasons.length, - })} - -
- {request.seasons.map((season) => ( - - - {season.seasonNumber === 0 - ? intl.formatMessage(globalMessages.specials) - : season.seasonNumber} - - - ))} + {!isMovie(title) && + hasSeasons(title) && + request.seasons.length > 0 && ( +
+ + {intl.formatMessage(messages.seasons, { + seasonCount: + (settings.currentSettings.enableSpecialEpisodes + ? title.seasons.length + : title.seasons.filter( + (season) => season.seasonNumber !== 0 + ).length) === request.seasons.length + ? 0 + : request.seasons.length, + })} + +
+ {request.seasons.map((season) => ( + + + {season.seasonNumber === 0 + ? intl.formatMessage(globalMessages.specials) + : season.seasonNumber} + + + ))} +
-
- )} + )}
{intl.formatMessage(globalMessages.status)} @@ -495,7 +540,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' ] } - title={isMovie(title) ? title.title : title.name} + title={ + isMovie(title) + ? title.title + : isAlbum(title) + ? title.title + : (title as TvDetails).name + } inProgress={ ( requestData.media[ @@ -659,8 +710,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { type={request.type === 'music' ? 'music' : 'tmdb'} src={ request.type === 'music' && isAlbum(title) - ? title.images?.find((image) => image.CoverType === 'Cover') - ?.Url ?? '/images/overseerr_poster_not_found.png' + ? title.posterPath + ? title.posterPath + : '/images/jellyseerr_poster_not_found_square.png' : title.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` : '/images/seerr_poster_not_found.png' diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 784a2bb3..5304b634 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -5,6 +5,7 @@ import ConfirmButton from '@app/components/Common/ConfirmButton'; import RequestModal from '@app/components/RequestModal'; import StatusBadge from '@app/components/StatusBadge'; import useDeepLinks from '@app/hooks/useDeepLinks'; +import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -51,6 +52,11 @@ const messages = defineMessages('components.RequestList.RequestItem', { profileName: 'Profile', }); +interface ExtendedMedia { + posterPath?: string; + needsCoverArt?: boolean; +} + const isMovie = ( media: MovieDetails | TvDetails | MusicDetails ): media is MovieDetails => { @@ -63,7 +69,7 @@ const isMovie = ( const isMusic = ( media: MovieDetails | TvDetails | MusicDetails ): media is MusicDetails => { - return (media as MusicDetails).artistId !== undefined; + return (media as MusicDetails).artist?.id !== undefined; }; interface RequestItemErrorProps { @@ -336,7 +342,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { : request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; - const { data: title, error } = useSWR< + const { data: titleData, error } = useSWR< MovieDetails | TvDetails | MusicDetails >(inView ? url : null); const { data: requestData, mutate: revalidate } = useSWR< @@ -403,6 +409,28 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, }); + const title = + useProgressiveCovers( + requestData?.type === 'music' && titleData && isMusic(titleData) + ? [ + { + ...titleData, + posterPath: + (requestData.media as ExtendedMedia)?.posterPath || + titleData.posterPath, + needsCoverArt: + (requestData.media as ExtendedMedia)?.needsCoverArt !== + undefined + ? (requestData.media as ExtendedMedia).needsCoverArt + : (titleData as MusicDetails & { needsCoverArt?: boolean }) + .needsCoverArt, + } as MusicDetails, + ] + : titleData + ? [titleData] + : [] + )[0] ?? titleData; + if (!title && !error) { return (
{ type={isMusic(title) ? 'music' : 'tmdb'} src={ isMusic(title) - ? title.artist.images?.find((img) => img.CoverType === 'Fanart') - ?.Url || - title.artist.images?.find((img) => img.CoverType === 'Poster') - ?.Url || - title.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url || - '' - : `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${ - title.backdropPath ?? '' - }` + ? title.artistBackdrop + ? title.artistBackdrop + : title.artistThumb + ? title.artistThumb + : title.posterPath + ? title.posterPath + : '/images/jellyseerr_poster_not_found_square.png' + : title.backdropPath + ? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}` + : '/images/jellyseerr_poster_not_found.png' } alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} @@ -482,8 +509,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { type={isMusic(title) ? 'music' : 'tmdb'} src={ isMusic(title) - ? title.images?.find((image) => image.CoverType === 'Cover') - ?.Url ?? '/images/overseerr_poster_not_found.png' + ? title.posterPath + ? title.posterPath + : '/images/jellyseerr_poster_not_found_square.png' : title.posterPath ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` : '/images/seerr_poster_not_found.png' @@ -515,7 +543,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl" > {isMusic(title) - ? `${title.artist.artistName} - ${title.title}` + ? `${title.artist.name} - ${title.title}` : isMovie(title) ? title.title : title.name} diff --git a/src/components/RequestModal/MusicRequestModal.tsx b/src/components/RequestModal/MusicRequestModal.tsx index 7dc97fa9..c2d4a63f 100644 --- a/src/components/RequestModal/MusicRequestModal.tsx +++ b/src/components/RequestModal/MusicRequestModal.tsx @@ -226,12 +226,8 @@ const MusicRequestModal = ({ backgroundClickable onCancel={onCancel} title={intl.formatMessage(messages.pendingrequest)} - subTitle={ - data ? `${data.artist.artistName} - ${data.title}` : undefined - } - backdrop={ - data?.artist?.images?.find((img) => img.CoverType === 'Fanart')?.Url - } + subTitle={data ? `${data.artist.name} - ${data.title}` : undefined} + backdrop={data?.artistBackdrop || data?.artistThumb || data?.posterPath} onOk={() => hasPermission(Permission.MANAGE_REQUESTS) ? updateRequest(true) @@ -315,19 +311,14 @@ const MusicRequestModal = ({ onOk={sendRequest} okDisabled={isUpdating || quota?.music?.restricted} title={intl.formatMessage(messages.requestmusictitle)} - subTitle={data ? `${data.artist.artistName} - ${data.title}` : undefined} + subTitle={data ? `${data.artist.name} - ${data.title}` : undefined} okText={ isUpdating ? intl.formatMessage(globalMessages.requesting) : intl.formatMessage(globalMessages.request) } okButtonType="primary" - backdrop={ - data?.artist?.images?.find((img) => img.CoverType === 'Fanart')?.Url || - data?.artist?.images?.find((img) => img.CoverType === 'Poster')?.Url || - data?.images?.find((img) => img.CoverType.toLowerCase() === 'cover') - ?.Url - } + backdrop={data?.artistBackdrop || data?.artistThumb || data?.posterPath} > {hasAutoApprove && !quota?.music?.restricted && (
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index a0fae386..cdd8643d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -5,6 +5,7 @@ import useDiscover from '@app/hooks/useDiscover'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import type { + AlbumResult, MovieResult, PersonResult, TvResult, @@ -29,7 +30,7 @@ const Search = () => { titles, fetchMore, error, - } = useDiscover( + } = useDiscover( `/api/v1/search`, { query: router.query.query, diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 388b266e..07180cab 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -752,25 +752,12 @@ const SettingsJobs = () => { - Lidarr Images (lidarr) + The Audio Database (tadb) - {intl.formatNumber( - cacheData?.imageCache.lidarr.imageCount ?? 0 - )} + {intl.formatNumber(cacheData?.imageCache.tadb.imageCount ?? 0)} - {formatBytes(cacheData?.imageCache.lidarr.size ?? 0)} - - - - Fanart.tv (fanart) - - {intl.formatNumber( - cacheData?.imageCache.fanart.imageCount ?? 0 - )} - - - {formatBytes(cacheData?.imageCache.fanart.size ?? 0)} + {formatBytes(cacheData?.imageCache.tadb.size ?? 0)} diff --git a/src/components/TitleCard/TmdbTitleCard.tsx b/src/components/TitleCard/TmdbTitleCard.tsx deleted file mode 100644 index 825e52cc..00000000 --- a/src/components/TitleCard/TmdbTitleCard.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import TitleCard from '@app/components/TitleCard'; -import { Permission, useUser } from '@app/hooks/useUser'; -import type { MovieDetails } from '@server/models/Movie'; -import type { TvDetails } from '@server/models/Tv'; -import { useInView } from 'react-intersection-observer'; -import useSWR from 'swr'; - -export interface TmdbTitleCardProps { - id: number; - tmdbId: number; - tvdbId?: number; - type: 'movie' | 'tv'; - canExpand?: boolean; - isAddedToWatchlist?: boolean; - mutateParent?: () => void; -} - -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; -}; - -const TmdbTitleCard = ({ - id, - tmdbId, - tvdbId, - type, - canExpand, - isAddedToWatchlist = false, - mutateParent, -}: TmdbTitleCardProps) => { - const { hasPermission } = useUser(); - - const { ref, inView } = useInView({ - triggerOnce: true, - }); - const url = - type === 'movie' ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`; - const { data: title, error } = useSWR( - inView ? `${url}` : null - ); - - if (!title && !error) { - return ( -
- -
- ); - } - - if (!title) { - return hasPermission(Permission.ADMIN) ? ( - - ) : null; - } - - return isMovie(title) ? ( - - ) : ( - - ); -}; - -export default TmdbTitleCard; diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index e7bf7a45..8f476e84 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -8,6 +8,7 @@ import RequestModal from '@app/components/RequestModal'; import ErrorCard from '@app/components/TitleCard/ErrorCard'; import Placeholder from '@app/components/TitleCard/Placeholder'; import { useIsTouch } from '@app/hooks/useIsTouch'; +import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; @@ -44,6 +45,7 @@ interface TitleCardProps { canExpand?: boolean; inProgress?: boolean; isAddedToWatchlist?: number | boolean; + needsCoverArt?: boolean; mutateParent?: () => void; } @@ -71,6 +73,7 @@ const TitleCard = ({ inProgress = false, canExpand = false, mutateParent, + needsCoverArt, }: TitleCardProps) => { const isTouch = useIsTouch(); const intl = useIntl(); @@ -86,7 +89,16 @@ const TitleCard = ({ const [showBlacklistModal, setShowBlacklistModal] = useState(false); const cardRef = useRef(null); - // Just to get the year from the date + const enhancedItem = useProgressiveCovers([ + { + id: id ?? '', + posterPath: image, + needsCoverArt: needsCoverArt, + }, + ])[0]; + + const displayImage = enhancedItem?.posterPath ?? image; + if (year) { year = year.slice(0, 4); } @@ -352,7 +364,9 @@ const TitleCard = ({ className="h-full w-full rounded object-contain" alt="" src={ - image ?? '/images/seerr_poster_not_found_logo_top.png' + displayImage + ? displayImage + : '/images/seerr_poster_not_found_square.png' } fill /> @@ -362,26 +376,22 @@ const TitleCard = ({ {title}
{artist && ( -
+
{artist}
)} {type && (
{type} @@ -395,8 +405,8 @@ const TitleCard = ({ className="absolute inset-0 h-full w-full" alt="" src={ - image - ? `https://image.tmdb.org/t/p/w300_and_h450_face${image}` + displayImage + ? `https://image.tmdb.org/t/p/w300_and_h450_face${displayImage}` : '/images/seerr_poster_not_found_logo_top.png' } style={{ width: '100%', height: '100%', objectFit: 'cover' }} @@ -507,7 +517,11 @@ const TitleCard = ({ {
)} {!!streamingProviders.length && ( -
+
{intl.formatMessage(messages.streamingproviders)} - + {streamingProviders.map((p) => { return ( - - {p.name} - + + + + + ); })} diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx index a3177fd6..e0523bcf 100644 --- a/src/components/UserProfile/index.tsx +++ b/src/components/UserProfile/index.tsx @@ -143,15 +143,10 @@ const UserProfile = () => { } if ('artist' in media) { return ( - media.artist.images?.find( - (img) => img.CoverType === 'Fanart' - )?.Url || - media.artist.images?.find( - (img) => img.CoverType === 'Poster' - )?.Url || - media.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url + media.artistBackdrop || + media.artistThumb || + media.posterPath || + '' ); } return false; @@ -161,13 +156,12 @@ const UserProfile = () => { return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`; } if ('artist' in media) { - const fanart = media.artist.images?.find( - (img) => img.CoverType === 'Fanart' + return ( + media.artistBackdrop || + media.artistThumb || + media.posterPath || + '' ); - const cover = media.artist.images?.find( - (img) => img.CoverType === 'Cover' - ); - return fanart?.Url || cover?.Url || ''; } return ''; }) diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts index c4a8aa6b..b8cec86b 100644 --- a/src/hooks/useDiscover.ts +++ b/src/hooks/useDiscover.ts @@ -11,7 +11,7 @@ export interface BaseSearchResult { } interface BaseMedia { - id: number; + id: number | string; mediaType: string; mediaInfo?: { status: MediaStatus; @@ -86,7 +86,7 @@ const useDiscover = < } ); - const resultIds: Set = new Set(); + const resultIds: Set = new Set(); const isLoadingInitialData = !data && !error; const isLoadingMore = diff --git a/src/hooks/useProgressiveCovers.ts b/src/hooks/useProgressiveCovers.ts new file mode 100644 index 00000000..015f5059 --- /dev/null +++ b/src/hooks/useProgressiveCovers.ts @@ -0,0 +1,190 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +interface ItemWithCover { + id: string | number; + posterPath?: string | null; + needsCoverArt?: boolean; +} + +const globalRequestedIds = new Set(); +const globalPendingRequests = new Map>(); +let isProcessingBatch = false; +let pendingItems: { id: string | number }[] = []; +const coverDataCache = new Map(); + +const processPendingBatch = async (batchSize = 20) => { + if (isProcessingBatch || pendingItems.length === 0) return; + + isProcessingBatch = true; + + const currentBatch = pendingItems.slice(0, batchSize); + pendingItems = pendingItems.slice(batchSize); + + const batchIds = currentBatch + .map((item) => item.id) + .filter(Boolean) + .map((id) => String(id)); + + if (batchIds.length === 0) { + isProcessingBatch = false; + if (pendingItems.length > 0) { + setTimeout(() => processPendingBatch(batchSize), 50); + } + return; + } + + try { + const batchUrl = `/api/v1/coverart/batch/${batchIds.join(',')}`; + + const requestPromise = fetch(batchUrl) + .then((response) => { + if (!response.ok) { + throw new Error(`Error fetching cover art: ${response.status}`); + } + return response.json(); + }) + .then((coverData) => { + Object.entries(coverData).forEach(([id, url]) => { + coverDataCache.set(id, url as string); + }); + + batchIds.forEach((id) => globalPendingRequests.delete(id)); + + window.dispatchEvent(new CustomEvent('cover-data-updated')); + + return coverData; + }) + .catch(() => { + batchIds.forEach((id) => globalPendingRequests.delete(id)); + }); + + batchIds.forEach((id) => globalPendingRequests.set(id, requestPromise)); + + await requestPromise; + + isProcessingBatch = false; + + if (pendingItems.length > 0) { + setTimeout(() => processPendingBatch(batchSize), 300); + } + } catch (error) { + isProcessingBatch = false; + if (pendingItems.length > 0) { + setTimeout(() => processPendingBatch(batchSize), 300); + } + } +}; + +export function useProgressiveCovers( + items: T[], + batchSize = 20 +): T[] { + const itemsRef = useRef(items); + const [coverLoadTrigger, setCoverLoadTrigger] = useState(0); + const itemsSignatureRef = useRef(''); + + useEffect(() => { + const updateFromCache = () => { + const newItems = [...itemsRef.current]; + let hasUpdates = false; + + for (let i = 0; i < newItems.length; i++) { + const item = newItems[i]; + if (item?.id && coverDataCache.has(item.id) && item.needsCoverArt) { + newItems[i] = { + ...item, + posterPath: coverDataCache.get(item.id), + needsCoverArt: false, + }; + hasUpdates = true; + } + } + + if (hasUpdates) { + itemsRef.current = newItems; + setCoverLoadTrigger((prev) => prev + 1); + } + }; + + window.addEventListener('cover-data-updated', updateFromCache); + return () => { + window.removeEventListener('cover-data-updated', updateFromCache); + }; + }, []); + + useEffect(() => { + const currentSignature = JSON.stringify(items.map((item) => item.id)); + + if (currentSignature === itemsSignatureRef.current) { + return; + } + + itemsSignatureRef.current = currentSignature; + + const newItems = [...items]; + const oldItemsMap = new Map( + itemsRef.current.map((item) => [item.id, item]) + ); + + let hasChanges = false; + for (let i = 0; i < newItems.length; i++) { + if ( + newItems[i]?.id && + coverDataCache.has(newItems[i].id) && + newItems[i].needsCoverArt + ) { + newItems[i] = { + ...newItems[i], + posterPath: coverDataCache.get(newItems[i].id), + needsCoverArt: false, + }; + hasChanges = true; + continue; + } + + const existingItem = oldItemsMap.get(newItems[i].id); + if (existingItem && existingItem.posterPath && !newItems[i].posterPath) { + newItems[i] = { + ...newItems[i], + posterPath: existingItem.posterPath, + needsCoverArt: false, + }; + hasChanges = true; + } + } + + if (hasChanges || newItems.length !== itemsRef.current.length) { + itemsRef.current = newItems; + setCoverLoadTrigger((prev) => prev + 1); + } + }, [items]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const enhancedItems = useMemo(() => itemsRef.current, [coverLoadTrigger]); + + useEffect(() => { + const itemsNeedingCovers = itemsRef.current.filter( + (item) => + item?.needsCoverArt && + item?.id && + !globalRequestedIds.has(item.id) && + !globalPendingRequests.has(item.id) && + !coverDataCache.has(item.id) + ); + + if (!itemsNeedingCovers.length) return; + + itemsNeedingCovers.forEach((item) => { + if (item.id) { + globalRequestedIds.add(item.id); + pendingItems.push({ id: item.id }); + } + }); + + if (!isProcessingBatch) { + processPendingBatch(batchSize); + } + }, [coverLoadTrigger, batchSize]); + + return enhancedItems; +} diff --git a/src/i18n/locale/ar.json b/src/i18n/locale/ar.json index 043f10d5..957351c3 100644 --- a/src/i18n/locale/ar.json +++ b/src/i18n/locale/ar.json @@ -656,7 +656,7 @@ "components.Settings.plexlibraries": "مكتبات بليكس", "components.Settings.noDefaultServer": "على الأقل {serverType} سيرفر واحد يجب أن يكون معدا كإفتراضي لإتاحة تنفيذ طلبات الـ {mediaType}.", "components.Settings.plexsettings": "إعدادات بليكس", - "components.Settings.videoServiceSettingsDescription": "قم بإعداد {serverType} بالإسفل.تستطيع الاتصال بأكثر من سيرفر {serverType} ,ولكن إثنان فقط يمكن إعدادهما كإفتراضيين (واحد لجودة الفور كي والأخر لغير جودة الفور كي). أصحاب الصلاحيات الإدارية بإمكانهم تجاوز السيرفر المستخدم قبل تأكيدهم لأي طلب محتوى جديد.", + "components.Settings.serviceSettingsDescription": "قم بإعداد {serverType} بالإسفل.تستطيع الاتصال بأكثر من سيرفر {serverType} ,ولكن إثنان فقط يمكن إعدادهما كإفتراضيين (واحد لجودة الفور كي والأخر لغير جودة الفور كي). أصحاب الصلاحيات الإدارية بإمكانهم تجاوز السيرفر المستخدم قبل تأكيدهم لأي طلب محتوى جديد.", "components.Settings.serverpresetManualMessage": "ضبط يدوي", "components.Settings.sonarrsettings": "إعدادات سونار", "components.Settings.tautulliSettingsDescription": "بشكل إختياري أضبط إعدادات سيرفرك الخاص بـ Tautulli.أوفرسيرر سيقوم بجلب بيانات سجل المشاهدة لمحتوى بليكس من Tautulli.", diff --git a/src/i18n/locale/bg.json b/src/i18n/locale/bg.json index 08ba31c0..72252025 100644 --- a/src/i18n/locale/bg.json +++ b/src/i18n/locale/bg.json @@ -854,7 +854,7 @@ "components.Settings.hostname": "Име на хост или IP адрес", "components.Settings.SonarrModal.tagRequestsInfo": "Автоматично добавяне на допълнителен етикет с потребителското ID и показваното име на заявителя", "components.TvDetails.Season.noepisodes": "Няма наличен списък с епизоди.", - "components.Settings.videoServiceSettingsDescription": "Конфигурирайте вашия {serverType} сървър(и) по-долу. Можете да свържете множество {serverType} сървъри, но само два от тях могат да бъдат маркирани като стандартни (един не-4K и един 4K). Администраторите могат да заменят сървъра, използван за обработка на нови заявки преди одобрението.", + "components.Settings.serviceSettingsDescription": "Конфигурирайте вашия {serverType} сървър(и) по-долу. Можете да свържете множество {serverType} сървъри, но само два от тях могат да бъдат маркирани като стандартни (един не-4K и един 4K). Администраторите могат да заменят сървъра, използван за обработка на нови заявки преди одобрението.", "components.TvDetails.seasonstitle": "Сезони", "i18n.available": "Наличен", "components.Settings.menuGeneralSettings": "Общ", diff --git a/src/i18n/locale/ca.json b/src/i18n/locale/ca.json index f7297e1e..223f38b8 100644 --- a/src/i18n/locale/ca.json +++ b/src/i18n/locale/ca.json @@ -683,7 +683,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "La configuració de les notificacions de Discord s'ha desat correctament!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "No s'ha pogut desar la configuració de les notificacions de Discord.", "components.UserList.autogeneratepasswordTip": "Envieu a l'usuari una contrasenya generada per servidor", - "components.Settings.videoServiceSettingsDescription": "Configureu els vostres servidors {serverType} a continuació. Podeu connectar diversos servidors {serverType}, però només dos es poden marcar com a valors predeterminats (un no 4K i un 4K). Els administradors poden substituir el servidor utilitzat per processar noves sol·licituds abans de l’aprovació.", + "components.Settings.serviceSettingsDescription": "Configureu els vostres servidors {serverType} a continuació. Podeu connectar diversos servidors {serverType}, però només dos es poden marcar com a valors predeterminats (un no 4K i un 4K). Els administradors poden substituir el servidor utilitzat per processar noves sol·licituds abans de l’aprovació.", "components.Settings.serverSecure": "segur", "components.Settings.noDefaultServer": "Cal marcar com a mínim un servidor {serverType} com a predeterminat perquè es processin les sol·licituds de {mediaType}.", "components.Settings.noDefaultNon4kServer": "Si només teniu un servidor únic {serverType} per a contingut no 4K i 4K (o si només descarregueu contingut 4K), el vostre servidor {serverType} NO deuria marcar-se com a servidor 4K.", diff --git a/src/i18n/locale/cs.json b/src/i18n/locale/cs.json index 11411400..efe0f7eb 100644 --- a/src/i18n/locale/cs.json +++ b/src/i18n/locale/cs.json @@ -914,7 +914,7 @@ "components.UserList.importfromplexerror": "Při importu uživatelů systému Plex se něco pokazilo.", "components.Settings.Notifications.validationSmtpHostRequired": "Musíte zadat platný název hostitele nebo IP adresu", "components.Settings.RadarrModal.validationProfileRequired": "Je třeba vybrat profil kvality", - "components.Settings.videoServiceSettingsDescription": "Níže nakonfigurujte server(y) {serverType}. Můžete připojit více serverů {serverType}, ale pouze dva z nich mohou být označeny jako výchozí (jeden ne-4K a jeden 4K). Správci mohou před schválením změnit server používaný ke zpracování nových požadavků.", + "components.Settings.serviceSettingsDescription": "Níže nakonfigurujte server(y) {serverType}. Můžete připojit více serverů {serverType}, ale pouze dva z nich mohou být označeny jako výchozí (jeden ne-4K a jeden 4K). Správci mohou před schválením změnit server používaný ke zpracování nových požadavků.", "components.Settings.RadarrModal.testFirstTags": "Testovací připojení k načítání značek", "components.UserList.validationpasswordminchars": "Heslo je příliš krátké; mělo by mít minimálně 8 znaků", "components.UserProfile.pastdays": "{type} (posledních {days} dnů)", diff --git a/src/i18n/locale/da.json b/src/i18n/locale/da.json index 03335a19..b98474fa 100644 --- a/src/i18n/locale/da.json +++ b/src/i18n/locale/da.json @@ -763,7 +763,7 @@ "components.UserList.deleteconfirm": "Er du sikker på at du vil slette denne bruger? Alle deres forespørgselsdata vil blive slettet permanent.", "components.Settings.serverpresetManualMessage": "Manuel konfiguration", "components.Settings.serverpresetRefreshing": "Henter servere…", - "components.Settings.videoServiceSettingsDescription": "Konfigurér dine {serverType}server(e) nedenfor. Du kan forbinde til flere forskellige {serverType}servere men kun to af dem kan markeres som standard (én ikke-4K og én 4K). Administratorer kan ændre på serveren der bruges til at behandle nye forespørgsler inden godkendelse.", + "components.Settings.serviceSettingsDescription": "Konfigurér dine {serverType}server(e) nedenfor. Du kan forbinde til flere forskellige {serverType}servere men kun to af dem kan markeres som standard (én ikke-4K og én 4K). Administratorer kan ændre på serveren der bruges til at behandle nye forespørgsler inden godkendelse.", "components.Settings.settingUpPlexDescription": "For at sætte Plex op skal du enten indtaste oplysningerne manuelt eller vælge en server som hentes fra plex.tv. Klik på knappen til højre for rullemenuen for at hente en liste af tilgængelige servere.", "components.Settings.toastPlexConnectingFailure": "Kunne ikke forbinde til Plex.", "components.Settings.toastPlexConnectingSuccess": "Plex forbindelse er etableret!", diff --git a/src/i18n/locale/el.json b/src/i18n/locale/el.json index 9411e532..62402cac 100644 --- a/src/i18n/locale/el.json +++ b/src/i18n/locale/el.json @@ -636,7 +636,7 @@ "components.Settings.sonarrsettings": "Ρυθμίσεις Sonarr", "components.Settings.settingUpPlexDescription": "Για να ρυθμίσεις το Plex, μπορείς είτε να εισαγάγεις τα στοιχεία χειροκίνητα είτε να επιλέξεις έναν διακομιστή που ανακτήθηκε από το plex.tv. Πάτα το κουμπί στα δεξιά του αναπτυσσόμενου μενού για να ανακτήσεις τη λίστα με τους διαθέσιμους διακομιστές.", "components.Settings.services": "Υπηρεσίες", - "components.Settings.videoServiceSettingsDescription": "Διαμόρφωσε τον {serverType} διακομιστή(-ές) παρακάτω . Μπορείτε να συνδέσεις πολλούς {serverType} διακομιστές, αλλά μόνο δύο από αυτούς μπορούν να χαρακτηριστούν ως προεπιλεγμένοι (ένας μη-4K και ένας 4K). Οι διαχειριστές έχουν τη δυνατότητα να παρακάμψουν τον διακομιστή που χρησιμοποιείται για την επεξεργασία νέων αιτημάτων πριν από την έγκριση.", + "components.Settings.serviceSettingsDescription": "Διαμόρφωσε τον {serverType} διακομιστή(-ές) παρακάτω . Μπορείτε να συνδέσεις πολλούς {serverType} διακομιστές, αλλά μόνο δύο από αυτούς μπορούν να χαρακτηριστούν ως προεπιλεγμένοι (ένας μη-4K και ένας 4K). Οι διαχειριστές έχουν τη δυνατότητα να παρακάμψουν τον διακομιστή που χρησιμοποιείται για την επεξεργασία νέων αιτημάτων πριν από την έγκριση.", "components.Settings.serverpresetRefreshing": "Ανάκτηση διακομιστών…", "components.Settings.serverpresetManualMessage": "Χειροκίνητη διαμόρφωση", "components.Settings.serverpresetLoad": "Πάτα το κουμπί για να φορτώσει τους διαθέσιμους διακομιστές", diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 0628ada8..4657e0e4 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -5,6 +5,21 @@ "components.AirDateBadge.airedrelative": "Aired {relativeTime}", "components.AirDateBadge.airsrelative": "Airing {relativeTime}", "components.AppDataWarning.dockerVolumeMissingDescription": "The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.", + "components.ArtistDetails.album": "Album", + "components.ArtistDetails.alsoknownas": "Also Known As: {names}", + "components.ArtistDetails.birthdate": "Born {birthdate}", + "components.ArtistDetails.broadcast": "Broadcast", + "components.ArtistDetails.compilation": "Compilation", + "components.ArtistDetails.demo": "Demo", + "components.ArtistDetails.ep": "EP", + "components.ArtistDetails.lifespan": "{birthdate} – {deathdate}", + "components.ArtistDetails.live": "Live", + "components.ArtistDetails.other": "Other", + "components.ArtistDetails.remix": "Remix", + "components.ArtistDetails.showall": "Show All", + "components.ArtistDetails.showless": "Show Less", + "components.ArtistDetails.single": "Single", + "components.ArtistDetails.soundtrack": "Soundtrack", "components.Blacklist.blacklistNotFoundError": "{title} is not blacklisted.", "components.Blacklist.blacklistSettingsDescription": "Manage blacklisted media.", "components.Blacklist.blacklistdate": "date", @@ -41,13 +56,6 @@ "components.Discover.CreateSlider.starttyping": "Starting typing to search.", "components.Discover.CreateSlider.validationDatarequired": "You must provide a data value.", "components.Discover.CreateSlider.validationTitlerequired": "You must provide a title.", - "components.Discover.DiscoverMusic.discovermusics": "Music", - "components.Discover.DiscoverMusic.sortPopularityDesc": "Most Listened", - "components.Discover.DiscoverMusic.sortPopularityAsc": "Least Listened", - "components.Discover.DiscoverMusic.sortReleaseDateDesc": "Newest First", - "components.Discover.DiscoverMusic.sortReleaseDateAsc": "Oldest First", - "components.Discover.DiscoverMusic.sortTitleAsc": "Title (A-Z)", - "components.Discover.DiscoverMusic.sortTitleDesc": "Title (Z-A)", "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", "components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies", "components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies", @@ -61,6 +69,25 @@ "components.Discover.DiscoverMovies.sortTitleDesc": "Title (Z-A) Descending", "components.Discover.DiscoverMovies.sortTmdbRatingAsc": "TMDB Rating Ascending", "components.Discover.DiscoverMovies.sortTmdbRatingDesc": "TMDB Rating Descending", + "components.Discover.DiscoverMusic.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", + "components.Discover.DiscoverMusic.discovermusic": "Music", + "components.Discover.DiscoverMusic.filters": "Filters", + "components.Discover.DiscoverMusic.sortArtistAsc": "Artist Name (A-Z) Ascending", + "components.Discover.DiscoverMusic.sortArtistDesc": "Artist Name (Z-A) Descending", + "components.Discover.DiscoverMusic.sortReleaseDateAsc": "Release Date Ascending", + "components.Discover.DiscoverMusic.sortReleaseDateDesc": "Release Date Descending", + "components.Discover.DiscoverMusic.sortTitleAsc": "Title (A-Z) Ascending", + "components.Discover.DiscoverMusic.sortTitleDesc": "Title (Z-A) Descending", + "components.Discover.DiscoverMusicAlbums.discoveralbums": "Albums", + "components.Discover.DiscoverMusicAlbums.sortPopularityAsc": "Least Listened", + "components.Discover.DiscoverMusicAlbums.sortPopularityDesc": "Most Listened", + "components.Discover.DiscoverMusicAlbums.sortTitleAsc": "Title (A-Z) Ascending", + "components.Discover.DiscoverMusicAlbums.sortTitleDesc": "Title (Z-A) Descending", + "components.Discover.DiscoverMusicArtists.discoverartists": "Artists", + "components.Discover.DiscoverMusicArtists.sortNameAsc": "Name (A-Z) Ascending", + "components.Discover.DiscoverMusicArtists.sortNameDesc": "Name (Z-A) Descending", + "components.Discover.DiscoverMusicArtists.sortPopularityAsc": "Least Listened", + "components.Discover.DiscoverMusicArtists.sortPopularityDesc": "Most Listened", "components.Discover.DiscoverNetwork.networkSeries": "{network} Series", "components.Discover.DiscoverSliderEdit.deletefail": "Failed to delete slider.", "components.Discover.DiscoverSliderEdit.deletesuccess": "Sucessfully deleted slider.", @@ -91,6 +118,7 @@ "components.Discover.FilterSlideover.from": "From", "components.Discover.FilterSlideover.genres": "Genres", "components.Discover.FilterSlideover.keywords": "Keywords", + "components.Discover.FilterSlideover.onlyWithCoverArt": "Only show releases with cover art", "components.Discover.FilterSlideover.originalLanguage": "Original Language", "components.Discover.FilterSlideover.ratingText": "Ratings between {minValue} and {maxValue}", "components.Discover.FilterSlideover.releaseDate": "Release Date", @@ -112,7 +140,6 @@ "components.Discover.StudioSlider.studios": "Studios", "components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres", - "components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series", "components.Discover.createnewslider": "Create New Slider", "components.Discover.customizediscover": "Customize Discover", "components.Discover.discover": "Discover", @@ -121,6 +148,7 @@ "components.Discover.networks": "Networks", "components.Discover.plexwatchlist": "Your Watchlist", "components.Discover.popularalbums": "Popular Albums", + "components.Discover.popularartists": "Popular Artists", "components.Discover.popularmovies": "Popular Movies", "components.Discover.populartv": "Popular Series", "components.Discover.recentlyAdded": "Recently Added", @@ -147,6 +175,7 @@ "components.Discover.upcomingtv": "Upcoming Series", "components.Discover.updatefailed": "Something went wrong updating the discover customization settings.", "components.Discover.updatesuccess": "Updated discover customization settings.", + "components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series", "components.DownloadBlock.estimatedtime": "Estimated {time}", "components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}", "components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?", @@ -219,24 +248,26 @@ "components.IssueModal.CreateIssueModal.validationMessageRequired": "You must provide a description", "components.IssueModal.CreateIssueModal.whatswrong": "What's wrong?", "components.IssueModal.issueAudio": "Audio", + "components.IssueModal.issueLyrics": "Lyrics", "components.IssueModal.issueOther": "Other", "components.IssueModal.issueSubtitles": "Subtitle", "components.IssueModal.issueVideo": "Video", - "components.IssueModal.issueLyrics": "Lyrics", "components.LanguageSelector.languageServerDefault": "Default ({language})", "components.LanguageSelector.originalLanguageDefault": "All Languages", "components.Layout.LanguagePicker.displaylanguage": "Display Language", "components.Layout.SearchInput.searchPlaceholder": "Search Movies, TV & Music", "components.Layout.Sidebar.blacklist": "Blacklist", - "components.Layout.Sidebar.browsemusic": "Music", "components.Layout.Sidebar.browsemovies": "Movies", + "components.Layout.Sidebar.browsemusic": "Music", "components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.dashboard": "Discover", "components.Layout.Sidebar.issues": "Issues", + "components.Layout.Sidebar.music": "Music", "components.Layout.Sidebar.requests": "Requests", "components.Layout.Sidebar.settings": "Settings", "components.Layout.Sidebar.users": "Users", "components.Layout.UserDropdown.MiniQuotaDisplay.movierequests": "Movie Requests", + "components.Layout.UserDropdown.MiniQuotaDisplay.musicrequests": "Music Requests", "components.Layout.UserDropdown.MiniQuotaDisplay.seriesrequests": "Series Requests", "components.Layout.UserDropdown.myprofile": "Profile", "components.Layout.UserDropdown.requests": "Requests", @@ -292,6 +323,7 @@ "components.Login.validationpasswordrequired": "You must provide a password", "components.Login.validationservertyperequired": "Please select a server type", "components.Login.validationusernamerequired": "Username required", + "components.ManageSlideOver.album": "album", "components.ManageSlideOver.alltime": "All Time", "components.ManageSlideOver.downloadstatus": "Downloads", "components.ManageSlideOver.manageModalAdvanced": "Advanced", @@ -331,27 +363,8 @@ "components.GroupDetails.overview": "Overview", "components.GroupDetails.status": "Status: {status}", "components.GroupDetails.loadmore": "Load More", - "components.MusicDetails.biography": "Biography", - "components.MusicDetails.runtime": "{minutes} minutes", - "components.MusicDetails.album": "Album", - "components.MusicDetails.releasedate": "Release Date", - "components.MusicDetails.play": "Play on {mediaServerName}", - "components.MusicDetails.reportissue": "Report an Issue", - "components.MusicDetails.managemusic": "Manage Music", - "components.MusicDetails.biographyunavailable": "Biography unavailable.", - "components.MusicDetails.trackstitle": "Tracks", - "components.MusicDetails.tracksunavailable": "No tracks available.", - "components.MusicDetails.watchlistSuccess": "{title} added to watchlist successfully!", - "components.MusicDetails.watchlistDeleted": "{title} removed from watchlist successfully!", - "components.MusicDetails.watchlistError": "Something went wrong try again.", - "components.MusicDetails.removefromwatchlist": "Remove From Watchlist", - "components.MusicDetails.addtowatchlist": "Add To Watchlist", "components.MusicDetails.status": "Status", "components.MusicDetails.label": "Label", - "components.MusicDetails.artisttype": "Artist Type", - "components.MusicDetails.artiststatus": "Artist Status", - "components.MusicDetails.discography": "{artistName}'s discography", - "components.MusicDetails.similarArtists": "Similar Artists", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.addtowatchlist": "Add To Watchlist", @@ -393,6 +406,28 @@ "components.MovieDetails.watchlistError": "Something went wrong. Please try again.", "components.MovieDetails.watchlistSuccess": "{title} added to watchlist successfully!", "components.MovieDetails.watchtrailer": "Watch Trailer", + "components.MusicDetails.addtowatchlist": "Add To Watchlist", + "components.MusicDetails.album": "Album", + "components.MusicDetails.artiststatus": "Artist Status", + "components.MusicDetails.artisttype": "Artist Type", + "components.MusicDetails.beginYear": "Started in", + "components.MusicDetails.biography": "Biography", + "components.MusicDetails.biographyunavailable": "Biography unavailable.", + "components.MusicDetails.byartist": "by", + "components.MusicDetails.country": "Country", + "components.MusicDetails.discography": "{artistName}'s discography", + "components.MusicDetails.managemusic": "Manage Music", + "components.MusicDetails.play": "Play on {mediaServerName}", + "components.MusicDetails.releasedate": "Release Date", + "components.MusicDetails.removefromwatchlist": "Remove From Watchlist", + "components.MusicDetails.reportissue": "Report an Issue", + "components.MusicDetails.runtime": "{minutes} minutes", + "components.MusicDetails.similarArtists": "Similar Artists", + "components.MusicDetails.trackstitle": "Tracks", + "components.MusicDetails.tracksunavailable": "No tracks available.", + "components.MusicDetails.watchlistDeleted": "{title} removed from watchlist successfully!", + "components.MusicDetails.watchlistError": "Something went wrong try again.", + "components.MusicDetails.watchlistSuccess": "{title} added to watchlist successfully!", "components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.", "components.NotificationTypeSelector.adminissuereopenedDescription": "Get notified when issues are reopened by other users.", "components.NotificationTypeSelector.adminissueresolvedDescription": "Get notified when issues are resolved by other users.", @@ -443,16 +478,18 @@ "components.PermissionEdit.autoapproveDescription": "Grant automatic approval for all non-4K media requests.", "components.PermissionEdit.autoapproveMovies": "Auto-Approve Movies", "components.PermissionEdit.autoapproveMoviesDescription": "Grant automatic approval for non-4K movie requests.", + "components.PermissionEdit.autoapproveMusic": "Auto-Approve Music", + "components.PermissionEdit.autoapproveMusicDescription": "Grant automatic approval for music album requests.", "components.PermissionEdit.autoapproveSeries": "Auto-Approve Series", "components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests.", "components.PermissionEdit.autorequest": "Auto-Request", - "components.PermissionEdit.autorequestDescription": "Grant permission to automatically submit requests for non-4K media via Watchlist.", - "components.PermissionEdit.autorequestMusic": "Auto-Request Music", - "components.PermissionEdit.autorequestMusicDescription": "Grant permission to automatically submit requests for music via Watchlist", + "components.PermissionEdit.autorequestDescription": "Grant permission to automatically submit requests for non-4K media via Plex Watchlist.", "components.PermissionEdit.autorequestMovies": "Auto-Request Movies", - "components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Watchlist.", + "components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.", + "components.PermissionEdit.autorequestMusic": "Auto-Request Music", + "components.PermissionEdit.autorequestMusicDescription": "Grant permission to automatically submit requests for music via Plex Watchlist.", "components.PermissionEdit.autorequestSeries": "Auto-Request Series", - "components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Watchlist.", + "components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Plex Watchlist.", "components.PermissionEdit.blacklistedItems": "Blacklist media.", "components.PermissionEdit.blacklistedItemsDescription": "Grant permission to blacklist media.", "components.PermissionEdit.createissues": "Report Issues", @@ -473,6 +510,8 @@ "components.PermissionEdit.requestDescription": "Grant permission to submit requests for non-4K media.", "components.PermissionEdit.requestMovies": "Request Movies", "components.PermissionEdit.requestMoviesDescription": "Grant permission to submit requests for non-4K movies.", + "components.PermissionEdit.requestMusic": "Request Music", + "components.PermissionEdit.requestMusicDescription": "Grant permission to submit requests for music albums.", "components.PermissionEdit.requestTv": "Request Series", "components.PermissionEdit.requestTvDescription": "Grant permission to submit requests for non-4K series.", "components.PermissionEdit.users": "Manage Users", @@ -487,20 +526,29 @@ "components.PermissionEdit.viewrequestsDescription": "Grant permission to view media requests submitted by other users.", "components.PermissionEdit.viewwatchlists": "View {mediaServerName} Watchlists", "components.PermissionEdit.viewwatchlistsDescription": "Grant permission to view other users' {mediaServerName} Watchlists.", + "components.PersonDetails.album": "Album", "components.PersonDetails.alsoknownas": "Also Known As: {names}", "components.PersonDetails.appearsin": "Appearances", "components.PersonDetails.ascharacter": "as {character}", "components.PersonDetails.birthdate": "Born {birthdate}", + "components.PersonDetails.broadcast": "Broadcast", + "components.PersonDetails.compilation": "Compilation", "components.PersonDetails.crewmember": "Crew", + "components.PersonDetails.demo": "Demo", + "components.PersonDetails.ep": "EP", "components.PersonDetails.lifespan": "{birthdate} – {deathdate}", - "components.PersonDetails.albums": "Albums", - "components.PersonDetails.singles": "Singles", - "components.PersonDetails.eps": "EPs", - "components.PersonDetails.otherReleases": "Other", - "components.PersonDetails.loadmore": "Load More", + "components.PersonDetails.live": "Live", + "components.PersonDetails.other": "Other", + "components.PersonDetails.remix": "Remix", + "components.PersonDetails.showall": "Show All", + "components.PersonDetails.showless": "Show Less", + "components.PersonDetails.single": "Single", + "components.PersonDetails.soundtrack": "Soundtrack", + "components.QuotaSelector.albums": "{count, plural, one {album} other {albums}}", "components.QuotaSelector.days": "{count, plural, one {day} other {days}}", "components.QuotaSelector.movieRequests": "{quotaLimit} {movies} per {quotaDays} {days}", "components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}", + "components.QuotaSelector.musicRequests": "{quotaLimit} {albums} per {quotaDays} {days}", "components.QuotaSelector.seasons": "{count, plural, one {season} other {seasons}}", "components.QuotaSelector.tvRequests": "{quotaLimit} {seasons} per {quotaDays} {days}", "components.QuotaSelector.unlimited": "Unlimited", @@ -546,6 +594,7 @@ "components.RequestList.RequestItem.deleterequest": "Delete Request", "components.RequestList.RequestItem.editrequest": "Edit Request", "components.RequestList.RequestItem.failedretry": "Something went wrong while retrying the request.", + "components.RequestList.RequestItem.mbid": "MusicBrainz ID", "components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found", "components.RequestList.RequestItem.modified": "Modified", "components.RequestList.RequestItem.modifieduserdate": "{date} by {user}", @@ -574,10 +623,12 @@ "components.RequestModal.AdvancedRequester.rootfolder": "Root Folder", "components.RequestModal.AdvancedRequester.selecttags": "Select tags", "components.RequestModal.AdvancedRequester.tags": "Tags", + "components.RequestModal.QuotaDisplay.album": "album", "components.RequestModal.QuotaDisplay.allowedRequests": "You are allowed to request {limit} {type} every {days} days.", "components.RequestModal.QuotaDisplay.allowedRequestsUser": "This user is allowed to request {limit} {type} every {days} days.", "components.RequestModal.QuotaDisplay.movie": "movie", "components.RequestModal.QuotaDisplay.movielimit": "{limit, plural, one {movie} other {movies}}", + "components.RequestModal.QuotaDisplay.musiclimit": "{limit, plural, one {album} other {albums}}", "components.RequestModal.QuotaDisplay.notenoughseasonrequests": "Not enough season requests remaining", "components.RequestModal.QuotaDisplay.quotaLink": "You can view a summary of your request limits on your profile page.", "components.RequestModal.QuotaDisplay.quotaLinkUser": "You can view a summary of this user's request limits on their profile page.", @@ -612,6 +663,7 @@ "components.RequestModal.requestmovies": "Request {count} {count, plural, one {Movie} other {Movies}}", "components.RequestModal.requestmovies4k": "Request {count} {count, plural, one {Movie} other {Movies}} in 4K", "components.RequestModal.requestmovietitle": "Request Movie", + "components.RequestModal.requestmusictitle": "Request Music", "components.RequestModal.requestseasons": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}", "components.RequestModal.requestseasons4k": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}} in 4K", "components.RequestModal.requestseries4ktitle": "Request Series in 4K", @@ -657,6 +709,46 @@ "components.Selector.showless": "Show Less", "components.Selector.showmore": "Show More", "components.Selector.starttyping": "Starting typing to search.", + "components.Settings.LidarrModal.add": "Add Server", + "components.Settings.LidarrModal.apiKey": "API Key", + "components.Settings.LidarrModal.baseUrl": "URL Base", + "components.Settings.LidarrModal.createlidarr": "Add New Lidarr Server", + "components.Settings.LidarrModal.defaultserver": "Default Server", + "components.Settings.LidarrModal.editlidarr": "Edit Lidarr Server", + "components.Settings.LidarrModal.enableSearch": "Enable Automatic Search", + "components.Settings.LidarrModal.externalUrl": "External URL", + "components.Settings.LidarrModal.hostname": "Hostname or IP Address", + "components.Settings.LidarrModal.loadingTags": "Loading tags…", + "components.Settings.LidarrModal.loadingprofiles": "Loading quality profiles…", + "components.Settings.LidarrModal.loadingrootfolders": "Loading root folders…", + "components.Settings.LidarrModal.notagoptions": "No tags.", + "components.Settings.LidarrModal.port": "Port", + "components.Settings.LidarrModal.qualityprofile": "Quality Profile", + "components.Settings.LidarrModal.rootfolder": "Root Folder", + "components.Settings.LidarrModal.selectQualityProfile": "Select quality profile", + "components.Settings.LidarrModal.selectRootFolder": "Select root folder", + "components.Settings.LidarrModal.selecttags": "Select tags", + "components.Settings.LidarrModal.servername": "Server Name", + "components.Settings.LidarrModal.ssl": "Use SSL", + "components.Settings.LidarrModal.syncEnabled": "Enable Scan", + "components.Settings.LidarrModal.tagRequests": "Tag Requests", + "components.Settings.LidarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name", + "components.Settings.LidarrModal.tags": "Tags", + "components.Settings.LidarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", + "components.Settings.LidarrModal.testFirstRootFolders": "Test connection to load root folders", + "components.Settings.LidarrModal.testFirstTags": "Test connection to load tags", + "components.Settings.LidarrModal.toastLidarrTestFailure": "Failed to connect to Lidarr.", + "components.Settings.LidarrModal.toastLidarrTestSuccess": "Lidarr connection established successfully!", + "components.Settings.LidarrModal.validationApiKeyRequired": "You must provide an API key", + "components.Settings.LidarrModal.validationApplicationUrl": "You must provide a valid URL", + "components.Settings.LidarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash", + "components.Settings.LidarrModal.validationBaseUrlLeadingSlash": "Base URL must have a leading slash", + "components.Settings.LidarrModal.validationBaseUrlTrailingSlash": "Base URL must not end in a trailing slash", + "components.Settings.LidarrModal.validationHostnameRequired": "You must provide a valid hostname or IP address", + "components.Settings.LidarrModal.validationNameRequired": "You must provide a server name", + "components.Settings.LidarrModal.validationPortRequired": "You must provide a valid port number", + "components.Settings.LidarrModal.validationProfileRequired": "You must select a quality profile", + "components.Settings.LidarrModal.validationRootFolderRequired": "You must select a root folder", "components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.", "components.Settings.Notifications.NotificationsGotify.gotifysettingssaved": "Gotify notification settings saved successfully!", @@ -973,6 +1065,7 @@ "components.Settings.SettingsJobsCache.jobstarted": "{jobname} started.", "components.Settings.SettingsJobsCache.jobtype": "Type", "components.Settings.SettingsJobsCache.misses": "Misses", + "components.Settings.SettingsJobsCache.lidarr-scan": "Lidarr Scan", "components.Settings.SettingsJobsCache.nextexecution": "Next Execution", "components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan", "components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan", @@ -980,7 +1073,6 @@ "components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync", "components.Settings.SettingsJobsCache.process": "Process", "components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags", - "components.Settings.SettingsJobsCache.lidarr-scan": "Lidarr Scan", "components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan", "components.Settings.SettingsJobsCache.runnow": "Run Now", "components.Settings.SettingsJobsCache.size": "Size", @@ -1080,8 +1172,8 @@ "components.Settings.SettingsUsers.loginMethodsTip": "Configure login methods for users.", "components.Settings.SettingsUsers.mediaServerLogin": "Enable {mediaServerName} Sign-In", "components.Settings.SettingsUsers.mediaServerLoginTip": "Allow users to sign in using their {mediaServerName} account", - "components.Settings.SettingsUsers.musicRequestLimitLabel": "Global Music Request Limit", "components.Settings.SettingsUsers.movieRequestLimitLabel": "Global Movie Request Limit", + "components.Settings.SettingsUsers.musicRequestLimitLabel": "Global Music Request Limit", "components.Settings.SettingsUsers.newPlexLogin": "Enable New {mediaServerName} Sign-In", "components.Settings.SettingsUsers.newPlexLoginTip": "Allow {mediaServerName} users to sign in without first being imported", "components.Settings.SettingsUsers.toastSettingsFailure": "Something went wrong while saving settings.", @@ -1147,6 +1239,7 @@ "components.Settings.SonarrModal.validationProfileRequired": "You must select a quality profile", "components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder", "components.Settings.activeProfile": "Active Profile", + "components.Settings.addlidarr": "Add Lidarr Server", "components.Settings.addradarr": "Add Radarr Server", "components.Settings.address": "Address", "components.Settings.addrule": "New Override Rule", @@ -1196,11 +1289,13 @@ "components.Settings.jellyfinsettings": "{mediaServerName} Settings", "components.Settings.jellyfinsettingsDescription": "Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.", "components.Settings.librariesRemaining": "Libraries Remaining: {count}", + "components.Settings.lidarrsettings": "Lidarr Settings", "components.Settings.manualscan": "Manual Library Scan", "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Seerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", "components.Settings.manualscanDescriptionJellyfin": "Normally, this will only be run once every 24 hours. Seerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Seerr, a one-time full manual library scan is recommended!", "components.Settings.manualscanJellyfin": "Manual Library Scan", "components.Settings.mediaTypeMovie": "movie", + "components.Settings.mediaTypeMusic": "music", "components.Settings.mediaTypeSeries": "series", "components.Settings.menuAbout": "About", "components.Settings.menuGeneralSettings": "General", @@ -1218,6 +1313,7 @@ "components.Settings.metadataSettings": "Settings for metadata provider", "components.Settings.metadataSettingsSaved": "Metadata provider settings saved", "components.Settings.no": "No", + "components.Settings.musicServiceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only one of them can be marked as default. Administrators are able to override the server used to process new requests prior to approval.", "components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.", "components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should NOT be designated as a 4K server.", "components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.", @@ -1238,7 +1334,6 @@ "components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Seerr scans your Plex libraries to determine content availability.", "components.Settings.port": "Port", "components.Settings.providerStatus": "Metadata Provider Status", - "components.Settings.lidarrsettings": "Lidarr Settings", "components.Settings.radarrsettings": "Radarr Settings", "components.Settings.restartrequiredTooltip": "Seerr must be restarted for changes to this setting to take effect", "components.Settings.save": "Save Changes", @@ -1255,8 +1350,6 @@ "components.Settings.serverpresetLoad": "Press the button to load available servers", "components.Settings.serverpresetManualMessage": "Manual configuration", "components.Settings.serverpresetRefreshing": "Retrieving servers…", - "components.Settings.musicServiceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only one of them can be marked as default. Administrators are able to override the server used to process new requests prior to approval.", - "components.Settings.videoServiceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.", "components.Settings.services": "Services", "components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to fetch the list of available servers.", "components.Settings.settings": "Settings", @@ -1290,6 +1383,7 @@ "components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash", "components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash", "components.Settings.valueRequired": "You must provide a value.", + "components.Settings.videoServiceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.", "components.Settings.webAppUrl": "Web App URL", "components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app", "components.Settings.webhook": "Webhook", @@ -1307,7 +1401,7 @@ "components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.", "components.Setup.servertype": "Choose Server Type", "components.Setup.setup": "Setup", - "components.Setup.signin": "Sign In", + "components.Setup.signin": "Sign in to your account", "components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details", @@ -1328,6 +1422,7 @@ "components.StatusChecker.restartRequiredDescription": "Please restart the server to apply the updated settings.", "components.TitleCard.addToWatchList": "Add to watchlist", "components.TitleCard.cleardata": "Clear Data", + "components.TitleCard.mbId": "MusicBrainz ID", "components.TitleCard.mediaerror": "{mediaType} Not Found", "components.TitleCard.tmdbid": "TMDB ID", "components.TitleCard.tvdbid": "TheTVDB ID", @@ -1576,6 +1671,7 @@ "components.UserProfile.limit": "{remaining} of {limit}", "components.UserProfile.localWatchlist": "{username}'s Watchlist", "components.UserProfile.movierequests": "Movie Requests", + "components.UserProfile.musicrequests": "Music Requests", "components.UserProfile.pastdays": "{type} (past {days} days)", "components.UserProfile.plexwatchlist": "Plex Watchlist", "components.UserProfile.recentlywatched": "Recently Watched", @@ -1586,6 +1682,8 @@ "components.UserProfile.unlimited": "Unlimited", "i18n.addToBlacklist": "Add to Blacklist", "i18n.advanced": "Advanced", + "i18n.album": "Album", + "i18n.albums": "Albums", "i18n.all": "All", "i18n.approve": "Approve", "i18n.approved": "Approved", @@ -1616,6 +1714,8 @@ "i18n.loading": "Loading…", "i18n.movie": "Movie", "i18n.movies": "Movies", + "i18n.music": "Music", + "i18n.musics": "Musics", "i18n.next": "Next", "i18n.noresults": "No results.", "i18n.notrequested": "Not Requested", diff --git a/src/i18n/locale/es.json b/src/i18n/locale/es.json index aa529ac8..ed087d8a 100644 --- a/src/i18n/locale/es.json +++ b/src/i18n/locale/es.json @@ -658,7 +658,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "¡Los ajustes de notificaciones de Discord se han guardado correctamente!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "No se han podido guardar los ajustes de notificaciones de Discord.", - "components.Settings.videoServiceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", + "components.Settings.serviceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", "components.Settings.mediaTypeMovie": "película", "components.Settings.SonarrModal.testFirstTags": "Probar conexión para cargar etiquetas", "components.Settings.SonarrModal.tags": "Etiquetas", diff --git a/src/i18n/locale/es_MX.json b/src/i18n/locale/es_MX.json index 8cfa458c..b93490c6 100644 --- a/src/i18n/locale/es_MX.json +++ b/src/i18n/locale/es_MX.json @@ -658,7 +658,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "¡Los ajustes de notificaciones de Discord se han guardado correctamente!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "No se han podido guardar los ajustes de notificaciones de Discord.", - "components.Settings.videoServiceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", + "components.Settings.serviceSettingsDescription": "Configura tu(s) servidor(es) {serverType} a continuación. Puedes conectar a múltiples servidores de {serverType}, pero solo dos de ellos pueden ser marcados como por defecto (uno no-4k y otro 4k). Los administradores podrán modificar el servidor usado al procesar nuevas solicitudes antes de su aprobación.", "components.Settings.mediaTypeMovie": "película", "components.Settings.SonarrModal.testFirstTags": "Probar conexión para cargar etiquetas", "components.Settings.SonarrModal.tags": "Etiquetas", diff --git a/src/i18n/locale/fr.json b/src/i18n/locale/fr.json index 97ec432b..99ed0e00 100644 --- a/src/i18n/locale/fr.json +++ b/src/i18n/locale/fr.json @@ -765,7 +765,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKey": "Clé Publique PGP", "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Les paramètres de notification Discord n’ont pas pu être enregistrés.", - "components.Settings.videoServiceSettingsDescription": "Configurez votre serveur {serverType} ci-dessous. Vous pouvez connecter plusieurs serveurs {serverType}, mais seulement deux d’entre eux peuvent être marqués par défaut (un non-4K et un 4K). Les administrateurs peuvent modifier le serveur utilisé pour traiter les nouvelles demandes avant la validation.", + "components.Settings.serviceSettingsDescription": "Configurez votre serveur {serverType} ci-dessous. Vous pouvez connecter plusieurs serveurs {serverType}, mais seulement deux d’entre eux peuvent être marqués par défaut (un non-4K et un 4K). Les administrateurs peuvent modifier le serveur utilisé pour traiter les nouvelles demandes avant la validation.", "components.Settings.mediaTypeSeries": "séries", "components.Settings.mediaTypeMovie": "film", "components.Settings.SettingsAbout.uptodate": "À jour", diff --git a/src/i18n/locale/hr.json b/src/i18n/locale/hr.json index 09b6c53b..f17ec31d 100644 --- a/src/i18n/locale/hr.json +++ b/src/i18n/locale/hr.json @@ -378,7 +378,7 @@ "components.Settings.serverSecure": "sigurno", "components.Settings.serverpreset": "Poslužitelj", "components.Settings.serverpresetRefreshing": "Dohvaćanje poslužitelja …", - "components.Settings.videoServiceSettingsDescription": "Dolje donfiguriraj svoje {serverType} poslužitelje. Možeš povezati više {serverType} poslužitelja, ali samo dva od njih mogu biti označena kao zadana (jedan ne-4K i jedan 4K). Administratori mogu promijeniti poslužitelj koji se koristi za obradu novih zahtjeva prije odobrenja.", + "components.Settings.serviceSettingsDescription": "Dolje donfiguriraj svoje {serverType} poslužitelje. Možeš povezati više {serverType} poslužitelja, ali samo dva od njih mogu biti označena kao zadana (jedan ne-4K i jedan 4K). Administratori mogu promijeniti poslužitelj koji se koristi za obradu novih zahtjeva prije odobrenja.", "components.Settings.serverpresetLoad": "Pritisni gumb za učitavanje dostupnih polsužitelja", "components.Settings.serverpresetManualMessage": "Ručna konfiguracija", "components.Settings.services": "Usluge", diff --git a/src/i18n/locale/hu.json b/src/i18n/locale/hu.json index d7ea6d2a..5be406a6 100644 --- a/src/i18n/locale/hu.json +++ b/src/i18n/locale/hu.json @@ -297,7 +297,7 @@ "components.Settings.sonarrsettings": "Sonarr beállítások", "components.Settings.settingUpPlexDescription": "A Plex beállításához megadhatja kézzel az adatokat, vagy kiválaszthat egy plex.tv-ről elérhető szervert. Nyomja meg a legördülő menü jobb oldalán lévő gombot az elérhető szerverek listájának lekérdezéséhez.", "components.Settings.services": "Szolgáltatások", - "components.Settings.videoServiceSettingsDescription": "Az alábbiakban konfigurálja a {serverType} szerver(eke)t. Több {serverType} szervert is csatlakoztathat, de ezek közül csak kettő jelölhető meg alapértelmezettként (egy nem 4K és egy 4K). A rendszergazdák felülbírálhatják az új kérések feldolgozásához használt szervert a jóváhagyás előtt.", + "components.Settings.serviceSettingsDescription": "Az alábbiakban konfigurálja a {serverType} szerver(eke)t. Több {serverType} szervert is csatlakoztathat, de ezek közül csak kettő jelölhető meg alapértelmezettként (egy nem 4K és egy 4K). A rendszergazdák felülbírálhatják az új kérések feldolgozásához használt szervert a jóváhagyás előtt.", "components.Settings.serverpresetRefreshing": "Szerverek lekérése…", "components.Settings.serverpresetManualMessage": "Kézi beállítás", "components.Settings.serverpresetLoad": "Nyomja meg a gombot az elérhető szerverek betöltéséhez", diff --git a/src/i18n/locale/it.json b/src/i18n/locale/it.json index a7f0568d..4af30cb1 100644 --- a/src/i18n/locale/it.json +++ b/src/i18n/locale/it.json @@ -667,7 +667,7 @@ "components.RequestModal.AdvancedRequester.notagoptions": "Nessun tag.", "components.Layout.VersionStatus.outofdate": "Non aggiornato", "components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {versione} other {versioni}} indietro", - "components.Settings.videoServiceSettingsDescription": "Configura i tuoi server {serverType} qui sotto. Puoi collegare più server {serverType}, ma solo due possono essere contrassegnati come predefiniti (uno non-4K e uno 4K). Gli amministratori possono selezionare il server usato per elaborare le nuove richieste prima dell'approvazione.", + "components.Settings.serviceSettingsDescription": "Configura i tuoi server {serverType} qui sotto. Puoi collegare più server {serverType}, ma solo due possono essere contrassegnati come predefiniti (uno non-4K e uno 4K). Gli amministratori possono selezionare il server usato per elaborare le nuove richieste prima dell'approvazione.", "components.Settings.noDefaultServer": "Almeno un server {serverType} deve essere contrassegnato come predefinito affinché le richieste {mediaType} possano essere processate.", "components.Settings.noDefaultNon4kServer": "Se hai solo un singolo server {serverType} per contenuti non-4K e 4K (o se scarichi solo contenuti 4K), il tuo server {serverType} NON dovrebbe essere designato come server 4K.", "components.Settings.mediaTypeSeries": "serie", diff --git a/src/i18n/locale/ko.json b/src/i18n/locale/ko.json index 3180af2c..fede8d64 100644 --- a/src/i18n/locale/ko.json +++ b/src/i18n/locale/ko.json @@ -1093,7 +1093,7 @@ "components.Settings.SonarrModal.loadinglanguageprofiles": "언어 프로필 불러오는 중…", "components.Settings.SonarrModal.validationBaseUrlLeadingSlash": "기본 URL 앞에는 슬래시가 있어야 합니다", "components.Settings.SonarrModal.validationNameRequired": "서버 이름을 입력해야 합니다", - "components.Settings.videoServiceSettingsDescription": "아래에서 {serverType} 서버(들)를 구성하세요. 여러 개의 {serverType} 서버를 연결할 수 있지만, 기본 설정으로는 두 개만 지정할 수 있습니다 (비-4K 한 대와 4K 한 대). 관리자는 승인 이전에 새로운 요청을 처리하는 데 사용할 서버를 재지정할 수 있습니다.", + "components.Settings.serviceSettingsDescription": "아래에서 {serverType} 서버(들)를 구성하세요. 여러 개의 {serverType} 서버를 연결할 수 있지만, 기본 설정으로는 두 개만 지정할 수 있습니다 (비-4K 한 대와 4K 한 대). 관리자는 승인 이전에 새로운 요청을 처리하는 데 사용할 서버를 재지정할 수 있습니다.", "components.StatusBadge.managemedia": "{mediaType} 관리", "components.Settings.SonarrModal.animeTags": "애니메이션 태그", "components.Settings.SonarrModal.hostname": "호스트 네임 또는 IP 주소", diff --git a/src/i18n/locale/nb_NO.json b/src/i18n/locale/nb_NO.json index de5c3c14..1da54838 100644 --- a/src/i18n/locale/nb_NO.json +++ b/src/i18n/locale/nb_NO.json @@ -929,7 +929,7 @@ "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload tilbakestilt!", "components.Settings.Notifications.NotificationsPushover.userTokenTip": "Din 30-tegns bruker- eller gruppe-nøkkel", "components.Settings.SettingsJobsCache.cachevsize": "Verdistørrelse", - "components.Settings.videoServiceSettingsDescription": "Konfigurer dine {serverType}tjener(e) nedenfor. Du kan koble til flere forskellige {serverType}tjenere men kun to av dem kan markeres som standard (en som ikke er 4K og en 4K). Administratorer kan endre hvilken tjener som brukes før godkjennelse av nye forespørsler.", + "components.Settings.serviceSettingsDescription": "Konfigurer dine {serverType}tjener(e) nedenfor. Du kan koble til flere forskellige {serverType}tjenere men kun to av dem kan markeres som standard (en som ikke er 4K og en 4K). Administratorer kan endre hvilken tjener som brukes før godkjennelse av nye forespørsler.", "components.ManageSlideOver.manageModalClearMediaWarning": "* Dette vil slette all data for denne tittelen uten mulighet for å bli gjennopprettet, det inkluderer alle forespørsler, avvik osv. Hvis denne tittelen finnes i ditt {mediaServerName} bibliotek vil medieinformasjon bli opprettet på nytt under neste skanning.", "components.Settings.Notifications.NotificationsWebhook.authheader": "Autorisasjonshode", "components.Settings.SettingsJobsCache.cacheksize": "Nøkkelstørrelse", diff --git a/src/i18n/locale/pl.json b/src/i18n/locale/pl.json index 42d9021e..70ed0d8d 100644 --- a/src/i18n/locale/pl.json +++ b/src/i18n/locale/pl.json @@ -874,7 +874,7 @@ "components.Settings.sonarrsettings": "Ustawienia Sonarr", "components.Settings.ssl": "Protokół SSL", "components.Settings.toastPlexConnectingSuccess": "Połączenie Plex nawiązane pomyślnie!", - "components.Settings.videoServiceSettingsDescription": "Skonfiguruj poniżej swój serwer(y) {serverType}. Możesz podłączyć wiele serwerów {serverType}, ale tylko dwa z nich mogą być oznaczone jako domyślne (jeden nie-4K i jeden 4K). Administratorzy mogą zmienić serwer używany do przetwarzania nowych żądań przed zatwierdzeniem.", + "components.Settings.serviceSettingsDescription": "Skonfiguruj poniżej swój serwer(y) {serverType}. Możesz podłączyć wiele serwerów {serverType}, ale tylko dwa z nich mogą być oznaczone jako domyślne (jeden nie-4K i jeden 4K). Administratorzy mogą zmienić serwer używany do przetwarzania nowych żądań przed zatwierdzeniem.", "components.Settings.toastPlexRefreshSuccess": "Lista serwerów Plex została pobrana pomyślnie!", "components.Settings.startscan": "Rozpocznij skanowanie", "components.Setup.finish": "Zakończ konfigurację", diff --git a/src/i18n/locale/pt_BR.json b/src/i18n/locale/pt_BR.json index a31dc96f..e6dc5d0f 100644 --- a/src/i18n/locale/pt_BR.json +++ b/src/i18n/locale/pt_BR.json @@ -669,7 +669,7 @@ "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "Esse usuário ainda não possui uma senha definida. Defina uma senha abaixo para habilitar autenticação como \"usuário local.\"", "components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "Você deve prover uma chave pública PGP válida", "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKeyTip": "Criptografa mensagens de e-mail usando OpenPGP", - "components.Settings.videoServiceSettingsDescription": "Configure seu(s) servidor(es) {serverType} abaixo. Você pode se conectar à múltiplos servidores {serverType}, mas apenas dois podem ser marcados como padrão (um não 4K e outro 4K). Administradores podem sobrescrever o servidor usado antes de aprovar as novas solicitações.", + "components.Settings.serviceSettingsDescription": "Configure seu(s) servidor(es) {serverType} abaixo. Você pode se conectar à múltiplos servidores {serverType}, mas apenas dois podem ser marcados como padrão (um não 4K e outro 4K). Administradores podem sobrescrever o servidor usado antes de aprovar as novas solicitações.", "components.Settings.noDefaultServer": "Ao menos um servidor {serverType} deve ser marcado como padrão para que as solicitações de {mediaType} sejam processadas.", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingssaved": "Configurações de notificação via Telegram salvas com sucesso!", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Falha ao salvar configurações de notificação via Telegram.", diff --git a/src/i18n/locale/pt_PT.json b/src/i18n/locale/pt_PT.json index 960b7d1a..3851d11d 100644 --- a/src/i18n/locale/pt_PT.json +++ b/src/i18n/locale/pt_PT.json @@ -692,7 +692,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "Definições de notificação Discord gravadas com sucesso!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Falha ao gravar as definições de notificação Discord.", "components.RequestList.RequestItem.cancelRequest": "Cancelar Pedido", - "components.Settings.videoServiceSettingsDescription": "Configure o seu(s) servidor(es) {serverType} abaixo. Pode ligar vários servidores {serverType}, mas apenas dois deles podem ser marcados como predefinidos (um não 4K e um 4K). Os administradores podem mudar o servidor usado para processar novos pedidos antes da aprovação.", + "components.Settings.serviceSettingsDescription": "Configure o seu(s) servidor(es) {serverType} abaixo. Pode ligar vários servidores {serverType}, mas apenas dois deles podem ser marcados como predefinidos (um não 4K e um 4K). Os administradores podem mudar o servidor usado para processar novos pedidos antes da aprovação.", "components.Settings.noDefaultServer": "Pelo menos um servidor {serverType} deve ser marcado como predefinido para que os pedidos de {mediaType} sejam processados.", "components.Settings.noDefaultNon4kServer": "Se tiver apenas um único servidor {serverType} para conteúdo não 4K e 4K (ou se apenas transfere conteúdo 4K), o seu servidor {serverType} NÃO deve ser designado como um servidor 4K.", "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Atualmente, sua conta não tem uma palavra-passe definida. Configure uma palavra-passe abaixo para permitir o inicio de sessão como um \"utilizador local\" usando o seu e-mail.", diff --git a/src/i18n/locale/ru.json b/src/i18n/locale/ru.json index 87059e68..e8f1b27c 100644 --- a/src/i18n/locale/ru.json +++ b/src/i18n/locale/ru.json @@ -759,7 +759,7 @@ "components.Settings.toastPlexConnecting": "Попытка подключения к Plex…", "components.Settings.settingUpPlexDescription": "Чтобы настроить Plex, вы можете либо ввести данные вручную, либо выбрать сервер, полученный со страницы plex.tv. Нажмите кнопку справа от выпадающего списка, чтобы получить список доступных серверов.", "components.Settings.services": "Службы", - "components.Settings.videoServiceSettingsDescription": "Настройте сервер(ы) {serverType} ниже. Вы можете подключить несколько серверов {serverType}, но только два из них могут быть помечены как серверы по умолчанию (один не 4К и один 4К). Администраторы могут переопределить сервер для обработки новых запросов до их одобрения.", + "components.Settings.serviceSettingsDescription": "Настройте сервер(ы) {serverType} ниже. Вы можете подключить несколько серверов {serverType}, но только два из них могут быть помечены как серверы по умолчанию (один не 4К и один 4К). Администраторы могут переопределить сервер для обработки новых запросов до их одобрения.", "components.Settings.serverpresetLoad": "Нажмите кнопку, чтобы загрузить список доступных серверов", "components.Settings.serverSecure": "защищённый", "components.Settings.serverRemote": "удалённый", diff --git a/src/i18n/locale/sq.json b/src/i18n/locale/sq.json index ea1675e1..c33895ac 100644 --- a/src/i18n/locale/sq.json +++ b/src/i18n/locale/sq.json @@ -932,7 +932,7 @@ "components.Settings.SonarrModal.syncEnabled": "Aktivo skanimin", "components.Settings.SonarrModal.testFirstTags": "Testo lidhjen për të ngarkuar etiketat", "components.Settings.SonarrModal.validationRootFolderRequired": "Duhet të zgjedhësh një dosje rrënjë", - "components.Settings.videoServiceSettingsDescription": "Konfiguro serverin tënd {serverType} më poshtë. Ju mund të lidhni shumë servera {serverType}, por vetëm dy prej tyre mund të shënohen si të prezgjedhur (një jo-4K dhe një 4K). Administratorët janë në gjendje të kapërcejnë serverin e përdorur për të përpunuar kërkesa të reja përpara miratimit.", + "components.Settings.serviceSettingsDescription": "Konfiguro serverin tënd {serverType} më poshtë. Ju mund të lidhni shumë servera {serverType}, por vetëm dy prej tyre mund të shënohen si të prezgjedhur (një jo-4K dhe një 4K). Administratorët janë në gjendje të kapërcejnë serverin e përdorur për të përpunuar kërkesa të reja përpara miratimit.", "components.Settings.validationHostnameRequired": "Ju duhet të siguroni një emër të vlefshëm host ose adrese IP", "components.UserProfile.UserSettings.UserPasswordChange.nopermissionDescription": "Ju nuk keni leje për të modifikuar fjalëkalimin e këtij përdoruesi.", "components.UserProfile.UserSettings.UserPasswordChange.toastSettingsFailureVerifyCurrent": "Diçka shkoi keq duke ruajtur fjalëkalimin. A u fut fjalëkalimi në mënyrë korrekte?", diff --git a/src/i18n/locale/tr.json b/src/i18n/locale/tr.json index edf61b60..5fa53430 100644 --- a/src/i18n/locale/tr.json +++ b/src/i18n/locale/tr.json @@ -962,7 +962,7 @@ "components.Settings.manualscanDescription": "Normalde, bu yalnızca her 24 saatte bir çalıştırılır. Jellyseerr, Plex sunucunuzun son eklenenlerini sık sık kontrol edecektir. Plex'i ilk kez yapılandırıyorsanız, tek seferlik tam manuel kütüphane taraması önerilir!", "components.Settings.manualscanJellyfin": "Kütüphaneleri Elle Tara", "components.Settings.menuJobs": "İşlemler & Önbellek", - "components.Settings.videoServiceSettingsDescription": "{serverType} sunucu(lar)ınızı aşağıdan yapılandırın. Birden fazla {serverType} sunucusuna bağlanabilirsiniz, ancak sadece iki tanesi varsayılan olarak tanımlanabilir (bir adet 4K ve bir adet 4K dışı). Yöneticiler sunucuları her daim mutlak bir biçimde yönetebilirler.", + "components.Settings.serviceSettingsDescription": "{serverType} sunucu(lar)ınızı aşağıdan yapılandırın. Birden fazla {serverType} sunucusuna bağlanabilirsiniz, ancak sadece iki tanesi varsayılan olarak tanımlanabilir (bir adet 4K ve bir adet 4K dışı). Yöneticiler sunucuları her daim mutlak bir biçimde yönetebilirler.", "components.Settings.services": "Servisler", "components.Settings.settingUpPlexDescription": "Plex'i kurmak için, ayrıntıları manuel olarak girebilir veya plex.tv adresinden alınan bir sunucuyu seçebilirsiniz. Kullanılabilir sunucuların listesini almak için açılır menünün sağındaki düğmeyi kullanın.", "components.Settings.ssl": "SSL", diff --git a/src/i18n/locale/uk.json b/src/i18n/locale/uk.json index e6a522b0..c7abc6c6 100644 --- a/src/i18n/locale/uk.json +++ b/src/i18n/locale/uk.json @@ -790,7 +790,7 @@ "components.Settings.serverpresetLoad": "Натисніть кнопку, щоб завантажити список доступних серверів", "components.Settings.serverpresetManualMessage": "Ручне налаштування", "components.Settings.serverpresetRefreshing": "Отримання списку серверів…", - "components.Settings.videoServiceSettingsDescription": "Налаштуйте сервер(и) {serverType} нижче. Ви можете підключити кілька серверів {serverType}, але тільки два з них можуть бути позначені як сервери за промовчанням (один не 4К і один 4К). Адміністратори можуть перевизначити сервер для обробки нових запитів до їх схвалення.", + "components.Settings.serviceSettingsDescription": "Налаштуйте сервер(и) {serverType} нижче. Ви можете підключити кілька серверів {serverType}, але тільки два з них можуть бути позначені як сервери за промовчанням (один не 4К і один 4К). Адміністратори можуть перевизначити сервер для обробки нових запитів до їх схвалення.", "components.Settings.services": "Служби", "components.Settings.settingUpPlexDescription": "Щоб налаштувати Plex, ви можете або ввести дані вручну, або вибрати сервер, отриманий зі сторінки plex.tv. Натисніть кнопку праворуч від випадаючого списку, щоб отримати список доступних серверів .", "components.Settings.sonarrsettings": "Налаштування Sonarr", diff --git a/src/i18n/locale/zh_Hans.json b/src/i18n/locale/zh_Hans.json index 4e923a26..adf57ae4 100644 --- a/src/i18n/locale/zh_Hans.json +++ b/src/i18n/locale/zh_Hans.json @@ -83,7 +83,7 @@ "components.Settings.sonarrsettings": "Sonarr 设置", "components.Settings.settingUpPlexDescription": "你可以手动输入你的 Plex 服务器资料,或从 plex.tv 返回的设置做选择以及自动配置。请点下拉式选单右边的按钮获取服务器列表。", "components.Settings.services": "服务器", - "components.Settings.videoServiceSettingsDescription": "关于 {serverType} 服务器的设置。{serverType} 服务器数没有最大值限制,但你只能指定兩个服务器为默认(一个非 4K、一个 4K)。", + "components.Settings.serviceSettingsDescription": "关于 {serverType} 服务器的设置。{serverType} 服务器数没有最大值限制,但你只能指定兩个服务器为默认(一个非 4K、一个 4K)。", "components.Settings.serverpresetRefreshing": "载入中…", "components.Settings.serverpresetManualMessage": "手动设定", "components.Settings.serverpresetLoad": "请点右边的按钮", diff --git a/src/i18n/locale/zh_Hant.json b/src/i18n/locale/zh_Hant.json index 4a8d886a..9540a6bf 100644 --- a/src/i18n/locale/zh_Hant.json +++ b/src/i18n/locale/zh_Hant.json @@ -693,7 +693,7 @@ "components.Settings.SettingsAbout.uptodate": "最新", "components.Settings.noDefaultNon4kServer": "如果您只有一個 {serverType} 伺服器,請勿把它設定為 4K 伺服器。", "components.Settings.noDefaultServer": "您必須至少指定一個預設 {serverType} 伺服器,才能處理{mediaType}請求。", - "components.Settings.videoServiceSettingsDescription": "關於 {serverType} 伺服器的設定。{serverType} 伺服器數沒有最大值限制,但您只能指定兩個預設伺服器(一個非 4K、一個 4K)。", + "components.Settings.serviceSettingsDescription": "關於 {serverType} 伺服器的設定。{serverType} 伺服器數沒有最大值限制,但您只能指定兩個預設伺服器(一個非 4K、一個 4K)。", "components.Settings.mediaTypeSeries": "影集", "components.Settings.mediaTypeMovie": "電影", "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "此使用者的帳戶目前沒有設密碼。若在以下設定密碼,此使用者就能使用「本地登入」。", diff --git a/src/pages/artist/[artistId]/index.tsx b/src/pages/artist/[artistId]/index.tsx new file mode 100644 index 00000000..114e10b2 --- /dev/null +++ b/src/pages/artist/[artistId]/index.tsx @@ -0,0 +1,8 @@ +import ArtistDetails from '@app/components/ArtistDetails'; +import type { NextPage } from 'next'; + +const ArtistPage: NextPage = () => { + return ; +}; + +export default ArtistPage; diff --git a/src/pages/discover/albums/index.tsx b/src/pages/discover/albums/index.tsx new file mode 100644 index 00000000..8b516b34 --- /dev/null +++ b/src/pages/discover/albums/index.tsx @@ -0,0 +1,8 @@ +import DiscoverMusicAlbums from '@app/components/Discover/DiscoverMusicAlbums'; +import type { NextPage } from 'next'; + +const DiscoverMusicAlbumsPage: NextPage = () => { + return ; +}; + +export default DiscoverMusicAlbumsPage; diff --git a/src/pages/discover/artists/index.tsx b/src/pages/discover/artists/index.tsx new file mode 100644 index 00000000..cba433f9 --- /dev/null +++ b/src/pages/discover/artists/index.tsx @@ -0,0 +1,8 @@ +import DiscoverMusicArtists from '@app/components/Discover/DiscoverMusicArtists'; +import type { NextPage } from 'next'; + +const DiscoverMusicArtistsPage: NextPage = () => { + return ; +}; + +export default DiscoverMusicArtistsPage; diff --git a/src/pages/group/[groupId]/index.tsx b/src/pages/group/[groupId]/index.tsx deleted file mode 100644 index f340857f..00000000 --- a/src/pages/group/[groupId]/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import GroupDetails from '@app/components/GroupDetails'; -import type { NextPage } from 'next'; - -const GroupPage: NextPage = () => { - return ; -}; - -export default GroupPage; diff --git a/src/pages/music/[musicId]/index.tsx b/src/pages/music/[musicId]/index.tsx index 74d98711..24d7dd79 100644 --- a/src/pages/music/[musicId]/index.tsx +++ b/src/pages/music/[musicId]/index.tsx @@ -1,36 +1,8 @@ import MusicDetails from '@app/components/MusicDetails'; -import type { MusicDetails as MusicDetailsType } from '@server/models/Music'; -import type { GetServerSideProps, NextPage } from 'next'; +import type { NextPage } from 'next'; -interface MusicPageProps { - music?: MusicDetailsType; -} - -const MusicPage: NextPage = ({ music }) => { - return ; -}; - -export const getServerSideProps: GetServerSideProps = async ( - ctx -) => { - const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/music/${ - ctx.query.musicId - }`, - { - headers: ctx.req?.headers?.cookie - ? { cookie: ctx.req.headers.cookie } - : undefined, - } - ); - if (!res.ok) throw new Error(); - const music: MusicDetailsType = await res.json(); - - return { - props: { - music, - }, - }; +const MusicPage: NextPage = () => { + return ; }; export default MusicPage; diff --git a/src/pages/music/[musicId]/similar.tsx b/src/pages/music/[musicId]/similar.tsx deleted file mode 100644 index 8daa46b4..00000000 --- a/src/pages/music/[musicId]/similar.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import MusicArtistSimilar from '@app/components/MusicDetails/MusicArtistSimilar'; -import type { NextPage } from 'next'; - -const MusicArtistSimilarPage: NextPage = () => { - return ; -}; - -export default MusicArtistSimilarPage;