refactor(person details): merging Person Details
This commit is contained in:
@@ -11,6 +11,8 @@ module.exports = {
|
|||||||
{ hostname: 'image.tmdb.org' },
|
{ hostname: 'image.tmdb.org' },
|
||||||
{ hostname: 'artworks.thetvdb.com' },
|
{ hostname: 'artworks.thetvdb.com' },
|
||||||
{ hostname: 'plex.tv' },
|
{ hostname: 'plex.tv' },
|
||||||
|
{ hostname: 'archive.org' },
|
||||||
|
{ hostname: 'r2.theaudiodb.com' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -59,7 +59,7 @@
|
|||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"dayjs": "1.11.19",
|
"dayjs": "1.11.19",
|
||||||
"dns-caching": "^0.2.7",
|
"dns-caching": "^0.2.7",
|
||||||
"dompurify": "^3.2.3",
|
"dompurify": "^3.2.4",
|
||||||
"email-templates": "12.0.1",
|
"email-templates": "12.0.1",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"http-proxy-agent": "^7.0.2",
|
"http-proxy-agent": "^7.0.2",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
"mime": "3",
|
||||||
"next": "^14.2.25",
|
"next": "^14.2.25",
|
||||||
@@ -124,13 +125,15 @@
|
|||||||
"@tailwindcss/forms": "0.5.10",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/country-flag-icons": "1.2.2",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
"@types/csurf": "1.11.5",
|
"@types/csurf": "1.11.2",
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/email-templates": "8.0.4",
|
"@types/email-templates": "8.0.4",
|
||||||
"@types/express": "4.17.17",
|
"@types/express": "4.17.17",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.17.6",
|
||||||
"@types/lodash": "4.17.21",
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/lodash": "4.14.191",
|
||||||
"@types/mime": "3",
|
"@types/mime": "3",
|
||||||
"@types/node": "22.10.5",
|
"@types/node": "22.10.5",
|
||||||
"@types/node-schedule": "2.1.8",
|
"@types/node-schedule": "2.1.8",
|
||||||
|
|||||||
330
pnpm-lock.yaml
generated
330
pnpm-lock.yaml
generated
@@ -87,8 +87,8 @@ importers:
|
|||||||
specifier: ^0.2.7
|
specifier: ^0.2.7
|
||||||
version: 0.2.7
|
version: 0.2.7
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: ^3.2.3
|
specifier: ^3.2.4
|
||||||
version: 3.2.3
|
version: 3.2.4
|
||||||
email-templates:
|
email-templates:
|
||||||
specifier: 12.0.3
|
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)
|
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:
|
https-proxy-agent:
|
||||||
specifier: ^7.0.6
|
specifier: ^7.0.6
|
||||||
version: 7.0.6
|
version: 7.0.6
|
||||||
|
jsdom:
|
||||||
|
specifier: ^26.0.0
|
||||||
|
version: 26.0.0
|
||||||
lodash:
|
lodash:
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -283,8 +286,11 @@ importers:
|
|||||||
specifier: 1.2.2
|
specifier: 1.2.2
|
||||||
version: 1.2.2
|
version: 1.2.2
|
||||||
'@types/csurf':
|
'@types/csurf':
|
||||||
specifier: 1.11.5
|
specifier: 1.11.2
|
||||||
version: 1.11.5
|
version: 1.11.2
|
||||||
|
'@types/dompurify':
|
||||||
|
specifier: ^3.2.0
|
||||||
|
version: 3.2.0
|
||||||
'@types/email-templates':
|
'@types/email-templates':
|
||||||
specifier: 8.0.4
|
specifier: 8.0.4
|
||||||
version: 8.0.4(encoding@0.1.13)
|
version: 8.0.4(encoding@0.1.13)
|
||||||
@@ -292,8 +298,11 @@ importers:
|
|||||||
specifier: 4.17.17
|
specifier: 4.17.17
|
||||||
version: 4.17.17
|
version: 4.17.17
|
||||||
'@types/express-session':
|
'@types/express-session':
|
||||||
specifier: 1.18.2
|
specifier: 1.17.6
|
||||||
version: 1.18.2
|
version: 1.17.6
|
||||||
|
'@types/jsdom':
|
||||||
|
specifier: ^21.1.7
|
||||||
|
version: 21.1.7
|
||||||
'@types/lodash':
|
'@types/lodash':
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -442,6 +451,9 @@ packages:
|
|||||||
'@apidevtools/json-schema-ref-parser@9.0.9':
|
'@apidevtools/json-schema-ref-parser@9.0.9':
|
||||||
resolution: {integrity: sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==}
|
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':
|
'@babel/code-frame@7.24.7':
|
||||||
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
|
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1572,6 +1584,34 @@ packages:
|
|||||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
engines: {node: '>=12'}
|
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':
|
'@cypress/request@3.0.7':
|
||||||
resolution: {integrity: sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==}
|
resolution: {integrity: sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3258,6 +3298,10 @@ packages:
|
|||||||
'@types/debug@4.1.12':
|
'@types/debug@4.1.12':
|
||||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
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':
|
'@types/email-templates@8.0.4':
|
||||||
resolution: {integrity: sha512-HYvVoyG8qS6PrimZZOS4wMrtQ9MelKEl0sOpi4zVpz2Ds74v+UvWckIFz3NyGyTwAR1okMbwJkApgR2GL/ALjg==}
|
resolution: {integrity: sha512-HYvVoyG8qS6PrimZZOS4wMrtQ9MelKEl0sOpi4zVpz2Ds74v+UvWckIFz3NyGyTwAR1okMbwJkApgR2GL/ALjg==}
|
||||||
|
|
||||||
@@ -3297,6 +3341,9 @@ packages:
|
|||||||
'@types/istanbul-reports@3.0.4':
|
'@types/istanbul-reports@3.0.4':
|
||||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||||
|
|
||||||
|
'@types/jsdom@21.1.7':
|
||||||
|
resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@@ -3421,6 +3468,9 @@ packages:
|
|||||||
'@types/swagger-ui-express@4.1.8':
|
'@types/swagger-ui-express@4.1.8':
|
||||||
resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
|
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':
|
'@types/triple-beam@1.3.5':
|
||||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||||
|
|
||||||
@@ -4544,6 +4594,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==}
|
resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
cssstyle@4.2.1:
|
||||||
|
resolution: {integrity: sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
csstype@2.6.21:
|
csstype@2.6.21:
|
||||||
resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==}
|
resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==}
|
||||||
|
|
||||||
@@ -4573,6 +4627,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
|
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
|
data-urls@5.0.0:
|
||||||
|
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
data-view-buffer@1.0.1:
|
data-view-buffer@1.0.1:
|
||||||
resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==}
|
resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4803,8 +4861,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
dompurify@3.2.3:
|
dompurify@3.2.4:
|
||||||
resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==}
|
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||||
|
|
||||||
domutils@2.8.0:
|
domutils@2.8.0:
|
||||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||||
@@ -5386,8 +5444,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
|
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
|
||||||
engines: {node: '>= 0.12'}
|
engines: {node: '>= 0.12'}
|
||||||
|
|
||||||
form-data@4.0.2:
|
form-data@4.0.1:
|
||||||
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
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'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
form-data@4.0.5:
|
form-data@4.0.5:
|
||||||
@@ -5703,6 +5765,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
html-encoding-sniffer@4.0.0:
|
||||||
|
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
html-to-text@9.0.5:
|
html-to-text@9.0.5:
|
||||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -5755,6 +5821,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
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:
|
human-signals@1.1.1:
|
||||||
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
|
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
|
||||||
engines: {node: '>=8.12.0'}
|
engines: {node: '>=8.12.0'}
|
||||||
@@ -6037,6 +6111,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
|
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-potential-custom-element-name@1.0.1:
|
||||||
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
is-promise@2.2.2:
|
is-promise@2.2.2:
|
||||||
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
|
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
|
||||||
|
|
||||||
@@ -6239,6 +6316,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/preset-env': ^7.1.6
|
'@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:
|
jsesc@0.5.0:
|
||||||
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
|
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6555,6 +6641,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==}
|
resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
|
lru-cache@10.4.3:
|
||||||
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
@@ -7167,6 +7256,9 @@ packages:
|
|||||||
nullthrows@1.1.1:
|
nullthrows@1.1.1:
|
||||||
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
|
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
|
||||||
|
|
||||||
|
nwsapi@2.2.16:
|
||||||
|
resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==}
|
||||||
|
|
||||||
oauth-sign@0.9.0:
|
oauth-sign@0.9.0:
|
||||||
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
|
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
|
||||||
|
|
||||||
@@ -7362,6 +7454,9 @@ packages:
|
|||||||
parse5@7.1.2:
|
parse5@7.1.2:
|
||||||
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
|
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
|
||||||
|
|
||||||
|
parse5@7.2.1:
|
||||||
|
resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==}
|
||||||
|
|
||||||
parseley@0.12.1:
|
parseley@0.12.1:
|
||||||
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
||||||
|
|
||||||
@@ -8203,6 +8298,9 @@ packages:
|
|||||||
rndm@1.2.0:
|
rndm@1.2.0:
|
||||||
resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==}
|
resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==}
|
||||||
|
|
||||||
|
rrweb-cssom@0.8.0:
|
||||||
|
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||||
|
|
||||||
run-applescript@3.2.0:
|
run-applescript@3.2.0:
|
||||||
resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==}
|
resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -8263,6 +8361,10 @@ packages:
|
|||||||
sax@1.4.1:
|
sax@1.4.1:
|
||||||
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
|
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
|
||||||
|
|
||||||
|
saxes@6.0.0:
|
||||||
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
|
engines: {node: '>=v12.22.7'}
|
||||||
|
|
||||||
scheduler@0.21.0:
|
scheduler@0.21.0:
|
||||||
resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==}
|
resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==}
|
||||||
|
|
||||||
@@ -8718,6 +8820,9 @@ packages:
|
|||||||
tailwind-merge@2.6.0:
|
tailwind-merge@2.6.0:
|
||||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
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:
|
tailwindcss@3.2.7:
|
||||||
resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==}
|
resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==}
|
||||||
engines: {node: '>=12.13.0'}
|
engines: {node: '>=12.13.0'}
|
||||||
@@ -8806,6 +8911,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-fSgYrW0ITH0SR/CqKMXIruYIPpNu5aDgUp22UhYoSrnUQwc7SBqifEBFNce7AAcygUPBo6a/gbtcguWdmko4RQ==}
|
resolution: {integrity: sha512-fSgYrW0ITH0SR/CqKMXIruYIPpNu5aDgUp22UhYoSrnUQwc7SBqifEBFNce7AAcygUPBo6a/gbtcguWdmko4RQ==}
|
||||||
hasBin: true
|
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:
|
tmp@0.0.33:
|
||||||
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
||||||
engines: {node: '>=0.6.0'}
|
engines: {node: '>=0.6.0'}
|
||||||
@@ -8850,9 +8962,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==}
|
resolution: {integrity: sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
tough-cookie@5.1.0:
|
||||||
|
resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
tr46@0.0.3:
|
tr46@0.0.3:
|
||||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
|
traverse@0.6.9:
|
||||||
|
resolution: {integrity: sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
tree-kill@1.2.2:
|
tree-kill@1.2.2:
|
||||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -9278,6 +9398,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
w3c-xmlserializer@5.0.0:
|
||||||
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
walker@1.0.8:
|
walker@1.0.8:
|
||||||
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
|
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
|
||||||
|
|
||||||
@@ -9303,9 +9427,25 @@ packages:
|
|||||||
webidl-conversions@3.0.1:
|
webidl-conversions@3.0.1:
|
||||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
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:
|
whatwg-fetch@3.6.20:
|
||||||
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
|
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:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
@@ -9424,6 +9564,22 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
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:
|
xml2js@0.4.16:
|
||||||
resolution: {integrity: sha512-9rH7UTUNphxeDRCeJBi4Fxp/z0fd92WeXNQ1dtUYMpqO3PaK59hVDCuUmOGHRZvufJDzcX8TG+Kdty7ylM0t2w==}
|
resolution: {integrity: sha512-9rH7UTUNphxeDRCeJBi4Fxp/z0fd92WeXNQ1dtUYMpqO3PaK59hVDCuUmOGHRZvufJDzcX8TG+Kdty7ylM0t2w==}
|
||||||
|
|
||||||
@@ -9446,6 +9602,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==}
|
resolution: {integrity: sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
xmlchars@2.2.0:
|
||||||
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -9549,6 +9708,14 @@ snapshots:
|
|||||||
call-me-maybe: 1.0.2
|
call-me-maybe: 1.0.2
|
||||||
js-yaml: 4.1.0
|
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':
|
'@babel/code-frame@7.24.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/highlight': 7.24.7
|
'@babel/highlight': 7.24.7
|
||||||
@@ -11151,6 +11318,26 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.9
|
'@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':
|
'@cypress/request@3.0.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
aws-sign2: 0.7.0
|
aws-sign2: 0.7.0
|
||||||
@@ -13441,6 +13628,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 0.7.34
|
'@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)':
|
'@types/email-templates@8.0.4(encoding@0.1.13)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/html-to-text': 9.0.4
|
'@types/html-to-text': 9.0.4
|
||||||
@@ -13497,6 +13688,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/istanbul-lib-report': 3.0.3
|
'@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-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/json-stable-stringify@1.0.36': {}
|
'@types/json-stable-stringify@1.0.36': {}
|
||||||
@@ -13617,6 +13814,8 @@ snapshots:
|
|||||||
'@types/express': 4.17.17
|
'@types/express': 4.17.17
|
||||||
'@types/serve-static': 1.15.7
|
'@types/serve-static': 1.15.7
|
||||||
|
|
||||||
|
'@types/tough-cookie@4.0.5': {}
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5': {}
|
'@types/triple-beam@1.3.5': {}
|
||||||
|
|
||||||
'@types/ua-parser-js@0.7.39': {}
|
'@types/ua-parser-js@0.7.39': {}
|
||||||
@@ -14930,6 +15129,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
css-tree: 1.1.3
|
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@2.6.21: {}
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
@@ -15004,6 +15208,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
assert-plus: 1.0.0
|
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:
|
data-view-buffer@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.7
|
call-bind: 1.0.7
|
||||||
@@ -15207,7 +15416,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
dompurify@3.2.3:
|
dompurify@3.2.4:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
@@ -16149,11 +16358,10 @@ snapshots:
|
|||||||
combined-stream: 1.0.8
|
combined-stream: 1.0.8
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
form-data@4.0.2:
|
form-data@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
combined-stream: 1.0.8
|
combined-stream: 1.0.8
|
||||||
es-set-tostringtag: 2.1.0
|
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
form-data@4.0.5:
|
form-data@4.0.5:
|
||||||
@@ -16520,6 +16728,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 6.0.0
|
lru-cache: 6.0.0
|
||||||
|
|
||||||
|
html-encoding-sniffer@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
html-to-text@9.0.5:
|
html-to-text@9.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@selderee/plugin-htmlparser2': 0.11.0
|
'@selderee/plugin-htmlparser2': 0.11.0
|
||||||
@@ -16611,6 +16823,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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@1.1.1: {}
|
||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
@@ -16881,6 +17100,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isobject: 3.0.1
|
isobject: 3.0.1
|
||||||
|
|
||||||
|
is-plain-object@5.0.0: {}
|
||||||
|
|
||||||
is-promise@2.2.2: {}
|
is-promise@2.2.2: {}
|
||||||
|
|
||||||
is-regex@1.1.4:
|
is-regex@1.1.4:
|
||||||
@@ -17119,6 +17340,34 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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@0.5.0: {}
|
||||||
|
|
||||||
jsesc@2.5.2: {}
|
jsesc@2.5.2: {}
|
||||||
@@ -17442,9 +17691,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.2.2: {}
|
||||||
|
|
||||||
lru-cache@11.2.1: {}
|
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18302,6 +18549,8 @@ snapshots:
|
|||||||
|
|
||||||
nullthrows@1.1.1: {}
|
nullthrows@1.1.1: {}
|
||||||
|
|
||||||
|
nwsapi@2.2.16: {}
|
||||||
|
|
||||||
oauth-sign@0.9.0: {}
|
oauth-sign@0.9.0: {}
|
||||||
|
|
||||||
ob1@0.80.12:
|
ob1@0.80.12:
|
||||||
@@ -18522,6 +18771,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
entities: 4.5.0
|
entities: 4.5.0
|
||||||
|
|
||||||
|
parse5@7.2.1:
|
||||||
|
dependencies:
|
||||||
|
entities: 4.5.0
|
||||||
|
|
||||||
parseley@0.12.1:
|
parseley@0.12.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
leac: 0.6.0
|
leac: 0.6.0
|
||||||
@@ -19515,6 +19768,8 @@ snapshots:
|
|||||||
|
|
||||||
rndm@1.2.0: {}
|
rndm@1.2.0: {}
|
||||||
|
|
||||||
|
rrweb-cssom@0.8.0: {}
|
||||||
|
|
||||||
run-applescript@3.2.0:
|
run-applescript@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
execa: 0.10.0
|
execa: 0.10.0
|
||||||
@@ -19582,6 +19837,10 @@ snapshots:
|
|||||||
|
|
||||||
sax@1.4.1: {}
|
sax@1.4.1: {}
|
||||||
|
|
||||||
|
saxes@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
scheduler@0.21.0:
|
scheduler@0.21.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -20223,6 +20482,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tldts-core: 6.1.78
|
tldts-core: 6.1.78
|
||||||
|
|
||||||
|
tldts-core@6.1.75: {}
|
||||||
|
|
||||||
|
tldts@6.1.75:
|
||||||
|
dependencies:
|
||||||
|
tldts-core: 6.1.75
|
||||||
|
|
||||||
tmp@0.0.33:
|
tmp@0.0.33:
|
||||||
dependencies:
|
dependencies:
|
||||||
os-tmpdir: 1.0.2
|
os-tmpdir: 1.0.2
|
||||||
@@ -20256,8 +20521,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tldts: 6.1.78
|
tldts: 6.1.78
|
||||||
|
|
||||||
|
tough-cookie@5.1.0:
|
||||||
|
dependencies:
|
||||||
|
tldts: 6.1.75
|
||||||
|
|
||||||
tr46@0.0.3: {}
|
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: {}
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
trim-lines@3.0.1: {}
|
trim-lines@3.0.1: {}
|
||||||
@@ -20672,6 +20947,10 @@ snapshots:
|
|||||||
|
|
||||||
void-elements@3.1.0: {}
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
|
w3c-xmlserializer@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
walker@1.0.8:
|
walker@1.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
makeerror: 1.0.12
|
makeerror: 1.0.12
|
||||||
@@ -20718,8 +20997,21 @@ snapshots:
|
|||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
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-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:
|
whatwg-url@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
@@ -20891,6 +21183,10 @@ snapshots:
|
|||||||
|
|
||||||
ws@7.5.10: {}
|
ws@7.5.10: {}
|
||||||
|
|
||||||
|
ws@8.18.0: {}
|
||||||
|
|
||||||
|
xml-name-validator@5.0.0: {}
|
||||||
|
|
||||||
xml2js@0.4.16:
|
xml2js@0.4.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
sax: 1.4.1
|
sax: 1.4.1
|
||||||
@@ -20914,6 +21210,8 @@ snapshots:
|
|||||||
|
|
||||||
xmlbuilder@9.0.7: {}
|
xmlbuilder@9.0.7: {}
|
||||||
|
|
||||||
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@4.0.3: {}
|
y18n@4.0.3: {}
|
||||||
|
|||||||
BIN
public/images/jellyseerr_poster_not_found_square.png
Normal file
BIN
public/images/jellyseerr_poster_not_found_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
public/images/overseerr_poster_not_found_square.png
Normal file
BIN
public/images/overseerr_poster_not_found_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB |
717
seerr-api.yml
717
seerr-api.yml
@@ -6274,6 +6274,91 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
example: Genre Name
|
example: Genre Name
|
||||||
/discover/music:
|
/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:
|
get:
|
||||||
summary: Discover trending music albums
|
summary: Discover trending music albums
|
||||||
description: Returns a list of trending albums from ListenBrainz in a JSON object
|
description: Returns a list of trending albums from ListenBrainz in a JSON object
|
||||||
@@ -6326,6 +6411,57 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/AlbumResult'
|
$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:
|
/discover/watchlist:
|
||||||
get:
|
get:
|
||||||
summary: Get the Plex watchlist.
|
summary: Get the Plex watchlist.
|
||||||
@@ -6458,7 +6594,46 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
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:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Succesfully created the request
|
description: Succesfully created the request
|
||||||
@@ -7056,6 +7231,26 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: en
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Returned person
|
description: Returned person
|
||||||
@@ -7099,10 +7294,37 @@ paths:
|
|||||||
$ref: '#/components/schemas/CreditCrew'
|
$ref: '#/components/schemas/CreditCrew'
|
||||||
id:
|
id:
|
||||||
type: number
|
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:
|
get:
|
||||||
summary: Get artist details
|
summary: Get artist details
|
||||||
description: Returns artist details in a JSON object.
|
description: Returns full artist details including discography and metadata
|
||||||
tags:
|
tags:
|
||||||
- music
|
- music
|
||||||
parameters:
|
parameters:
|
||||||
@@ -7111,188 +7333,149 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
|
example: 'f27ec8db-af05-4f36-916e-3d57f91ecf5e'
|
||||||
- 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
|
|
||||||
- in: query
|
- in: query
|
||||||
name: page
|
name: page
|
||||||
|
description: Page number for paginated results
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: integer
|
||||||
default: 1
|
default: 1
|
||||||
example: 1
|
example: 1
|
||||||
- in: query
|
|
||||||
name: type
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [Album, Single, EP, Other]
|
|
||||||
description: Filter releases by type
|
|
||||||
- in: query
|
- in: query
|
||||||
name: pageSize
|
name: pageSize
|
||||||
|
description: Number of items per page
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: integer
|
||||||
default: 20
|
default: 20
|
||||||
example: 20
|
example: 20
|
||||||
|
- in: query
|
||||||
|
name: albumType
|
||||||
|
description: Filter results by album type (Album, EP, Single, etc.)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 'Album'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Paginated artist discography
|
description: Artist details returned successfully
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
page:
|
name:
|
||||||
|
type: string
|
||||||
|
example: 'Artist Name'
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
example: 'Person'
|
||||||
|
area:
|
||||||
|
type: string
|
||||||
|
example: 'United States'
|
||||||
|
beginYear:
|
||||||
type: number
|
type: number
|
||||||
pageInfo:
|
example: 1990
|
||||||
|
endYear:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
wikipedia:
|
||||||
type: object
|
type: object
|
||||||
|
nullable: true
|
||||||
properties:
|
properties:
|
||||||
total:
|
content:
|
||||||
type: number
|
type: string
|
||||||
totalPages:
|
artistThumb:
|
||||||
type: number
|
type: string
|
||||||
results:
|
nullable: true
|
||||||
|
artistBackdrop:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
releaseGroups:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
mediaType:
|
||||||
|
type: string
|
||||||
|
enum: ['album']
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
type:
|
first-release-date:
|
||||||
type: string
|
type: string
|
||||||
enum: [Album, Single, EP, Other]
|
format: date
|
||||||
releasedate:
|
artist-credit:
|
||||||
type: string
|
|
||||||
images:
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
CoverType:
|
name:
|
||||||
type: string
|
|
||||||
Url:
|
|
||||||
type: string
|
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':
|
'404':
|
||||||
description: Artist not found
|
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:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
page:
|
status:
|
||||||
type: number
|
type: number
|
||||||
pageInfo:
|
example: 404
|
||||||
type: object
|
message:
|
||||||
properties:
|
type: string
|
||||||
total:
|
example: 'Artist not found'
|
||||||
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
|
|
||||||
'500':
|
'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}:
|
/music/{mbId}:
|
||||||
get:
|
get:
|
||||||
summary: Get album details
|
summary: Get album details
|
||||||
@@ -7331,10 +7514,10 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/AlbumResult'
|
$ref: '#/components/schemas/AlbumResult'
|
||||||
/music/{mbId}/wikipedia-extract:
|
/music/{mbId}/artist:
|
||||||
get:
|
get:
|
||||||
summary: Get artist Wikipedia extract
|
summary: Get artist details
|
||||||
description: Returns Wikipedia extract for the artist of the specified album
|
description: Returns details about the artist of the specified album
|
||||||
tags:
|
tags:
|
||||||
- music
|
- music
|
||||||
parameters:
|
parameters:
|
||||||
@@ -7343,221 +7526,57 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: da04991d-8e8a-43be-9892-1e6342a77410
|
example: b5b4bb4b-8ba5-3acf-88cb-4cae2699d8da
|
||||||
- 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
|
|
||||||
- in: query
|
- in: query
|
||||||
name: page
|
name: page
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: number
|
||||||
minimum: 1
|
|
||||||
default: 1
|
default: 1
|
||||||
- in: query
|
- in: query
|
||||||
name: language
|
name: pageSize
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: number
|
||||||
example: en
|
default: 20
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: List of artist albums
|
description: Artist details
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
page:
|
artist:
|
||||||
type: integer
|
type: object
|
||||||
totalPages:
|
properties:
|
||||||
type: integer
|
area:
|
||||||
totalResults:
|
type: string
|
||||||
type: integer
|
artist_mbid:
|
||||||
results:
|
type: string
|
||||||
type: array
|
begin_year:
|
||||||
items:
|
type: integer
|
||||||
type: object
|
mbid:
|
||||||
properties:
|
type: string
|
||||||
id:
|
name:
|
||||||
type: integer
|
type: string
|
||||||
mediaType:
|
rels:
|
||||||
type: string
|
type: object
|
||||||
enum: [music]
|
properties:
|
||||||
title:
|
free streaming:
|
||||||
type: string
|
type: string
|
||||||
artistName:
|
purchase for download:
|
||||||
type: string
|
type: string
|
||||||
posterPath:
|
social network:
|
||||||
type: string
|
type: string
|
||||||
mediaInfo:
|
streaming:
|
||||||
$ref: '#/components/schemas/MediaInfo'
|
type: string
|
||||||
'404':
|
wikidata:
|
||||||
description: Artist not found
|
type: string
|
||||||
content:
|
youtube:
|
||||||
application/json:
|
type: string
|
||||||
schema:
|
tag:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
type:
|
||||||
message:
|
type: string
|
||||||
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:
|
|
||||||
'404':
|
'404':
|
||||||
description: Artist not found
|
description: Artist not found
|
||||||
content:
|
content:
|
||||||
@@ -7565,6 +7584,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
'500':
|
'500':
|
||||||
@@ -7574,6 +7595,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
/media:
|
/media:
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import type { CoverArtResponse } from './interfaces';
|
import type { CoverArtResponse } from './interfaces';
|
||||||
|
|
||||||
class CoverArtArchive extends ExternalAPI {
|
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(
|
super(
|
||||||
'https://coverartarchive.org',
|
'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<string | null | undefined> {
|
||||||
|
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<CoverArtResponse> {
|
public async getCoverArt(id: string): Promise<CoverArtResponse> {
|
||||||
|
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<CoverArtResponse> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<CoverArtResponse>(
|
const data = await this.get<CoverArtResponse>(
|
||||||
`/release-group/${id}`,
|
`/release-group/${id}`,
|
||||||
undefined,
|
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;
|
return data;
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
throw new Error(
|
await getRepository(MetadataAlbum).upsert(
|
||||||
`[CoverArtArchive] Failed to fetch cover art: ${e.message}`
|
{ mbAlbumId: id, caaUrl: null },
|
||||||
|
{ conflictPaths: ['mbAlbumId'] }
|
||||||
);
|
);
|
||||||
|
return this.createEmptyResponse(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async batchGetCoverArt(
|
||||||
|
ids: string[]
|
||||||
|
): Promise<Record<string, string | null>> {
|
||||||
|
if (!ids.length) return {};
|
||||||
|
|
||||||
|
const metadataRepository = getRepository(MetadataAlbum);
|
||||||
|
const existingMetadata = await metadataRepository.find({
|
||||||
|
where: { mbAlbumId: In(ids) },
|
||||||
|
select: ['mbAlbumId', 'caaUrl', 'updatedAt'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: Record<string, string | null> = {};
|
||||||
|
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;
|
export default CoverArtArchive;
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
interface CoverArtThumbnails {
|
interface CoverArtThumbnails {
|
||||||
1200: string;
|
|
||||||
250: string;
|
250: string;
|
||||||
500: string;
|
|
||||||
large: string;
|
|
||||||
small: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoverArtImage {
|
interface CoverArtImage {
|
||||||
approved: boolean;
|
approved: boolean;
|
||||||
back: boolean;
|
|
||||||
comment: string;
|
|
||||||
edit: number;
|
|
||||||
front: boolean;
|
front: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
image: string;
|
|
||||||
thumbnails: CoverArtThumbnails;
|
thumbnails: CoverArtThumbnails;
|
||||||
types: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoverArtResponse {
|
export interface CoverArtResponse {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import type {
|
import type {
|
||||||
LbSimilarArtistResponse,
|
LbAlbumDetails,
|
||||||
|
LbArtistDetails,
|
||||||
|
LbFreshReleasesResponse,
|
||||||
LbTopAlbumsResponse,
|
LbTopAlbumsResponse,
|
||||||
|
LbTopArtistsResponse,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
|
|
||||||
class ListenBrainzAPI extends ExternalAPI {
|
class ListenBrainzAPI extends ExternalAPI {
|
||||||
@@ -13,48 +16,60 @@ class ListenBrainzAPI extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
nodeCache: cacheManager.getCache('listenbrainz').data,
|
nodeCache: cacheManager.getCache('listenbrainz').data,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
maxRPS: 50,
|
maxRPS: 25,
|
||||||
id: 'listenbrainz',
|
id: 'listenbrainz',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSimilarArtists(
|
public async getAlbum(mbid: string): Promise<LbAlbumDetails> {
|
||||||
artistMbid: string,
|
try {
|
||||||
options: {
|
return await this.getRolling<LbAlbumDetails>(
|
||||||
days?: number;
|
`/album/${mbid}`,
|
||||||
session?: number;
|
{},
|
||||||
contribution?: number;
|
43200,
|
||||||
threshold?: number;
|
{
|
||||||
limit?: number;
|
method: 'POST',
|
||||||
skip?: number;
|
headers: {
|
||||||
} = {}
|
'Content-Type': 'application/json',
|
||||||
): Promise<LbSimilarArtistResponse[]> {
|
},
|
||||||
const {
|
body: JSON.stringify({}),
|
||||||
days = 9000,
|
},
|
||||||
session = 300,
|
'https://listenbrainz.org'
|
||||||
contribution = 5,
|
);
|
||||||
threshold = 15,
|
} catch (e) {
|
||||||
limit = 50,
|
throw new Error(
|
||||||
skip = 30,
|
`[ListenBrainz] Failed to fetch album details: ${e.message}`
|
||||||
} = options;
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.getRolling<LbSimilarArtistResponse[]>(
|
public async getArtist(mbid: string): Promise<LbArtistDetails> {
|
||||||
'/similar-artists/json',
|
try {
|
||||||
{
|
return await this.getRolling<LbArtistDetails>(
|
||||||
artist_mbids: artistMbid,
|
`/artist/${mbid}`,
|
||||||
algorithm: `session_based_days_${days}_session_${session}_contribution_${contribution}_threshold_${threshold}_limit_${limit}_skip_${skip}`,
|
{},
|
||||||
},
|
43200,
|
||||||
43200,
|
{
|
||||||
undefined,
|
method: 'POST',
|
||||||
'https://labs.api.listenbrainz.org'
|
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({
|
public async getTopAlbums({
|
||||||
offset = 0,
|
offset = 0,
|
||||||
range = 'week',
|
range = 'month',
|
||||||
count = 20,
|
count = 20,
|
||||||
}: {
|
}: {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@@ -71,6 +86,49 @@ class ListenBrainzAPI extends ExternalAPI {
|
|||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getTopArtists({
|
||||||
|
offset = 0,
|
||||||
|
range = 'month',
|
||||||
|
count = 20,
|
||||||
|
}: {
|
||||||
|
offset?: number;
|
||||||
|
range?: string;
|
||||||
|
count?: number;
|
||||||
|
}): Promise<LbTopArtistsResponse> {
|
||||||
|
return this.get<LbTopArtistsResponse>(
|
||||||
|
'/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<LbFreshReleasesResponse> {
|
||||||
|
return this.get<LbFreshReleasesResponse>(
|
||||||
|
'/explore/fresh-releases',
|
||||||
|
{
|
||||||
|
days: days.toString(),
|
||||||
|
sort,
|
||||||
|
offset: offset.toString(),
|
||||||
|
count: count.toString(),
|
||||||
|
},
|
||||||
|
43200
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ListenBrainzAPI;
|
export default ListenBrainzAPI;
|
||||||
|
|||||||
@@ -29,3 +29,215 @@ export interface LbTopAlbumsResponse {
|
|||||||
to_ts: number;
|
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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,274 +1,164 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import type {
|
import { JSDOM } from 'jsdom';
|
||||||
MbAlbumDetails,
|
import type { MbAlbumDetails, MbArtistDetails } from './interfaces';
|
||||||
MbArtistDetails,
|
|
||||||
MbLink,
|
const window = new JSDOM('').window;
|
||||||
MbSearchMultiResponse,
|
const purify = DOMPurify(window);
|
||||||
} from './interfaces';
|
|
||||||
|
|
||||||
class MusicBrainz extends ExternalAPI {
|
class MusicBrainz extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(
|
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,
|
nodeCache: cacheManager.getCache('musicbrainz').data,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
maxRPS: 50,
|
maxRPS: 25,
|
||||||
id: 'musicbrainz',
|
id: 'musicbrainz',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public searchMulti = async ({
|
public async searchAlbum({
|
||||||
query,
|
query,
|
||||||
|
limit = 30,
|
||||||
|
offset = 0,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
}): Promise<MbSearchMultiResponse[]> => {
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<MbAlbumDetails[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<MbSearchMultiResponse[]>('/search', {
|
const data = await this.get<{
|
||||||
type: 'all',
|
created: string;
|
||||||
query,
|
count: number;
|
||||||
});
|
offset: number;
|
||||||
|
'release-groups': MbAlbumDetails[];
|
||||||
return data.filter(
|
}>(
|
||||||
(result) => !result.artist || result.artist.type === 'Group'
|
'/release-group',
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public async searchArtist({
|
|
||||||
query,
|
|
||||||
}: {
|
|
||||||
query: string;
|
|
||||||
}): Promise<MbArtistDetails[]> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<MbArtistDetails[]>(
|
|
||||||
'/search',
|
|
||||||
{
|
{
|
||||||
type: 'artist',
|
|
||||||
query,
|
query,
|
||||||
|
fmt: 'json',
|
||||||
|
limit: limit.toString(),
|
||||||
|
offset: offset.toString(),
|
||||||
},
|
},
|
||||||
43200
|
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<MbArtistDetails[]> {
|
||||||
|
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) {
|
} catch (e) {
|
||||||
throw new Error(`[MusicBrainz] Failed to search artists: ${e.message}`);
|
throw new Error(`[MusicBrainz] Failed to search artists: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAlbum({
|
public async getArtistWikipediaExtract({
|
||||||
albumId,
|
artistMbid,
|
||||||
|
language = 'en',
|
||||||
}: {
|
}: {
|
||||||
albumId?: string;
|
artistMbid: string;
|
||||||
}): Promise<MbAlbumDetails> {
|
language?: string;
|
||||||
|
}): Promise<{ title: string; url: string; content: string } | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<MbAlbumDetails>(
|
const response = await fetch(
|
||||||
`/album/${albumId}`,
|
`https://musicbrainz.org/artist/${artistMbid}/wikipedia-extract`,
|
||||||
{},
|
{
|
||||||
43200
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Accept-Language': language,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
if (!response.ok) {
|
||||||
} catch (e) {
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
throw new Error(
|
}
|
||||||
`[MusicBrainz] Failed to fetch album details: ${e.message}`
|
|
||||||
);
|
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<MbArtistDetails> {
|
|
||||||
try {
|
|
||||||
const artistData = await this.get<MbArtistDetails>(
|
|
||||||
`/artist/${artistId}`,
|
|
||||||
{},
|
|
||||||
43200
|
|
||||||
);
|
|
||||||
|
|
||||||
return artistData;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`[MusicBrainz] Failed to fetch artist details: ${e.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static requestQueue: Promise<void> = Promise.resolve();
|
|
||||||
private lastRequestTime = 0;
|
|
||||||
private readonly RATE_LIMIT_DELAY = 1100;
|
|
||||||
|
|
||||||
public async getReleaseGroup({
|
public async getReleaseGroup({
|
||||||
releaseId,
|
releaseId,
|
||||||
}: {
|
}: {
|
||||||
releaseId: string;
|
releaseId: string;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
await MusicBrainz.requestQueue;
|
const data = await this.get<{
|
||||||
|
'release-group': {
|
||||||
MusicBrainz.requestQueue = (async () => {
|
id: string;
|
||||||
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<any>(
|
|
||||||
`/release/${releaseId}`,
|
`/release/${releaseId}`,
|
||||||
{
|
{
|
||||||
inc: 'release-groups',
|
inc: 'release-groups',
|
||||||
fmt: 'json',
|
fmt: 'json',
|
||||||
},
|
},
|
||||||
43200,
|
43200
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'User-Agent':
|
|
||||||
'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr; hello@jellyseerr.com)',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'https://musicbrainz.org/ws/2'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return data['release-group']?.id || null;
|
return data['release-group']?.id ?? null;
|
||||||
} catch (e) {
|
} 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(
|
throw new Error(
|
||||||
`[MusicBrainz] Failed to fetch release group: ${e.message}`
|
`[MusicBrainz] Failed to fetch release group: ${e.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getWikipediaExtract(
|
|
||||||
id: string,
|
|
||||||
language = 'en',
|
|
||||||
type: 'artist' | 'album' = 'album'
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const data =
|
|
||||||
type === 'album'
|
|
||||||
? await this.get<MbAlbumDetails>(`/album/${id}`, { language }, 43200)
|
|
||||||
: await this.get<MbArtistDetails>(
|
|
||||||
`/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<MbArtistDetails>(
|
|
||||||
`/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, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
|
|
||||||
return decoded;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MusicBrainz;
|
export default MusicBrainz;
|
||||||
|
|||||||
@@ -1,126 +1,119 @@
|
|||||||
interface MbMediaResult {
|
interface MbResult {
|
||||||
id: string;
|
id: string;
|
||||||
score: number;
|
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 {
|
export interface MbLink {
|
||||||
target: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
|
target: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MbSearchMultiResponse {
|
export interface MbAlbumResult extends MbResult {
|
||||||
artist: MbArtistResult | null;
|
media_type: 'album';
|
||||||
album: MbAlbumResult | null;
|
title: string;
|
||||||
score: number;
|
'primary-type': 'Album' | 'Single' | 'EP';
|
||||||
}
|
'first-release-date': string;
|
||||||
|
'artist-credit': {
|
||||||
export interface MbArtistDetails extends MbArtistResult {
|
name: string;
|
||||||
artistaliases: string[];
|
artist: {
|
||||||
oldids: string[];
|
id: string;
|
||||||
links: MbLink[];
|
name: string;
|
||||||
images: MbImage[];
|
'sort-name': string;
|
||||||
rating: {
|
overview?: string;
|
||||||
Count: number;
|
};
|
||||||
Value: number | null;
|
}[];
|
||||||
};
|
posterPath: string | undefined;
|
||||||
Albums?: Album[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MbAlbumDetails extends MbAlbumResult {
|
export interface MbAlbumDetails extends MbAlbumResult {
|
||||||
aliases: string[];
|
'type-id': string;
|
||||||
artists: MbArtistResult[];
|
'primary-type-id': string;
|
||||||
releases: MbRelease[];
|
count: number;
|
||||||
rating: {
|
'secondary-types'?: string[];
|
||||||
Count: number;
|
'secondary-type-ids'?: string[];
|
||||||
Value: number | null;
|
releases: {
|
||||||
};
|
id: string;
|
||||||
overview: string;
|
title: string;
|
||||||
secondaryTypes: string[];
|
status: string;
|
||||||
}
|
'status-id': 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;
|
|
||||||
}[];
|
}[];
|
||||||
|
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)[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export interface LidarrArtistResult extends LidarrMediaResult {
|
|||||||
|
|
||||||
export interface LidarrAlbumResult extends LidarrMediaResult {
|
export interface LidarrAlbumResult extends LidarrMediaResult {
|
||||||
album: {
|
album: {
|
||||||
|
disambiguation: string;
|
||||||
|
duration: number;
|
||||||
|
mediumCount: number;
|
||||||
|
ratings: LidarrRating | undefined;
|
||||||
|
links: never[];
|
||||||
media_type: 'music';
|
media_type: 'music';
|
||||||
title: string;
|
title: string;
|
||||||
foreignAlbumId: string;
|
foreignAlbumId: string;
|
||||||
@@ -29,6 +34,22 @@ export interface LidarrAlbumResult extends LidarrMediaResult {
|
|||||||
genres: string[];
|
genres: string[];
|
||||||
images: LidarrImage[];
|
images: LidarrImage[];
|
||||||
artist: {
|
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;
|
artistName: string;
|
||||||
overview: string;
|
overview: string;
|
||||||
};
|
};
|
||||||
@@ -60,6 +81,8 @@ export interface LidarrArtistDetails {
|
|||||||
added: string;
|
added: string;
|
||||||
ratings: LidarrRating;
|
ratings: LidarrRating;
|
||||||
remotePoster?: string;
|
remotePoster?: string;
|
||||||
|
cleanName?: string;
|
||||||
|
sortName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LidarrAlbumDetails {
|
export interface LidarrAlbumDetails {
|
||||||
@@ -165,24 +188,53 @@ export interface LidarrSearchResponse {
|
|||||||
|
|
||||||
export interface LidarrAlbumOptions {
|
export interface LidarrAlbumOptions {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
profileId: number;
|
|
||||||
mbId: string;
|
|
||||||
qualityProfileId: number;
|
|
||||||
rootFolderPath: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
monitored: boolean;
|
disambiguation?: string;
|
||||||
tags: string[];
|
overview?: string;
|
||||||
searchNow: boolean;
|
|
||||||
artistId: number;
|
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: {
|
artist: {
|
||||||
id: number;
|
status: string;
|
||||||
foreignArtistId: string;
|
ended: boolean;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
|
foreignArtistId: string;
|
||||||
|
tadbId?: number;
|
||||||
|
discogsId?: number;
|
||||||
|
overview?: string;
|
||||||
|
artistType: string;
|
||||||
|
disambiguation?: string;
|
||||||
|
links: LidarrLink[];
|
||||||
|
images: LidarrImage[];
|
||||||
|
path: string;
|
||||||
qualityProfileId: number;
|
qualityProfileId: number;
|
||||||
metadataProfileId: number;
|
metadataProfileId: number;
|
||||||
rootFolderPath: string;
|
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
monitorNewItems: string;
|
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<string, unknown> {
|
||||||
|
name: 'AlbumSearch';
|
||||||
|
albumIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
class LidarrAPI extends ServarrBase<{ albumId: number }> {
|
class LidarrAPI extends ServarrBase<{ albumId: number }> {
|
||||||
protected apiKey: string;
|
protected apiKey: string;
|
||||||
constructor({ url, apiKey }: { url: string; 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<LidarrArtistDetails> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<LidarrArtistDetails>(`/artist/${id}`);
|
|
||||||
return data;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAlbum({ id }: { id: number }): Promise<LidarrAlbum> {
|
public async getAlbum({ id }: { id: number }): Promise<LidarrAlbum> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<LidarrAlbum>(`/album/${id}`);
|
const data = await this.get<LidarrAlbum>(`/album/${id}`);
|
||||||
@@ -255,29 +303,6 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAlbumByMusicBrainzId(
|
|
||||||
mbId: string
|
|
||||||
): Promise<LidarrAlbumDetails> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<LidarrAlbumDetails[]>('/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<void> {
|
public async removeAlbum(albumId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.delete(`/album/${albumId}`, {
|
await this.delete(`/album/${albumId}`, {
|
||||||
@@ -290,215 +315,60 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getArtistByMusicBrainzId(
|
public async searchAlbum(mbid: string): Promise<LidarrAlbumResult[]> {
|
||||||
mbId: string
|
|
||||||
): Promise<LidarrArtistDetails> {
|
|
||||||
try {
|
try {
|
||||||
const data = await this.get<LidarrArtistDetails[]>('/artist/lookup', {
|
const data = await this.get<LidarrAlbumResult[]>(`/search`, {
|
||||||
term: `lidarr:${mbId}`,
|
term: `lidarr:${mbid}`,
|
||||||
});
|
});
|
||||||
|
return data;
|
||||||
if (!data[0]) {
|
|
||||||
throw new Error('Artist not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data[0];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving artist by foreign ID', {
|
throw new Error(`[Lidarr] Failed to search album: ${e.message}`);
|
||||||
label: 'Lidarr API',
|
|
||||||
errorMessage: e.message,
|
|
||||||
mbId: mbId,
|
|
||||||
});
|
|
||||||
throw new Error('Artist not found');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addAlbum(
|
public async addAlbum(options: LidarrAlbumOptions): Promise<LidarrAlbum> {
|
||||||
options: LidarrAlbumOptions
|
|
||||||
): Promise<LidarrAlbumDetails> {
|
|
||||||
try {
|
try {
|
||||||
const data = await this.post<LidarrAlbumDetails>('/album', options);
|
const existingAlbums = await this.get<LidarrAlbum[]>('/album', {
|
||||||
return data;
|
foreignAlbumId: options.foreignAlbumId,
|
||||||
} catch (e) {
|
includeAllArtistAlbums: 'true',
|
||||||
if (e.message.includes('This album has already been added')) {
|
});
|
||||||
logger.info('Album already exists in Lidarr, monitoring it in Lidarr', {
|
|
||||||
label: 'Lidarr',
|
if (existingAlbums.length > 0 && existingAlbums[0].monitored) {
|
||||||
albumTitle: options.title,
|
logger.info(
|
||||||
mbId: options.mbId,
|
'Album is already monitored in Lidarr. Skipping add and returning success',
|
||||||
});
|
{
|
||||||
throw e;
|
label: 'Lidarr',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return existingAlbums[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Failed to add album to Lidarr', {
|
const data = await this.post<LidarrAlbum>('/album', {
|
||||||
label: 'Lidarr',
|
...options,
|
||||||
options,
|
monitored: true,
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
});
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
throw new Error(`[Lidarr] Failed to add album: ${e.message}`);
|
throw new Error(`[Lidarr] Failed to add album: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addArtist(
|
public async searchAlbumByMusicBrainzId(
|
||||||
options: LidarrArtistOptions
|
mbid: string
|
||||||
): Promise<LidarrArtistDetails> {
|
): Promise<LidarrAlbumResult[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.post<LidarrArtistDetails>('/artist', options);
|
const data = await this.get<LidarrAlbumResult[]>('/search', {
|
||||||
|
term: `lidarr:${mbid}`,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to add artist to Lidarr', {
|
throw new Error(
|
||||||
label: 'Lidarr',
|
`[Lidarr] Failed to search album by MusicBrainz ID: ${e.message}`
|
||||||
options,
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
throw new Error(`[Lidarr] Failed to add artist: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async searchMulti(searchTerm: string): Promise<LidarrSearchResponse> {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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(
|
public async searchOnAdd(albumId: number): Promise<void> {
|
||||||
artist: LidarrArtistDetails
|
|
||||||
): Promise<LidarrArtistDetails> {
|
|
||||||
try {
|
|
||||||
const data = await this.put<LidarrArtistDetails>(`/artist/${artist.id}`, {
|
|
||||||
...artist,
|
|
||||||
} as Record<string, unknown>);
|
|
||||||
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<LidarrAlbumDetails> {
|
|
||||||
try {
|
|
||||||
const data = await this.put<LidarrAlbumDetails>(`/album/${album.id}`, {
|
|
||||||
...album,
|
|
||||||
} as Record<string, unknown>);
|
|
||||||
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<void> {
|
|
||||||
logger.info('Executing album search command', {
|
logger.info('Executing album search command', {
|
||||||
label: 'Lidarr API',
|
label: 'Lidarr API',
|
||||||
albumId,
|
albumId,
|
||||||
|
|||||||
227
server/api/theaudiodb/index.ts
Normal file
227
server/api/theaudiodb/index.ts
Normal file
@@ -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<TadbArtistResponse>(
|
||||||
|
`/${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;
|
||||||
8
server/api/theaudiodb/interfaces.ts
Normal file
8
server/api/theaudiodb/interfaces.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
interface TadbArtist {
|
||||||
|
strArtistThumb: string | null;
|
||||||
|
strArtistFanart: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TadbArtistResponse {
|
||||||
|
artists?: TadbArtist[];
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import type {
|
|||||||
TmdbNetwork,
|
TmdbNetwork,
|
||||||
TmdbPersonCombinedCredits,
|
TmdbPersonCombinedCredits,
|
||||||
TmdbPersonDetails,
|
TmdbPersonDetails,
|
||||||
TmdbPersonSearchResponse,
|
|
||||||
TmdbProductionCompany,
|
TmdbProductionCompany,
|
||||||
TmdbRegion,
|
TmdbRegion,
|
||||||
TmdbSearchMovieResponse,
|
TmdbSearchMovieResponse,
|
||||||
@@ -231,31 +230,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public async searchPerson({
|
|
||||||
query,
|
|
||||||
page = 1,
|
|
||||||
includeAdult = false,
|
|
||||||
language = 'en',
|
|
||||||
}: SearchOptions): Promise<TmdbPersonSearchResponse> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<TmdbPersonSearchResponse>('/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 ({
|
public getPerson = async ({
|
||||||
personId,
|
personId,
|
||||||
language = this.locale,
|
language = this.locale,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface TmdbCollectionResult {
|
|||||||
|
|
||||||
export interface TmdbPersonResult {
|
export interface TmdbPersonResult {
|
||||||
id: number;
|
id: number;
|
||||||
|
known_for_department: string;
|
||||||
name: string;
|
name: string;
|
||||||
popularity: number;
|
popularity: number;
|
||||||
profile_path?: string;
|
profile_path?: string;
|
||||||
@@ -464,20 +465,12 @@ export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
|||||||
results: TmdbCompany[];
|
results: TmdbCompany[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbSearchPersonResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbPersonResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TmdbWatchProviderRegion {
|
export interface TmdbWatchProviderRegion {
|
||||||
iso_3166_1: string;
|
iso_3166_1: string;
|
||||||
english_name: string;
|
english_name: string;
|
||||||
native_name: string;
|
native_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbPersonSearchResponse extends TmdbPaginatedResponse {
|
|
||||||
results: TmdbPersonSearchResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TmdbPersonSearchResult
|
|
||||||
extends Omit<TmdbPersonResult, 'known_for'> {
|
|
||||||
gender: number;
|
|
||||||
known_for_department: string;
|
|
||||||
original_name: string;
|
|
||||||
known_for: (TmdbMovieResult | TmdbTvResult)[];
|
|
||||||
}
|
|
||||||
|
|||||||
348
server/api/themoviedb/personMapper.ts
Normal file
348
server/api/themoviedb/personMapper.ts
Normal file
@@ -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<TmdbSearchPersonResponse>(
|
||||||
|
'/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<string, { personId: number | null; profilePath: string | null }>
|
||||||
|
> {
|
||||||
|
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<TmdbSearchPersonResponse> {
|
||||||
|
try {
|
||||||
|
return await this.get<TmdbSearchPersonResponse>(
|
||||||
|
'/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;
|
||||||
@@ -4,7 +4,6 @@ export enum DiscoverSliderType {
|
|||||||
RECENTLY_ADDED = 1,
|
RECENTLY_ADDED = 1,
|
||||||
RECENT_REQUESTS,
|
RECENT_REQUESTS,
|
||||||
PLEX_WATCHLIST,
|
PLEX_WATCHLIST,
|
||||||
POPULAR_ALBUMS,
|
|
||||||
TRENDING,
|
TRENDING,
|
||||||
POPULAR_MOVIES,
|
POPULAR_MOVIES,
|
||||||
MOVIE_GENRES,
|
MOVIE_GENRES,
|
||||||
@@ -23,6 +22,8 @@ export enum DiscoverSliderType {
|
|||||||
TMDB_NETWORK,
|
TMDB_NETWORK,
|
||||||
TMDB_MOVIE_STREAMING_SERVICES,
|
TMDB_MOVIE_STREAMING_SERVICES,
|
||||||
TMDB_TV_STREAMING_SERVICES,
|
TMDB_TV_STREAMING_SERVICES,
|
||||||
|
POPULAR_ALBUMS,
|
||||||
|
POPULAR_ARTISTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||||
@@ -51,57 +52,63 @@ export const defaultSliders: Partial<DiscoverSlider>[] = [
|
|||||||
order: 3,
|
order: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.POPULAR_ALBUMS,
|
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 4,
|
order: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.POPULAR_MOVIES,
|
type: DiscoverSliderType.MOVIE_GENRES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 5,
|
order: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.MOVIE_GENRES,
|
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 6,
|
order: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.UPCOMING_MOVIES,
|
type: DiscoverSliderType.STUDIOS,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 7,
|
order: 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.STUDIOS,
|
type: DiscoverSliderType.POPULAR_TV,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 8,
|
order: 8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.POPULAR_TV,
|
type: DiscoverSliderType.TV_GENRES,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 9,
|
order: 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.TV_GENRES,
|
type: DiscoverSliderType.UPCOMING_TV,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 10,
|
order: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.UPCOMING_TV,
|
type: DiscoverSliderType.NETWORKS,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 11,
|
order: 11,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: DiscoverSliderType.NETWORKS,
|
type: DiscoverSliderType.POPULAR_ALBUMS,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
order: 12,
|
order: 12,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_ARTISTS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 13,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ export enum IssueType {
|
|||||||
VIDEO = 1,
|
VIDEO = 1,
|
||||||
AUDIO = 2,
|
AUDIO = 2,
|
||||||
SUBTITLES = 3,
|
SUBTITLES = 3,
|
||||||
LYRICS = 4,
|
OTHER = 4,
|
||||||
OTHER = 5,
|
LYRICS = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum IssueStatus {
|
export enum IssueStatus {
|
||||||
@@ -15,6 +15,6 @@ export const IssueTypeName = {
|
|||||||
[IssueType.AUDIO]: 'Audio',
|
[IssueType.AUDIO]: 'Audio',
|
||||||
[IssueType.VIDEO]: 'Video',
|
[IssueType.VIDEO]: 'Video',
|
||||||
[IssueType.SUBTITLES]: 'Subtitle',
|
[IssueType.SUBTITLES]: 'Subtitle',
|
||||||
[IssueType.LYRICS]: 'Lyrics',
|
|
||||||
[IssueType.OTHER]: 'Other',
|
[IssueType.OTHER]: 'Other',
|
||||||
|
[IssueType.LYRICS]: 'Lyrics',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,39 +30,38 @@ import Season from './Season';
|
|||||||
class Media {
|
class Media {
|
||||||
public static async getRelatedMedia(
|
public static async getRelatedMedia(
|
||||||
user: User | undefined,
|
user: User | undefined,
|
||||||
ids: (number | string)[]
|
ids: number | number[] | string | string[]
|
||||||
): Promise<Media[]> {
|
): Promise<Media[]> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ids.length === 0) {
|
let finalIds: (number | string)[];
|
||||||
|
if (!Array.isArray(ids)) {
|
||||||
|
finalIds = [ids];
|
||||||
|
} else {
|
||||||
|
finalIds = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdbIds = ids.filter((id): id is number => typeof id === 'number');
|
const media = await mediaRepository
|
||||||
const mbIds = ids.filter((id): id is string => typeof id === 'string');
|
|
||||||
|
|
||||||
const queryBuilder = mediaRepository
|
|
||||||
.createQueryBuilder('media')
|
.createQueryBuilder('media')
|
||||||
.leftJoinAndSelect(
|
.leftJoinAndSelect(
|
||||||
'media.watchlists',
|
'media.watchlists',
|
||||||
'watchlist',
|
'watchlist',
|
||||||
'media.id = watchlist.media and watchlist.requestedBy = :userId',
|
'media.id = watchlist.media and watchlist.requestedBy = :userId',
|
||||||
{ userId: user?.id }
|
{ 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;
|
return media;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e.message);
|
logger.error(e.message);
|
||||||
@@ -77,13 +76,11 @@ class Media {
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const whereClause =
|
|
||||||
typeof id === 'string'
|
|
||||||
? { mbId: id, mediaType }
|
|
||||||
: { tmdbId: id, mediaType };
|
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: whereClause,
|
where:
|
||||||
|
typeof id === 'string'
|
||||||
|
? { mbId: id, mediaType }
|
||||||
|
: { tmdbId: id, mediaType },
|
||||||
relations: { requests: true, issues: true },
|
relations: { requests: true, issues: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import type { LidarrAlbumDetails } from '@server/api/servarr/lidarr';
|
import CoverArtArchive from '@server/api/coverartarchive';
|
||||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||||
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces';
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
import MusicBrainz from '@server/api/musicbrainz';
|
||||||
import type {
|
|
||||||
AddSeriesOptions,
|
|
||||||
SonarrSeries,
|
|
||||||
} from '@server/api/servarr/sonarr';
|
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
import type {
|
import type {
|
||||||
|
TmdbKeyword,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/themoviedb/interfaces';
|
||||||
@@ -58,12 +54,8 @@ export class MediaRequest {
|
|||||||
requestBody: MediaRequestBody,
|
requestBody: MediaRequestBody,
|
||||||
user: User,
|
user: User,
|
||||||
options: MediaRequestOptions = {}
|
options: MediaRequestOptions = {}
|
||||||
): Promise<MediaRequest | undefined> {
|
): Promise<MediaRequest> {
|
||||||
const tmdb = new TheMovieDb();
|
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 mediaRepository = getRepository(Media);
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
const userRepository = getRepository(User);
|
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();
|
const quotas = await requestUser.getQuota();
|
||||||
|
|
||||||
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
||||||
@@ -152,51 +130,55 @@ export class MediaRequest {
|
|||||||
throw new QuotaRestrictedError('Music Quota exceeded.');
|
throw new QuotaRestrictedError('Music Quota exceeded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdbMedia =
|
const requestedMedia =
|
||||||
requestBody.mediaType === MediaType.MOVIE
|
requestBody.mediaType === MediaType.MOVIE
|
||||||
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||||
: requestBody.mediaType === MediaType.TV
|
: requestBody.mediaType === MediaType.TV
|
||||||
? await tmdb.getTvShow({ tvId: requestBody.mediaId })
|
? await tmdb.getTvShow({ tvId: requestBody.mediaId })
|
||||||
: await lidarr.getAlbumByMusicBrainzId(requestBody.mediaId.toString());
|
: await new ListenBrainzAPI().getAlbum(requestBody.mediaId.toString());
|
||||||
|
|
||||||
let media = await mediaRepository.findOne({
|
let media = await mediaRepository.findOne({
|
||||||
where: {
|
where:
|
||||||
mbId:
|
requestBody.mediaType === MediaType.MUSIC
|
||||||
requestBody.mediaType === MediaType.MUSIC
|
? {
|
||||||
? requestBody.mediaId.toString()
|
mbId: requestBody.mediaId.toString(),
|
||||||
: undefined,
|
mediaType: requestBody.mediaType,
|
||||||
tmdbId:
|
}
|
||||||
requestBody.mediaType !== MediaType.MUSIC
|
: { tmdbId: requestBody.mediaId, mediaType: requestBody.mediaType },
|
||||||
? requestBody.mediaId
|
|
||||||
: undefined,
|
|
||||||
mediaType: requestBody.mediaType,
|
|
||||||
},
|
|
||||||
relations: ['requests'],
|
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) {
|
if (!media) {
|
||||||
media = new Media({
|
media = new Media({
|
||||||
mbId:
|
tmdbId: isTmdbMedia(requestedMedia) ? requestedMedia.id : undefined,
|
||||||
requestBody.mediaType === MediaType.MUSIC
|
mbId: isLbAlbum(requestedMedia)
|
||||||
? requestBody.mediaId.toString()
|
? requestedMedia.release_group_mbid
|
||||||
: undefined,
|
: undefined,
|
||||||
tmdbId:
|
tvdbId: isTmdbMedia(requestedMedia)
|
||||||
requestBody.mediaType !== MediaType.MUSIC
|
? requestBody.tvdbId ?? requestedMedia.external_ids?.tvdb_id
|
||||||
? requestBody.mediaId
|
: undefined,
|
||||||
: undefined,
|
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||||
|
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (media.status === MediaStatus.BLACKLISTED) {
|
if (media.status === MediaStatus.BLACKLISTED) {
|
||||||
logger.warn('Request for media blocked due to being blacklisted', {
|
logger.warn('Request for media blocked due to being blacklisted', {
|
||||||
mbId:
|
id: isLbAlbum(requestedMedia)
|
||||||
requestBody.mediaType === MediaType.MUSIC
|
? requestedMedia.release_group_mbid
|
||||||
? requestBody.mediaId
|
: requestedMedia.id,
|
||||||
: undefined,
|
|
||||||
tmdbId:
|
|
||||||
requestBody.mediaType !== MediaType.MUSIC
|
|
||||||
? tmdbMedia.id
|
|
||||||
: undefined,
|
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
});
|
});
|
||||||
@@ -207,19 +189,31 @@ export class MediaRequest {
|
|||||||
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||||
media.status = MediaStatus.PENDING;
|
media.status = MediaStatus.PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
|
||||||
|
media.status4k = MediaStatus.PENDING;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await requestRepository
|
const existing = await requestRepository
|
||||||
.createQueryBuilder('request')
|
.createQueryBuilder('request')
|
||||||
.leftJoin('request.media', 'media')
|
.leftJoin('request.media', 'media')
|
||||||
.leftJoinAndSelect('request.requestedBy', 'user')
|
.leftJoinAndSelect('request.requestedBy', 'user')
|
||||||
.where(
|
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
||||||
requestBody.mediaType === MediaType.MUSIC
|
.andWhere(
|
||||||
|
requestBody.mediaType === 'music'
|
||||||
? 'media.mbId = :mbId'
|
? 'media.mbId = :mbId'
|
||||||
: 'media.tmdbId = :tmdbId',
|
: 'media.tmdbId = :tmdbId',
|
||||||
requestBody.mediaType === MediaType.MUSIC
|
requestBody.mediaType === 'music'
|
||||||
? { mbId: requestBody.mediaId }
|
? {
|
||||||
: { tmdbId: tmdbMedia.id }
|
mbId: (requestedMedia as { release_group_mbid: string })
|
||||||
|
.release_group_mbid,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tmdbId: isTmdbMedia(requestedMedia)
|
||||||
|
? requestedMedia.id
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.andWhere('media.mediaType = :mediaType', {
|
.andWhere('media.mediaType = :mediaType', {
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
@@ -229,12 +223,17 @@ export class MediaRequest {
|
|||||||
if (existing && existing.length > 0) {
|
if (existing && existing.length > 0) {
|
||||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||||
if (
|
if (
|
||||||
requestBody.mediaType === MediaType.MUSIC &&
|
(requestBody.mediaType === MediaType.MOVIE ||
|
||||||
|
requestBody.mediaType === MediaType.MUSIC) &&
|
||||||
existing[0].status !== MediaRequestStatus.DECLINED
|
existing[0].status !== MediaRequestStatus.DECLINED
|
||||||
) {
|
) {
|
||||||
logger.warn('Duplicate request for media blocked', {
|
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,
|
mediaType: requestBody.mediaType,
|
||||||
|
is4k: requestBody.is4k,
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,116 +255,140 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTmdbMedia = (
|
// Apply overrides if the user is not an admin or has the "advanced request" permission
|
||||||
media: LidarrAlbumDetails | TmdbMovieDetails | TmdbTvDetails
|
|
||||||
): media is TmdbMovieDetails | TmdbTvDetails => {
|
|
||||||
return 'original_language' in media && 'keywords' in media;
|
|
||||||
};
|
|
||||||
|
|
||||||
let prioritizedRule: OverrideRule | undefined;
|
|
||||||
|
|
||||||
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
|
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let rootFolder = requestBody.rootFolder;
|
||||||
|
let profileId = requestBody.profileId;
|
||||||
|
let tags = requestBody.tags;
|
||||||
|
|
||||||
if (useOverrides) {
|
if (useOverrides) {
|
||||||
if (requestBody.mediaType !== MediaType.MUSIC) {
|
const defaultRadarrId = requestBody.is4k
|
||||||
const defaultRadarrId = requestBody.is4k
|
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
|
||||||
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
|
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
|
||||||
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
|
const defaultSonarrId = requestBody.is4k
|
||||||
const defaultSonarrId = requestBody.is4k
|
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
||||||
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
: 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 overrideRuleRepository = getRepository(OverrideRule);
|
||||||
const overrideRules = await overrideRuleRepository.find({
|
const overrideRules = await overrideRuleRepository.find({
|
||||||
where:
|
where:
|
||||||
requestBody.mediaType === MediaType.MOVIE
|
requestBody.mediaType === MediaType.MOVIE
|
||||||
? { radarrServiceId: defaultRadarrId }
|
? { radarrServiceId: defaultRadarrId }
|
||||||
: { sonarrServiceId: defaultSonarrId },
|
: requestBody.mediaType === MediaType.TV
|
||||||
});
|
? { sonarrServiceId: defaultSonarrId }
|
||||||
|
: { lidarrServiceId: defaultLidarrId },
|
||||||
|
});
|
||||||
|
|
||||||
const appliedOverrideRules = overrideRules.filter((rule) => {
|
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||||
if (isTmdbMedia(tmdbMedia)) {
|
// Only apply keyword/genre rules for TMDB media
|
||||||
if (
|
if (isTmdbMedia(requestedMedia)) {
|
||||||
rule.language &&
|
const hasAnimeKeyword =
|
||||||
!rule.language
|
'results' in requestedMedia.keywords &&
|
||||||
.split('|')
|
requestedMedia.keywords.results.some(
|
||||||
.some(
|
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
rule.users &&
|
requestBody.mediaType === MediaType.TV &&
|
||||||
!rule.users
|
hasAnimeKeyword &&
|
||||||
.split(',')
|
(!rule.keywords ||
|
||||||
.some((userId) => Number(userId) === requestUser.id)
|
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
||||||
) {
|
) {
|
||||||
return false;
|
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) => {
|
if (
|
||||||
const keys: (keyof OverrideRule)[] = [
|
rule.language &&
|
||||||
'genre',
|
!rule.language
|
||||||
'language',
|
.split('|')
|
||||||
'keywords',
|
.some(
|
||||||
];
|
(languageId) => languageId === requestedMedia.original_language
|
||||||
return (
|
)
|
||||||
keys.filter((key) => b[key] !== null).length -
|
) {
|
||||||
keys.filter((key) => a[key] !== null).length
|
return false;
|
||||||
);
|
}
|
||||||
})[0];
|
|
||||||
|
|
||||||
if (prioritizedRule) {
|
if (
|
||||||
logger.debug('Override rule applied.', {
|
rule.keywords &&
|
||||||
label: 'Media Request',
|
!rule.keywords.split(',').some((keywordId) => {
|
||||||
overrides: prioritizedRule,
|
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,
|
: undefined,
|
||||||
is4k: requestBody.is4k,
|
is4k: requestBody.is4k,
|
||||||
serverId: requestBody.serverId,
|
serverId: requestBody.serverId,
|
||||||
profileId: prioritizedRule?.profileId ?? requestBody.profileId,
|
profileId: profileId,
|
||||||
rootFolder: prioritizedRule?.rootFolder ?? requestBody.rootFolder,
|
rootFolder: rootFolder,
|
||||||
tags: prioritizedRule?.tags
|
tags: tags,
|
||||||
? [
|
|
||||||
...new Set([
|
|
||||||
...(requestBody.tags || []),
|
|
||||||
...prioritizedRule.tags.split(',').map(Number),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
: requestBody.tags,
|
|
||||||
isAutoRequest: options.isAutoRequest ?? false,
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await requestRepository.save(request);
|
await requestRepository.save(request);
|
||||||
return request;
|
return request;
|
||||||
} else if (requestBody.mediaType === MediaType.TV) {
|
} else if (requestBody.mediaType === MediaType.MUSIC) {
|
||||||
const tmdbMediaShow = tmdbMedia as Awaited<
|
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<typeof tmdb.getTvShow>
|
ReturnType<typeof tmdb.getTvShow>
|
||||||
>;
|
>;
|
||||||
const requestedSeasons =
|
let requestedSeasons =
|
||||||
requestBody.seasons === 'all'
|
requestBody.seasons === 'all'
|
||||||
? tmdbMediaShow.seasons
|
? tmdbMediaShow.seasons
|
||||||
.filter((season) => season.season_number !== 0)
|
.filter((season) => season.season_number !== 0)
|
||||||
.map((season) => season.season_number)
|
.map((season) => season.season_number)
|
||||||
: (requestBody.seasons as number[]);
|
: (requestBody.seasons as number[]);
|
||||||
|
if (!settings.main.enableSpecialEpisodes) {
|
||||||
|
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
||||||
|
}
|
||||||
|
|
||||||
let existingSeasons: number[] = [];
|
let existingSeasons: number[] = [];
|
||||||
|
|
||||||
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
// 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,
|
: undefined,
|
||||||
is4k: requestBody.is4k,
|
is4k: requestBody.is4k,
|
||||||
serverId: requestBody.serverId,
|
serverId: requestBody.serverId,
|
||||||
profileId: requestBody.profileId,
|
profileId: profileId,
|
||||||
rootFolder: requestBody.rootFolder,
|
rootFolder: rootFolder,
|
||||||
languageProfileId: requestBody.languageProfileId,
|
languageProfileId: requestBody.languageProfileId,
|
||||||
tags: requestBody.tags,
|
tags: tags,
|
||||||
seasons: finalSeasons.map(
|
seasons: finalSeasons.map(
|
||||||
(sn) =>
|
(sn) =>
|
||||||
new SeasonRequest({
|
new SeasonRequest({
|
||||||
@@ -547,42 +604,6 @@ export class MediaRequest {
|
|||||||
isAutoRequest: options.isAutoRequest ?? false,
|
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);
|
await requestRepository.save(request);
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
@@ -794,13 +815,17 @@ export class MediaRequest {
|
|||||||
type: Notification
|
type: Notification
|
||||||
) {
|
) {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
const lidarr = new LidarrAPI({
|
const listenbrainz = new ListenBrainzAPI();
|
||||||
apiKey: getSettings().lidarr[0].apiKey,
|
const coverArt = CoverArtArchive.getInstance();
|
||||||
url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'),
|
const musicbrainz = new MusicBrainz();
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
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 event: string | undefined;
|
||||||
let notifyAdmin = true;
|
let notifyAdmin = true;
|
||||||
let notifySystem = true;
|
let notifySystem = true;
|
||||||
@@ -880,16 +905,18 @@ export class MediaRequest {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} else if (this.type === MediaType.MUSIC) {
|
} else if (this.type === MediaType.MUSIC && media.mbId) {
|
||||||
if (!media.mbId) {
|
const album = await listenbrainz.getAlbum(media.mbId);
|
||||||
throw new Error('MusicBrainz ID not found for media');
|
const coverArtResponse = await coverArt.getCoverArt(media.mbId);
|
||||||
}
|
const coverArtUrl =
|
||||||
|
coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||||
const album = await lidarr.getAlbumByMusicBrainzId(media.mbId);
|
const artistId =
|
||||||
|
album.release_group_metadata?.artist?.artists[0]?.artist_mbid;
|
||||||
const coverUrl = album.images?.find(
|
const artistWiki = artistId
|
||||||
(img) => img.coverType === 'Cover'
|
? await musicbrainz.getArtistWikipediaExtract({
|
||||||
)?.url;
|
artistMbid: artistId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
notificationManager.sendNotification(type, {
|
notificationManager.sendNotification(type, {
|
||||||
media,
|
media,
|
||||||
@@ -898,15 +925,13 @@ export class MediaRequest {
|
|||||||
notifySystem,
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${album.title}${
|
subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`,
|
||||||
album.releaseDate ? ` (${album.releaseDate.slice(0, 4)})` : ''
|
message: truncate(artistWiki?.content ?? '', {
|
||||||
}`,
|
|
||||||
message: truncate(album.overview || '', {
|
|
||||||
length: 500,
|
length: 500,
|
||||||
separator: /\s/,
|
separator: /\s/,
|
||||||
omission: '…',
|
omission: '…',
|
||||||
}),
|
}),
|
||||||
image: coverUrl,
|
image: coverArtUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
31
server/entity/MetadataAlbum.ts
Normal file
31
server/entity/MetadataAlbum.ts
Normal file
@@ -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<MetadataAlbum>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MetadataAlbum;
|
||||||
42
server/entity/MetadataArtist.ts
Normal file
42
server/entity/MetadataArtist.ts
Normal file
@@ -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<MetadataArtist>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MetadataArtist;
|
||||||
@@ -12,6 +12,9 @@ class OverrideRule {
|
|||||||
@Column({ type: 'int', nullable: true })
|
@Column({ type: 'int', nullable: true })
|
||||||
public sonarrServiceId?: number;
|
public sonarrServiceId?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
public lidarrServiceId?: number;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public users?: string;
|
public users?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ import clearCookies from '@server/middleware/clearcookies';
|
|||||||
import routes from '@server/routes';
|
import routes from '@server/routes';
|
||||||
import avatarproxy from '@server/routes/avatarproxy';
|
import avatarproxy from '@server/routes/avatarproxy';
|
||||||
import caaproxy from '@server/routes/caaproxy';
|
import caaproxy from '@server/routes/caaproxy';
|
||||||
import fanartproxy from '@server/routes/fanartproxy';
|
import tadbproxy from '@server/routes/tadbproxy';
|
||||||
import lidarrproxy from '@server/routes/lidarrproxy';
|
|
||||||
import tmdbproxy from '@server/routes/tmdbproxy';
|
import tmdbproxy from '@server/routes/tmdbproxy';
|
||||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
@@ -241,8 +240,7 @@ app
|
|||||||
server.use('/tmdbproxy', clearCookies, tmdbproxy);
|
server.use('/tmdbproxy', clearCookies, tmdbproxy);
|
||||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
server.use('/avatarproxy', clearCookies, avatarproxy);
|
||||||
server.use('/caaproxy', clearCookies, caaproxy);
|
server.use('/caaproxy', clearCookies, caaproxy);
|
||||||
server.use('/lidarrproxy', clearCookies, lidarrproxy);
|
server.use('/tadbproxy', clearCookies, tadbproxy);
|
||||||
server.use('/fanartproxy', clearCookies, fanartproxy);
|
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ export interface CacheItem {
|
|||||||
|
|
||||||
export interface CacheResponse {
|
export interface CacheResponse {
|
||||||
apiCaches: CacheItem[];
|
apiCaches: CacheItem[];
|
||||||
imageCache: Record<'tmdb' | 'avatar' | 'caa' | 'lidarr' | 'fanart', { size: number; imageCount: number }>;
|
imageCache: Record<
|
||||||
|
'tmdb' | 'avatar' | 'caa' | 'tadb',
|
||||||
|
{ size: number; imageCount: number }
|
||||||
|
>;
|
||||||
dnsCache: {
|
dnsCache: {
|
||||||
stats: DnsStats | undefined;
|
stats: DnsStats | undefined;
|
||||||
entries: DnsEntries | undefined;
|
entries: DnsEntries | undefined;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type AvailableCacheIds =
|
|||||||
| 'musicbrainz'
|
| 'musicbrainz'
|
||||||
| 'listenbrainz'
|
| 'listenbrainz'
|
||||||
| 'covertartarchive'
|
| 'covertartarchive'
|
||||||
|
| 'tadb'
|
||||||
| 'radarr'
|
| 'radarr'
|
||||||
| 'sonarr'
|
| 'sonarr'
|
||||||
| 'lidarr'
|
| 'lidarr'
|
||||||
@@ -64,6 +65,10 @@ class CacheManager {
|
|||||||
stdTtl: 21600,
|
stdTtl: 21600,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
|
tadb: new Cache('tadb', 'The Audio Database API', {
|
||||||
|
stdTtl: 21600,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
radarr: new Cache('radarr', 'Radarr API'),
|
radarr: new Cache('radarr', 'Radarr API'),
|
||||||
sonarr: new Cache('sonarr', 'Sonarr API'),
|
sonarr: new Cache('sonarr', 'Sonarr API'),
|
||||||
lidarr: new Cache('lidarr', 'Lidarr API'),
|
lidarr: new Cache('lidarr', 'Lidarr API'),
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ class ImageProxy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await promises.mkdir(dir, { recursive: true });
|
await promises.mkdir(dir, { recursive: true });
|
||||||
await promises.writeFile(filename, new Uint8Array(buffer));
|
await promises.writeFile(filename, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCacheKey(path: string) {
|
private getCacheKey(path: string) {
|
||||||
@@ -340,9 +340,7 @@ class ImageProxy {
|
|||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (typeof item === 'number') hash.update(String(item));
|
if (typeof item === 'number') hash.update(String(item));
|
||||||
else if (Buffer.isBuffer(item)) {
|
else {
|
||||||
hash.update(item.toString());
|
|
||||||
} else {
|
|
||||||
hash.update(item);
|
hash.update(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ class EmailAgent
|
|||||||
const mediaType = payload.media
|
const mediaType = payload.media
|
||||||
? payload.media.mediaType === MediaType.MOVIE
|
? payload.media.mediaType === MediaType.MOVIE
|
||||||
? 'movie'
|
? 'movie'
|
||||||
: 'series'
|
: payload.media.mediaType === MediaType.TV
|
||||||
|
? 'series'
|
||||||
|
: 'album'
|
||||||
: undefined;
|
: undefined;
|
||||||
const is4k = payload.request?.is4k;
|
const is4k = payload.request?.is4k;
|
||||||
|
|
||||||
|
|||||||
@@ -239,28 +239,17 @@ searchProviders.push({
|
|||||||
const musicbrainz = new MusicBrainz();
|
const musicbrainz = new MusicBrainz();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await musicbrainz.searchMulti({
|
const albumResults = await musicbrainz.searchAlbum({
|
||||||
query: query || '',
|
query: query || '',
|
||||||
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results: CombinedSearchResponse['results'] = response.map(
|
const results: CombinedSearchResponse['results'] = albumResults.map(
|
||||||
(result) => {
|
(album) =>
|
||||||
if (result.artist) {
|
({
|
||||||
return {
|
...album,
|
||||||
...result.artist,
|
media_type: 'album',
|
||||||
media_type: 'artist',
|
} as MbAlbumResult)
|
||||||
} as MbArtistResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.album) {
|
|
||||||
return {
|
|
||||||
...result.album,
|
|
||||||
media_type: 'album',
|
|
||||||
} as MbAlbumResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Invalid search result type');
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
125
server/migration/postgres/1714310036946-AddMusicSupport.ts
Normal file
125
server/migration/postgres/1714310036946-AddMusicSupport.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddMusicSupport1714310036946 implements MigrationInterface {
|
||||||
|
name = 'AddMusicSupport1714310036946';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export class AddOverrideRules1734805738349 implements MigrationInterface {
|
|||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(
|
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"))`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export class AddMusicSupport1714310036946 implements MigrationInterface {
|
|||||||
"requestedById" integer,
|
"requestedById" integer,
|
||||||
"mediaId" integer,
|
"mediaId" integer,
|
||||||
CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"),
|
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,
|
"ratingKey" varchar,
|
||||||
"ratingKey4k" varchar,
|
"ratingKey4k" varchar,
|
||||||
"jellyfinMediaId" 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(
|
await queryRunner.query(
|
||||||
`CREATE INDEX "IDX_media_mbid" ON "media" ("mbId")`
|
`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<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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_watchlist_mbid"`);
|
||||||
await queryRunner.query(`DROP INDEX "IDX_media_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')),
|
"updatedAt" datetime NOT NULL DEFAULT (datetime('now')),
|
||||||
"requestedById" integer,
|
"requestedById" integer,
|
||||||
"mediaId" 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
|
||||||
)`
|
)`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export class AddOverrideRules1734805733535 implements MigrationInterface {
|
|||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(
|
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')))`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,34 @@
|
|||||||
import type { MbArtistDetails } from '@server/api/musicbrainz/interfaces';
|
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
|
|
||||||
export interface ArtistDetailsType {
|
export interface ArtistDetails {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
area?: string;
|
||||||
overview: string;
|
artist: {
|
||||||
disambiguation: string;
|
name: string;
|
||||||
status: string;
|
artist_mbid: string;
|
||||||
genres: string[];
|
begin_year?: number;
|
||||||
images: {
|
end_year?: number;
|
||||||
CoverType: string;
|
area?: string;
|
||||||
Url: string;
|
};
|
||||||
}[];
|
alsoKnownAs?: string[];
|
||||||
links: {
|
biography?: string;
|
||||||
target: string;
|
wikipedia?: {
|
||||||
type: string;
|
content: string;
|
||||||
}[];
|
};
|
||||||
Albums?: {
|
artistThumb?: string | null;
|
||||||
|
artistBackdrop?: string | null;
|
||||||
|
profilePath?: string;
|
||||||
|
releaseGroups?: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
'first-release-date': string;
|
||||||
releasedate: string;
|
'artist-credit': {
|
||||||
images?: {
|
name: string;
|
||||||
CoverType: string;
|
|
||||||
Url: string;
|
|
||||||
}[];
|
}[];
|
||||||
|
'primary-type': string;
|
||||||
|
secondary_types?: string[];
|
||||||
|
total_listen_count?: number;
|
||||||
|
posterPath?: string;
|
||||||
mediaInfo?: Media;
|
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: [],
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces';
|
||||||
MbAlbumDetails,
|
|
||||||
MbImage,
|
|
||||||
MbLink,
|
|
||||||
} from '@server/api/musicbrainz/interfaces';
|
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
|
|
||||||
export interface MusicDetails {
|
export interface MusicDetails {
|
||||||
@@ -10,121 +6,120 @@ export interface MusicDetails {
|
|||||||
mbId: string;
|
mbId: string;
|
||||||
title: string;
|
title: string;
|
||||||
titleSlug?: string;
|
titleSlug?: string;
|
||||||
overview: string;
|
mediaType: 'album';
|
||||||
artistId: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
releaseDate: 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: {
|
artist: {
|
||||||
id: string;
|
id: string;
|
||||||
artistName: string;
|
name: string;
|
||||||
sortName: string;
|
area?: string;
|
||||||
type: 'Group' | 'Person';
|
beginYear?: number;
|
||||||
disambiguation: string;
|
type?: string;
|
||||||
overview: string;
|
};
|
||||||
genres: string[];
|
tracks: {
|
||||||
status: string;
|
name: string;
|
||||||
images: MbImage[];
|
position: number;
|
||||||
links: MbLink[];
|
length: number;
|
||||||
rating?: {
|
recordingMbid: string;
|
||||||
count: number;
|
totalListenCount: number;
|
||||||
value: number | null;
|
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;
|
mediaInfo?: Media;
|
||||||
onUserWatchlist?: boolean;
|
onUserWatchlist?: boolean;
|
||||||
|
posterPath?: string;
|
||||||
|
artistWikipedia?: {
|
||||||
|
content: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
tmdbPersonId?: number;
|
||||||
|
artistBackdrop?: string;
|
||||||
|
artistThumb?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapMusicDetails = (
|
export const mapMusicDetails = (
|
||||||
album: MbAlbumDetails,
|
album: LbAlbumDetails,
|
||||||
media?: Media,
|
media?: Media,
|
||||||
userWatchlist?: boolean
|
userWatchlist?: boolean
|
||||||
): MusicDetails => ({
|
): MusicDetails => ({
|
||||||
id: album.id,
|
id: album.release_group_mbid,
|
||||||
mbId: album.id,
|
mbId: album.release_group_mbid,
|
||||||
title: album.title,
|
title: album.release_group_metadata.release_group.name,
|
||||||
titleSlug: album.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
titleSlug: album.release_group_metadata.release_group.name
|
||||||
overview: album.overview,
|
.toLowerCase()
|
||||||
artistId: album.artistid,
|
.replace(/[^a-z0-9]+/g, '-'),
|
||||||
|
mediaType: 'album',
|
||||||
type: album.type,
|
type: album.type,
|
||||||
releaseDate: album.releasedate,
|
releaseDate: album.release_group_metadata.release_group.date,
|
||||||
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,
|
|
||||||
})),
|
|
||||||
artist: {
|
artist: {
|
||||||
id: album.artists[0].id,
|
id: album.release_group_metadata.artist.artists[0].artist_mbid,
|
||||||
artistName: album.artists[0].artistname,
|
name: album.release_group_metadata.artist.name,
|
||||||
sortName: album.artists[0].sortname,
|
area: album.release_group_metadata.artist.artists[0].area,
|
||||||
type: album.artists[0].type,
|
beginYear: album.release_group_metadata.artist.artists[0].begin_year,
|
||||||
disambiguation: album.artists[0].disambiguation,
|
type: album.release_group_metadata.artist.artists[0].type,
|
||||||
overview: album.artists[0].overview,
|
},
|
||||||
genres: album.artists[0].genres,
|
tracks: album.mediums.flatMap((medium) =>
|
||||||
status: album.artists[0].status,
|
medium.tracks.map((track) => ({
|
||||||
images: album.artists[0].images,
|
name: track.name,
|
||||||
links: album.artists[0].links,
|
position: track.position,
|
||||||
rating: album.artists[0].rating
|
length: track.length,
|
||||||
? {
|
recordingMbid: track.recording_mbid,
|
||||||
count: album.artists[0].rating.Count,
|
totalListenCount: track.total_listen_count,
|
||||||
value: album.artists[0].rating.Value,
|
totalUserCount: track.total_user_count,
|
||||||
}
|
artists: track.artists.map((artist) => ({
|
||||||
: undefined,
|
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,
|
mediaInfo: media,
|
||||||
onUserWatchlist: userWatchlist,
|
onUserWatchlist: userWatchlist,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,21 @@ export interface PersonDetails {
|
|||||||
adult: boolean;
|
adult: boolean;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
homepage?: 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 {
|
export interface PersonCredit {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
MbAlbumDetails,
|
|
||||||
MbAlbumResult,
|
MbAlbumResult,
|
||||||
MbArtistDetails,
|
|
||||||
MbArtistResult,
|
MbArtistResult,
|
||||||
MbImage,
|
|
||||||
} from '@server/api/musicbrainz/interfaces';
|
} from '@server/api/musicbrainz/interfaces';
|
||||||
import type {
|
import type {
|
||||||
TmdbCollectionResult,
|
TmdbCollectionResult,
|
||||||
@@ -87,35 +84,34 @@ export interface PersonResult {
|
|||||||
|
|
||||||
export interface ArtistResult extends MbSearchResult {
|
export interface ArtistResult extends MbSearchResult {
|
||||||
mediaType: 'artist';
|
mediaType: 'artist';
|
||||||
artistname: string;
|
tmdbPersonId?: number;
|
||||||
overview: string;
|
name: string;
|
||||||
disambiguation: string;
|
|
||||||
type: 'Group' | 'Person';
|
type: 'Group' | 'Person';
|
||||||
status: string;
|
'sort-name': string;
|
||||||
sortname: string;
|
country?: string;
|
||||||
genres: string[];
|
disambiguation?: string;
|
||||||
images: MbImage[];
|
artistThumb?: string | null;
|
||||||
artistimage?: string;
|
artistBackdrop?: string | null;
|
||||||
rating?: {
|
|
||||||
Count: number;
|
|
||||||
Value: number | null;
|
|
||||||
};
|
|
||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlbumResult extends MbSearchResult {
|
export interface AlbumResult extends MbSearchResult {
|
||||||
mediaType: 'album';
|
mediaType: 'album';
|
||||||
title: string;
|
title: string;
|
||||||
artistid: string;
|
'primary-type': 'Album' | 'Single' | 'EP';
|
||||||
artistname?: string;
|
'first-release-date': string;
|
||||||
type: string;
|
releaseDate?: string;
|
||||||
releasedate: string;
|
'artist-credit': {
|
||||||
disambiguation: string;
|
name: string;
|
||||||
genres: string[];
|
artist: {
|
||||||
images: MbImage[];
|
id: string;
|
||||||
secondarytypes: string[];
|
name: string;
|
||||||
|
'sort-name': string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
posterPath?: string;
|
||||||
|
needsCoverArt?: boolean;
|
||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
overview?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Results =
|
export type Results =
|
||||||
@@ -203,22 +199,18 @@ export const mapPersonResult = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const mapArtistResult = (
|
export const mapArtistResult = (
|
||||||
artistResult: MbArtistResult,
|
artistResult: MbArtistResult
|
||||||
media?: Media
|
|
||||||
): ArtistResult => ({
|
): ArtistResult => ({
|
||||||
id: artistResult.id,
|
id: artistResult.id,
|
||||||
score: artistResult.score,
|
score: artistResult.score,
|
||||||
mediaType: 'artist',
|
mediaType: 'artist',
|
||||||
artistname: artistResult.artistname,
|
name: artistResult.name,
|
||||||
overview: artistResult.overview,
|
|
||||||
disambiguation: artistResult.disambiguation,
|
|
||||||
type: artistResult.type,
|
type: artistResult.type,
|
||||||
status: artistResult.status,
|
'sort-name': artistResult['sort-name'],
|
||||||
sortname: artistResult.sortname,
|
country: artistResult.country,
|
||||||
genres: artistResult.genres,
|
disambiguation: artistResult.disambiguation,
|
||||||
images: artistResult.images,
|
artistThumb: artistResult.artistThumb,
|
||||||
rating: artistResult.rating,
|
artistBackdrop: artistResult.artistBackdrop,
|
||||||
mediaInfo: media,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mapAlbumResult = (
|
export const mapAlbumResult = (
|
||||||
@@ -229,16 +221,12 @@ export const mapAlbumResult = (
|
|||||||
score: albumResult.score,
|
score: albumResult.score,
|
||||||
mediaType: 'album',
|
mediaType: 'album',
|
||||||
title: albumResult.title,
|
title: albumResult.title,
|
||||||
artistid: albumResult.artistid,
|
'primary-type': albumResult['primary-type'],
|
||||||
artistname: albumResult.artists?.[0]?.artistname,
|
'first-release-date': albumResult['first-release-date'],
|
||||||
type: albumResult.type,
|
'artist-credit': albumResult['artist-credit'],
|
||||||
releasedate: albumResult.releasedate,
|
posterPath: albumResult.posterPath,
|
||||||
disambiguation: albumResult.disambiguation,
|
needsCoverArt: !albumResult.posterPath,
|
||||||
genres: albumResult.genres,
|
|
||||||
images: albumResult.images,
|
|
||||||
secondarytypes: albumResult.secondarytypes,
|
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
overview: albumResult.artists?.[0]?.overview,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isTmdbMovie = (
|
const isTmdbMovie = (
|
||||||
@@ -289,7 +277,7 @@ const isTmdbCollection = (
|
|||||||
return result.media_type === 'collection';
|
return result.media_type === 'collection';
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLidarrArtist = (
|
const isMbArtist = (
|
||||||
result:
|
result:
|
||||||
| TmdbMovieResult
|
| TmdbMovieResult
|
||||||
| TmdbTvResult
|
| TmdbTvResult
|
||||||
@@ -301,7 +289,7 @@ const isLidarrArtist = (
|
|||||||
return result.media_type === 'artist';
|
return result.media_type === 'artist';
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLidarrAlbum = (
|
const isMbAlbum = (
|
||||||
result:
|
result:
|
||||||
| TmdbMovieResult
|
| TmdbMovieResult
|
||||||
| TmdbTvResult
|
| TmdbTvResult
|
||||||
@@ -346,9 +334,9 @@ export const mapSearchResults = async (
|
|||||||
return mapPersonResult(result);
|
return mapPersonResult(result);
|
||||||
} else if (isTmdbCollection(result)) {
|
} else if (isTmdbCollection(result)) {
|
||||||
return mapCollectionResult(result);
|
return mapCollectionResult(result);
|
||||||
} else if (isLidarrArtist(result)) {
|
} else if (isMbArtist(result)) {
|
||||||
return mapArtistResult(result);
|
return mapArtistResult(result);
|
||||||
} else if (isLidarrAlbum(result)) {
|
} else if (isMbAlbum(result)) {
|
||||||
return mapAlbumResult(
|
return mapAlbumResult(
|
||||||
result,
|
result,
|
||||||
media?.find(
|
media?.find(
|
||||||
@@ -405,6 +393,7 @@ export const mapPersonDetailsToResult = (
|
|||||||
personDetails: TmdbPersonDetails
|
personDetails: TmdbPersonDetails
|
||||||
): TmdbPersonResult => ({
|
): TmdbPersonResult => ({
|
||||||
id: personDetails.id,
|
id: personDetails.id,
|
||||||
|
known_for_department: personDetails.known_for_department,
|
||||||
media_type: 'person',
|
media_type: 'person',
|
||||||
name: personDetails.name,
|
name: personDetails.name,
|
||||||
popularity: personDetails.popularity,
|
popularity: personDetails.popularity,
|
||||||
@@ -412,39 +401,3 @@ export const mapPersonDetailsToResult = (
|
|||||||
profile_path: personDetails.profile_path,
|
profile_path: personDetails.profile_path,
|
||||||
known_for: [],
|
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 || '',
|
|
||||||
});
|
|
||||||
|
|||||||
169
server/routes/artist.ts
Normal file
169
server/routes/artist.ts
Normal file
@@ -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<string, typeof artistData.releaseGroups>);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -3,14 +3,17 @@ import logger from '@server/logger';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const caaImageProxy = new ImageProxy('caa', 'http://coverartarchive.org', {
|
const caaImageProxy = new ImageProxy('caa', 'https://archive.org/download', {
|
||||||
rateLimitOptions: {
|
rateLimitOptions: {
|
||||||
maxRPS: 50,
|
maxRPS: 50,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image Proxy
|
||||||
|
*/
|
||||||
router.get('/*', async (req, res) => {
|
router.get('/*', async (req, res) => {
|
||||||
const imagePath = req.path;
|
const imagePath = req.path.replace('/download', '');
|
||||||
try {
|
try {
|
||||||
const imageData = await caaImageProxy.getImage(imagePath);
|
const imageData = await caaImageProxy.getImage(imagePath);
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ router.get('/*', async (req, res) => {
|
|||||||
imagePath,
|
imagePath,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
});
|
});
|
||||||
res.status(500).end();
|
res.status(500).send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
28
server/routes/coverart.ts
Normal file
28
server/routes/coverart.ts
Normal file
@@ -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;
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||||
import MusicBrainz from '@server/api/musicbrainz';
|
|
||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
|
import TheAudioDb from '@server/api/theaudiodb';
|
||||||
import type { SortOptions } from '@server/api/themoviedb';
|
import type { SortOptions } from '@server/api/themoviedb';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
|
import TmdbPersonMapper from '@server/api/themoviedb/personMapper';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
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 { User } from '@server/entity/User';
|
||||||
import { Watchlist } from '@server/entity/Watchlist';
|
import { Watchlist } from '@server/entity/Watchlist';
|
||||||
import type {
|
import type {
|
||||||
@@ -26,6 +29,7 @@ import { mapNetwork } from '@server/models/Tv';
|
|||||||
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
|
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||||
@@ -858,7 +862,246 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
|
|
||||||
discoverRoutes.get('/music', async (req, res, next) => {
|
discoverRoutes.get('/music', async (req, res, next) => {
|
||||||
const listenbrainz = new ListenBrainzAPI();
|
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 {
|
try {
|
||||||
const page = Number(req.query.page) || 1;
|
const page = Number(req.query.page) || 1;
|
||||||
@@ -866,113 +1109,110 @@ discoverRoutes.get('/music', async (req, res, next) => {
|
|||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
const sortBy = (req.query.sortBy as string) || 'listen_count.desc';
|
const sortBy = (req.query.sortBy as string) || 'listen_count.desc';
|
||||||
|
|
||||||
const data = await listenbrainz.getTopAlbums({
|
const topAlbumsData = await listenbrainz.getTopAlbums({
|
||||||
offset,
|
offset,
|
||||||
count: pageSize,
|
count: pageSize,
|
||||||
range: 'week',
|
range: 'month',
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const mbIds = topAlbumsData.payload.release_groups
|
||||||
req.user,
|
.map((album) => album.release_group_mbid)
|
||||||
data.payload.release_groups.map((album) => album.release_group_mbid)
|
.filter((id): id is string => !!id);
|
||||||
);
|
|
||||||
|
|
||||||
const albumDetailsPromises = data.payload.release_groups.map(
|
if (mbIds.length === 0) {
|
||||||
async (album) => {
|
const results = topAlbumsData.payload.release_groups.map((album) => ({
|
||||||
try {
|
id: null,
|
||||||
const details = await musicbrainz.getAlbum({
|
mediaType: 'album',
|
||||||
albumId: album.release_group_mbid,
|
'primary-type': 'Album',
|
||||||
});
|
title: album.release_group_name,
|
||||||
|
'artist-credit': [{ name: album.artist_name }],
|
||||||
|
listenCount: album.listen_count,
|
||||||
|
posterPath: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
const images =
|
return res.json({
|
||||||
details.images?.length > 0
|
page,
|
||||||
? details.images.filter((img) => img.CoverType === 'Cover')
|
totalPages: Math.ceil(topAlbumsData.payload.count / pageSize),
|
||||||
: album.caa_id
|
totalResults: topAlbumsData.payload.count,
|
||||||
? [
|
results,
|
||||||
{
|
});
|
||||||
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.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,
|
page,
|
||||||
totalPages: Math.ceil(data.payload.count / pageSize),
|
totalPages: Math.ceil(topAlbumsData.payload.count / pageSize),
|
||||||
totalResults: data.payload.count,
|
totalResults: topAlbumsData.payload.count,
|
||||||
results,
|
results,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Something went wrong retrieving popular music', {
|
logger.error('Failed to retrieve popular music', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
errorMessage: e.message,
|
error: e instanceof Error ? e.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
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<ArtistImageResults>)
|
||||||
|
: ({} 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<Record<string, unknown>, WatchlistResponse>(
|
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||||
'/watchlist',
|
'/watchlist',
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -27,11 +27,12 @@ import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
|
|||||||
import restartFlag from '@server/utils/restartFlag';
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { isPerson } from '@server/utils/typeHelpers';
|
import { isPerson } from '@server/utils/typeHelpers';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import artistRoutes from './artist';
|
||||||
import authRoutes from './auth';
|
import authRoutes from './auth';
|
||||||
import blacklistRoutes from './blacklist';
|
import blacklistRoutes from './blacklist';
|
||||||
import collectionRoutes from './collection';
|
import collectionRoutes from './collection';
|
||||||
|
import coverArtRoutes from './coverart';
|
||||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||||
import groupRoutes from './group';
|
|
||||||
import issueRoutes from './issue';
|
import issueRoutes from './issue';
|
||||||
import issueCommentRoutes from './issueComment';
|
import issueCommentRoutes from './issueComment';
|
||||||
import mediaRoutes from './media';
|
import mediaRoutes from './media';
|
||||||
@@ -159,7 +160,7 @@ router.use('/tv', isAuthenticated(), tvRoutes);
|
|||||||
router.use('/music', isAuthenticated(), musicRoutes);
|
router.use('/music', isAuthenticated(), musicRoutes);
|
||||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||||
router.use('/person', isAuthenticated(), personRoutes);
|
router.use('/person', isAuthenticated(), personRoutes);
|
||||||
router.use('/group', isAuthenticated(), groupRoutes);
|
router.use('/artist', isAuthenticated(), artistRoutes);
|
||||||
router.use('/collection', isAuthenticated(), collectionRoutes);
|
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||||
router.use('/service', isAuthenticated(), serviceRoutes);
|
router.use('/service', isAuthenticated(), serviceRoutes);
|
||||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||||
@@ -170,7 +171,7 @@ router.use(
|
|||||||
isAuthenticated(Permission.ADMIN),
|
isAuthenticated(Permission.ADMIN),
|
||||||
overrideRuleRoutes
|
overrideRuleRoutes
|
||||||
);
|
);
|
||||||
|
router.use('/coverart', isAuthenticated(), coverArtRoutes);
|
||||||
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
|||||||
@@ -173,18 +173,18 @@ issueRoutes.get('/count', async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
const lyricsCount = await query
|
|
||||||
.where('issue.issueType = :issueType', {
|
|
||||||
issueType: IssueType.LYRICS,
|
|
||||||
})
|
|
||||||
.getCount();
|
|
||||||
|
|
||||||
const othersCount = await query
|
const othersCount = await query
|
||||||
.where('issue.issueType = :issueType', {
|
.where('issue.issueType = :issueType', {
|
||||||
issueType: IssueType.OTHER,
|
issueType: IssueType.OTHER,
|
||||||
})
|
})
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
|
const lyricsCount = await query
|
||||||
|
.where('issue.issueType = :issueType', {
|
||||||
|
issueType: IssueType.LYRICS,
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
|
||||||
const openCount = await query
|
const openCount = await query
|
||||||
.where('issue.status = :issueStatus', {
|
.where('issue.status = :issueStatus', {
|
||||||
issueStatus: IssueStatus.OPEN,
|
issueStatus: IssueStatus.OPEN,
|
||||||
@@ -202,8 +202,8 @@ issueRoutes.get('/count', async (req, res, next) => {
|
|||||||
video: videoCount,
|
video: videoCount,
|
||||||
audio: audioCount,
|
audio: audioCount,
|
||||||
subtitles: subtitlesCount,
|
subtitles: subtitlesCount,
|
||||||
lyrics: lyricsCount,
|
|
||||||
others: othersCount,
|
others: othersCount,
|
||||||
|
lyrics: lyricsCount,
|
||||||
open: openCount,
|
open: openCount,
|
||||||
closed: closedCount,
|
closed: closedCount,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -6,6 +6,7 @@ import TheMovieDb from '@server/api/themoviedb';
|
|||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||||
import Season from '@server/entity/Season';
|
import Season from '@server/entity/Season';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type {
|
import type {
|
||||||
@@ -24,6 +25,7 @@ const mediaRoutes = Router();
|
|||||||
|
|
||||||
mediaRoutes.get('/', async (req, res, next) => {
|
mediaRoutes.get('/', async (req, res, next) => {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
const metadataAlbumRepository = getRepository(MetadataAlbum);
|
||||||
|
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
@@ -78,6 +80,37 @@ mediaRoutes.get('/', async (req, res, next) => {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
skip,
|
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({
|
return res.status(200).json({
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
pages: Math.ceil(mediaCount / pageSize),
|
pages: Math.ceil(mediaCount / pageSize),
|
||||||
@@ -85,10 +118,14 @@ mediaRoutes.get('/', async (req, res, next) => {
|
|||||||
results: mediaCount,
|
results: mediaCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
},
|
},
|
||||||
results: media,
|
results: mediaWithCoverArt,
|
||||||
} as MediaResultsResponse);
|
} as MediaResultsResponse);
|
||||||
} catch (e) {
|
} 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(
|
serviceSettings = settings.radarr.find(
|
||||||
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
||||||
);
|
);
|
||||||
} else if(media.mediaType === MediaType.TV) {
|
} else if (media.mediaType === MediaType.TV) {
|
||||||
serviceSettings = settings.sonarr.find(
|
serviceSettings = settings.sonarr.find(
|
||||||
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
serviceSettings = settings.lidarr.find(
|
serviceSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
|
||||||
(lidarr) => lidarr.isDefault);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const specificServiceId = is4k ? media.serviceId4k : media.serviceId;
|
||||||
const specificServiceId = is4k ? media.serviceId4k : media.serviceId;
|
if (
|
||||||
if (
|
specificServiceId &&
|
||||||
specificServiceId &&
|
specificServiceId >= 0 &&
|
||||||
specificServiceId >= 0 &&
|
serviceSettings?.id !== specificServiceId
|
||||||
serviceSettings?.id !== specificServiceId
|
) {
|
||||||
) {
|
if (media.mediaType === MediaType.MOVIE) {
|
||||||
if (media.mediaType === MediaType.MOVIE) {
|
serviceSettings = settings.radarr.find(
|
||||||
serviceSettings = settings.radarr.find(
|
(radarr) => radarr.id === specificServiceId
|
||||||
(radarr) => radarr.id === specificServiceId
|
);
|
||||||
);
|
} else if (media.mediaType === MediaType.TV) {
|
||||||
} else if (media.mediaType === MediaType.TV) {
|
serviceSettings = settings.sonarr.find(
|
||||||
serviceSettings = settings.sonarr.find(
|
(sonarr) => sonarr.id === specificServiceId
|
||||||
(sonarr) => sonarr.id === specificServiceId
|
);
|
||||||
);
|
} else {
|
||||||
} else {
|
serviceSettings = settings.lidarr.find(
|
||||||
serviceSettings = settings.lidarr.find(
|
(lidarr) => lidarr.id === media.serviceId
|
||||||
(lidarr) => lidarr.id === media.serviceId
|
);
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!serviceSettings) {
|
|
||||||
|
if (!serviceSettings) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`There is no default ${
|
`There is no default ${
|
||||||
media.mediaType === MediaType.MOVIE
|
media.mediaType === MediaType.MOVIE
|
||||||
@@ -260,7 +295,7 @@ mediaRoutes.delete(
|
|||||||
apiKey: serviceSettings.apiKey,
|
apiKey: serviceSettings.apiKey,
|
||||||
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await (service as RadarrAPI).removeMovie(media.tmdbId);
|
await (service as RadarrAPI).removeMovie(media.tmdbId);
|
||||||
} else if (media.mediaType === MediaType.TV) {
|
} else if (media.mediaType === MediaType.TV) {
|
||||||
service = new SonarrAPI({
|
service = new SonarrAPI({
|
||||||
@@ -276,8 +311,7 @@ mediaRoutes.delete(
|
|||||||
throw new Error('TVDB ID not found');
|
throw new Error('TVDB ID not found');
|
||||||
}
|
}
|
||||||
await (service as SonarrAPI).removeSeries(tvdbId);
|
await (service as SonarrAPI).removeSeries(tvdbId);
|
||||||
} else if (media.mediaType == MediaType.MUSIC)
|
} else if (media.mediaType == MediaType.MUSIC) {
|
||||||
{
|
|
||||||
service = new LidarrAPI({
|
service = new LidarrAPI({
|
||||||
apiKey: serviceSettings.apiKey,
|
apiKey: serviceSettings.apiKey,
|
||||||
url: LidarrAPI.buildUrl(serviceSettings, '/api/v1'),
|
url: LidarrAPI.buildUrl(serviceSettings, '/api/v1'),
|
||||||
@@ -291,7 +325,6 @@ mediaRoutes.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong fetching media in delete request', {
|
logger.error('Something went wrong fetching media in delete request', {
|
||||||
label: 'Media',
|
label: 'Media',
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import CoverArtArchive from '@server/api/coverartarchive';
|
|
||||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||||
import MusicBrainz from '@server/api/musicbrainz';
|
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 { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
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 { Watchlist } from '@server/entity/Watchlist';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { mapMusicDetails } from '@server/models/Music';
|
import { mapMusicDetails } from '@server/models/Music';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { In } from 'typeorm';
|
||||||
|
|
||||||
const musicRoutes = Router();
|
const musicRoutes = Router();
|
||||||
|
|
||||||
musicRoutes.get('/:id', async (req, res, next) => {
|
musicRoutes.get('/:id', async (req, res, next) => {
|
||||||
|
const listenbrainz = new ListenBrainzAPI();
|
||||||
const musicbrainz = new MusicBrainz();
|
const musicbrainz = new MusicBrainz();
|
||||||
const locale = req.locale || 'en';
|
const personMapper = TmdbPersonMapper.getInstance();
|
||||||
|
const theAudioDb = TheAudioDb.getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [albumDetails, wikipediaExtract] = await Promise.all([
|
const [albumDetails, media, onUserWatchlist] = await Promise.all([
|
||||||
musicbrainz.getAlbum({
|
listenbrainz.getAlbum(req.params.id),
|
||||||
albumId: req.params.id,
|
|
||||||
}),
|
|
||||||
musicbrainz.getWikipediaExtract(req.params.id, locale),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [media, onUserWatchlist] = await Promise.all([
|
|
||||||
getRepository(Media)
|
getRepository(Media)
|
||||||
.createQueryBuilder('media')
|
.createQueryBuilder('media')
|
||||||
.leftJoinAndSelect('media.requests', 'requests')
|
.leftJoinAndSelect('media.requests', 'requests')
|
||||||
@@ -36,31 +35,143 @@ musicRoutes.get('/:id', async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
.getOne()
|
.getOne()
|
||||||
.then((media) => media ?? undefined),
|
.then((media) => media ?? undefined),
|
||||||
|
|
||||||
getRepository(Watchlist).exist({
|
getRepository(Watchlist).exist({
|
||||||
where: {
|
where: {
|
||||||
mbId: req.params.id,
|
mbId: req.params.id,
|
||||||
requestedBy: {
|
requestedBy: { id: req.user?.id },
|
||||||
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 mappedDetails = mapMusicDetails(albumDetails, media, onUserWatchlist);
|
||||||
|
const finalTrackArtistMetadata =
|
||||||
|
updatedArtistMetadata || trackArtistMetadata;
|
||||||
|
|
||||||
if (wikipediaExtract) {
|
return res.status(200).json({
|
||||||
mappedDetails.artist.overview = wikipediaExtract;
|
...mappedDetails,
|
||||||
}
|
posterPath: metadataAlbum?.caaUrl ?? null,
|
||||||
|
needsCoverArt: !metadataAlbum?.caaUrl,
|
||||||
return res.status(200).json(mappedDetails);
|
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) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong retrieving album details', {
|
logger.error('Something went wrong retrieving album details', {
|
||||||
label: 'Music API',
|
label: 'Music API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
mbId: req.params.id,
|
mbId: req.params.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Unable to retrieve album details.',
|
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) => {
|
musicRoutes.get('/:id/artist', async (req, res, next) => {
|
||||||
const musicbrainz = new MusicBrainz();
|
|
||||||
const coverArtArchive = new CoverArtArchive();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const albumDetails = await musicbrainz.getAlbum({
|
const listenbrainzApi = new ListenBrainzAPI();
|
||||||
albumId: req.params.id,
|
const personMapper = TmdbPersonMapper.getInstance();
|
||||||
});
|
const theAudioDb = TheAudioDb.getInstance();
|
||||||
|
const metadataAlbumRepository = getRepository(MetadataAlbum);
|
||||||
if (!albumDetails.artists?.[0]?.id) {
|
const metadataArtistRepository = getRepository(MetadataArtist);
|
||||||
throw new Error('No artist found for album');
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = Number(req.query.page) || 1;
|
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({
|
const albumData = await listenbrainzApi.getAlbum(req.params.id);
|
||||||
artistId: albumDetails.artists[0].id,
|
const artistData = albumData?.release_group_metadata?.artist?.artists?.[0];
|
||||||
});
|
const artistType = artistData?.type;
|
||||||
|
|
||||||
const albums =
|
if (!artistData?.artist_mbid || artistType === 'Other') {
|
||||||
artistData.Albums?.map((album) => ({
|
return res.status(404).json({
|
||||||
id: album.Id.toLowerCase(),
|
status: 404,
|
||||||
title: album.Title,
|
message: 'Artist details not available for this type',
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = Number(req.query.page) || 1;
|
const [artistDetails, cachedTheAudioDb, metadataArtist] = await Promise.all(
|
||||||
const pageSize = 20;
|
[
|
||||||
|
listenbrainzApi.getArtist(artistData.artist_mbid),
|
||||||
const similarArtists = await listenbrainz.getSimilarArtists(
|
theAudioDb.getArtistImagesFromCache(artistData.artist_mbid),
|
||||||
albumDetails.artists[0].id
|
metadataArtistRepository.findOne({
|
||||||
|
where: { mbArtistId: artistData.artist_mbid },
|
||||||
|
}),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const start = (page - 1) * pageSize;
|
if (!artistDetails) {
|
||||||
const end = start + pageSize;
|
return res.status(404).json({ status: 404, message: 'Artist not found' });
|
||||||
const paginatedArtists = similarArtists.slice(start, end);
|
}
|
||||||
|
|
||||||
const artistDetailsPromises = paginatedArtists.map(async (artist) => {
|
const totalReleaseGroups = artistDetails.releaseGroups.length;
|
||||||
try {
|
const paginatedReleaseGroups =
|
||||||
let tmdbId = null;
|
isSlider || page === 1
|
||||||
if (artist.type === 'Person') {
|
? artistDetails.releaseGroups.slice(0, pageSize)
|
||||||
const searchResults = await tmdb.searchPerson({
|
: artistDetails.releaseGroups.slice(
|
||||||
query: artist.name,
|
(page - 1) * pageSize,
|
||||||
page: 1,
|
page * pageSize
|
||||||
});
|
|
||||||
|
|
||||||
const match = searchResults.results.find(
|
|
||||||
(result) => result.name.toLowerCase() === artist.name.toLowerCase()
|
|
||||||
);
|
);
|
||||||
if (match) {
|
|
||||||
tmdbId = match.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = await musicbrainz.getArtist({
|
const releaseGroupIds = paginatedReleaseGroups.map((rg) => rg.mbid);
|
||||||
artistId: artist.artist_mbid,
|
const similarArtistIds =
|
||||||
});
|
artistDetails.similarArtists?.artists?.map((a) => a.artist_mbid) ?? [];
|
||||||
|
|
||||||
return {
|
const [relatedMedia, albumMetadata, similarArtistMetadata] =
|
||||||
id: tmdbId || artist.artist_mbid,
|
await Promise.all([
|
||||||
mediaType: 'artist' as const,
|
Media.getRelatedMedia(req.user, releaseGroupIds),
|
||||||
artistname: artist.name,
|
metadataAlbumRepository.find({
|
||||||
type: artist.type || 'Person',
|
where: { mbAlbumId: In(releaseGroupIds) },
|
||||||
overview: artist.comment,
|
}),
|
||||||
score: artist.score,
|
similarArtistIds.length > 0
|
||||||
images: details.images || [],
|
? metadataArtistRepository.find({
|
||||||
artistimage: details.images?.find((img) => img.CoverType === 'Poster')
|
where: { mbArtistId: In(similarArtistIds) },
|
||||||
?.Url,
|
})
|
||||||
};
|
: Promise.resolve([]),
|
||||||
} catch (e) {
|
]);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const artistDetails = (await Promise.all(artistDetailsPromises)).filter(
|
const albumMetadataMap = new Map(
|
||||||
(artist): artist is NonNullable<typeof artist> => artist !== null
|
albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata])
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(200).json({
|
const similarArtistMetadataMap = new Map(
|
||||||
page,
|
similarArtistMetadata.map((metadata) => [metadata.mbArtistId, metadata])
|
||||||
totalPages: Math.ceil(similarArtists.length / pageSize),
|
);
|
||||||
totalResults: similarArtists.length,
|
|
||||||
results: artistDetails,
|
const artistsNeedingImages = similarArtistIds.filter((id) => {
|
||||||
});
|
const metadata = similarArtistMetadataMap.get(id);
|
||||||
} catch (e) {
|
return !metadata?.tadbThumb && !metadata?.tadbCover;
|
||||||
logger.error('Something went wrong retrieving similar artists', {
|
|
||||||
label: 'Music API',
|
|
||||||
errorMessage: e.message,
|
|
||||||
albumId: req.params.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return next({
|
const personArtists =
|
||||||
status: 500,
|
artistDetails.similarArtists?.artists
|
||||||
message: 'Unable to retrieve similar 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.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import CoverArtArchive from '@server/api/coverartarchive';
|
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||||
import MusicBrainz from '@server/api/musicbrainz';
|
import TheAudioDb from '@server/api/theaudiodb';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||||
|
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import {
|
import {
|
||||||
mapCastCredits,
|
mapCastCredits,
|
||||||
@@ -9,53 +12,176 @@ import {
|
|||||||
mapPersonDetails,
|
mapPersonDetails,
|
||||||
} from '@server/models/Person';
|
} from '@server/models/Person';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { In } from 'typeorm';
|
||||||
|
|
||||||
const personRoutes = Router();
|
const personRoutes = Router();
|
||||||
|
|
||||||
personRoutes.get('/:id', async (req, res, next) => {
|
personRoutes.get('/:id', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
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 {
|
try {
|
||||||
const person = await tmdb.getPerson({
|
const [person, existingMetadata] = await Promise.all([
|
||||||
personId: Number(req.params.id),
|
tmdb.getPerson({
|
||||||
language: (req.query.language as string) ?? req.locale,
|
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;
|
let artistData = null;
|
||||||
try {
|
|
||||||
const artists = await musicBrainz.searchArtist({
|
|
||||||
query: person.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
const matchedArtist = artists.find((artist) => {
|
if (existingMetadata?.mbArtistId) {
|
||||||
if (artist.type !== 'Person') {
|
artistData = await listenbrainz.getArtist(existingMetadata.mbArtistId);
|
||||||
return false;
|
|
||||||
|
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<string, typeof artistData.releaseGroups>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 =
|
const allReleaseGroupIds = releaseGroupsToProcess.map((rg) => rg.mbid);
|
||||||
artist.artistname.toLowerCase() === person.name.toLowerCase();
|
|
||||||
const aliasMatches = artist.artistaliases?.some(
|
|
||||||
(alias) => alias.toLowerCase() === person.name.toLowerCase()
|
|
||||||
);
|
|
||||||
return nameMatches || aliasMatches;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchedArtist) {
|
const [artistImagesPromise, relatedMedia, albumMetadata] =
|
||||||
mbArtistId = matchedArtist.id;
|
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),
|
...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) {
|
} catch (e) {
|
||||||
logger.debug('Something went wrong retrieving person', {
|
logger.debug('Something went wrong retrieving person', {
|
||||||
label: 'API',
|
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) => {
|
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import MusicBrainz from '@server/api/musicbrainz';
|
import MusicBrainz from '@server/api/musicbrainz';
|
||||||
import type {
|
import TheAudioDb from '@server/api/theaudiodb';
|
||||||
MbAlbumResult,
|
|
||||||
MbArtistResult,
|
|
||||||
} from '@server/api/musicbrainz/interfaces';
|
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
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 Media from '@server/entity/Media';
|
||||||
|
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||||
|
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||||
import {
|
import {
|
||||||
findSearchProvider,
|
findSearchProvider,
|
||||||
type CombinedSearchResponse,
|
type CombinedSearchResponse,
|
||||||
@@ -12,69 +13,272 @@ import {
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { mapSearchResults } from '@server/models/Search';
|
import { mapSearchResults } from '@server/models/Search';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { In } from 'typeorm';
|
||||||
|
|
||||||
const searchRoutes = Router();
|
const searchRoutes = Router();
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
searchRoutes.get('/', async (req, res, next) => {
|
searchRoutes.get('/', async (req, res, next) => {
|
||||||
const queryString = req.query.query as string;
|
const queryString = req.query.query as string;
|
||||||
const searchProvider = findSearchProvider(queryString.toLowerCase());
|
const page = Number(req.query.page) || 1;
|
||||||
let results: CombinedSearchResponse;
|
const language = (req.query.language as string) ?? req.locale;
|
||||||
let combinedResults: CombinedSearchResponse['results'] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const searchProvider = findSearchProvider(queryString.toLowerCase());
|
||||||
|
let results: CombinedSearchResponse;
|
||||||
|
|
||||||
if (searchProvider) {
|
if (searchProvider) {
|
||||||
const [id] = queryString
|
const [id] = queryString
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.match(searchProvider.pattern) as RegExpMatchArray;
|
.match(searchProvider.pattern) as RegExpMatchArray;
|
||||||
results = await searchProvider.search({
|
results = await searchProvider.search({
|
||||||
id,
|
id,
|
||||||
language: (req.query.language as string) ?? req.locale,
|
language,
|
||||||
query: queryString,
|
query: queryString,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
const tmdbResults = await tmdb.searchMulti({
|
const musicbrainz = new MusicBrainz();
|
||||||
query: queryString,
|
const theAudioDb = TheAudioDb.getInstance();
|
||||||
page: Number(req.query.page),
|
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 artistsNeedingImages = artistIds.filter((id) => {
|
||||||
const mbResults = await musicbrainz.searchMulti({ query: queryString });
|
const metadata = artistsMetadataMap.get(id);
|
||||||
|
return !metadata?.tadbThumb && !metadata?.tadbCover;
|
||||||
|
});
|
||||||
|
|
||||||
if (mbResults.length > 0) {
|
type PersonMappingResult = Record<
|
||||||
const mbMappedResults = mbResults.map((result) => {
|
string,
|
||||||
if (result.artist) {
|
{ personId: number | null; profilePath: string | null }
|
||||||
return {
|
>;
|
||||||
...result.artist,
|
type ArtistImageResult = Record<
|
||||||
media_type: 'artist',
|
string,
|
||||||
} as MbArtistResult;
|
{ 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<typeof artist> => 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 = {
|
results = {
|
||||||
page: tmdbResults.page,
|
page: tmdbResults.page,
|
||||||
total_pages: tmdbResults.total_pages,
|
total_pages: totalPages,
|
||||||
total_results: tmdbResults.total_results + mbResults.length,
|
total_results: totalItems,
|
||||||
results: combinedResults,
|
results: combinedResults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const movieTvIds = results.results
|
||||||
req.user,
|
.filter(
|
||||||
results.results.map((result) => ('id' in result ? result.id : 0))
|
(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);
|
const mappedResults = await mapSearchResults(results.results, media);
|
||||||
|
|
||||||
@@ -87,8 +291,8 @@ searchRoutes.get('/', async (req, res, next) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Something went wrong retrieving search results', {
|
logger.debug('Something went wrong retrieving search results', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
errorMessage: e.message,
|
errorMessage: e instanceof Error ? e.message : 'Unknown error',
|
||||||
query: req.query.query,
|
query: queryString,
|
||||||
});
|
});
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|||||||
@@ -761,8 +761,7 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
|||||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||||
const caaImageCache = await ImageProxy.getImageStats('caa');
|
const caaImageCache = await ImageProxy.getImageStats('caa');
|
||||||
const lidarrImageCache = await ImageProxy.getImageStats('lidarr');
|
const tadbImageCache = await ImageProxy.getImageStats('tadb');
|
||||||
const fanartImageCache = await ImageProxy.getImageStats('fanart');
|
|
||||||
|
|
||||||
const stats: DnsStats | undefined = dnsCache?.getStats();
|
const stats: DnsStats | undefined = dnsCache?.getStats();
|
||||||
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
|
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
|
||||||
@@ -773,8 +772,7 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
|||||||
tmdb: tmdbImageCache,
|
tmdb: tmdbImageCache,
|
||||||
avatar: avatarImageCache,
|
avatar: avatarImageCache,
|
||||||
caa: caaImageCache,
|
caa: caaImageCache,
|
||||||
lidarr: lidarrImageCache,
|
tadb: tadbImageCache,
|
||||||
fanart: fanartImageCache,
|
|
||||||
},
|
},
|
||||||
dnsCache: {
|
dnsCache: {
|
||||||
stats,
|
stats,
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ function initTvdbImageProxy() {
|
|||||||
return _tvdbImageProxy;
|
return _tvdbImageProxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tadbImageProxy = new ImageProxy('tadb', 'https://r2.theaudiodb.com', {
|
||||||
|
rateLimitOptions: {
|
||||||
|
maxRequests: 20,
|
||||||
|
maxRPS: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:type/*', async (req, res) => {
|
router.get('/:type/*', async (req, res) => {
|
||||||
const imagePath = req.path.replace(/^\/\w+/, '');
|
const imagePath = req.path.replace(/^\/\w+/, '');
|
||||||
try {
|
try {
|
||||||
@@ -38,6 +45,8 @@ router.get('/:type/*', async (req, res) => {
|
|||||||
imageData = await initTmdbImageProxy().getImage(imagePath);
|
imageData = await initTmdbImageProxy().getImage(imagePath);
|
||||||
} else if (req.params.type === 'tvdb') {
|
} else if (req.params.type === 'tvdb') {
|
||||||
imageData = await initTvdbImageProxy().getImage(imagePath);
|
imageData = await initTvdbImageProxy().getImage(imagePath);
|
||||||
|
} else if (req.params.type === 'tabd') {
|
||||||
|
imageData = await tadbImageProxy.getImage(imagePath);
|
||||||
} else {
|
} else {
|
||||||
logger.error('Unsupported image type', {
|
logger.error('Unsupported image type', {
|
||||||
imagePath,
|
imagePath,
|
||||||
@@ -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 TheMovieDb from '@server/api/themoviedb';
|
||||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
@@ -8,7 +9,6 @@ import Media from '@server/entity/Media';
|
|||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { getSettings } from '@server/lib/settings';
|
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
|
import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
|
||||||
@@ -26,6 +26,8 @@ export class IssueCommentSubscriber
|
|||||||
let title = '';
|
let title = '';
|
||||||
let image = '';
|
let image = '';
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
const listenbrainz = new ListenBrainzAPI();
|
||||||
|
const coverArt = CoverArtArchive.getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issue = (
|
const issue = (
|
||||||
@@ -57,26 +59,12 @@ export class IssueCommentSubscriber
|
|||||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
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}`;
|
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||||
} else if (media.mediaType === MediaType.MUSIC) {
|
} else if (media.mediaType === MediaType.MUSIC && media.mbId) {
|
||||||
const settings = getSettings();
|
const album = await listenbrainz.getAlbum(media.mbId);
|
||||||
if (!settings.lidarr[0]) {
|
const coverArtResponse = await coverArt.getCoverArt(media.mbId);
|
||||||
throw new Error('No Lidarr server configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
const lidarr = new LidarrAPI({
|
title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`;
|
||||||
apiKey: settings.lidarr[0].apiKey,
|
image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||||
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 ?? '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [firstComment] = sortBy(issue.comments, 'id');
|
const [firstComment] = sortBy(issue.comments, 'id');
|
||||||
|
|||||||
@@ -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 TheMovieDb from '@server/api/themoviedb';
|
||||||
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
|
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import Issue from '@server/entity/Issue';
|
import Issue from '@server/entity/Issue';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { getSettings } from '@server/lib/settings';
|
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +25,8 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
|||||||
let title = '';
|
let title = '';
|
||||||
let image = '';
|
let image = '';
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
const listenbrainz = new ListenBrainzAPI();
|
||||||
|
const coverArt = CoverArtArchive.getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (entity.media.mediaType === MediaType.MOVIE) {
|
if (entity.media.mediaType === MediaType.MOVIE) {
|
||||||
@@ -41,26 +43,15 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
|||||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
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}`;
|
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||||
} else if (entity.media.mediaType === MediaType.MUSIC) {
|
} else if (
|
||||||
const settings = getSettings();
|
entity.media.mediaType === MediaType.MUSIC &&
|
||||||
if (!settings.lidarr[0]) {
|
entity.media.mbId
|
||||||
throw new Error('No Lidarr server configured');
|
) {
|
||||||
}
|
const album = await listenbrainz.getAlbum(entity.media.mbId);
|
||||||
|
const coverArtResponse = await coverArt.getCoverArt(entity.media.mbId);
|
||||||
|
|
||||||
const lidarr = new LidarrAPI({
|
title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`;
|
||||||
apiKey: settings.lidarr[0].apiKey,
|
image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||||
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 ?? '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [firstComment] = sortBy(entity.comments, 'id');
|
const [firstComment] = sortBy(entity.comments, 'id');
|
||||||
|
|||||||
@@ -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 type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
import type {
|
import type {
|
||||||
AddSeriesOptions,
|
AddSeriesOptions,
|
||||||
SonarrSeries,
|
SonarrSeries,
|
||||||
} from '@server/api/servarr/sonarr';
|
} 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 SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
@@ -28,7 +30,6 @@ import type {
|
|||||||
InsertEvent,
|
InsertEvent,
|
||||||
RemoveEvent,
|
RemoveEvent,
|
||||||
UpdateEvent,
|
UpdateEvent,
|
||||||
Not
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { EventSubscriber } from 'typeorm';
|
import { EventSubscriber } from 'typeorm';
|
||||||
|
|
||||||
@@ -178,7 +179,6 @@ export class MediaRequestSubscriber
|
|||||||
entity: MediaRequest,
|
entity: MediaRequest,
|
||||||
event?: UpdateEvent<MediaRequest>
|
event?: UpdateEvent<MediaRequest>
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// Get fresh media state using event manager
|
// Get fresh media state using event manager
|
||||||
let latestMedia: Media | null = null;
|
let latestMedia: Media | null = null;
|
||||||
if (event?.manager) {
|
if (event?.manager) {
|
||||||
@@ -192,44 +192,55 @@ export class MediaRequestSubscriber
|
|||||||
where: { id: entity.media.id },
|
where: { id: entity.media.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if(!latestMedia
|
if (
|
||||||
|| latestMedia.mediaType !== MediaType.MUSIC
|
!latestMedia ||
|
||||||
|| latestMedia['status'] != MediaStatus.AVAILABLE)
|
latestMedia.mediaType !== MediaType.MUSIC ||
|
||||||
{
|
latestMedia['status'] != MediaStatus.AVAILABLE
|
||||||
return
|
) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const listenbrainz = new ListenBrainzAPI();
|
||||||
const musicbrainz = new MusicBrainz();
|
const coverArt = CoverArtArchive.getInstance();
|
||||||
const albumDetails = await musicbrainz.getAlbum({
|
const musicbrainz = new MusicBrainz();
|
||||||
albumId: latestMedia.mbId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const coverImage = albumDetails.images?.find(
|
try {
|
||||||
(img) => img.CoverType.toLowerCase() === 'cover'
|
const album = await listenbrainz.getAlbum(latestMedia.mbId ?? '');
|
||||||
)?.Url;
|
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(
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
Notification.MEDIA_AVAILABLE,
|
event: 'Album Request Now Available',
|
||||||
{
|
notifyAdmin: false,
|
||||||
event: `Album Request Now Available`,
|
notifySystem: true,
|
||||||
notifyAdmin: false,
|
notifyUser: entity.requestedBy,
|
||||||
notifySystem: true,
|
subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`,
|
||||||
notifyUser: entity.requestedBy,
|
message: truncate(artistWiki?.content ?? '', {
|
||||||
subject: albumDetails.title ?? latestMedia.mbId ?? 'Unknown Album',
|
length: 500,
|
||||||
message: albumDetails.overview || 'Album is now available.',
|
separator: /\s/,
|
||||||
media: latestMedia,
|
omission: '…',
|
||||||
request: entity,
|
}),
|
||||||
image: coverImage,
|
media: latestMedia,
|
||||||
}
|
image: coverArtUrl,
|
||||||
);
|
request: entity,
|
||||||
} catch (e) {
|
});
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
} catch (e) {
|
||||||
label: 'Notifications',
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
errorMessage: e.message,
|
label: 'Notifications',
|
||||||
mediaId: entity.id,
|
errorMessage: e.message,
|
||||||
});
|
mediaId: entity.id,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,224 +813,216 @@ export class MediaRequestSubscriber
|
|||||||
|
|
||||||
public async sendToLidarr(entity: MediaRequest): Promise<void> {
|
public async sendToLidarr(entity: MediaRequest): Promise<void> {
|
||||||
if (
|
if (
|
||||||
entity.status !== MediaRequestStatus.APPROVED ||
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
entity.type !== MediaType.MUSIC
|
entity.type === MediaType.MUSIC
|
||||||
) {
|
) {
|
||||||
return;
|
try {
|
||||||
}
|
const mediaRepository = getRepository(Media);
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
try {
|
if (settings.lidarr.length === 0 && !settings.lidarr[0]) {
|
||||||
const mediaRepository = getRepository(Media);
|
logger.info(
|
||||||
const settings = getSettings();
|
'No Lidarr server configured, skipping request processing',
|
||||||
const media = await mediaRepository.findOne({
|
{
|
||||||
where: { id: entity.media.id },
|
label: 'Media Request',
|
||||||
relations: { requests: true },
|
requestId: entity.id,
|
||||||
});
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!media?.mbId) {
|
let lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
|
||||||
throw new Error('Media data or MusicBrainz ID not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const lidarrSettings =
|
if (
|
||||||
entity.serverId !== null && entity.serverId >= 0
|
entity.serverId !== null &&
|
||||||
? settings.lidarr.find((l) => l.id === entity.serverId)
|
entity.serverId >= 0 &&
|
||||||
: settings.lidarr.find((l) => l.isDefault);
|
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) {
|
if (!lidarrSettings) {
|
||||||
logger.warn('No valid Lidarr server configured', {
|
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',
|
label: 'Media Request',
|
||||||
requestId: entity.id,
|
requestId: entity.id,
|
||||||
mediaId: entity.media.id,
|
mediaId: entity.media.id,
|
||||||
});
|
});
|
||||||
return;
|
} catch (e) {
|
||||||
}
|
logger.error('Something went wrong sending request to Lidarr', {
|
||||||
|
label: 'Media Request',
|
||||||
const rootFolder = entity.rootFolder || lidarrSettings.activeDirectory;
|
errorMessage: e.message,
|
||||||
const qualityProfile = entity.profileId || lidarrSettings.activeProfileId;
|
requestId: entity.id,
|
||||||
const tags = lidarrSettings.tags?.map((t) => t.toString()) || [];
|
mediaId: entity.media.id,
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
throw new Error(e.message);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60000));
|
|
||||||
artistId = addedArtist.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<void> {
|
public async updateParentStatus(entity: MediaRequest): Promise<void> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
|
|||||||
@@ -109,9 +109,11 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
|||||||
|
|
||||||
const allSeasonsReady = allSeasonResults.every((result) => result);
|
const allSeasonsReady = allSeasonResults.every((result) => result);
|
||||||
shouldComplete = allSeasonsReady;
|
shouldComplete = allSeasonsReady;
|
||||||
} else if (event.mediaType === MediaType.MUSIC)
|
} else if (event.mediaType === MediaType.MUSIC) {
|
||||||
{
|
if (
|
||||||
if(event['status'] == MediaStatus.AVAILABLE || event['status'] === MediaStatus.DELETED) {
|
event['status'] == MediaStatus.AVAILABLE ||
|
||||||
|
event['status'] === MediaStatus.DELETED
|
||||||
|
) {
|
||||||
shouldComplete = true;
|
shouldComplete = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import type {
|
|
||||||
LidarrAlbumDetails,
|
|
||||||
LidarrAlbumResult,
|
|
||||||
LidarrArtistDetails,
|
|
||||||
LidarrArtistResult,
|
|
||||||
} from '@server/api/servarr/lidarr';
|
|
||||||
import type {
|
import type {
|
||||||
TmdbCollectionResult,
|
TmdbCollectionResult,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
@@ -44,18 +38,6 @@ export const isCollection = (
|
|||||||
return (collection as TmdbCollectionResult).media_type === 'collection';
|
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 = (
|
export const isMovieDetails = (
|
||||||
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
||||||
): movie is TmdbMovieDetails => {
|
): movie is TmdbMovieDetails => {
|
||||||
@@ -67,15 +49,3 @@ export const isTvDetails = (
|
|||||||
): tv is TmdbTvDetails => {
|
): tv is TmdbTvDetails => {
|
||||||
return (tv as TmdbTvDetails).number_of_seasons !== undefined;
|
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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
|
import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { MusicDetails } from '@server/models/Music';
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
@@ -15,6 +16,8 @@ export interface AddedCardProps {
|
|||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
isAddedToWatchlist?: boolean;
|
isAddedToWatchlist?: boolean;
|
||||||
mutateParent?: () => void;
|
mutateParent?: () => void;
|
||||||
|
posterPath?: string | null;
|
||||||
|
needsCoverArt?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMovie = (
|
const isMovie = (
|
||||||
@@ -26,7 +29,7 @@ const isMovie = (
|
|||||||
const isMusic = (
|
const isMusic = (
|
||||||
media: MovieDetails | TvDetails | MusicDetails
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
): media is MusicDetails => {
|
): media is MusicDetails => {
|
||||||
return (media as MusicDetails).artistId !== undefined;
|
return (media as MusicDetails).artist !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AddedCard = ({
|
const AddedCard = ({
|
||||||
@@ -38,6 +41,8 @@ const AddedCard = ({
|
|||||||
canExpand,
|
canExpand,
|
||||||
isAddedToWatchlist = false,
|
isAddedToWatchlist = false,
|
||||||
mutateParent,
|
mutateParent,
|
||||||
|
posterPath: initialPosterPath,
|
||||||
|
needsCoverArt: initialNeedsCoverArt,
|
||||||
}: AddedCardProps) => {
|
}: AddedCardProps) => {
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
|
|
||||||
@@ -52,10 +57,31 @@ const AddedCard = ({
|
|||||||
? `/api/v1/movie/${tmdbId}`
|
? `/api/v1/movie/${tmdbId}`
|
||||||
: `/api/v1/tv/${tmdbId}`;
|
: `/api/v1/tv/${tmdbId}`;
|
||||||
|
|
||||||
const { data: title, error } = useSWR<
|
const { data: titleData, error } = useSWR<
|
||||||
MovieDetails | TvDetails | MusicDetails
|
MovieDetails | TvDetails | MusicDetails
|
||||||
>(inView ? url : null);
|
>(inView ? url : null);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
useProgressiveCovers<MovieDetails | TvDetails | MusicDetails>(
|
||||||
|
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) {
|
if (!title && !error) {
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
@@ -84,10 +110,10 @@ const AddedCard = ({
|
|||||||
isAddedToWatchlist={
|
isAddedToWatchlist={
|
||||||
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
|
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
|
||||||
}
|
}
|
||||||
image={title.images?.find((image) => image.CoverType === 'Cover')?.Url}
|
image={title.posterPath}
|
||||||
status={title.mediaInfo?.status}
|
status={title.mediaInfo?.status}
|
||||||
title={title.title}
|
title={title.title}
|
||||||
artist={title.artist.artistName}
|
artist={title.artist.name}
|
||||||
type={title.type}
|
type={title.type}
|
||||||
year={title.releaseDate}
|
year={title.releaseDate}
|
||||||
mediaType={'album'}
|
mediaType={'album'}
|
||||||
|
|||||||
@@ -3,26 +3,30 @@ import { UserCircleIcon } from '@heroicons/react/24/solid';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface GroupCardProps {
|
interface ArtistCardProps {
|
||||||
groupId: string;
|
artistId: string;
|
||||||
name: string;
|
name: string;
|
||||||
subName?: string;
|
subName?: string;
|
||||||
image?: string;
|
profilePath?: string | null;
|
||||||
|
artistThumb?: string | null;
|
||||||
|
type?: string;
|
||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupCard = ({
|
const ArtistCard = ({
|
||||||
groupId,
|
artistId,
|
||||||
name,
|
name,
|
||||||
subName,
|
subName,
|
||||||
image,
|
profilePath,
|
||||||
|
artistThumb,
|
||||||
|
type,
|
||||||
canExpand = false,
|
canExpand = false,
|
||||||
}: GroupCardProps) => {
|
}: ArtistCardProps) => {
|
||||||
const [isHovered, setHovered] = useState(false);
|
const [isHovered, setHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/group/${groupId}`}
|
href={`/artist/${artistId}`}
|
||||||
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
|
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
@@ -48,11 +52,11 @@ const GroupCard = ({
|
|||||||
<div style={{ paddingBottom: '150%' }}>
|
<div style={{ paddingBottom: '150%' }}>
|
||||||
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||||
<div className="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
<div className="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
||||||
{image ? (
|
{artistThumb || profilePath ? (
|
||||||
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type="music"
|
type="music"
|
||||||
src={image}
|
src={artistThumb || profilePath || ''}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -67,7 +71,7 @@ const GroupCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full truncate text-center font-bold">{name}</div>
|
<div className="w-full truncate text-center font-bold">{name}</div>
|
||||||
{subName && (
|
{(subName || type) && (
|
||||||
<div
|
<div
|
||||||
className="overflow-hidden whitespace-normal text-center text-sm text-gray-300"
|
className="overflow-hidden whitespace-normal text-center text-sm text-gray-300"
|
||||||
style={{
|
style={{
|
||||||
@@ -77,7 +81,7 @@ const GroupCard = ({
|
|||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{subName}
|
{subName || type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -92,4 +96,4 @@ const GroupCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GroupCard;
|
export default ArtistCard;
|
||||||
542
src/components/ArtistDetails/index.tsx
Normal file
542
src/components/ArtistDetails/index.tsx
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import Ellipsis from '@app/assets/ellipsis.svg';
|
||||||
|
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 { ArrowRightCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import TruncateMarkup from 'react-truncate-markup';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.ArtistDetails', {
|
||||||
|
birthdate: 'Born {birthdate}',
|
||||||
|
lifespan: '{birthdate} – {deathdate}',
|
||||||
|
alsoknownas: 'Also Known As: {names}',
|
||||||
|
album: 'Album',
|
||||||
|
single: 'Single',
|
||||||
|
ep: 'EP',
|
||||||
|
live: 'Live',
|
||||||
|
compilation: 'Compilation',
|
||||||
|
remix: 'Remix',
|
||||||
|
soundtrack: 'Soundtrack',
|
||||||
|
broadcast: 'Broadcast',
|
||||||
|
demo: 'Demo',
|
||||||
|
other: 'Other',
|
||||||
|
showall: 'Show All',
|
||||||
|
showless: 'Show Less',
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ArtistData {
|
||||||
|
artist?: {
|
||||||
|
name: string;
|
||||||
|
area?: string;
|
||||||
|
};
|
||||||
|
name?: string;
|
||||||
|
artistThumb?: string;
|
||||||
|
artistBackdrop?: string;
|
||||||
|
biography?: string;
|
||||||
|
wikipedia?: {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
birthday?: string;
|
||||||
|
deathday?: string;
|
||||||
|
releaseGroups: Album[];
|
||||||
|
typeCounts?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlbumTypeState {
|
||||||
|
albums: Album[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isHovered: boolean;
|
||||||
|
isCollapsing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumTypeMessages: Record<string, keyof typeof messages> = {
|
||||||
|
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 (
|
||||||
|
<div className="relative text-left">
|
||||||
|
<div
|
||||||
|
className="group outline-none ring-0"
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<TruncateMarkup
|
||||||
|
lines={showBio ? 200 : 6}
|
||||||
|
ellipsis={
|
||||||
|
<Ellipsis className="relative -top-0.5 ml-2 inline-block opacity-70 transition duration-300 group-hover:opacity-100" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="pt-2 text-sm lg:text-base">{content}</p>
|
||||||
|
</TruncateMarkup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{title}</span>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<span className="ml-2 text-sm text-gray-400">({totalCount})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="cards-vertical">
|
||||||
|
{displayAlbums
|
||||||
|
.filter((media) => media && media.id)
|
||||||
|
.map((media) => (
|
||||||
|
<li key={`release-${media.id}`}>
|
||||||
|
<TitleCard
|
||||||
|
id={media.id}
|
||||||
|
title={media.title || 'Unknown Album'}
|
||||||
|
year={media['first-release-date']}
|
||||||
|
image={media.posterPath ?? undefined}
|
||||||
|
mediaType="album"
|
||||||
|
artist={media['artist-credit']?.[0]?.name || artistName}
|
||||||
|
type={media['primary-type']}
|
||||||
|
status={media.mediaInfo?.status ?? MediaStatus.UNKNOWN}
|
||||||
|
canExpand
|
||||||
|
needsCoverArt={!media.posterPath}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{shouldShowExpandButton && !isLoading && (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={`w-40 transition-all duration-300 sm:w-40 md:w-40 ${
|
||||||
|
isCollapsing ? 'scale-95 opacity-50' : 'scale-100 opacity-100'
|
||||||
|
}`}
|
||||||
|
style={{ paddingBottom: '150%' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer"
|
||||||
|
onClick={() => 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
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative h-full w-full transform-gpu cursor-pointer
|
||||||
|
overflow-hidden rounded-xl text-white shadow-lg ring-1 transition duration-150 ease-in-out ${
|
||||||
|
isHovered
|
||||||
|
? 'scale-105 bg-gray-600 ring-gray-500'
|
||||||
|
: 'scale-100 bg-gray-800 ring-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center text-white">
|
||||||
|
{isExpanded ? (
|
||||||
|
<XCircleIcon className="w-14" />
|
||||||
|
) : (
|
||||||
|
<ArrowRightCircleIcon className="w-14" />
|
||||||
|
)}
|
||||||
|
<div className="mt-2 font-extrabold">
|
||||||
|
{intl.formatMessage(
|
||||||
|
isExpanded ? messages.showless : messages.showall
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isExpanded && totalCount > 20 && (
|
||||||
|
<div className="mt-1 text-sm text-gray-300">
|
||||||
|
{`${totalCount} total`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading &&
|
||||||
|
placeholdersToShow > 0 &&
|
||||||
|
[...Array(placeholdersToShow)].map((_, i) => (
|
||||||
|
<li key={`placeholder-${type}-${i}`}>
|
||||||
|
<TitleCard.Placeholder canExpand />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArtistDetails = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
const artistId = router.query.artistId as string;
|
||||||
|
|
||||||
|
const { data, error } = useSWR<ArtistData>(
|
||||||
|
artistId ? `/api/v1/artist/${artistId}` : null,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
dedupingInterval: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [albumTypes, setAlbumTypes] = useState<Record<string, AlbumTypeState>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [showBio, setShowBio] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.typeCounts) {
|
||||||
|
const initialAlbumTypes: Record<string, AlbumTypeState> = {};
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
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 <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumTypeOrder = [
|
||||||
|
'Album',
|
||||||
|
'EP',
|
||||||
|
'Single',
|
||||||
|
'Live',
|
||||||
|
'Compilation',
|
||||||
|
'Remix',
|
||||||
|
'Soundtrack',
|
||||||
|
'Broadcast',
|
||||||
|
'Demo',
|
||||||
|
'Other',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={artistName} />
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-0 h-96">
|
||||||
|
<ImageFader
|
||||||
|
isDarker
|
||||||
|
backgroundImages={data.artistBackdrop ? [data.artistBackdrop] : []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`relative z-10 mt-4 mb-8 flex flex-col items-center lg:flex-row ${
|
||||||
|
biographyContent ? 'lg:items-start' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.artistThumb && (
|
||||||
|
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
|
||||||
|
<CachedImage
|
||||||
|
type="music"
|
||||||
|
src={data.artistThumb}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-center text-gray-300 lg:text-left">
|
||||||
|
<h1 className="text-3xl text-white lg:text-4xl">{artistName}</h1>
|
||||||
|
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
|
||||||
|
<div>{personAttributes.join(' | ')}</div>
|
||||||
|
</div>
|
||||||
|
{biographyContent && (
|
||||||
|
<Biography
|
||||||
|
content={biographyContent}
|
||||||
|
showBio={showBio}
|
||||||
|
onClick={() => setShowBio((show) => !show)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{albumTypeOrder
|
||||||
|
.filter((type) => (albumTypes[type]?.albums.length ?? 0) > 0)
|
||||||
|
.map((type) => (
|
||||||
|
<AlbumSection
|
||||||
|
key={`section-${type}`}
|
||||||
|
type={type}
|
||||||
|
state={albumTypes[type]}
|
||||||
|
totalCount={data.typeCounts?.[type] ?? 0}
|
||||||
|
artistName={artistName}
|
||||||
|
onToggleExpand={toggleExpandType}
|
||||||
|
onHover={handleHover}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArtistDetails;
|
||||||
@@ -63,7 +63,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|||||||
const isMusic = (
|
const isMusic = (
|
||||||
media: MovieDetails | TvDetails | MusicDetails
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
): media is MusicDetails => {
|
): media is MusicDetails => {
|
||||||
return (media as MusicDetails).artistId !== undefined;
|
return (media as MusicDetails).artist.id !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Blacklist = () => {
|
const Blacklist = () => {
|
||||||
@@ -341,13 +341,9 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
isMusic(title)
|
isMusic(title)
|
||||||
? title.artist.images?.find((img) => img.CoverType === 'Fanart')
|
? title.artistBackdrop ||
|
||||||
?.Url ||
|
title.artistThumb ||
|
||||||
title.artist.images?.find((img) => img.CoverType === 'Poster')
|
title.posterPath ||
|
||||||
?.Url ||
|
|
||||||
title.images?.find(
|
|
||||||
(img) => img.CoverType.toLowerCase() === 'cover'
|
|
||||||
)?.Url ||
|
|
||||||
''
|
''
|
||||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
||||||
title.backdropPath ?? ''
|
title.backdropPath ?? ''
|
||||||
@@ -383,12 +379,12 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
src={
|
src={
|
||||||
title
|
title
|
||||||
? isMusic(title)
|
? isMusic(title)
|
||||||
? title.images?.find((image) => image.CoverType === 'Cover')
|
? title.posterPath ||
|
||||||
?.Url ?? '/images/seerr_poster_not_found.png'
|
'/images/jellyseerr_poster_not_found_square.png'
|
||||||
: title.posterPath
|
: title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
: '/images/seerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
: '/images/seerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
@@ -418,7 +414,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
|
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
|
||||||
{title &&
|
{title &&
|
||||||
(isMusic(title)
|
(isMusic(title)
|
||||||
? `${title.artist.artistName} - ${title.title}`
|
? `${title.artist.name} - ${title.title}`
|
||||||
: isMovie(title)
|
: isMovie(title)
|
||||||
? title.title
|
? title.title
|
||||||
: title.name)}
|
: title.name)}
|
||||||
@@ -513,7 +509,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
item.mbId,
|
item.mbId,
|
||||||
title &&
|
title &&
|
||||||
(isMusic(title)
|
(isMusic(title)
|
||||||
? `${title.artist.artistName} - ${title.title}`
|
? `${title.artist.name} - ${title.title}`
|
||||||
: isMovie(title)
|
: isMovie(title)
|
||||||
? title.title
|
? title.title
|
||||||
: title.name)
|
: title.name)
|
||||||
|
|||||||
@@ -27,16 +27,14 @@ const isMovie = (
|
|||||||
media: MovieDetails | TvDetails | MusicDetails | null
|
media: MovieDetails | TvDetails | MusicDetails | null
|
||||||
): media is MovieDetails => {
|
): media is MovieDetails => {
|
||||||
if (!media) return false;
|
if (!media) return false;
|
||||||
return (
|
return 'title' in media && !('artist' in media);
|
||||||
(media as MovieDetails).title !== undefined && !('artistName' in media)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMusic = (
|
const isMusic = (
|
||||||
media: MovieDetails | TvDetails | MusicDetails | null
|
media: MovieDetails | TvDetails | MusicDetails | null
|
||||||
): media is MusicDetails => {
|
): media is MusicDetails => {
|
||||||
if (!media) return false;
|
if (!media) return false;
|
||||||
return (media as MusicDetails).artistId !== undefined;
|
return 'artist' in media && typeof media.artist?.name === 'string';
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlacklistModal = ({
|
const BlacklistModal = ({
|
||||||
@@ -69,7 +67,7 @@ const BlacklistModal = ({
|
|||||||
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (isMusic(data)) {
|
if (isMusic(data)) {
|
||||||
return `${data.artist.artistName} - ${data.title}`;
|
return `${data.artist.name} - ${data.title}`;
|
||||||
}
|
}
|
||||||
return isMovie(data) ? data.title : data?.name;
|
return isMovie(data) ? data.title : data?.name;
|
||||||
};
|
};
|
||||||
@@ -85,7 +83,7 @@ const BlacklistModal = ({
|
|||||||
|
|
||||||
const getBackdrop = () => {
|
const getBackdrop = () => {
|
||||||
if (isMusic(data)) {
|
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}`;
|
return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import type { ImageLoader, ImageProps } from 'next/image';
|
import type { ImageLoader, ImageProps } from 'next/image';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
const imageLoader: ImageLoader = ({ src }) => src;
|
const imageLoader: ImageLoader = ({ src }) => src;
|
||||||
|
|
||||||
@@ -15,8 +16,11 @@ export type CachedImageProps = ImageProps & {
|
|||||||
**/
|
**/
|
||||||
const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
|
const [, setIsLoading] = useState(true);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
let imageUrl: string;
|
let imageUrl: string;
|
||||||
|
let fallbackImage = '';
|
||||||
|
|
||||||
if (type === 'tmdb') {
|
if (type === 'tmdb') {
|
||||||
// tmdb stuff
|
// tmdb stuff
|
||||||
@@ -32,23 +36,39 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
|||||||
'/imageproxy/tvdb/'
|
'/imageproxy/tvdb/'
|
||||||
)
|
)
|
||||||
: src;
|
: src;
|
||||||
} else if (type === 'avatar') {
|
fallbackImage = '/images/jellyseerr_poster_not_found.png';
|
||||||
// jellyfin avatar (if any)
|
|
||||||
imageUrl = src;
|
|
||||||
} else if (type === 'music') {
|
} else if (type === 'music') {
|
||||||
// Handle CAA, Fanart and Lidarr images
|
// Cover Art Archive and TheAudioDB images
|
||||||
imageUrl = /^https?:\/\/coverartarchive\.org\//.test(src)
|
imageUrl = src.startsWith('https://archive.org/')
|
||||||
? src.replace(/^https?:\/\/coverartarchive\.org\//, '/caaproxy/')
|
? src.replace(/^https:\/\/archive\.org\//, '/caaproxy/')
|
||||||
: /^https?:\/\/assets\.fanart\.tv\//.test(src)
|
: currentSettings.cacheImages &&
|
||||||
? src.replace(/^https?:\/\/assets\.fanart\.tv\//, '/fanartproxy/')
|
!src.startsWith('/') &&
|
||||||
: currentSettings.cacheImages
|
src.startsWith('https://r2.theaudiodb.com/')
|
||||||
? src.replace(/^https:\/\/imagecache\.lidarr\.audio\//, '/lidarrproxy/')
|
? src.replace(/^https:\/\/r2\.theaudiodb\.com\//, '/tadbproxy/')
|
||||||
: src;
|
: src;
|
||||||
|
fallbackImage = '/images/jellyseerr_poster_not_found_square.png';
|
||||||
|
} else if (type === 'avatar') {
|
||||||
|
imageUrl = src;
|
||||||
|
fallbackImage = '/images/user_placeholder.png';
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
|
const displaySrc = isError ? fallbackImage : imageUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
unoptimized
|
||||||
|
loader={imageLoader}
|
||||||
|
src={displaySrc}
|
||||||
|
{...props}
|
||||||
|
onLoad={() => setIsLoading(false)}
|
||||||
|
onError={() => {
|
||||||
|
setIsError(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CachedImage;
|
export default CachedImage;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AddedCard from '@app/components/AddedCard';
|
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 PersonCard from '@app/components/PersonCard';
|
||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
@@ -162,42 +162,40 @@ const ListView = ({
|
|||||||
isAddedToWatchlist={
|
isAddedToWatchlist={
|
||||||
title.mediaInfo?.watchlists?.length ?? 0
|
title.mediaInfo?.watchlists?.length ?? 0
|
||||||
}
|
}
|
||||||
image={
|
image={title.posterPath}
|
||||||
title.images?.find((image) => image.CoverType === 'Cover')
|
|
||||||
?.Url
|
|
||||||
}
|
|
||||||
status={title.mediaInfo?.status}
|
status={title.mediaInfo?.status}
|
||||||
title={title.title}
|
title={title.title}
|
||||||
artist={title.artistname}
|
artist={title['artist-credit']?.[0]?.name}
|
||||||
type={title.type}
|
type={title['primary-type']}
|
||||||
year={title.releasedate}
|
year={
|
||||||
|
title.releaseDate
|
||||||
|
? title.releaseDate.split('-')[0]
|
||||||
|
: title['first-release-date']?.split('-')[0]
|
||||||
|
}
|
||||||
mediaType={title.mediaType}
|
mediaType={title.mediaType}
|
||||||
inProgress={
|
inProgress={
|
||||||
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
||||||
}
|
}
|
||||||
|
needsCoverArt={title.needsCoverArt}
|
||||||
canExpand
|
canExpand
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'artist':
|
case 'artist':
|
||||||
return title.type === 'Group' ? (
|
return title.tmdbPersonId ? (
|
||||||
<GroupCard
|
<PersonCard
|
||||||
key={title.id}
|
key={title.id}
|
||||||
groupId={title.id}
|
personId={title.tmdbPersonId}
|
||||||
name={title.artistname}
|
name={title.name}
|
||||||
image={
|
profilePath={title.artistThumb ?? undefined}
|
||||||
title.images.find((image) => image.CoverType === 'Poster')
|
|
||||||
?.Url ?? title.artistimage
|
|
||||||
}
|
|
||||||
canExpand
|
canExpand
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PersonCard
|
<ArtistCard
|
||||||
key={title.id}
|
key={title.id}
|
||||||
personId={title.id}
|
artistId={title.id}
|
||||||
name={title.artistname}
|
name={title.name}
|
||||||
mediaType="artist"
|
artistThumb={title.artistThumb}
|
||||||
profilePath={title.artistimage}
|
|
||||||
canExpand
|
canExpand
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
29
src/components/Common/Toggle/index.tsx
Normal file
29
src/components/Common/Toggle/index.tsx
Normal file
@@ -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 (
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full ${
|
||||||
|
checked ? 'bg-indigo-600' : 'bg-gray-700'
|
||||||
|
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Toggle</span>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition ${
|
||||||
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toggle;
|
||||||
@@ -1,40 +1,49 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
import Header from '@app/components/Common/Header';
|
import Header from '@app/components/Common/Header';
|
||||||
import ListView from '@app/components/Common/ListView';
|
import ListView from '@app/components/Common/ListView';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
import {
|
||||||
import { prepareFilterValues } from '@app/components/Discover/constants';
|
countActiveFilters,
|
||||||
|
prepareFilterValues,
|
||||||
|
} from '@app/components/Discover/constants';
|
||||||
|
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
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 type { AlbumResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages('components.Discover.DiscoverMusic', {
|
const messages = defineMessages('components.Discover.DiscoverMusic', {
|
||||||
discovermusics: 'Music',
|
discovermusic: 'Music',
|
||||||
sortPopularityDesc: 'Most Listened',
|
sortReleaseDateAsc: 'Release Date Ascending',
|
||||||
sortPopularityAsc: 'Least Listened',
|
sortReleaseDateDesc: 'Release Date Descending',
|
||||||
sortReleaseDateDesc: 'Newest First',
|
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||||
sortReleaseDateAsc: 'Oldest First',
|
sortTitleDesc: 'Title (Z-A) Descending',
|
||||||
sortTitleAsc: 'Title (A-Z)',
|
sortArtistAsc: 'Artist Name (A-Z) Ascending',
|
||||||
sortTitleDesc: 'Title (Z-A)',
|
sortArtistDesc: 'Artist Name (Z-A) Descending',
|
||||||
|
activefilters:
|
||||||
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
filters: 'Filters',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SortOptions = {
|
const SortOptions = {
|
||||||
PopularityDesc: 'listen_count.desc',
|
|
||||||
PopularityAsc: 'listen_count.asc',
|
|
||||||
ReleaseDateDesc: 'release_date.desc',
|
ReleaseDateDesc: 'release_date.desc',
|
||||||
ReleaseDateAsc: 'release_date.asc',
|
ReleaseDateAsc: 'release_date.asc',
|
||||||
TitleAsc: 'title.asc',
|
TitleAsc: 'title.asc',
|
||||||
TitleDesc: 'title.desc',
|
TitleDesc: 'title.desc',
|
||||||
|
ArtistAsc: 'artist.asc',
|
||||||
|
ArtistDesc: 'artist.desc',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DiscoverMusic = () => {
|
const DiscoverMusic = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const updateQueryParams = useUpdateQueryParams({});
|
const updateQueryParams = useUpdateQueryParams({});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
const preparedFilters = prepareFilterValues(router.query);
|
const preparedFilters = prepareFilterValues(router.query);
|
||||||
|
|
||||||
@@ -46,16 +55,16 @@ const DiscoverMusic = () => {
|
|||||||
titles,
|
titles,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
error,
|
error,
|
||||||
} = useDiscover<AlbumResult & { id: number }, unknown, FilterOptions>( // Add intersection type to ensure id is number
|
} = useDiscover<AlbumResult>('/api/v1/discover/music', {
|
||||||
'/api/v1/discover/music',
|
...preparedFilters,
|
||||||
preparedFilters
|
days: preparedFilters.days ?? '7',
|
||||||
);
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Error statusCode={500} />;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = intl.formatMessage(messages.discovermusics);
|
const title = intl.formatMessage(messages.discovermusic);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -74,12 +83,6 @@ const DiscoverMusic = () => {
|
|||||||
value={preparedFilters.sortBy}
|
value={preparedFilters.sortBy}
|
||||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value={SortOptions.PopularityDesc}>
|
|
||||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
|
||||||
</option>
|
|
||||||
<option value={SortOptions.PopularityAsc}>
|
|
||||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
|
||||||
</option>
|
|
||||||
<option value={SortOptions.ReleaseDateDesc}>
|
<option value={SortOptions.ReleaseDateDesc}>
|
||||||
{intl.formatMessage(messages.sortReleaseDateDesc)}
|
{intl.formatMessage(messages.sortReleaseDateDesc)}
|
||||||
</option>
|
</option>
|
||||||
@@ -92,8 +95,30 @@ const DiscoverMusic = () => {
|
|||||||
<option value={SortOptions.TitleDesc}>
|
<option value={SortOptions.TitleDesc}>
|
||||||
{intl.formatMessage(messages.sortTitleDesc)}
|
{intl.formatMessage(messages.sortTitleDesc)}
|
||||||
</option>
|
</option>
|
||||||
|
<option value={SortOptions.ArtistAsc}>
|
||||||
|
{intl.formatMessage(messages.sortArtistAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.ArtistDesc}>
|
||||||
|
{intl.formatMessage(messages.sortArtistDesc)}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<FilterSlideover
|
||||||
|
type="music"
|
||||||
|
currentFilters={preparedFilters}
|
||||||
|
onClose={() => setShowFilters(false)}
|
||||||
|
show={showFilters}
|
||||||
|
/>
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||||
|
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||||
|
<FunnelIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.activefilters, {
|
||||||
|
count: countActiveFilters(preparedFilters),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
|
|||||||
101
src/components/Discover/DiscoverMusicAlbums/index.tsx
Normal file
101
src/components/Discover/DiscoverMusicAlbums/index.tsx
Normal file
@@ -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 { AlbumResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Discover.DiscoverMusicAlbums', {
|
||||||
|
discoveralbums: 'Albums',
|
||||||
|
sortPopularityDesc: 'Most Listened',
|
||||||
|
sortPopularityAsc: 'Least Listened',
|
||||||
|
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||||
|
sortTitleDesc: 'Title (Z-A) Descending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const SortOptions = {
|
||||||
|
PopularityDesc: 'listen_count.desc',
|
||||||
|
PopularityAsc: 'listen_count.asc',
|
||||||
|
TitleAsc: 'title.asc',
|
||||||
|
TitleDesc: 'title.desc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DiscoverMusicAlbums = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
const updateQueryParams = useUpdateQueryParams({});
|
||||||
|
|
||||||
|
const preparedFilters = prepareFilterValues(router.query);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
} = useDiscover<AlbumResult>(
|
||||||
|
'/api/v1/discover/music/albums',
|
||||||
|
preparedFilters
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = intl.formatMessage(messages.discoveralbums);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
<BarsArrowDownIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
id="sortBy"
|
||||||
|
name="sortBy"
|
||||||
|
className="rounded-r-only"
|
||||||
|
value={preparedFilters.sortBy}
|
||||||
|
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={SortOptions.PopularityDesc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.PopularityAsc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TitleAsc}>
|
||||||
|
{intl.formatMessage(messages.sortTitleAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.TitleDesc}>
|
||||||
|
{intl.formatMessage(messages.sortTitleDesc)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMusicAlbums;
|
||||||
101
src/components/Discover/DiscoverMusicArtists/index.tsx
Normal file
101
src/components/Discover/DiscoverMusicArtists/index.tsx
Normal file
@@ -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<ArtistResult>(
|
||||||
|
'/api/v1/discover/music/artists',
|
||||||
|
preparedFilters
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = intl.formatMessage(messages.discoverartists);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
<BarsArrowDownIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
id="sortBy"
|
||||||
|
name="sortBy"
|
||||||
|
className="rounded-r-only"
|
||||||
|
value={preparedFilters.sortBy}
|
||||||
|
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={SortOptions.PopularityDesc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.PopularityAsc}>
|
||||||
|
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.NameAsc}>
|
||||||
|
{intl.formatMessage(messages.sortNameAsc)}
|
||||||
|
</option>
|
||||||
|
<option value={SortOptions.NameDesc}>
|
||||||
|
{intl.formatMessage(messages.sortNameDesc)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMusicArtists;
|
||||||
@@ -135,8 +135,6 @@ const DiscoverSliderEdit = ({
|
|||||||
return intl.formatMessage(sliderTitles.plexwatchlist);
|
return intl.formatMessage(sliderTitles.plexwatchlist);
|
||||||
case DiscoverSliderType.TRENDING:
|
case DiscoverSliderType.TRENDING:
|
||||||
return intl.formatMessage(sliderTitles.trending);
|
return intl.formatMessage(sliderTitles.trending);
|
||||||
case DiscoverSliderType.POPULAR_ALBUMS:
|
|
||||||
return intl.formatMessage(sliderTitles.popularalbums);
|
|
||||||
case DiscoverSliderType.POPULAR_MOVIES:
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
return intl.formatMessage(sliderTitles.popularmovies);
|
return intl.formatMessage(sliderTitles.popularmovies);
|
||||||
case DiscoverSliderType.MOVIE_GENRES:
|
case DiscoverSliderType.MOVIE_GENRES:
|
||||||
@@ -171,6 +169,10 @@ const DiscoverSliderEdit = ({
|
|||||||
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
|
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
|
||||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||||
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
|
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
|
||||||
|
case DiscoverSliderType.POPULAR_ALBUMS:
|
||||||
|
return intl.formatMessage(sliderTitles.popularalbums);
|
||||||
|
case DiscoverSliderType.POPULAR_ARTISTS:
|
||||||
|
return intl.formatMessage(sliderTitles.popularartists);
|
||||||
default:
|
default:
|
||||||
return 'Unknown Slider';
|
return 'Unknown Slider';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
|
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
|
||||||
import SlideOver from '@app/components/Common/SlideOver';
|
import SlideOver from '@app/components/Common/SlideOver';
|
||||||
|
import Toggle from '@app/components/Common/Toggle';
|
||||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||||
import { countActiveFilters } from '@app/components/Discover/constants';
|
import { countActiveFilters } from '@app/components/Discover/constants';
|
||||||
import LanguageSelector from '@app/components/LanguageSelector';
|
import LanguageSelector from '@app/components/LanguageSelector';
|
||||||
@@ -19,7 +20,10 @@ import {
|
|||||||
} from '@app/hooks/useUpdateQueryParams';
|
} from '@app/hooks/useUpdateQueryParams';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import type { MultiValue } from 'react-select';
|
||||||
|
import AsyncSelect from 'react-select/async';
|
||||||
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
||||||
|
|
||||||
const messages = defineMessages('components.Discover.FilterSlideover', {
|
const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||||
@@ -45,12 +49,13 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
|||||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
certification: 'Content Rating',
|
certification: 'Content Rating',
|
||||||
|
onlyWithCoverArt: 'Only show releases with cover art',
|
||||||
});
|
});
|
||||||
|
|
||||||
type FilterSlideoverProps = {
|
type FilterSlideoverProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv' | 'music';
|
||||||
currentFilters: FilterOptions;
|
currentFilters: FilterOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,12 +69,188 @@ const FilterSlideover = ({
|
|||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
const updateQueryParams = useUpdateQueryParams({});
|
const updateQueryParams = useUpdateQueryParams({});
|
||||||
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
|
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
|
||||||
|
const [defaultSelectedGenres, setDefaultSelectedGenres] = useState<
|
||||||
|
{ label: string; value: string }[] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const dateGte =
|
const dateGte =
|
||||||
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
|
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
|
||||||
const dateLte =
|
const dateLte =
|
||||||
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
|
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 (
|
||||||
|
<SlideOver
|
||||||
|
show={show}
|
||||||
|
title={intl.formatMessage(messages.filters)}
|
||||||
|
subText={intl.formatMessage(messages.activefilters, {
|
||||||
|
count: countActiveFilters(currentFilters),
|
||||||
|
})}
|
||||||
|
onClose={() => onClose()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.releaseDate)}
|
||||||
|
</div>
|
||||||
|
<div className="relative z-40 flex space-x-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
|
||||||
|
<Datepicker
|
||||||
|
primaryColor="indigo"
|
||||||
|
value={{
|
||||||
|
startDate: currentFilters.releaseDateGte ?? null,
|
||||||
|
endDate: currentFilters.releaseDateGte ?? null,
|
||||||
|
}}
|
||||||
|
onChange={(value) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
|
||||||
|
<Datepicker
|
||||||
|
primaryColor="indigo"
|
||||||
|
value={{
|
||||||
|
startDate: currentFilters.releaseDateLte ?? null,
|
||||||
|
endDate: currentFilters.releaseDateLte ?? null,
|
||||||
|
}}
|
||||||
|
onChange={(value) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col space-y-4">
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.genres)}
|
||||||
|
</span>
|
||||||
|
<AsyncSelect
|
||||||
|
key={`music-genre-select-${defaultSelectedGenres}`}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultValue={defaultSelectedGenres}
|
||||||
|
defaultOptions={musicGenreOptions}
|
||||||
|
isMulti
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={loadMusicGenreOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.genres)}
|
||||||
|
onChange={(value: MultiValue<{ label: string; value: string }>) => {
|
||||||
|
updateQueryParams(
|
||||||
|
'genre',
|
||||||
|
value?.length ? value.map((v) => v.value).join(',') : undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.onlyWithCoverArt)}
|
||||||
|
</span>
|
||||||
|
<Toggle
|
||||||
|
checked={currentFilters.onlyWithCoverArt === 'true'}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const newValue = checked ? 'true' : undefined;
|
||||||
|
updateQueryParams('onlyWithCoverArt', newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={Object.keys(currentFilters).length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const copyCurrent = Object.assign({}, currentFilters);
|
||||||
|
(
|
||||||
|
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
|
||||||
|
).forEach((k) => {
|
||||||
|
copyCurrent[k] = undefined;
|
||||||
|
});
|
||||||
|
batchUpdateQueryParams(copyCurrent);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircleIcon />
|
||||||
|
<span>{intl.formatMessage(messages.clearfilters)}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlideOver
|
<SlideOver
|
||||||
show={show}
|
show={show}
|
||||||
@@ -96,16 +277,26 @@ const FilterSlideover = ({
|
|||||||
endDate: currentFilters[dateGte] ?? null,
|
endDate: currentFilters[dateGte] ?? null,
|
||||||
}}
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
updateQueryParams(
|
// Format the date as YYYY-MM-DD before setting it
|
||||||
dateGte,
|
let formattedDate: string | undefined = undefined;
|
||||||
value?.startDate ? (value.startDate as string) : 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"
|
inputName="fromdate"
|
||||||
useRange={false}
|
useRange={false}
|
||||||
asSingle
|
asSingle
|
||||||
containerClassName="datepicker-wrapper"
|
containerClassName="datepicker-wrapper"
|
||||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||||
|
displayFormat="YYYY-MM-DD" // Add this to enforce the correct format
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -117,16 +308,25 @@ const FilterSlideover = ({
|
|||||||
endDate: currentFilters[dateLte] ?? null,
|
endDate: currentFilters[dateLte] ?? null,
|
||||||
}}
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
updateQueryParams(
|
let formattedDate: string | undefined = undefined;
|
||||||
dateLte,
|
if (value?.startDate) {
|
||||||
value?.startDate ? (value.startDate as string) : undefined
|
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"
|
inputName="todate"
|
||||||
useRange={false}
|
useRange={false}
|
||||||
asSingle
|
asSingle
|
||||||
containerClassName="datepicker-wrapper"
|
containerClassName="datepicker-wrapper"
|
||||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||||
|
displayFormat="YYYY-MM-DD" // Add this to enforce the correct format
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const RecentlyAddedSlider = () => {
|
|||||||
<Slider
|
<Slider
|
||||||
sliderKey="media"
|
sliderKey="media"
|
||||||
isLoading={!media}
|
isLoading={!media}
|
||||||
items={(media?.results ?? []).map((item) => (
|
items={media?.results.map((item) => (
|
||||||
<AddedCard
|
<AddedCard
|
||||||
key={`media-slider-item-${item.id}`}
|
key={`media-slider-item-${item.id}`}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export const genreColorMap: Record<number, [string, string]> = {
|
|||||||
|
|
||||||
export const sliderTitles = defineMessages('components.Discover', {
|
export const sliderTitles = defineMessages('components.Discover', {
|
||||||
recentrequests: 'Recent Requests',
|
recentrequests: 'Recent Requests',
|
||||||
popularalbums: 'Popular Albums',
|
|
||||||
popularmovies: 'Popular Movies',
|
popularmovies: 'Popular Movies',
|
||||||
populartv: 'Popular Series',
|
populartv: 'Popular Series',
|
||||||
upcomingtv: 'Upcoming Series',
|
upcomingtv: 'Upcoming Series',
|
||||||
@@ -89,6 +88,8 @@ export const sliderTitles = defineMessages('components.Discover', {
|
|||||||
tmdbsearch: 'TMDB Search',
|
tmdbsearch: 'TMDB Search',
|
||||||
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
|
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
|
||||||
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
|
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
|
||||||
|
popularalbums: 'Popular Albums',
|
||||||
|
popularartists: 'Popular Artists',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const QueryFilterOptions = z.object({
|
export const QueryFilterOptions = z.object({
|
||||||
@@ -116,6 +117,10 @@ export const QueryFilterOptions = z.object({
|
|||||||
certificationLte: z.string().optional(),
|
certificationLte: z.string().optional(),
|
||||||
certificationCountry: z.string().optional(),
|
certificationCountry: z.string().optional(),
|
||||||
certificationMode: z.enum(['exact', 'range']).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<typeof QueryFilterOptions>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
@@ -227,6 +232,18 @@ export const prepareFilterValues = (
|
|||||||
filterValues.certificationMode = 'range';
|
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;
|
return filterValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -272,6 +289,17 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete clonedFilters.certificationMode;
|
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;
|
totalCount += Object.keys(clonedFilters).length;
|
||||||
|
|
||||||
return totalCount;
|
return totalCount;
|
||||||
|
|||||||
@@ -219,16 +219,6 @@ const Discover = () => {
|
|||||||
case DiscoverSliderType.PLEX_WATCHLIST:
|
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||||
sliderComponent = <PlexWatchlistSlider />;
|
sliderComponent = <PlexWatchlistSlider />;
|
||||||
break;
|
break;
|
||||||
case DiscoverSliderType.POPULAR_ALBUMS:
|
|
||||||
sliderComponent = (
|
|
||||||
<MediaSlider
|
|
||||||
sliderKey="popular-albums"
|
|
||||||
title={intl.formatMessage(sliderTitles.popularalbums)}
|
|
||||||
url="/api/v1/discover/music"
|
|
||||||
linkUrl="/discover/music"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case DiscoverSliderType.TRENDING:
|
case DiscoverSliderType.TRENDING:
|
||||||
sliderComponent = (
|
sliderComponent = (
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
@@ -406,6 +396,26 @@ const Discover = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_ALBUMS:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey="popular-albums"
|
||||||
|
title={intl.formatMessage(sliderTitles.popularalbums)}
|
||||||
|
url="/api/v1/discover/music/albums"
|
||||||
|
linkUrl="/discover/albums"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_ARTISTS:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey="popular-artists"
|
||||||
|
title={intl.formatMessage(sliderTitles.popularartists)}
|
||||||
|
url="/api/v1/discover/music/artists"
|
||||||
|
linkUrl="/discover/artists"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
|
|||||||
@@ -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<DiscographyResponse>(
|
|
||||||
(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<DiscographyResponse>(
|
|
||||||
(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<DiscographyResponse>(
|
|
||||||
(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<DiscographyResponse>(
|
|
||||||
(index) =>
|
|
||||||
`/api/v1/group/${router.query.groupId}/discography?page=${
|
|
||||||
index + 1
|
|
||||||
}&type=Other`,
|
|
||||||
{ revalidateFirstPage: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, error } = useSWR<ArtistDetailsType>(
|
|
||||||
`/api/v1/group/${router.query.groupId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data && !error) {
|
|
||||||
return <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return <Error statusCode={404} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<div className="slider-header">
|
|
||||||
<div className="slider-title">
|
|
||||||
<span>{title}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="cards-vertical">
|
|
||||||
{albums?.map((album) => (
|
|
||||||
<li key={`album-${album.id}`}>
|
|
||||||
<TitleCard
|
|
||||||
id={album.id}
|
|
||||||
isAddedToWatchlist={album.mediaInfo?.watchlists?.length ?? 0}
|
|
||||||
title={album.title}
|
|
||||||
image={album.images?.[0]?.Url}
|
|
||||||
year={album.releasedate}
|
|
||||||
type={album.type}
|
|
||||||
mediaType="album"
|
|
||||||
status={album.mediaInfo?.status}
|
|
||||||
inProgress={(album.mediaInfo?.downloadStatus ?? []).length > 0}
|
|
||||||
canExpand
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{isLoading &&
|
|
||||||
[...Array(20)].map((_, index) => (
|
|
||||||
<li key={`placeholder-${index}`}>
|
|
||||||
<TitleCard.Placeholder canExpand />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{!isReachingEnd && (
|
|
||||||
<div className="mt-4 flex justify-center">
|
|
||||||
<Button
|
|
||||||
onClick={onLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex h-9 w-32 items-center justify-center"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="h-5 w-5">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
intl.formatMessage(messages.loadmore)
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageTitle title={data.name} />
|
|
||||||
<div className="absolute top-0 left-0 right-0 z-0 h-96">
|
|
||||||
<ImageFader
|
|
||||||
isDarker
|
|
||||||
backgroundImages={[
|
|
||||||
...(albumData?.flatMap((page) => 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)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 mt-4 mb-8 flex flex-col items-center lg:flex-row lg:items-start">
|
|
||||||
{data.images?.[0]?.Url && (
|
|
||||||
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
|
|
||||||
<CachedImage
|
|
||||||
type="music"
|
|
||||||
src={
|
|
||||||
data.images.find((img) => img.CoverType === 'Poster')?.Url ??
|
|
||||||
data.images[0]?.Url
|
|
||||||
}
|
|
||||||
alt=""
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
fill
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-center text-gray-300 lg:text-left">
|
|
||||||
<h1 className="text-3xl text-white lg:text-4xl">{data.name}</h1>
|
|
||||||
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
|
|
||||||
<div>{groupAttributes.join(' | ')}</div>
|
|
||||||
</div>
|
|
||||||
{data.overview && (
|
|
||||||
<div className="relative text-left">
|
|
||||||
<div
|
|
||||||
className="group outline-none ring-0"
|
|
||||||
onClick={() => setShowBio((show) => !show)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === 'Space') {
|
|
||||||
setShowBio((show) => !show);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<TruncateMarkup
|
|
||||||
lines={showBio ? 200 : 6}
|
|
||||||
ellipsis={
|
|
||||||
<Ellipsis className="relative -top-0.5 ml-2 inline-block opacity-70 transition duration-300 group-hover:opacity-100" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="pt-2 text-sm lg:text-base">{data.overview}</p>
|
|
||||||
</TruncateMarkup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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;
|
|
||||||
@@ -192,7 +192,7 @@ const IssueDetails = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const title = isMusic(data)
|
const title = isMusic(data)
|
||||||
? `${data.artist.artistName} - ${data.title}`
|
? `${data.artist.name} - ${data.title}`
|
||||||
: isMovie(data)
|
: isMovie(data)
|
||||||
? data.title
|
? data.title
|
||||||
: data.name;
|
: data.name;
|
||||||
@@ -238,14 +238,7 @@ const IssueDetails = () => {
|
|||||||
alt=""
|
alt=""
|
||||||
src={
|
src={
|
||||||
isMusic(data)
|
isMusic(data)
|
||||||
? data.artist.images?.find((img) => img.CoverType === 'Fanart')
|
? data?.artistBackdrop || data?.artistThumb || ''
|
||||||
?.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'
|
|
||||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`
|
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`
|
||||||
}
|
}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -267,9 +260,8 @@ const IssueDetails = () => {
|
|||||||
type={isMusic(data) ? 'music' : 'tmdb'}
|
type={isMusic(data) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
isMusic(data)
|
isMusic(data)
|
||||||
? data.images?.find(
|
? data.posterPath ||
|
||||||
(img) => img.CoverType.toLowerCase() === 'cover'
|
'/images/jellyseerr_poster_not_found_square.png'
|
||||||
)?.Url || '/images/overseerr_poster_not_found.png'
|
|
||||||
: data.posterPath
|
: data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
: '/images/seerr_poster_not_found.png'
|
: '/images/seerr_poster_not_found.png'
|
||||||
|
|||||||
@@ -139,8 +139,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
isMusic(title)
|
isMusic(title)
|
||||||
? title.artist.images?.find((img) => img.CoverType === 'Fanart')
|
? title.artistBackdrop ?? title.artistThumb ?? ''
|
||||||
?.Url ?? '/images/overseerr_poster_not_found.png'
|
|
||||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
||||||
title.backdropPath ?? ''
|
title.backdropPath ?? ''
|
||||||
}`
|
}`
|
||||||
@@ -174,8 +173,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
isMusic(title)
|
isMusic(title)
|
||||||
? title.images?.find((image) => image.CoverType === 'Cover')
|
? title.posterPath ??
|
||||||
?.Url ?? '/images/overseerr_poster_not_found.png'
|
'/images/jellyseerr_poster_not_found_square.png'
|
||||||
: title.posterPath
|
: title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${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'
|
||||||
@@ -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"
|
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
|
||||||
>
|
>
|
||||||
{isMusic(title)
|
{isMusic(title)
|
||||||
? `${title.artist.artistName} - ${title.title}`
|
? `${title.artist.name} - ${title.title}`
|
||||||
: isMovie(title)
|
: isMovie(title)
|
||||||
? title.title
|
? title.title
|
||||||
: title.name}
|
: title.name}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const CreateIssueModal = ({
|
|||||||
<div>
|
<div>
|
||||||
{intl.formatMessage(messages.toastSuccessCreate, {
|
{intl.formatMessage(messages.toastSuccessCreate, {
|
||||||
title: isMusic(data)
|
title: isMusic(data)
|
||||||
? `${data.artist.artistName} - ${data.title}`
|
? `${data.artist.name} - ${data.title}`
|
||||||
: isMovie(data)
|
: isMovie(data)
|
||||||
? data.title
|
? data.title
|
||||||
: data.name,
|
: data.name,
|
||||||
@@ -180,7 +180,7 @@ const CreateIssueModal = ({
|
|||||||
subTitle={
|
subTitle={
|
||||||
data &&
|
data &&
|
||||||
(isMusic(data)
|
(isMusic(data)
|
||||||
? `${data.artist.artistName} - ${data.title}`
|
? `${data.artist.name} - ${data.title}`
|
||||||
: isMovie(data)
|
: isMovie(data)
|
||||||
? data.title
|
? data.title
|
||||||
: data.name)
|
: data.name)
|
||||||
@@ -192,11 +192,11 @@ const CreateIssueModal = ({
|
|||||||
backdrop={
|
backdrop={
|
||||||
data
|
data
|
||||||
? isMusic(data)
|
? isMusic(data)
|
||||||
? data.images?.find((image) => image.CoverType === 'Cover')
|
? data.posterPath ||
|
||||||
?.Url ?? '/images/overseerr_poster_not_found.png'
|
'/images/jellyseerr_poster_not_found_square.png'
|
||||||
: data.backdropPath
|
: data.backdropPath
|
||||||
? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${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
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ const messages = defineMessages('components.IssueModal', {
|
|||||||
issueAudio: 'Audio',
|
issueAudio: 'Audio',
|
||||||
issueVideo: 'Video',
|
issueVideo: 'Video',
|
||||||
issueSubtitles: 'Subtitle',
|
issueSubtitles: 'Subtitle',
|
||||||
issueLyrics: 'Lyrics',
|
|
||||||
issueOther: 'Other',
|
issueOther: 'Other',
|
||||||
|
issueLyrics: 'Lyrics',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IssueOption {
|
interface IssueOption {
|
||||||
@@ -29,14 +29,14 @@ export const issueOptions: IssueOption[] = [
|
|||||||
name: messages.issueSubtitles,
|
name: messages.issueSubtitles,
|
||||||
issueType: IssueType.SUBTITLES,
|
issueType: IssueType.SUBTITLES,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: messages.issueLyrics,
|
|
||||||
issueType: IssueType.LYRICS,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: messages.issueOther,
|
name: messages.issueOther,
|
||||||
issueType: IssueType.OTHER,
|
issueType: IssueType.OTHER,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: messages.issueLyrics,
|
||||||
|
issueType: IssueType.LYRICS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getIssueOptionsForMediaType = (
|
export const getIssueOptionsForMediaType = (
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ const SearchInput = () => {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="search_field"
|
id="search_field"
|
||||||
style={{ paddingRight: searchValue.length > 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)}
|
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||||
type="search"
|
type="search"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data === undefined && !error) {
|
if (!data && !error) {
|
||||||
return <SmallLoadingSpinner />;
|
return <SmallLoadingSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ const ManageSlideOver = ({
|
|||||||
isMovie(data)
|
isMovie(data)
|
||||||
? data.title
|
? data.title
|
||||||
: isMusic(data)
|
: isMusic(data)
|
||||||
? `${data.title} - ${data.artist.artistName}`
|
? `${data.title} - ${data.artist.name}`
|
||||||
: data.name
|
: data.name
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import GroupCard from '@app/components/GroupCard';
|
import ArtistCard from '@app/components/ArtistCard';
|
||||||
import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard';
|
import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard';
|
||||||
import PersonCard from '@app/components/PersonCard';
|
import PersonCard from '@app/components/PersonCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from '@server/models/Search';
|
} from '@server/models/Search';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import useSWRInfinite from 'swr/infinite';
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
|
||||||
interface MixedResult {
|
interface MixedResult {
|
||||||
@@ -34,12 +34,20 @@ interface MixedResult {
|
|||||||
|
|
||||||
interface MediaSliderProps {
|
interface MediaSliderProps {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url?: string;
|
||||||
linkUrl?: string;
|
linkUrl?: string;
|
||||||
sliderKey: string;
|
sliderKey: string;
|
||||||
hideWhenEmpty?: boolean;
|
hideWhenEmpty?: boolean;
|
||||||
extraParams?: string;
|
extraParams?: string;
|
||||||
onNewTitles?: (titleCount: number) => void;
|
onNewTitles?: (titleCount: number) => void;
|
||||||
|
items?: (
|
||||||
|
| MovieResult
|
||||||
|
| TvResult
|
||||||
|
| PersonResult
|
||||||
|
| AlbumResult
|
||||||
|
| ArtistResult
|
||||||
|
)[];
|
||||||
|
totalItems?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaSlider = ({
|
const MediaSlider = ({
|
||||||
@@ -50,12 +58,20 @@ const MediaSlider = ({
|
|||||||
sliderKey,
|
sliderKey,
|
||||||
hideWhenEmpty = false,
|
hideWhenEmpty = false,
|
||||||
onNewTitles,
|
onNewTitles,
|
||||||
|
items: passedItems,
|
||||||
|
totalItems,
|
||||||
}: MediaSliderProps) => {
|
}: MediaSliderProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
|
const [titles, setTitles] = useState<
|
||||||
|
(MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[]
|
||||||
|
>([]);
|
||||||
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
||||||
(pageIndex: number, previousPageData: MixedResult | null) => {
|
(pageIndex: number, previousPageData: MixedResult | null) => {
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
if (
|
||||||
|
!url ||
|
||||||
|
(previousPageData && pageIndex + 1 > previousPageData.totalPages)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,19 +85,33 @@ const MediaSlider = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let titles = (data ?? []).reduce(
|
useEffect(() => {
|
||||||
(a, v) => [...a, ...v.results],
|
const newTitles =
|
||||||
[] as (MovieResult | TvResult | PersonResult | AlbumResult | ArtistResult)[]
|
passedItems ??
|
||||||
);
|
(data ?? []).reduce(
|
||||||
|
(a, v) => [...a, ...v.results],
|
||||||
|
[] as (
|
||||||
|
| MovieResult
|
||||||
|
| TvResult
|
||||||
|
| PersonResult
|
||||||
|
| AlbumResult
|
||||||
|
| ArtistResult
|
||||||
|
)[]
|
||||||
|
);
|
||||||
|
|
||||||
if (settings.currentSettings.hideAvailable) {
|
if (settings.currentSettings.hideAvailable) {
|
||||||
titles = titles.filter(
|
setTitles(
|
||||||
(i) =>
|
newTitles.filter(
|
||||||
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
(i) =>
|
||||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||||
);
|
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||||
}
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setTitles(newTitles);
|
||||||
|
}
|
||||||
|
}, [data, passedItems, settings.currentSettings.hideAvailable]);
|
||||||
|
|
||||||
if (settings.currentSettings.hideBlacklisted) {
|
if (settings.currentSettings.hideBlacklisted) {
|
||||||
titles = titles.filter(
|
titles = titles.filter(
|
||||||
@@ -93,6 +123,7 @@ const MediaSlider = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
!passedItems &&
|
||||||
titles.length < 24 &&
|
titles.length < 24 &&
|
||||||
size < 5 &&
|
size < 5 &&
|
||||||
(data?.[0]?.totalResults ?? 0) > size * 20
|
(data?.[0]?.totalResults ?? 0) > size * 20
|
||||||
@@ -105,9 +136,14 @@ const MediaSlider = ({
|
|||||||
// at all for our purposes.
|
// at all for our purposes.
|
||||||
onNewTitles(titles.length);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,46 +210,44 @@ const MediaSlider = ({
|
|||||||
key={title.id}
|
key={title.id}
|
||||||
id={title.id}
|
id={title.id}
|
||||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||||
image={
|
image={title.posterPath}
|
||||||
title.images?.find((image) => image.CoverType === 'Cover')?.Url
|
|
||||||
}
|
|
||||||
status={title.mediaInfo?.status}
|
status={title.mediaInfo?.status}
|
||||||
title={title.title}
|
title={title.title}
|
||||||
year={title.releasedate}
|
year={title['first-release-date']?.split('-')[0]}
|
||||||
mediaType={title.mediaType}
|
mediaType={title.mediaType}
|
||||||
artist={title.artistname}
|
artist={title['artist-credit']?.[0]?.name}
|
||||||
type={title.type}
|
type={title['primary-type']}
|
||||||
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
|
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
|
needsCoverArt={title.needsCoverArt}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'artist':
|
case 'artist':
|
||||||
return title.type === 'Group' ? (
|
return title.tmdbPersonId ? (
|
||||||
<GroupCard
|
|
||||||
key={title.id}
|
|
||||||
groupId={title.id}
|
|
||||||
name={title.artistname}
|
|
||||||
image={title.artistimage}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PersonCard
|
<PersonCard
|
||||||
key={title.id}
|
key={title.id}
|
||||||
personId={title.id}
|
personId={title.tmdbPersonId}
|
||||||
name={title.artistname}
|
name={title.name}
|
||||||
mediaType="artist"
|
profilePath={title.artistThumb ?? undefined}
|
||||||
profilePath={title.artistimage}
|
/>
|
||||||
|
) : (
|
||||||
|
<ArtistCard
|
||||||
|
key={title.id}
|
||||||
|
artistId={title.id}
|
||||||
|
name={title.name}
|
||||||
|
artistThumb={title.artistThumb}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (linkUrl && titles.length > 20) {
|
if (linkUrl && (totalItems ? totalItems > 20 : titles.length > 20)) {
|
||||||
finalTitles.push(
|
finalTitles.push(
|
||||||
<ShowMoreCard
|
<ShowMoreCard
|
||||||
url={linkUrl}
|
url={linkUrl}
|
||||||
posters={titles
|
posters={titles
|
||||||
.slice(20, 24)
|
.slice(20, 24)
|
||||||
.map((title) =>
|
.map((title) =>
|
||||||
title.mediaType !== 'person'
|
title.mediaType !== 'person' && title.mediaType !== 'album'
|
||||||
? (title as MovieResult | TvResult).posterPath
|
? (title as MovieResult | TvResult).posterPath
|
||||||
: undefined
|
: undefined
|
||||||
)}
|
)}
|
||||||
@@ -237,7 +271,7 @@ const MediaSlider = ({
|
|||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
sliderKey={sliderKey}
|
sliderKey={sliderKey}
|
||||||
isLoading={!data && !error}
|
isLoading={!passedItems && !data && !error}
|
||||||
isEmpty={false}
|
isEmpty={false}
|
||||||
items={finalTitles}
|
items={finalTitles}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1060,14 +1060,26 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!streamingProviders.length && (
|
{!!streamingProviders.length && (
|
||||||
<div className="media-fact">
|
<div className="media-fact flex-col gap-1">
|
||||||
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
||||||
<span className="media-fact-value">
|
<span className="media-fact-value flex flex-row flex-wrap gap-5">
|
||||||
{streamingProviders.map((p) => {
|
{streamingProviders.map((p) => {
|
||||||
return (
|
return (
|
||||||
<span className="block" key={`provider-${p.id}`}>
|
<Tooltip content={p.name}>
|
||||||
{p.name}
|
<span
|
||||||
</span>
|
className="opacity-50 transition duration-300 hover:opacity-100"
|
||||||
|
key={`provider-${p.id}`}
|
||||||
|
>
|
||||||
|
<CachedImage
|
||||||
|
type="tmdb"
|
||||||
|
src={'https://image.tmdb.org/t/p/w45/' + p.logoPath}
|
||||||
|
alt={p.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-md"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,49 +1,131 @@
|
|||||||
import Header from '@app/components/Common/Header';
|
import Header from '@app/components/Common/Header';
|
||||||
import ListView from '@app/components/Common/ListView';
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import useDiscover from '@app/hooks/useDiscover';
|
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import type { MusicDetails } from '@server/models/Music';
|
import type { MusicDetails } from '@server/models/Music';
|
||||||
import type { AlbumResult } from '@server/models/Search';
|
import type { AlbumResult } from '@server/models/Search';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.MusicDetails', {
|
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 MusicArtistDiscography = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: musicData } = useSWR<MusicDetails>(
|
const musicId = router.query.musicId as string;
|
||||||
`/api/v1/music/${router.query.musicId}`
|
const [page, setPage] = useState(1);
|
||||||
|
const [allReleaseGroups, setAllReleaseGroups] = useState<ReleaseGroup[]>([]);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
const { data: musicData, error: musicError } = useSWR<MusicDetails>(
|
||||||
|
musicId ? `/api/v1/music/${musicId}` : null
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const refreshInterval = musicData
|
||||||
isLoadingInitialData,
|
? refreshIntervalHelper(
|
||||||
isEmpty,
|
{
|
||||||
isLoadingMore,
|
downloadStatus: musicData.mediaInfo?.downloadStatus,
|
||||||
isReachingEnd,
|
downloadStatus4k: undefined,
|
||||||
titles,
|
},
|
||||||
fetchMore,
|
15000
|
||||||
error,
|
)
|
||||||
} = useDiscover<AlbumResult & { id: number }>(
|
: 0;
|
||||||
`/api/v1/music/${router.query.musicId}/discography`
|
|
||||||
|
useSWR<MusicDetails>(musicId ? `/api/v1/music/${musicId}` : null, {
|
||||||
|
refreshInterval,
|
||||||
|
dedupingInterval: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: artistData, error: artistError } = useSWR<ArtistResponse>(
|
||||||
|
musicId ? `/api/v1/music/${musicId}/artist?page=${page}&pageSize=20` : null
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
useEffect(() => {
|
||||||
return <Error statusCode={500} />;
|
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 <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (musicError || artistError) {
|
||||||
|
return <Error statusCode={404} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
title={[
|
title={[
|
||||||
intl.formatMessage(messages.artistalbums),
|
intl.formatMessage(messages.discography, {
|
||||||
musicData?.artist.artistName,
|
artistName: mainArtistName,
|
||||||
|
}),
|
||||||
|
mainArtistName,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
@@ -53,21 +135,22 @@ const MusicArtistDiscography = () => {
|
|||||||
href={`/music/${musicData?.mbId}`}
|
href={`/music/${musicData?.mbId}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{musicData?.artist.artistName}
|
{`${musicData?.title} ${intl.formatMessage(
|
||||||
|
messages.byartist
|
||||||
|
)} ${mainArtistName}`}
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.artistalbums)}
|
{intl.formatMessage(messages.discography, {
|
||||||
|
artistName: mainArtistName,
|
||||||
|
})}
|
||||||
</Header>
|
</Header>
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
items={titles}
|
items={allReleaseGroups as unknown as AlbumResult[]}
|
||||||
isEmpty={isEmpty}
|
isEmpty={allReleaseGroups.length === 0}
|
||||||
isReachingEnd={isReachingEnd}
|
isLoading={!artistData}
|
||||||
isLoading={
|
onScrollBottom={loadMore}
|
||||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
|
||||||
}
|
|
||||||
onScrollBottom={fetchMore}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<MusicDetails>(
|
|
||||||
`/api/v1/music/${router.query.musicId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
isLoadingInitialData,
|
|
||||||
isEmpty,
|
|
||||||
isLoadingMore,
|
|
||||||
isReachingEnd,
|
|
||||||
titles,
|
|
||||||
fetchMore,
|
|
||||||
error,
|
|
||||||
} = useDiscover<ArtistResult & { id: number }>(
|
|
||||||
`/api/v1/music/${router.query.musicId}/similar`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <Error statusCode={500} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageTitle
|
|
||||||
title={[
|
|
||||||
intl.formatMessage(messages.similarArtists),
|
|
||||||
musicData?.artist.artistName,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="mt-1 mb-5">
|
|
||||||
<Header
|
|
||||||
subtext={
|
|
||||||
<Link
|
|
||||||
href={`/music/${musicData?.mbId}`}
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
{musicData?.artist.artistName}
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.similarArtists)}
|
|
||||||
</Header>
|
|
||||||
</div>
|
|
||||||
<ListView
|
|
||||||
items={titles}
|
|
||||||
isEmpty={isEmpty}
|
|
||||||
isReachingEnd={isReachingEnd}
|
|
||||||
isLoading={
|
|
||||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
|
||||||
}
|
|
||||||
onScrollBottom={fetchMore}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MusicArtistSimilar;
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Ellipsis from '@app/assets/ellipsis.svg';
|
||||||
import Spinner from '@app/assets/spinner.svg';
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
import BlacklistModal from '@app/components/BlacklistModal';
|
import BlacklistModal from '@app/components/BlacklistModal';
|
||||||
import Button from '@app/components/Common/Button';
|
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 PageTitle from '@app/components/Common/PageTitle';
|
||||||
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
|
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
|
||||||
import PlayButton 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 Tooltip from '@app/components/Common/Tooltip';
|
||||||
import IssueModal from '@app/components/IssueModal';
|
import IssueModal from '@app/components/IssueModal';
|
||||||
import ManageSlideOver from '@app/components/ManageSlideOver';
|
import ManageSlideOver from '@app/components/ManageSlideOver';
|
||||||
@@ -30,10 +32,14 @@ import { IssueStatus } from '@server/constants/issue';
|
|||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { MusicDetails as MusicDetailsType } from '@server/models/Music';
|
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 { useRouter } from 'next/router';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import TruncateMarkup from 'react-truncate-markup';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.MusicDetails', {
|
const messages = defineMessages('components.MusicDetails', {
|
||||||
@@ -53,18 +59,74 @@ const messages = defineMessages('components.MusicDetails', {
|
|||||||
watchlistError: 'Something went wrong try again.',
|
watchlistError: 'Something went wrong try again.',
|
||||||
removefromwatchlist: 'Remove From Watchlist',
|
removefromwatchlist: 'Remove From Watchlist',
|
||||||
addtowatchlist: 'Add To Watchlist',
|
addtowatchlist: 'Add To Watchlist',
|
||||||
status: 'Status',
|
|
||||||
label: 'Label',
|
|
||||||
artisttype: 'Artist Type',
|
artisttype: 'Artist Type',
|
||||||
artiststatus: 'Artist Status',
|
artiststatus: 'Artist Status',
|
||||||
discography: "{artistName}'s discography",
|
discography: "{artist.name}'s discography",
|
||||||
similarArtists: 'Similar Artists',
|
similarArtists: 'Similar Artists',
|
||||||
|
byartist: 'by',
|
||||||
|
country: 'Country',
|
||||||
|
beginYear: 'Started in',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MusicDetailsProps {
|
interface MusicDetailsProps {
|
||||||
music?: MusicDetailsType;
|
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 (
|
||||||
|
<div className="relative text-left">
|
||||||
|
<div
|
||||||
|
className="group outline-none ring-0"
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<TruncateMarkup
|
||||||
|
lines={showBio ? 200 : 6}
|
||||||
|
ellipsis={
|
||||||
|
<Ellipsis className="relative -top-0.5 ml-2 inline-block opacity-70 transition duration-300 group-hover:opacity-100" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="pt-2 text-sm lg:text-base">{content}</p>
|
||||||
|
</TruncateMarkup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MusicDetails = ({ music }: MusicDetailsProps) => {
|
const MusicDetails = ({ music }: MusicDetailsProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { user, hasPermission } = useUser();
|
const { user, hasPermission } = useUser();
|
||||||
@@ -80,6 +142,7 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
|
const [showBio, setShowBio] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -96,6 +159,10 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: artistData } = useSWR<ArtistDetails>(
|
||||||
|
data ? `/api/v1/music/${data.id}/artist?page=1&pageSize=20` : null
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowManager(router.query.manage == '1' ? true : false);
|
setShowManager(router.query.manage == '1' ? true : false);
|
||||||
}, [router.query.manage]);
|
}, [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() {
|
function getAvalaibleMediaServerName() {
|
||||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||||
@@ -283,17 +342,36 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
type: 'or',
|
type: 'or',
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalDurationMs = data.releases?.[0]?.tracks?.reduce(
|
const getCountryCode = (countryName: string): string => {
|
||||||
(sum, track) => sum + (track.durationMs || 0),
|
const countryMap: Record<string, string> = {
|
||||||
0
|
Argentina: 'AR',
|
||||||
);
|
Australia: 'AU',
|
||||||
|
Austria: 'AT',
|
||||||
const truncateOverview = (text: string): string => {
|
Belgium: 'BE',
|
||||||
const maxLength = 800;
|
Brazil: 'BR',
|
||||||
if (!text || text.length <= maxLength) return text;
|
Canada: 'CA',
|
||||||
|
China: 'CN',
|
||||||
const truncated = text.substring(0, maxLength);
|
Denmark: 'DK',
|
||||||
return truncated.substring(0, truncated.lastIndexOf('.') + 1);
|
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 (
|
return (
|
||||||
@@ -308,13 +386,10 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
type="music"
|
type="music"
|
||||||
alt=""
|
alt=""
|
||||||
src={
|
src={
|
||||||
data.artist.images?.find((img) => img.CoverType === 'Fanart')
|
data.artistBackdrop ||
|
||||||
?.Url ||
|
data.artistThumb ||
|
||||||
data.artist.images?.find((img) => img.CoverType === 'Poster')
|
data.posterPath ||
|
||||||
?.Url ||
|
'/images/jellyseerr_poster_not_found_square.png'
|
||||||
data.images?.find((img) => img.CoverType.toLowerCase() === 'cover')
|
|
||||||
?.Url ||
|
|
||||||
'/images/overseerr_poster_not_found.png'
|
|
||||||
}
|
}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
fill
|
fill
|
||||||
@@ -328,7 +403,11 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PageTitle title={`${data.title} - ${data.artist.artistName}`} />
|
<PageTitle
|
||||||
|
title={`${data.title} ${intl.formatMessage(messages.byartist)} ${
|
||||||
|
data.artist.name
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
<IssueModal
|
<IssueModal
|
||||||
onCancel={() => setShowIssueModal(false)}
|
onCancel={() => setShowIssueModal(false)}
|
||||||
show={showIssueModal}
|
show={showIssueModal}
|
||||||
@@ -361,9 +440,8 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
<CachedImage
|
<CachedImage
|
||||||
type="music"
|
type="music"
|
||||||
src={
|
src={
|
||||||
data.images?.find(
|
data?.posterPath ||
|
||||||
(img) => img.CoverType.toLowerCase() === 'cover'
|
'/images/jellyseerr_poster_not_found_square.png'
|
||||||
)?.Url || '/images/overseerr_poster_not_found.png'
|
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
@@ -386,7 +464,9 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 data-testid="media-title">
|
<h1 data-testid="media-title">
|
||||||
{data.title} - {data.artist.artistName}{' '}
|
{`${data.title} ${intl.formatMessage(messages.byartist)} ${
|
||||||
|
data.artist.name
|
||||||
|
}`}{' '}
|
||||||
{data.releaseDate && (
|
{data.releaseDate && (
|
||||||
<span className="media-year">
|
<span className="media-year">
|
||||||
({new Date(data.releaseDate).getFullYear()})
|
({new Date(data.releaseDate).getFullYear()})
|
||||||
@@ -396,8 +476,17 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
<span className="media-attributes">
|
<span className="media-attributes">
|
||||||
{[
|
{[
|
||||||
<span className="rounded-md border p-0.5 py-0">{data.type}</span>,
|
<span className="rounded-md border p-0.5 py-0">{data.type}</span>,
|
||||||
totalDurationMs ? formatDuration(totalDurationMs) : null,
|
<span>
|
||||||
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 });
|
||||||
|
})()}
|
||||||
|
</span>,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((t, k) => <span key={k}>{t}</span>)
|
.map((t, k) => <span key={k}>{t}</span>)
|
||||||
@@ -524,29 +613,79 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
<div className="media-overview">
|
<div className="media-overview">
|
||||||
<div className="media-overview-left">
|
<div className="media-overview-left">
|
||||||
<h2>{intl.formatMessage(messages.biography)}</h2>
|
<h2>{intl.formatMessage(messages.biography)}</h2>
|
||||||
<p>
|
<Biography
|
||||||
{data.artist.overview
|
content={
|
||||||
? truncateOverview(data.artist.overview)
|
data.artistWikipedia?.content
|
||||||
: intl.formatMessage(messages.biographyunavailable)}
|
? data.artistWikipedia.content
|
||||||
</p>
|
: intl.formatMessage(messages.biographyunavailable)
|
||||||
|
}
|
||||||
|
showBio={showBio}
|
||||||
|
onClick={() => 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) && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{data?.tags?.artist?.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={`artist-tag-${tag.artistMbid}-${tag.tag}`}
|
||||||
|
className="mb-2 mr-2 inline-flex last:mr-0"
|
||||||
|
>
|
||||||
|
<Tag>{tag.tag}</Tag>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{data?.tags?.releaseGroup?.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={`release-tag-${tag.genreMbid ?? ''}-${tag.tag}`}
|
||||||
|
className="mb-2 mr-2 inline-flex last:mr-0"
|
||||||
|
>
|
||||||
|
<Tag>{tag.tag}</Tag>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h2 className="py-4">{intl.formatMessage(messages.trackstitle)}</h2>
|
<h2 className="py-4">{intl.formatMessage(messages.trackstitle)}</h2>
|
||||||
{data.releases?.[0]?.tracks?.length > 0 ? (
|
{data.tracks?.length > 0 ? (
|
||||||
<div className="divide-y divide-gray-700 rounded-lg border border-gray-700">
|
<div className="divide-y divide-gray-700 rounded-lg border border-gray-700">
|
||||||
{data.releases[0].tracks.map((track, index) => (
|
{data.tracks.map((track) => (
|
||||||
<div
|
<div
|
||||||
key={track.id ?? index}
|
key={track.recordingMbid}
|
||||||
className="flex items-center justify-between px-4 py-2 text-sm transition duration-150 hover:bg-gray-700"
|
className="flex items-center justify-between px-4 py-2 text-sm transition duration-150 hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 items-center space-x-4">
|
<div className="flex flex-1 items-center space-x-4">
|
||||||
<span className="w-8 text-gray-500">{index + 1}</span>
|
<span className="flex w-8 items-center justify-center text-gray-500">
|
||||||
<span className="flex-1 truncate text-gray-300">
|
{track.position}
|
||||||
{track.trackName}
|
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="truncate text-gray-300">{track.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{track.artists.map((artist, index) => (
|
||||||
|
<Fragment
|
||||||
|
key={`${track.recordingMbid}-artist-${
|
||||||
|
artist.mbid || index
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index === 0 ? '' : index === 1 ? ' feat. ' : ', '}
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
artist.tmdbMapping?.personId
|
||||||
|
? `/person/${artist.tmdbMapping.personId}`
|
||||||
|
: `/artist/${artist.mbid}`
|
||||||
|
}
|
||||||
|
className="text-indigo-400 hover:underline"
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</Link>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span className="text-right text-gray-500">
|
<span className="text-right text-gray-500">
|
||||||
{Math.floor((track.durationMs ?? 0) / 1000 / 60)}:
|
{Math.floor(track.length / 1000 / 60)}:
|
||||||
{String(
|
{String(Math.floor((track.length / 1000) % 60)).padStart(
|
||||||
Math.floor(((track.durationMs ?? 0) / 1000) % 60)
|
2,
|
||||||
).padStart(2, '0')}
|
'0'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,39 +698,86 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="media-overview-right">
|
<div className="media-overview-right">
|
||||||
|
{data.artist.name !== 'Various Artists' && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
data.tmdbPersonId
|
||||||
|
? `/person/${data.tmdbPersonId}`
|
||||||
|
: `/artist/${data.artist.id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500">
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<CachedImage
|
||||||
|
type="music"
|
||||||
|
src={
|
||||||
|
data.artistThumb ??
|
||||||
|
data.artistBackdrop ??
|
||||||
|
data.posterPath ??
|
||||||
|
'/images/jellyseerr_poster_not_found_square.png'
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center 30%',
|
||||||
|
}}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 flex h-full items-center justify-between p-4 text-gray-200 transition duration-300 group-hover:text-white">
|
||||||
|
<div>
|
||||||
|
{data.artist.name.split(/[&,]|\sfeat\./)[0].trim()}
|
||||||
|
</div>
|
||||||
|
<Button buttonSize="sm">
|
||||||
|
{intl.formatMessage(globalMessages.view)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="media-facts">
|
<div className="media-facts">
|
||||||
{data.releases?.[0]?.status && (
|
|
||||||
<div className="media-fact">
|
|
||||||
<span>{intl.formatMessage(globalMessages.status)}</span>
|
|
||||||
<span className="media-fact-value">
|
|
||||||
{data.releases[0].status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.releases?.[0]?.label?.length > 0 && (
|
|
||||||
<div className="media-fact">
|
|
||||||
<span>{intl.formatMessage(messages.label)}</span>
|
|
||||||
<span className="media-fact-value">
|
|
||||||
{data.releases[0].label.map((label) => (
|
|
||||||
<span key={label} className="block">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.artist.type && (
|
{data.artist.type && (
|
||||||
<div className="media-fact">
|
<div className="media-fact">
|
||||||
<span>{intl.formatMessage(messages.artisttype)}</span>
|
<span>{intl.formatMessage(messages.artisttype)}</span>
|
||||||
<span className="media-fact-value">{data.artist.type}</span>
|
<span className="media-fact-value">{data.artist.type}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.artist.status && (
|
{data.artist.area && (
|
||||||
<div className="media-fact">
|
<div className="media-fact">
|
||||||
<span>{intl.formatMessage(messages.artiststatus)}</span>
|
<span>{intl.formatMessage(messages.country)}</span>
|
||||||
<span className="media-fact-value">
|
<span className="media-fact-value">
|
||||||
{data.artist.status.charAt(0).toUpperCase() +
|
<span className="flex items-center justify-end">
|
||||||
data.artist.status.slice(1)}
|
{data.artist.area && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`mr-1.5 text-xs leading-5 flag:${getCountryCode(
|
||||||
|
data.artist.area
|
||||||
|
)}`}
|
||||||
|
title={data.artist.area}
|
||||||
|
/>
|
||||||
|
{data.artist.area}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.artist.beginYear && (
|
||||||
|
<div className="media-fact">
|
||||||
|
<span>{intl.formatMessage(messages.beginYear)}</span>
|
||||||
|
<span className="media-fact-value">
|
||||||
|
{data.artist.beginYear}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -601,16 +787,31 @@ const MusicDetails = ({ music }: MusicDetailsProps) => {
|
|||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="artist-discography"
|
sliderKey="artist-discography"
|
||||||
title={intl.formatMessage(messages.discography, {
|
title={intl.formatMessage(messages.discography, {
|
||||||
artistName: data?.artist.artistName ?? '',
|
artistName: data?.artist.name.split(/[&,]|\sfeat\./)[0].trim() ?? '',
|
||||||
})}
|
})}
|
||||||
url={`/api/v1/music/${router.query.musicId}/discography`}
|
items={artistData?.artist.releaseGroups}
|
||||||
|
totalItems={artistData?.artist.pagination?.totalItems}
|
||||||
linkUrl={`/music/${data.id}/discography`}
|
linkUrl={`/music/${data.id}/discography`}
|
||||||
hideWhenEmpty
|
hideWhenEmpty
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="similar-artists"
|
sliderKey="artist-similar"
|
||||||
title={intl.formatMessage(messages.similarArtists)}
|
title={intl.formatMessage(messages.similarArtists)}
|
||||||
url={`/api/v1/music/${router.query.musicId}/similar`}
|
items={
|
||||||
|
artistData?.artist.similarArtists.artists.map(
|
||||||
|
(artist): ArtistResult => ({
|
||||||
|
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`}
|
linkUrl={`/music/${data.id}/similar`}
|
||||||
hideWhenEmpty
|
hideWhenEmpty
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import Link from 'next/link';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface PersonCardProps {
|
interface PersonCardProps {
|
||||||
personId: number | string;
|
personId: number;
|
||||||
name: string;
|
name: string;
|
||||||
subName?: string;
|
subName?: string;
|
||||||
profilePath?: string;
|
profilePath?: string;
|
||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
mediaType?: 'person' | 'artist';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PersonCard = ({
|
const PersonCard = ({
|
||||||
@@ -18,7 +17,6 @@ const PersonCard = ({
|
|||||||
subName,
|
subName,
|
||||||
profilePath,
|
profilePath,
|
||||||
canExpand = false,
|
canExpand = false,
|
||||||
mediaType = 'person',
|
|
||||||
}: PersonCardProps) => {
|
}: PersonCardProps) => {
|
||||||
const [isHovered, setHovered] = useState(false);
|
const [isHovered, setHovered] = useState(false);
|
||||||
|
|
||||||
@@ -53,11 +51,12 @@ const PersonCard = ({
|
|||||||
{profilePath ? (
|
{profilePath ? (
|
||||||
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
type={mediaType === 'person' ? 'tmdb' : 'music'}
|
type="tmdb"
|
||||||
src={
|
src={
|
||||||
mediaType === 'person'
|
// Check if profilePath already contains http(s)
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`
|
profilePath?.startsWith('http')
|
||||||
: profilePath
|
? profilePath
|
||||||
|
: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -71,15 +71,22 @@ const RequestButton = ({
|
|||||||
const [editRequest, setEditRequest] = useState(false);
|
const [editRequest, setEditRequest] = useState(false);
|
||||||
|
|
||||||
// All pending requests
|
// All pending requests
|
||||||
const activeRequests =
|
const activeRequests = useMemo(
|
||||||
media?.requests?.filter(
|
() =>
|
||||||
(request) =>
|
media?.requests?.filter(
|
||||||
request.status === MediaRequestStatus.PENDING && !request.is4k
|
(request) =>
|
||||||
) ?? [];
|
request.status === MediaRequestStatus.PENDING && !request.is4k
|
||||||
const active4kRequests =
|
) ?? [],
|
||||||
media?.requests?.filter(
|
[media?.requests]
|
||||||
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
|
);
|
||||||
) ?? [];
|
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
|
// Current user's pending request, or the first pending request
|
||||||
const activeRequest = useMemo(() => {
|
const activeRequest = useMemo(() => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Tooltip from '@app/components/Common/Tooltip';
|
|||||||
import RequestModal from '@app/components/RequestModal';
|
import RequestModal from '@app/components/RequestModal';
|
||||||
import StatusBadge from '@app/components/StatusBadge';
|
import StatusBadge from '@app/components/StatusBadge';
|
||||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
|
import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
@@ -55,7 +56,19 @@ const isMovie = (
|
|||||||
const isAlbum = (
|
const isAlbum = (
|
||||||
media: MovieDetails | TvDetails | MusicDetails
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
): media is 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 = () => {
|
const RequestCardPlaceholder = () => {
|
||||||
@@ -224,7 +237,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: NonFunctionProperties<MediaRequest>;
|
request: NonFunctionProperties<MediaRequest>;
|
||||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
onTitleData?: (
|
||||||
|
requestId: number,
|
||||||
|
title: MovieDetails | TvDetails | MusicDetails
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||||
@@ -244,9 +260,10 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
? `/api/v1/tv/${request.media.tmdbId}`
|
? `/api/v1/tv/${request.media.tmdbId}`
|
||||||
: `/api/v1/music/${request.media.mbId}`;
|
: `/api/v1/music/${request.media.mbId}`;
|
||||||
|
|
||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
const { data: titleData, error } = useSWR<
|
||||||
inView ? `${url}` : null
|
MovieDetails | TvDetails | MusicDetails
|
||||||
);
|
>(inView ? url : null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: requestData,
|
data: requestData,
|
||||||
error: requestError,
|
error: requestError,
|
||||||
@@ -307,12 +324,39 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title && onTitleData) {
|
if (titleData && onTitleData) {
|
||||||
onTitleData(request.id, title);
|
onTitleData(request.id, titleData);
|
||||||
}
|
}
|
||||||
}, [title, onTitleData, request]);
|
}, [titleData, onTitleData, request]);
|
||||||
|
|
||||||
if (!title && !error) {
|
interface ExtendedMedia {
|
||||||
|
posterPath?: string;
|
||||||
|
needsCoverArt?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
useProgressiveCovers<MovieDetails | TvDetails | MusicDetails>(
|
||||||
|
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 (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<RequestCardPlaceholder />
|
<RequestCardPlaceholder />
|
||||||
@@ -352,17 +396,16 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
alt=""
|
alt=""
|
||||||
src={
|
src={
|
||||||
request.type === 'music' && isAlbum(title)
|
request.type === 'music' && isAlbum(title)
|
||||||
? title.artist.images?.find((img) => img.CoverType === 'Fanart')
|
? title.artistBackdrop
|
||||||
?.Url ||
|
? title.artistBackdrop
|
||||||
title.artist.images?.find((img) => img.CoverType === 'Poster')
|
: title.artistThumb
|
||||||
?.Url ||
|
? title.artistThumb
|
||||||
title.images?.find(
|
: title.posterPath
|
||||||
(img) => img.CoverType.toLowerCase() === 'cover'
|
? title.posterPath
|
||||||
)?.Url ||
|
: '/images/jellyseerr_poster_not_found_square.png'
|
||||||
''
|
: hasBackdropPath(title) && title.backdropPath
|
||||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`
|
||||||
title.backdropPath ?? ''
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}`
|
|
||||||
}
|
}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
fill
|
fill
|
||||||
@@ -389,7 +432,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
{isAlbum(title) && (
|
{isAlbum(title) && (
|
||||||
<>
|
<>
|
||||||
<span className="mx-2">-</span>
|
<span className="mx-2">-</span>
|
||||||
<span>{title.artist.artistName}</span>
|
<span className="truncate">{title.artist.name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -434,33 +477,35 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMovie(title) && request.seasons.length > 0 && (
|
{!isMovie(title) &&
|
||||||
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
|
hasSeasons(title) &&
|
||||||
<span className="mr-2 font-bold ">
|
request.seasons.length > 0 && (
|
||||||
{intl.formatMessage(messages.seasons, {
|
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
|
||||||
seasonCount:
|
<span className="mr-2 font-bold ">
|
||||||
(settings.currentSettings.enableSpecialEpisodes
|
{intl.formatMessage(messages.seasons, {
|
||||||
? title.seasons.length
|
seasonCount:
|
||||||
: title.seasons.filter(
|
(settings.currentSettings.enableSpecialEpisodes
|
||||||
(season) => season.seasonNumber !== 0
|
? title.seasons.length
|
||||||
).length) === request.seasons.length
|
: title.seasons.filter(
|
||||||
? 0
|
(season) => season.seasonNumber !== 0
|
||||||
: request.seasons.length,
|
).length) === request.seasons.length
|
||||||
})}
|
? 0
|
||||||
</span>
|
: request.seasons.length,
|
||||||
<div className="hide-scrollbar overflow-x-scroll">
|
})}
|
||||||
{request.seasons.map((season) => (
|
</span>
|
||||||
<span key={`season-${season.id}`} className="mr-2">
|
<div className="hide-scrollbar overflow-x-scroll">
|
||||||
<Badge>
|
{request.seasons.map((season) => (
|
||||||
{season.seasonNumber === 0
|
<span key={`season-${season.id}`} className="mr-2">
|
||||||
? intl.formatMessage(globalMessages.specials)
|
<Badge>
|
||||||
: season.seasonNumber}
|
{season.seasonNumber === 0
|
||||||
</Badge>
|
? intl.formatMessage(globalMessages.specials)
|
||||||
</span>
|
: season.seasonNumber}
|
||||||
))}
|
</Badge>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
<div className="mt-2 flex items-center text-sm sm:mt-1">
|
<div className="mt-2 flex items-center text-sm sm:mt-1">
|
||||||
<span className="mr-2 hidden font-bold sm:block">
|
<span className="mr-2 hidden font-bold sm:block">
|
||||||
{intl.formatMessage(globalMessages.status)}
|
{intl.formatMessage(globalMessages.status)}
|
||||||
@@ -495,7 +540,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
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={
|
inProgress={
|
||||||
(
|
(
|
||||||
requestData.media[
|
requestData.media[
|
||||||
@@ -659,8 +710,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
type={request.type === 'music' ? 'music' : 'tmdb'}
|
type={request.type === 'music' ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
request.type === 'music' && isAlbum(title)
|
request.type === 'music' && isAlbum(title)
|
||||||
? title.images?.find((image) => image.CoverType === 'Cover')
|
? title.posterPath
|
||||||
?.Url ?? '/images/overseerr_poster_not_found.png'
|
? title.posterPath
|
||||||
|
: '/images/jellyseerr_poster_not_found_square.png'
|
||||||
: title.posterPath
|
: title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${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'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ConfirmButton from '@app/components/Common/ConfirmButton';
|
|||||||
import RequestModal from '@app/components/RequestModal';
|
import RequestModal from '@app/components/RequestModal';
|
||||||
import StatusBadge from '@app/components/StatusBadge';
|
import StatusBadge from '@app/components/StatusBadge';
|
||||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
|
import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
@@ -51,6 +52,11 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
|||||||
profileName: 'Profile',
|
profileName: 'Profile',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface ExtendedMedia {
|
||||||
|
posterPath?: string;
|
||||||
|
needsCoverArt?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const isMovie = (
|
const isMovie = (
|
||||||
media: MovieDetails | TvDetails | MusicDetails
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
): media is MovieDetails => {
|
): media is MovieDetails => {
|
||||||
@@ -63,7 +69,7 @@ const isMovie = (
|
|||||||
const isMusic = (
|
const isMusic = (
|
||||||
media: MovieDetails | TvDetails | MusicDetails
|
media: MovieDetails | TvDetails | MusicDetails
|
||||||
): media is MusicDetails => {
|
): media is MusicDetails => {
|
||||||
return (media as MusicDetails).artistId !== undefined;
|
return (media as MusicDetails).artist?.id !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemErrorProps {
|
interface RequestItemErrorProps {
|
||||||
@@ -336,7 +342,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
: request.type === 'movie'
|
: request.type === 'movie'
|
||||||
? `/api/v1/movie/${request.media.tmdbId}`
|
? `/api/v1/movie/${request.media.tmdbId}`
|
||||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||||
const { data: title, error } = useSWR<
|
const { data: titleData, error } = useSWR<
|
||||||
MovieDetails | TvDetails | MusicDetails
|
MovieDetails | TvDetails | MusicDetails
|
||||||
>(inView ? url : null);
|
>(inView ? url : null);
|
||||||
const { data: requestData, mutate: revalidate } = useSWR<
|
const { data: requestData, mutate: revalidate } = useSWR<
|
||||||
@@ -403,6 +409,28 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
|
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const title =
|
||||||
|
useProgressiveCovers<MovieDetails | TvDetails | MusicDetails>(
|
||||||
|
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) {
|
if (!title && !error) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -442,17 +470,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
isMusic(title)
|
isMusic(title)
|
||||||
? title.artist.images?.find((img) => img.CoverType === 'Fanart')
|
? title.artistBackdrop
|
||||||
?.Url ||
|
? title.artistBackdrop
|
||||||
title.artist.images?.find((img) => img.CoverType === 'Poster')
|
: title.artistThumb
|
||||||
?.Url ||
|
? title.artistThumb
|
||||||
title.images?.find(
|
: title.posterPath
|
||||||
(img) => img.CoverType.toLowerCase() === 'cover'
|
? title.posterPath
|
||||||
)?.Url ||
|
: '/images/jellyseerr_poster_not_found_square.png'
|
||||||
''
|
: title.backdropPath
|
||||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
? `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`
|
||||||
title.backdropPath ?? ''
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}`
|
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -482,8 +509,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||||
src={
|
src={
|
||||||
isMusic(title)
|
isMusic(title)
|
||||||
? title.images?.find((image) => image.CoverType === 'Cover')
|
? title.posterPath
|
||||||
?.Url ?? '/images/overseerr_poster_not_found.png'
|
? title.posterPath
|
||||||
|
: '/images/jellyseerr_poster_not_found_square.png'
|
||||||
: title.posterPath
|
: title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${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'
|
||||||
@@ -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"
|
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
|
||||||
>
|
>
|
||||||
{isMusic(title)
|
{isMusic(title)
|
||||||
? `${title.artist.artistName} - ${title.title}`
|
? `${title.artist.name} - ${title.title}`
|
||||||
: isMovie(title)
|
: isMovie(title)
|
||||||
? title.title
|
? title.title
|
||||||
: title.name}
|
: title.name}
|
||||||
|
|||||||
@@ -226,12 +226,8 @@ const MusicRequestModal = ({
|
|||||||
backgroundClickable
|
backgroundClickable
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
title={intl.formatMessage(messages.pendingrequest)}
|
title={intl.formatMessage(messages.pendingrequest)}
|
||||||
subTitle={
|
subTitle={data ? `${data.artist.name} - ${data.title}` : undefined}
|
||||||
data ? `${data.artist.artistName} - ${data.title}` : undefined
|
backdrop={data?.artistBackdrop || data?.artistThumb || data?.posterPath}
|
||||||
}
|
|
||||||
backdrop={
|
|
||||||
data?.artist?.images?.find((img) => img.CoverType === 'Fanart')?.Url
|
|
||||||
}
|
|
||||||
onOk={() =>
|
onOk={() =>
|
||||||
hasPermission(Permission.MANAGE_REQUESTS)
|
hasPermission(Permission.MANAGE_REQUESTS)
|
||||||
? updateRequest(true)
|
? updateRequest(true)
|
||||||
@@ -315,19 +311,14 @@ const MusicRequestModal = ({
|
|||||||
onOk={sendRequest}
|
onOk={sendRequest}
|
||||||
okDisabled={isUpdating || quota?.music?.restricted}
|
okDisabled={isUpdating || quota?.music?.restricted}
|
||||||
title={intl.formatMessage(messages.requestmusictitle)}
|
title={intl.formatMessage(messages.requestmusictitle)}
|
||||||
subTitle={data ? `${data.artist.artistName} - ${data.title}` : undefined}
|
subTitle={data ? `${data.artist.name} - ${data.title}` : undefined}
|
||||||
okText={
|
okText={
|
||||||
isUpdating
|
isUpdating
|
||||||
? intl.formatMessage(globalMessages.requesting)
|
? intl.formatMessage(globalMessages.requesting)
|
||||||
: intl.formatMessage(globalMessages.request)
|
: intl.formatMessage(globalMessages.request)
|
||||||
}
|
}
|
||||||
okButtonType="primary"
|
okButtonType="primary"
|
||||||
backdrop={
|
backdrop={data?.artistBackdrop || data?.artistThumb || data?.posterPath}
|
||||||
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
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{hasAutoApprove && !quota?.music?.restricted && (
|
{hasAutoApprove && !quota?.music?.restricted && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import useDiscover from '@app/hooks/useDiscover';
|
|||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type {
|
import type {
|
||||||
|
AlbumResult,
|
||||||
MovieResult,
|
MovieResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
@@ -29,7 +30,7 @@ const Search = () => {
|
|||||||
titles,
|
titles,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
error,
|
error,
|
||||||
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
} = useDiscover<MovieResult | TvResult | PersonResult | AlbumResult>(
|
||||||
`/api/v1/search`,
|
`/api/v1/search`,
|
||||||
{
|
{
|
||||||
query: router.query.query,
|
query: router.query.query,
|
||||||
|
|||||||
@@ -752,25 +752,12 @@ const SettingsJobs = () => {
|
|||||||
</Table.TD>
|
</Table.TD>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<Table.TD>Lidarr Images (lidarr)</Table.TD>
|
<Table.TD>The Audio Database (tadb)</Table.TD>
|
||||||
<Table.TD>
|
<Table.TD>
|
||||||
{intl.formatNumber(
|
{intl.formatNumber(cacheData?.imageCache.tadb.imageCount ?? 0)}
|
||||||
cacheData?.imageCache.lidarr.imageCount ?? 0
|
|
||||||
)}
|
|
||||||
</Table.TD>
|
</Table.TD>
|
||||||
<Table.TD>
|
<Table.TD>
|
||||||
{formatBytes(cacheData?.imageCache.lidarr.size ?? 0)}
|
{formatBytes(cacheData?.imageCache.tadb.size ?? 0)}
|
||||||
</Table.TD>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<Table.TD>Fanart.tv (fanart)</Table.TD>
|
|
||||||
<Table.TD>
|
|
||||||
{intl.formatNumber(
|
|
||||||
cacheData?.imageCache.fanart.imageCount ?? 0
|
|
||||||
)}
|
|
||||||
</Table.TD>
|
|
||||||
<Table.TD>
|
|
||||||
{formatBytes(cacheData?.imageCache.fanart.size ?? 0)}
|
|
||||||
</Table.TD>
|
</Table.TD>
|
||||||
</tr>
|
</tr>
|
||||||
</Table.TBody>
|
</Table.TBody>
|
||||||
|
|||||||
@@ -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<MovieDetails | TvDetails>(
|
|
||||||
inView ? `${url}` : null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!title && !error) {
|
|
||||||
return (
|
|
||||||
<div ref={ref}>
|
|
||||||
<TitleCard.Placeholder canExpand={canExpand} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return hasPermission(Permission.ADMIN) ? (
|
|
||||||
<TitleCard.ErrorCard
|
|
||||||
id={id}
|
|
||||||
tmdbId={tmdbId}
|
|
||||||
tvdbId={tvdbId}
|
|
||||||
type={type}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isMovie(title) ? (
|
|
||||||
<TitleCard
|
|
||||||
key={title.id}
|
|
||||||
id={title.id}
|
|
||||||
isAddedToWatchlist={
|
|
||||||
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
|
|
||||||
}
|
|
||||||
image={title.posterPath}
|
|
||||||
status={title.mediaInfo?.status}
|
|
||||||
summary={title.overview}
|
|
||||||
title={title.title}
|
|
||||||
userScore={title.voteAverage}
|
|
||||||
year={title.releaseDate}
|
|
||||||
mediaType={'movie'}
|
|
||||||
canExpand={canExpand}
|
|
||||||
mutateParent={mutateParent}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TitleCard
|
|
||||||
key={title.id}
|
|
||||||
id={title.id}
|
|
||||||
isAddedToWatchlist={
|
|
||||||
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
|
|
||||||
}
|
|
||||||
image={title.posterPath}
|
|
||||||
status={title.mediaInfo?.status}
|
|
||||||
summary={title.overview}
|
|
||||||
title={title.name}
|
|
||||||
userScore={title.voteAverage}
|
|
||||||
year={title.firstAirDate}
|
|
||||||
mediaType={'tv'}
|
|
||||||
canExpand={canExpand}
|
|
||||||
mutateParent={mutateParent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TmdbTitleCard;
|
|
||||||
@@ -8,6 +8,7 @@ import RequestModal from '@app/components/RequestModal';
|
|||||||
import ErrorCard from '@app/components/TitleCard/ErrorCard';
|
import ErrorCard from '@app/components/TitleCard/ErrorCard';
|
||||||
import Placeholder from '@app/components/TitleCard/Placeholder';
|
import Placeholder from '@app/components/TitleCard/Placeholder';
|
||||||
import { useIsTouch } from '@app/hooks/useIsTouch';
|
import { useIsTouch } from '@app/hooks/useIsTouch';
|
||||||
|
import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers';
|
||||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
@@ -44,6 +45,7 @@ interface TitleCardProps {
|
|||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
inProgress?: boolean;
|
inProgress?: boolean;
|
||||||
isAddedToWatchlist?: number | boolean;
|
isAddedToWatchlist?: number | boolean;
|
||||||
|
needsCoverArt?: boolean;
|
||||||
mutateParent?: () => void;
|
mutateParent?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ const TitleCard = ({
|
|||||||
inProgress = false,
|
inProgress = false,
|
||||||
canExpand = false,
|
canExpand = false,
|
||||||
mutateParent,
|
mutateParent,
|
||||||
|
needsCoverArt,
|
||||||
}: TitleCardProps) => {
|
}: TitleCardProps) => {
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -86,7 +89,16 @@ const TitleCard = ({
|
|||||||
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(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) {
|
if (year) {
|
||||||
year = year.slice(0, 4);
|
year = year.slice(0, 4);
|
||||||
}
|
}
|
||||||
@@ -352,7 +364,9 @@ const TitleCard = ({
|
|||||||
className="h-full w-full rounded object-contain"
|
className="h-full w-full rounded object-contain"
|
||||||
alt=""
|
alt=""
|
||||||
src={
|
src={
|
||||||
image ?? '/images/seerr_poster_not_found_logo_top.png'
|
displayImage
|
||||||
|
? displayImage
|
||||||
|
: '/images/seerr_poster_not_found_square.png'
|
||||||
}
|
}
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
@@ -362,26 +376,22 @@ const TitleCard = ({
|
|||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{artist && (
|
{artist && (
|
||||||
<div
|
<div className="w-full truncate text-center text-xs text-gray-300">
|
||||||
className="overflow-hidden whitespace-normal text-center text-xs text-gray-300"
|
|
||||||
style={{
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
display: '-webkit-box',
|
|
||||||
overflow: 'hidden',
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{artist}
|
{artist}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{type && (
|
{type && (
|
||||||
<div
|
<div
|
||||||
className="mt-4 overflow-hidden whitespace-normal text-center text-xs text-gray-500"
|
className="mt-auto overflow-hidden whitespace-normal text-center text-xs text-gray-500"
|
||||||
style={{
|
style={{
|
||||||
WebkitLineClamp: 1,
|
WebkitLineClamp: 1,
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '0.5rem',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{type}
|
{type}
|
||||||
@@ -395,8 +405,8 @@ const TitleCard = ({
|
|||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
alt=""
|
alt=""
|
||||||
src={
|
src={
|
||||||
image
|
displayImage
|
||||||
? `https://image.tmdb.org/t/p/w300_and_h450_face${image}`
|
? `https://image.tmdb.org/t/p/w300_and_h450_face${displayImage}`
|
||||||
: '/images/seerr_poster_not_found_logo_top.png'
|
: '/images/seerr_poster_not_found_logo_top.png'
|
||||||
}
|
}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
@@ -507,7 +517,11 @@ const TitleCard = ({
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={showDetail || showRequestModal}
|
show={
|
||||||
|
mediaType === 'album'
|
||||||
|
? showDetail || showRequestModal
|
||||||
|
: !image || showDetail || showRequestModal
|
||||||
|
}
|
||||||
enter="transition-opacity"
|
enter="transition-opacity"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user