refactor(person details): merging Person Details

This commit is contained in:
Pierre
2025-03-06 17:30:50 +01:00
committed by HiItsStolas
parent 31ce44c452
commit cdb9d2450a
134 changed files with 7519 additions and 4119 deletions

View File

@@ -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) {

View File

@@ -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
View File

@@ -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: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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

View File

@@ -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:

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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[];
};
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
return decoded;
} catch (e) {
return null;
}
}
} }
export default MusicBrainz; export default MusicBrainz;

View File

@@ -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)[];
} }

View File

@@ -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,

View 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;

View File

@@ -0,0 +1,8 @@
interface TadbArtist {
strArtistThumb: string | null;
strArtistFanart: string | null;
}
export interface TadbArtistResponse {
artists?: TadbArtist[];
}

View File

@@ -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,

View File

@@ -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)[];
}

View 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;

View File

@@ -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,
},
]; ];

View File

@@ -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',
}; };

View File

@@ -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 },
}); });

View File

@@ -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) {

View 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;

View 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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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;

View File

@@ -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'),

View File

@@ -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);
} }
} }

View File

@@ -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;

View File

@@ -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 {

View 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"`);
}
}

View File

@@ -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"))`
); );
} }

View File

@@ -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
)` )`
); );

View File

@@ -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')))`
); );
} }

View File

@@ -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: [],
})),
});

View File

@@ -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,
}); });

View File

@@ -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 {

View File

@@ -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
View 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;

View File

@@ -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
View 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;

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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,
}); });

View File

@@ -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;

View File

@@ -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',

View File

@@ -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.' });
} }
}); });

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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');

View File

@@ -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');

View File

@@ -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({

View File

@@ -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;
} }
} }

View File

@@ -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;
};

View File

@@ -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'}

View File

@@ -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;

View 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;

View File

@@ -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)

View File

@@ -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}`;
}; };

View File

@@ -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;

View File

@@ -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
/> />
); );

View 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;

View File

@@ -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

View 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;

View 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;

View File

@@ -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';
} }

View File

@@ -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>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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'

View File

@@ -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}

View File

@@ -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
} }
> >

View File

@@ -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 = (

View File

@@ -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"

View File

@@ -27,7 +27,7 @@ const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
return null; return null;
} }
if (data === undefined && !error) { if (!data && !error) {
return <SmallLoadingSpinner />; return <SmallLoadingSpinner />;
} }

View File

@@ -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
} }
> >

View File

@@ -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}
/> />

View File

@@ -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>

View File

@@ -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}
/> />
</> </>
); );

View File

@@ -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;

View File

@@ -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
/> />

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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'

View File

@@ -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}

View File

@@ -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">

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;

View File

@@ -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