mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
52 Commits
0xsysr3ll/
...
preview-mu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41e87c732b | ||
|
|
b9571f4e50 | ||
|
|
5c5e574a57 | ||
|
|
8b47ef8a02 | ||
|
|
ae65aaa339 | ||
|
|
0d02a2a9d8 | ||
|
|
768fb9260e | ||
|
|
b9df011615 | ||
|
|
a06e17e304 | ||
|
|
ff891568ee | ||
|
|
1afd724ca4 | ||
|
|
b0ca98b273 | ||
|
|
7b7a41b0a5 | ||
|
|
f6f90afe26 | ||
|
|
540652aae0 | ||
|
|
bcb33cce88 | ||
|
|
9015afdfe0 | ||
|
|
010953f4f3 | ||
|
|
c4af689665 | ||
|
|
602e1cef9c | ||
|
|
9e2b94d822 | ||
|
|
73fd051886 | ||
|
|
8eb4633501 | ||
|
|
86040219dc | ||
|
|
14bd34630f | ||
|
|
02f08e9e50 | ||
|
|
ce3414bf0b | ||
|
|
f7f8ffc09c | ||
|
|
8263b47d34 | ||
|
|
b8af9eeb78 | ||
|
|
ba71aeecf5 | ||
|
|
b5c0042ea4 | ||
|
|
30960dd3bc | ||
|
|
3315d9d2d9 | ||
|
|
1de60a6743 | ||
|
|
657dc94931 | ||
|
|
e572e3e9b5 | ||
|
|
c12d2922d6 | ||
|
|
d96e4e58a4 | ||
|
|
4216c86748 | ||
|
|
bbf14cd2a8 | ||
|
|
4f73fa1b01 | ||
|
|
fb212c992c | ||
|
|
c1d506ffe5 | ||
|
|
6469efd798 | ||
|
|
f4eb0f4c10 | ||
|
|
2a6186fd9f | ||
|
|
2a7ae0f73b | ||
|
|
6657a73bba | ||
|
|
ce9d470331 | ||
|
|
756d3f3c96 | ||
|
|
8336c3b7ec |
@@ -11,6 +11,8 @@ module.exports = {
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: 'artworks.thetvdb.com' },
|
||||
{ hostname: 'plex.tv' },
|
||||
{ hostname: 'archive.org' },
|
||||
{ hostname: 'r2.theaudiodb.com' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dns-caching": "^0.2.7",
|
||||
"dompurify": "^3.2.4",
|
||||
"email-templates": "12.0.1",
|
||||
"express": "4.21.2",
|
||||
"express-openapi-validator": "4.13.8",
|
||||
@@ -68,6 +69,7 @@
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"mime": "3",
|
||||
"next": "^14.2.25",
|
||||
@@ -126,9 +128,11 @@
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/mime": "3",
|
||||
"@types/node": "22.10.5",
|
||||
|
||||
285
pnpm-lock.yaml
generated
285
pnpm-lock.yaml
generated
@@ -86,6 +86,9 @@ importers:
|
||||
dns-caching:
|
||||
specifier: ^0.2.7
|
||||
version: 0.2.7
|
||||
dompurify:
|
||||
specifier: ^3.2.4
|
||||
version: 3.3.0
|
||||
email-templates:
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(@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)
|
||||
@@ -113,6 +116,9 @@ importers:
|
||||
https-proxy-agent:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
jsdom:
|
||||
specifier: ^26.0.0
|
||||
version: 26.1.0
|
||||
lodash:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
@@ -282,6 +288,9 @@ importers:
|
||||
'@types/csurf':
|
||||
specifier: 1.11.2
|
||||
version: 1.11.2
|
||||
'@types/dompurify':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
'@types/email-templates':
|
||||
specifier: 8.0.4
|
||||
version: 8.0.4(encoding@0.1.13)
|
||||
@@ -291,6 +300,9 @@ importers:
|
||||
'@types/express-session':
|
||||
specifier: 1.17.6
|
||||
version: 1.17.6
|
||||
'@types/jsdom':
|
||||
specifier: ^21.1.7
|
||||
version: 21.1.7
|
||||
'@types/lodash':
|
||||
specifier: 4.14.191
|
||||
version: 4.14.191
|
||||
@@ -436,6 +448,9 @@ packages:
|
||||
'@apidevtools/json-schema-ref-parser@9.0.9':
|
||||
resolution: {integrity: sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==}
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
|
||||
'@babel/code-frame@7.24.7':
|
||||
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1566,6 +1581,34 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/css-calc@2.1.4':
|
||||
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.1.0':
|
||||
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5':
|
||||
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4':
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@cypress/request@3.0.7':
|
||||
resolution: {integrity: sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3161,6 +3204,10 @@ packages:
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/email-templates@8.0.4':
|
||||
resolution: {integrity: sha512-HYvVoyG8qS6PrimZZOS4wMrtQ9MelKEl0sOpi4zVpz2Ds74v+UvWckIFz3NyGyTwAR1okMbwJkApgR2GL/ALjg==}
|
||||
|
||||
@@ -3203,6 +3250,9 @@ packages:
|
||||
'@types/istanbul-reports@3.0.4':
|
||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||
|
||||
'@types/jsdom@21.1.7':
|
||||
resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@@ -3334,9 +3384,15 @@ packages:
|
||||
'@types/swagger-ui-express@4.1.3':
|
||||
resolution: {integrity: sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==}
|
||||
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
|
||||
'@types/triple-beam@1.3.5':
|
||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/ua-parser-js@0.7.39':
|
||||
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
|
||||
|
||||
@@ -4449,6 +4505,10 @@ packages:
|
||||
resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@2.6.21:
|
||||
resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==}
|
||||
|
||||
@@ -4478,6 +4538,10 @@ packages:
|
||||
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
data-view-buffer@1.0.1:
|
||||
resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4548,6 +4612,9 @@ packages:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
decode-named-character-reference@1.0.2:
|
||||
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
|
||||
|
||||
@@ -4701,6 +4768,9 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.3.0:
|
||||
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
|
||||
|
||||
domutils@2.8.0:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
|
||||
@@ -4790,6 +4860,10 @@ packages:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5590,6 +5664,10 @@ packages:
|
||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
html-to-text@9.0.5:
|
||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -5896,6 +5974,9 @@ packages:
|
||||
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
is-promise@2.2.2:
|
||||
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
|
||||
|
||||
@@ -6077,6 +6158,15 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/preset-env': ^7.1.6
|
||||
|
||||
jsdom@26.1.0:
|
||||
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
jsesc@0.5.0:
|
||||
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
|
||||
hasBin: true
|
||||
@@ -6999,6 +7089,9 @@ packages:
|
||||
nullthrows@1.1.1:
|
||||
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
|
||||
|
||||
nwsapi@2.2.22:
|
||||
resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==}
|
||||
|
||||
oauth-sign@0.9.0:
|
||||
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
|
||||
|
||||
@@ -7197,6 +7290,9 @@ packages:
|
||||
parse5@7.1.2:
|
||||
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
parseley@0.12.1:
|
||||
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
||||
|
||||
@@ -8029,6 +8125,9 @@ packages:
|
||||
rndm@1.2.0:
|
||||
resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==}
|
||||
|
||||
rrweb-cssom@0.8.0:
|
||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||
|
||||
run-applescript@3.2.0:
|
||||
resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -8077,6 +8176,10 @@ packages:
|
||||
sax@1.4.1:
|
||||
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.21.0:
|
||||
resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==}
|
||||
|
||||
@@ -8517,6 +8620,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
@@ -8655,6 +8761,10 @@ packages:
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
tr46@5.1.1:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
@@ -9076,6 +9186,10 @@ packages:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
walker@1.0.8:
|
||||
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
|
||||
|
||||
@@ -9101,9 +9215,25 @@ packages:
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-fetch@3.6.20:
|
||||
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
|
||||
|
||||
whatwg-mimetype@4.0.0:
|
||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
@@ -9206,6 +9336,22 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xml2js@0.4.16:
|
||||
resolution: {integrity: sha512-9rH7UTUNphxeDRCeJBi4Fxp/z0fd92WeXNQ1dtUYMpqO3PaK59hVDCuUmOGHRZvufJDzcX8TG+Kdty7ylM0t2w==}
|
||||
|
||||
@@ -9228,6 +9374,9 @@ packages:
|
||||
resolution: {integrity: sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -9331,6 +9480,14 @@ snapshots:
|
||||
call-me-maybe: 1.0.2
|
||||
js-yaml: 4.1.0
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 10.4.3
|
||||
|
||||
'@babel/code-frame@7.24.7':
|
||||
dependencies:
|
||||
'@babel/highlight': 7.24.7
|
||||
@@ -10932,6 +11089,26 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.1.0
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@cypress/request@3.0.7':
|
||||
dependencies:
|
||||
aws-sign2: 0.7.0
|
||||
@@ -13065,6 +13242,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/ms': 0.7.34
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
dependencies:
|
||||
dompurify: 3.3.0
|
||||
|
||||
'@types/email-templates@8.0.4(encoding@0.1.13)':
|
||||
dependencies:
|
||||
'@types/html-to-text': 9.0.4
|
||||
@@ -13128,6 +13309,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/istanbul-lib-report': 3.0.3
|
||||
|
||||
'@types/jsdom@21.1.7':
|
||||
dependencies:
|
||||
'@types/node': 22.10.5
|
||||
'@types/tough-cookie': 4.0.5
|
||||
parse5: 7.1.2
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json-stable-stringify@1.0.36': {}
|
||||
@@ -13256,8 +13443,13 @@ snapshots:
|
||||
'@types/express': 4.17.21
|
||||
'@types/serve-static': 1.15.7
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/ua-parser-js@0.7.39': {}
|
||||
|
||||
'@types/unist@2.0.10': {}
|
||||
@@ -14553,6 +14745,11 @@ snapshots:
|
||||
dependencies:
|
||||
css-tree: 1.1.3
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
rrweb-cssom: 0.8.0
|
||||
|
||||
csstype@2.6.21: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
@@ -14627,6 +14824,11 @@ snapshots:
|
||||
dependencies:
|
||||
assert-plus: 1.0.0
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
whatwg-url: 14.2.0
|
||||
|
||||
data-view-buffer@1.0.1:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
@@ -14686,6 +14888,8 @@ snapshots:
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decode-named-character-reference@1.0.2:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -14842,6 +15046,10 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.3.0:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@2.8.0:
|
||||
dependencies:
|
||||
dom-serializer: 1.4.1
|
||||
@@ -14985,6 +15193,8 @@ snapshots:
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
envinfo@7.14.0: {}
|
||||
@@ -16113,6 +16323,10 @@ snapshots:
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
html-to-text@9.0.5:
|
||||
dependencies:
|
||||
'@selderee/plugin-htmlparser2': 0.11.0
|
||||
@@ -16240,7 +16454,6 @@ snapshots:
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
@@ -16440,6 +16653,8 @@ snapshots:
|
||||
dependencies:
|
||||
isobject: 3.0.1
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-promise@2.2.2: {}
|
||||
|
||||
is-regex@1.1.4:
|
||||
@@ -16654,6 +16869,33 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
jsdom@26.1.0:
|
||||
dependencies:
|
||||
cssstyle: 4.6.0
|
||||
data-urls: 5.0.0
|
||||
decimal.js: 10.6.0
|
||||
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.22
|
||||
parse5: 7.3.0
|
||||
rrweb-cssom: 0.8.0
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 5.1.1
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 7.0.0
|
||||
whatwg-encoding: 3.1.1
|
||||
whatwg-mimetype: 4.0.0
|
||||
whatwg-url: 14.2.0
|
||||
ws: 8.18.3
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
jsesc@0.5.0: {}
|
||||
|
||||
jsesc@2.5.2: {}
|
||||
@@ -17830,6 +18072,8 @@ snapshots:
|
||||
|
||||
nullthrows@1.1.1: {}
|
||||
|
||||
nwsapi@2.2.22: {}
|
||||
|
||||
oauth-sign@0.9.0: {}
|
||||
|
||||
ob1@0.80.12:
|
||||
@@ -18042,6 +18286,10 @@ snapshots:
|
||||
dependencies:
|
||||
entities: 4.5.0
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parseley@0.12.1:
|
||||
dependencies:
|
||||
leac: 0.6.0
|
||||
@@ -19005,6 +19253,8 @@ snapshots:
|
||||
|
||||
rndm@1.2.0: {}
|
||||
|
||||
rrweb-cssom@0.8.0: {}
|
||||
|
||||
run-applescript@3.2.0:
|
||||
dependencies:
|
||||
execa: 0.10.0
|
||||
@@ -19053,6 +19303,10 @@ snapshots:
|
||||
|
||||
sax@1.4.1: {}
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.21.0:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -19558,6 +19812,8 @@ snapshots:
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.2.2(react@18.3.1)
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss@3.2.7(postcss@8.4.31)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
||||
@@ -19705,6 +19961,10 @@ snapshots:
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@5.1.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
@@ -20080,6 +20340,10 @@ snapshots:
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
walker@1.0.8:
|
||||
dependencies:
|
||||
makeerror: 1.0.12
|
||||
@@ -20127,8 +20391,21 @@ snapshots:
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
|
||||
whatwg-fetch@3.6.20: {}
|
||||
|
||||
whatwg-mimetype@4.0.0: {}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
dependencies:
|
||||
tr46: 5.1.1
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
@@ -20260,6 +20537,10 @@ snapshots:
|
||||
|
||||
ws@7.5.10: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xml2js@0.4.16:
|
||||
dependencies:
|
||||
sax: 1.4.1
|
||||
@@ -20283,6 +20564,8 @@ snapshots:
|
||||
|
||||
xmlbuilder@9.0.7: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
BIN
public/images/jellyseerr_poster_not_found_square.png
Normal file
BIN
public/images/jellyseerr_poster_not_found_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
1000
seerr-api.yml
1000
seerr-api.yml
File diff suppressed because it is too large
Load Diff
211
server/api/coverartarchive/index.ts
Normal file
211
server/api/coverartarchive/index.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
import { In } from 'typeorm';
|
||||
import type { CoverArtResponse } from './interfaces';
|
||||
|
||||
class CoverArtArchive extends ExternalAPI {
|
||||
private readonly CACHE_TTL = 43200;
|
||||
private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'https://coverartarchive.org',
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('covertartarchive').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
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 {
|
||||
const data = await this.get<CoverArtResponse>(
|
||||
`/release-group/${id}`,
|
||||
undefined,
|
||||
this.CACHE_TTL
|
||||
);
|
||||
|
||||
const releaseMBID = data.release.split('/').pop();
|
||||
|
||||
data.images = data.images.map((image) => {
|
||||
const fullUrl = `https://archive.org/download/mbid-${releaseMBID}/mbid-${releaseMBID}-${image.id}_thumb250.jpg`;
|
||||
|
||||
if (image.front) {
|
||||
getRepository(MetadataAlbum)
|
||||
.upsert(
|
||||
{ mbAlbumId: id, caaUrl: fullUrl },
|
||||
{ conflictPaths: ['mbAlbumId'] }
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error('Failed to save album metadata', {
|
||||
label: 'CoverArtArchive',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
approved: image.approved,
|
||||
front: image.front,
|
||||
id: image.id,
|
||||
thumbnails: { 250: fullUrl },
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
await getRepository(MetadataAlbum).upsert(
|
||||
{ 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 validIds = ids.filter(
|
||||
(id) =>
|
||||
typeof id === 'string' &&
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
if (!validIds.length) return {};
|
||||
|
||||
const resultsMap = new Map<string, string | null>();
|
||||
const idsToFetch: string[] = [];
|
||||
|
||||
const metadataRepository = getRepository(MetadataAlbum);
|
||||
const existingMetadata = await metadataRepository.find({
|
||||
where: { mbAlbumId: In(validIds) },
|
||||
select: ['mbAlbumId', 'caaUrl', 'updatedAt'],
|
||||
});
|
||||
|
||||
const metadataMap = new Map(
|
||||
existingMetadata.map((metadata) => [metadata.mbAlbumId, metadata])
|
||||
);
|
||||
|
||||
for (const id of validIds) {
|
||||
const metadata = metadataMap.get(id);
|
||||
|
||||
if (metadata?.caaUrl) {
|
||||
resultsMap.set(id, metadata.caaUrl);
|
||||
} else if (metadata && !this.isMetadataStale(metadata)) {
|
||||
resultsMap.set(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);
|
||||
resultsMap.set(id, frontImage?.thumbnails?.[250] || null);
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to fetch cover art', {
|
||||
label: 'CoverArtArchive',
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
resultsMap.set(id, null);
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.allSettled(batchPromises);
|
||||
}
|
||||
|
||||
const results: Record<string, string | null> = {};
|
||||
for (const [key, value] of resultsMap.entries()) {
|
||||
results[key] = value;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export default CoverArtArchive;
|
||||
15
server/api/coverartarchive/interfaces.ts
Normal file
15
server/api/coverartarchive/interfaces.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
interface CoverArtThumbnails {
|
||||
250: string;
|
||||
}
|
||||
|
||||
interface CoverArtImage {
|
||||
approved: boolean;
|
||||
front: boolean;
|
||||
id: number;
|
||||
thumbnails: CoverArtThumbnails;
|
||||
}
|
||||
|
||||
export interface CoverArtResponse {
|
||||
images: CoverArtImage[];
|
||||
release: string;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ interface JellyfinMediaFolder {
|
||||
}
|
||||
|
||||
export interface JellyfinLibrary {
|
||||
type: 'show' | 'movie';
|
||||
type: 'show' | 'movie' | 'music';
|
||||
key: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
@@ -66,7 +66,13 @@ export interface JellyfinLibraryItem {
|
||||
Name: string;
|
||||
Id: string;
|
||||
HasSubtitles: boolean;
|
||||
Type: 'Movie' | 'Episode' | 'Season' | 'Series';
|
||||
Type:
|
||||
| 'Movie'
|
||||
| 'Episode'
|
||||
| 'Season'
|
||||
| 'Series'
|
||||
| 'MusicAlbum'
|
||||
| 'MusicArtist';
|
||||
LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual';
|
||||
SeriesName?: string;
|
||||
SeriesId?: string;
|
||||
@@ -76,6 +82,8 @@ export interface JellyfinLibraryItem {
|
||||
IndexNumberEnd?: number;
|
||||
ParentIndexNumber?: number;
|
||||
MediaType: string;
|
||||
AlbumId?: string;
|
||||
ArtistId?: string;
|
||||
}
|
||||
|
||||
export interface JellyfinMediaStream {
|
||||
@@ -104,6 +112,9 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
Imdb?: string;
|
||||
Tvdb?: string;
|
||||
AniDB?: string;
|
||||
MusicBrainzReleaseGroup: string | undefined;
|
||||
MusicBrainzAlbum?: string;
|
||||
MusicBrainzArtistId?: string;
|
||||
};
|
||||
MediaSources?: JellyfinMediaSource[];
|
||||
Width?: number;
|
||||
@@ -304,13 +315,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
|
||||
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
|
||||
const excludedTypes = [
|
||||
'music',
|
||||
'books',
|
||||
'musicvideos',
|
||||
'homevideos',
|
||||
'boxsets',
|
||||
];
|
||||
const excludedTypes = ['books', 'musicvideos', 'homevideos', 'boxsets'];
|
||||
|
||||
return mediaFolders
|
||||
.filter((Item: JellyfinMediaFolder) => {
|
||||
@@ -323,7 +328,12 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return <JellyfinLibrary>{
|
||||
key: Item.Id,
|
||||
title: Item.Name,
|
||||
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
|
||||
type:
|
||||
Item.CollectionType === 'movies'
|
||||
? 'movie'
|
||||
: Item.CollectionType === 'tvshows'
|
||||
? 'show'
|
||||
: 'music',
|
||||
agent: 'jellyfin',
|
||||
};
|
||||
});
|
||||
@@ -332,7 +342,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const libraryItemsResponse = await this.get<any>(
|
||||
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,MusicAlbum,MusicArtist,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||
);
|
||||
|
||||
return libraryItemsResponse.Items.filter(
|
||||
|
||||
134
server/api/listenbrainz/index.ts
Normal file
134
server/api/listenbrainz/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import type {
|
||||
LbAlbumDetails,
|
||||
LbArtistDetails,
|
||||
LbFreshReleasesResponse,
|
||||
LbTopAlbumsResponse,
|
||||
LbTopArtistsResponse,
|
||||
} from './interfaces';
|
||||
|
||||
class ListenBrainzAPI extends ExternalAPI {
|
||||
constructor() {
|
||||
super(
|
||||
'https://api.listenbrainz.org/1',
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('listenbrainz').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 25,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getAlbum(mbid: string): Promise<LbAlbumDetails> {
|
||||
try {
|
||||
return await this.post<LbAlbumDetails>(
|
||||
`/album/${mbid}`,
|
||||
{},
|
||||
{
|
||||
baseURL: 'https://listenbrainz.org',
|
||||
},
|
||||
43200
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[ListenBrainz] Failed to fetch album details: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getArtist(mbid: string): Promise<LbArtistDetails> {
|
||||
try {
|
||||
return await this.post<LbArtistDetails>(
|
||||
`/artist/${mbid}`,
|
||||
{},
|
||||
{
|
||||
baseURL: 'https://listenbrainz.org',
|
||||
},
|
||||
43200
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[ListenBrainz] Failed to fetch artist details: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTopAlbums({
|
||||
offset = 0,
|
||||
range = 'month',
|
||||
count = 20,
|
||||
}: {
|
||||
offset?: number;
|
||||
range?: string;
|
||||
count?: number;
|
||||
}): Promise<LbTopAlbumsResponse> {
|
||||
return this.get<LbTopAlbumsResponse>(
|
||||
'/stats/sitewide/release-groups',
|
||||
{
|
||||
params: {
|
||||
offset: offset.toString(),
|
||||
range,
|
||||
count: count.toString(),
|
||||
},
|
||||
},
|
||||
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',
|
||||
{
|
||||
params: {
|
||||
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',
|
||||
{
|
||||
params: {
|
||||
days: days.toString(),
|
||||
sort,
|
||||
offset: offset.toString(),
|
||||
count: count.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ListenBrainzAPI;
|
||||
243
server/api/listenbrainz/interfaces.ts
Normal file
243
server/api/listenbrainz/interfaces.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
export interface LbSimilarArtistResponse {
|
||||
artist_mbid: string;
|
||||
name: string;
|
||||
comment: string;
|
||||
type: string | null;
|
||||
gender: string | null;
|
||||
score: number;
|
||||
reference_mbid: string;
|
||||
}
|
||||
|
||||
export interface LbReleaseGroup {
|
||||
artist_mbids: string[];
|
||||
artist_name: string;
|
||||
caa_id: number;
|
||||
caa_release_mbid: string;
|
||||
listen_count: number;
|
||||
release_group_mbid: string;
|
||||
release_group_name: string;
|
||||
}
|
||||
|
||||
export interface LbTopAlbumsResponse {
|
||||
payload: {
|
||||
count: number;
|
||||
from_ts: number;
|
||||
last_updated: number;
|
||||
offset: number;
|
||||
range: string;
|
||||
release_groups: LbReleaseGroup[];
|
||||
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[];
|
||||
};
|
||||
}
|
||||
192
server/api/musicbrainz/index.ts
Normal file
192
server/api/musicbrainz/index.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import axios from 'axios';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { MbAlbumDetails, MbArtistDetails } from './interfaces';
|
||||
|
||||
const window = new JSDOM('').window;
|
||||
const purify = DOMPurify(window);
|
||||
|
||||
class MusicBrainz extends ExternalAPI {
|
||||
constructor() {
|
||||
super(
|
||||
'https://musicbrainz.org/ws/2',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr)',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('musicbrainz').data,
|
||||
rateLimit: {
|
||||
maxRequests: 1,
|
||||
maxRPS: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async searchAlbum({
|
||||
query,
|
||||
limit = 30,
|
||||
offset = 0,
|
||||
}: {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MbAlbumDetails[]> {
|
||||
try {
|
||||
const data = await this.get<{
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
'release-groups': MbAlbumDetails[];
|
||||
}>(
|
||||
'/release-group',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
fmt: 'json',
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data['release-groups'];
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to search albums: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
fmt: 'json',
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data.artists;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to search artists: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getArtistWikipediaExtract({
|
||||
artistMbid,
|
||||
language = 'en',
|
||||
}: {
|
||||
artistMbid: string;
|
||||
language?: string;
|
||||
}): Promise<{ title: string; url: string; content: string } | null> {
|
||||
if (
|
||||
!artistMbid ||
|
||||
typeof artistMbid !== 'string' ||
|
||||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
artistMbid
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid MusicBrainz artist ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
const safeUrl = `https://musicbrainz.org/artist/${artistMbid}/wikipedia-extract`;
|
||||
|
||||
const response = await axios.get(safeUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': language,
|
||||
'User-Agent':
|
||||
'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr)',
|
||||
},
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (!data.wikipediaExtract || !data.wikipediaExtract.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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(
|
||||
`[MusicBrainz] Failed to fetch Wikipedia extract: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getReleaseGroup({
|
||||
releaseId,
|
||||
}: {
|
||||
releaseId: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const data = await this.get<{
|
||||
'release-group': {
|
||||
id: string;
|
||||
};
|
||||
}>(
|
||||
`/release/${releaseId}`,
|
||||
{
|
||||
params: {
|
||||
inc: 'release-groups',
|
||||
fmt: 'json',
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data['release-group']?.id ?? null;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MusicBrainz] Failed to fetch release group: ${
|
||||
e instanceof Error ? e.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MusicBrainz;
|
||||
119
server/api/musicbrainz/interfaces.ts
Normal file
119
server/api/musicbrainz/interfaces.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
interface MbResult {
|
||||
id: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface MbLink {
|
||||
type: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface MbAlbumResult extends MbResult {
|
||||
media_type: 'album';
|
||||
title: string;
|
||||
'primary-type': 'Album' | 'Single' | 'EP';
|
||||
'first-release-date': string;
|
||||
'artist-credit': {
|
||||
name: string;
|
||||
artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
'sort-name': string;
|
||||
overview?: string;
|
||||
};
|
||||
}[];
|
||||
posterPath: string | undefined;
|
||||
}
|
||||
|
||||
export interface MbAlbumDetails extends MbAlbumResult {
|
||||
'type-id': string;
|
||||
'primary-type-id': string;
|
||||
count: number;
|
||||
'secondary-types'?: string[];
|
||||
'secondary-type-ids'?: string[];
|
||||
releases: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
'status-id': string;
|
||||
}[];
|
||||
releasedate: string;
|
||||
tags?: {
|
||||
count: number;
|
||||
name: string;
|
||||
}[];
|
||||
artists?: {
|
||||
id: string;
|
||||
name: string;
|
||||
overview?: string;
|
||||
}[];
|
||||
links?: MbLink[];
|
||||
poster_path?: string;
|
||||
}
|
||||
|
||||
export interface MbArtistResult extends MbResult {
|
||||
media_type: 'artist';
|
||||
name: string;
|
||||
type: 'Group' | 'Person';
|
||||
'sort-name': string;
|
||||
country?: string;
|
||||
disambiguation?: string;
|
||||
artistThumb?: string | null;
|
||||
artistBackdrop?: string | null;
|
||||
}
|
||||
|
||||
export interface MbArtistDetails extends MbArtistResult {
|
||||
'type-id': string;
|
||||
area?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
'type-id': string;
|
||||
'sort-name': string;
|
||||
};
|
||||
'begin-area'?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
'sort-name': string;
|
||||
};
|
||||
'life-span'?: {
|
||||
begin?: string;
|
||||
ended: boolean;
|
||||
};
|
||||
gender?: string;
|
||||
'gender-id'?: string;
|
||||
isnis?: string[];
|
||||
aliases?: {
|
||||
name: string;
|
||||
'sort-name': string;
|
||||
type?: string;
|
||||
'type-id'?: string;
|
||||
}[];
|
||||
tags?: {
|
||||
count: number;
|
||||
name: string;
|
||||
}[];
|
||||
links?: MbLink[];
|
||||
}
|
||||
|
||||
export interface MbSearchAlbumResponse {
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
'release-groups': MbAlbumDetails[];
|
||||
}
|
||||
|
||||
export interface MbSearchArtistResponse {
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
artists: MbArtistDetails[];
|
||||
}
|
||||
|
||||
export interface MbSearchMultiResponse {
|
||||
created: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
results: (MbArtistResult | MbAlbumResult)[];
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export interface PlexLibraryItem {
|
||||
Guid?: {
|
||||
id: string;
|
||||
}[];
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
|
||||
Media: Media[];
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PlexLibraryResponse {
|
||||
}
|
||||
|
||||
export interface PlexLibrary {
|
||||
type: 'show' | 'movie';
|
||||
type: 'show' | 'movie' | 'artist';
|
||||
key: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
@@ -44,7 +44,7 @@ export interface PlexMetadata {
|
||||
ratingKey: string;
|
||||
parentRatingKey?: string;
|
||||
guid: string;
|
||||
type: 'movie' | 'show' | 'season';
|
||||
type: 'movie' | 'show' | 'season' | 'artist' | 'album' | 'track';
|
||||
title: string;
|
||||
Guid: {
|
||||
id: string;
|
||||
@@ -152,7 +152,10 @@ class PlexAPI {
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter(
|
||||
(library) => library.type === 'movie' || library.type === 'show'
|
||||
(library) =>
|
||||
library.type === 'movie' ||
|
||||
library.type === 'show' ||
|
||||
library.type === 'artist'
|
||||
)
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
@@ -165,7 +168,7 @@ class PlexAPI {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
type: library.type,
|
||||
type: library.type === 'artist' ? 'music' : library.type,
|
||||
lastScan: existing?.lastScan,
|
||||
};
|
||||
});
|
||||
@@ -227,12 +230,19 @@ class PlexAPI {
|
||||
options: { addedAt: number } = {
|
||||
addedAt: Date.now() - 1000 * 60 * 60,
|
||||
},
|
||||
mediaType: 'movie' | 'show'
|
||||
mediaType: 'movie' | 'show' | 'album'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
let typeCode = '1';
|
||||
if (mediaType === 'show') {
|
||||
typeCode = '4';
|
||||
} else if (mediaType === 'album') {
|
||||
typeCode = '9';
|
||||
}
|
||||
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?type=${
|
||||
mediaType === 'show' ? '4' : '1'
|
||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||
uri: `/library/sections/${id}/all?type=${typeCode}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
||||
options.addedAt / 1000
|
||||
)}`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `0`,
|
||||
'X-Plex-Container-Size': `500`,
|
||||
|
||||
@@ -124,7 +124,7 @@ export interface PlexWatchlistItem {
|
||||
ratingKey: string;
|
||||
tmdbId: number;
|
||||
tvdbId?: number;
|
||||
type: 'movie' | 'show';
|
||||
type: 'movie' | 'show' | 'album';
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
||||
422
server/api/servarr/lidarr.ts
Normal file
422
server/api/servarr/lidarr.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
interface LidarrMediaResult {
|
||||
id: number;
|
||||
mbId: string;
|
||||
media_type: string;
|
||||
}
|
||||
|
||||
export interface LidarrArtistResult extends LidarrMediaResult {
|
||||
artist: {
|
||||
media_type: 'artist';
|
||||
artistName: string;
|
||||
overview: string;
|
||||
remotePoster?: string;
|
||||
artistType: string;
|
||||
genres: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LidarrAlbumResult extends LidarrMediaResult {
|
||||
album: {
|
||||
disambiguation: string;
|
||||
duration: number;
|
||||
mediumCount: number;
|
||||
ratings: LidarrRating | undefined;
|
||||
links: never[];
|
||||
media_type: 'music';
|
||||
title: string;
|
||||
foreignAlbumId: string;
|
||||
overview: string;
|
||||
releaseDate: string;
|
||||
albumType: string;
|
||||
genres: string[];
|
||||
images: LidarrImage[];
|
||||
artist: {
|
||||
id: number;
|
||||
status: string;
|
||||
ended: boolean;
|
||||
foreignArtistId: string;
|
||||
tadbId: number;
|
||||
discogsId: number;
|
||||
artistType: string;
|
||||
disambiguation: string | undefined;
|
||||
links: never[];
|
||||
images: never[];
|
||||
genres: never[];
|
||||
cleanName: string | undefined;
|
||||
sortName: string | undefined;
|
||||
tags: never[];
|
||||
added: string;
|
||||
ratings: LidarrRating | undefined;
|
||||
artistName: string;
|
||||
overview: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface LidarrArtistDetails {
|
||||
id: number;
|
||||
foreignArtistId: string;
|
||||
status: string;
|
||||
ended: boolean;
|
||||
artistName: string;
|
||||
tadbId: number;
|
||||
discogsId: number;
|
||||
overview: string;
|
||||
artistType: string;
|
||||
disambiguation: string;
|
||||
links: LidarrLink[];
|
||||
nextAlbum: LidarrAlbumResult | null;
|
||||
lastAlbum: LidarrAlbumResult | null;
|
||||
images: LidarrImage[];
|
||||
qualityProfileId: number;
|
||||
profileId: number;
|
||||
metadataProfileId: number;
|
||||
monitored: boolean;
|
||||
monitorNewItems: string;
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
added: string;
|
||||
ratings: LidarrRating;
|
||||
remotePoster?: string;
|
||||
cleanName?: string;
|
||||
sortName?: string;
|
||||
}
|
||||
|
||||
export interface LidarrAlbumDetails {
|
||||
id: number;
|
||||
mbId: string;
|
||||
foreignArtistId: string;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
title: string;
|
||||
titleSlug: string;
|
||||
path: string;
|
||||
artistName: string;
|
||||
disambiguation: string;
|
||||
overview: string;
|
||||
artistId: number;
|
||||
foreignAlbumId: string;
|
||||
anyReleaseOk: boolean;
|
||||
profileId: number;
|
||||
qualityProfileId: number;
|
||||
duration: number;
|
||||
isAvailable: boolean;
|
||||
folderName: string;
|
||||
metadataProfileId: number;
|
||||
added: string;
|
||||
albumType: string;
|
||||
secondaryTypes: string[];
|
||||
mediumCount: number;
|
||||
ratings: LidarrRating;
|
||||
releaseDate: string;
|
||||
releases: {
|
||||
id: number;
|
||||
albumId: number;
|
||||
foreignReleaseId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
media: unknown[];
|
||||
mediumCount: number;
|
||||
disambiguation: string;
|
||||
country: unknown[];
|
||||
label: unknown[];
|
||||
format: string;
|
||||
monitored: boolean;
|
||||
}[];
|
||||
genres: string[];
|
||||
media: {
|
||||
mediumNumber: number;
|
||||
mediumName: string;
|
||||
mediumFormat: string;
|
||||
}[];
|
||||
artist: LidarrArtistDetails & {
|
||||
artistName: string;
|
||||
nextAlbum: unknown | null;
|
||||
lastAlbum: unknown | null;
|
||||
};
|
||||
images: LidarrImage[];
|
||||
links: {
|
||||
url: string;
|
||||
name: string;
|
||||
}[];
|
||||
remoteCover?: string;
|
||||
}
|
||||
|
||||
export interface LidarrImage {
|
||||
url: string;
|
||||
coverType: string;
|
||||
}
|
||||
|
||||
export interface LidarrRating {
|
||||
votes: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface LidarrLink {
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LidarrRelease {
|
||||
id: number;
|
||||
albumId: number;
|
||||
foreignReleaseId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
media: LidarrMedia[];
|
||||
}
|
||||
|
||||
export interface LidarrMedia {
|
||||
mediumNumber: number;
|
||||
mediumFormat: string;
|
||||
mediumName: string;
|
||||
}
|
||||
|
||||
export interface LidarrSearchResponse {
|
||||
page: number;
|
||||
total_results: number;
|
||||
total_pages: number;
|
||||
results: (LidarrArtistResult | LidarrAlbumResult)[];
|
||||
}
|
||||
|
||||
export interface LidarrAlbumOptions {
|
||||
[key: string]: unknown;
|
||||
title: string;
|
||||
disambiguation?: string;
|
||||
overview?: string;
|
||||
artistId: number;
|
||||
foreignAlbumId: string;
|
||||
monitored: boolean;
|
||||
anyReleaseOk: boolean;
|
||||
profileId: number;
|
||||
duration?: number;
|
||||
albumType: string;
|
||||
secondaryTypes: string[];
|
||||
mediumCount?: number;
|
||||
ratings?: LidarrRating;
|
||||
releaseDate?: string;
|
||||
releases: unknown[];
|
||||
genres: string[];
|
||||
media: unknown[];
|
||||
artist: {
|
||||
status: string;
|
||||
ended: boolean;
|
||||
artistName: string;
|
||||
foreignArtistId: string;
|
||||
tadbId?: number;
|
||||
discogsId?: number;
|
||||
overview?: string;
|
||||
artistType: string;
|
||||
disambiguation?: string;
|
||||
links: LidarrLink[];
|
||||
images: LidarrImage[];
|
||||
path: string;
|
||||
qualityProfileId: number;
|
||||
metadataProfileId: number;
|
||||
monitored: boolean;
|
||||
monitorNewItems: string;
|
||||
rootFolderPath: string;
|
||||
genres: string[];
|
||||
cleanName?: string;
|
||||
sortName?: string;
|
||||
tags: number[];
|
||||
added?: string;
|
||||
ratings?: LidarrRating;
|
||||
id: number;
|
||||
};
|
||||
images: LidarrImage[];
|
||||
links: LidarrLink[];
|
||||
addOptions: {
|
||||
searchForNewAlbum: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LidarrArtistOptions {
|
||||
[key: string]: unknown;
|
||||
artistName: string;
|
||||
qualityProfileId: number;
|
||||
profileId: number;
|
||||
rootFolderPath: string;
|
||||
foreignArtistId: string;
|
||||
monitored: boolean;
|
||||
tags: number[];
|
||||
searchNow: boolean;
|
||||
monitorNewItems: string;
|
||||
monitor: string;
|
||||
searchForMissingAlbums: boolean;
|
||||
}
|
||||
|
||||
export interface LidarrAlbum {
|
||||
id: number;
|
||||
mbId: string;
|
||||
title: string;
|
||||
monitored: boolean;
|
||||
artistId: number;
|
||||
foreignAlbumId: string;
|
||||
titleSlug: string;
|
||||
profileId: number;
|
||||
duration: number;
|
||||
albumType: string;
|
||||
statistics: {
|
||||
trackFileCount: number;
|
||||
trackCount: number;
|
||||
totalTrackCount: number;
|
||||
sizeOnDisk: number;
|
||||
percentOfTracks: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchCommand extends Record<string, unknown> {
|
||||
name: 'AlbumSearch';
|
||||
albumIds: number[];
|
||||
}
|
||||
|
||||
export interface MetadataProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
class LidarrAPI extends ServarrBase<{ albumId: number }> {
|
||||
protected apiKey: string;
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' });
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public async getAlbums(): Promise<LidarrAlbum[]> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbum[]>('/album');
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getAlbum({ id }: { id: number }): Promise<LidarrAlbum> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbum>(`/album/${id}`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async removeAlbum(albumId: number): Promise<void> {
|
||||
try {
|
||||
await this.axios.delete(`/album/${albumId}`, {
|
||||
params: {
|
||||
deleteFiles: 'true',
|
||||
addImportExclusion: 'false',
|
||||
},
|
||||
});
|
||||
logger.info(`[Lidarr] Removed album ${albumId}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to remove album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchAlbum(mbid: string): Promise<LidarrAlbumResult[]> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbumResult[]>('/search', {
|
||||
params: {
|
||||
term: `lidarr:${mbid}`,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to search album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async addAlbum(options: LidarrAlbumOptions): Promise<LidarrAlbum> {
|
||||
try {
|
||||
const existingAlbums = await this.get<LidarrAlbum[]>('/album', {
|
||||
params: {
|
||||
foreignAlbumId: options.foreignAlbumId,
|
||||
includeAllArtistAlbums: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAlbums.length > 0 && existingAlbums[0].monitored) {
|
||||
logger.info(
|
||||
'Album is already monitored in Lidarr. Skipping add and returning success',
|
||||
{
|
||||
label: 'Lidarr',
|
||||
}
|
||||
);
|
||||
return existingAlbums[0];
|
||||
}
|
||||
|
||||
if (existingAlbums.length > 0) {
|
||||
logger.info(
|
||||
'Album exists in Lidarr but is not monitored. Updating monitored status.',
|
||||
{
|
||||
label: 'Lidarr',
|
||||
albumId: existingAlbums[0].id,
|
||||
albumTitle: existingAlbums[0].title,
|
||||
}
|
||||
);
|
||||
|
||||
const updatedAlbum = await this.axios.put<LidarrAlbum>(
|
||||
`/album/${existingAlbums[0].id}`,
|
||||
{
|
||||
...existingAlbums[0],
|
||||
monitored: true,
|
||||
}
|
||||
);
|
||||
|
||||
await this.post('/command', {
|
||||
name: 'AlbumSearch',
|
||||
albumIds: [updatedAlbum.data.id],
|
||||
});
|
||||
|
||||
return updatedAlbum.data;
|
||||
}
|
||||
|
||||
const data = await this.post<LidarrAlbum>('/album', {
|
||||
...options,
|
||||
monitored: true,
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Lidarr] Failed to add album: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchAlbumByMusicBrainzId(
|
||||
mbid: string
|
||||
): Promise<LidarrAlbumResult[]> {
|
||||
try {
|
||||
const data = await this.get<LidarrAlbumResult[]>('/search', {
|
||||
params: {
|
||||
term: `lidarr:${mbid}`,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[Lidarr] Failed to search album by MusicBrainz ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMetadataProfiles(): Promise<MetadataProfile[]> {
|
||||
try {
|
||||
const data = await this.get<MetadataProfile[]>('/metadataProfile');
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[Lidarr] Failed to retrieve metadata profiles: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LidarrAPI;
|
||||
219
server/api/theaudiodb/index.ts
Normal file
219
server/api/theaudiodb/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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 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: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 25,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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`,
|
||||
{ params: { i: id } },
|
||||
this.CACHE_TTL
|
||||
);
|
||||
|
||||
const result = {
|
||||
artistThumb: data.artists?.[0]?.strArtistThumb || null,
|
||||
artistBackground: data.artists?.[0]?.strArtistFanart || null,
|
||||
};
|
||||
|
||||
const metadataRepository = getRepository(MetadataArtist);
|
||||
await metadataRepository
|
||||
.upsert(
|
||||
{
|
||||
mbArtistId: id,
|
||||
tadbThumb: result.artistThumb,
|
||||
tadbCover: result.artistBackground,
|
||||
tadbUpdatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
conflictPaths: ['mbArtistId'],
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error('Failed to save artist metadata', {
|
||||
label: 'TheAudioDb',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await getRepository(MetadataArtist).upsert(
|
||||
{
|
||||
mbArtistId: id,
|
||||
tadbThumb: null,
|
||||
tadbCover: null,
|
||||
tadbUpdatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
conflictPaths: ['mbArtistId'],
|
||||
}
|
||||
);
|
||||
return this.createEmptyResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async batchGetArtistImages(ids: string[]): Promise<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
artistThumb: string | null;
|
||||
artistBackground: string | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
if (!ids.length) return {};
|
||||
|
||||
const metadataRepository = getRepository(MetadataArtist);
|
||||
const existingMetadata = await metadataRepository.find({
|
||||
where: { mbArtistId: In(ids) },
|
||||
select: ['mbArtistId', 'tadbThumb', 'tadbCover', 'tadbUpdatedAt'],
|
||||
});
|
||||
|
||||
const results: Record<
|
||||
string,
|
||||
{
|
||||
artistThumb: string | null;
|
||||
artistBackground: string | null;
|
||||
}
|
||||
> = {};
|
||||
const idsToFetch: string[] = [];
|
||||
|
||||
ids.forEach((id) => {
|
||||
const metadata = existingMetadata.find((m) => m.mbArtistId === id);
|
||||
|
||||
if (metadata?.tadbThumb || metadata?.tadbCover) {
|
||||
results[id] = {
|
||||
artistThumb: metadata.tadbThumb,
|
||||
artistBackground: metadata.tadbCover,
|
||||
};
|
||||
} else if (metadata && !this.isMetadataStale(metadata)) {
|
||||
results[id] = {
|
||||
artistThumb: null,
|
||||
artistBackground: null,
|
||||
};
|
||||
} else {
|
||||
idsToFetch.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (idsToFetch.length > 0) {
|
||||
const batchPromises = idsToFetch.map((id) =>
|
||||
this.fetchArtistImages(id)
|
||||
.then((response) => {
|
||||
results[id] = response;
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
results[id] = {
|
||||
artistThumb: null,
|
||||
artistBackground: null,
|
||||
};
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export default TheAudioDb;
|
||||
8
server/api/theaudiodb/interfaces.ts
Normal file
8
server/api/theaudiodb/interfaces.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
interface TadbArtist {
|
||||
strArtistThumb: string | null;
|
||||
strArtistFanart: string | null;
|
||||
}
|
||||
|
||||
export interface TadbArtistResponse {
|
||||
artists?: TadbArtist[];
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface TmdbCollectionResult {
|
||||
|
||||
export interface TmdbPersonResult {
|
||||
id: number;
|
||||
known_for_department: string;
|
||||
name: string;
|
||||
popularity: number;
|
||||
profile_path?: string;
|
||||
@@ -464,6 +465,10 @@ export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbCompany[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchPersonResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbPersonResult[];
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviderRegion {
|
||||
iso_3166_1: string;
|
||||
english_name: string;
|
||||
|
||||
341
server/api/themoviedb/personMapper.ts
Normal file
341
server/api/themoviedb/personMapper.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
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 readonly CACHE_TTL = 43200;
|
||||
private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000;
|
||||
private tmdb: TheMovieDb;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'https://api.themoviedb.org/3',
|
||||
{
|
||||
api_key: '431a8708161bcd1f1fbe7536137e61ed',
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('tmdb').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
}
|
||||
);
|
||||
this.tmdb = new TheMovieDb();
|
||||
}
|
||||
|
||||
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',
|
||||
{
|
||||
params: {
|
||||
query: cleanArtistName,
|
||||
page: '1',
|
||||
include_adult: 'false',
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
this.CACHE_TTL
|
||||
);
|
||||
|
||||
const normalizeName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/['′]/g, "'")
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const exactMatches = searchResults.results.filter((person) => {
|
||||
const normalizedPersonName = normalizeName(person.name);
|
||||
const normalizedArtistName = normalizeName(cleanArtistName);
|
||||
|
||||
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',
|
||||
{
|
||||
params: {
|
||||
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;
|
||||
@@ -22,6 +22,8 @@ export enum DiscoverSliderType {
|
||||
TMDB_NETWORK,
|
||||
TMDB_MOVIE_STREAMING_SERVICES,
|
||||
TMDB_TV_STREAMING_SERVICES,
|
||||
POPULAR_ALBUMS,
|
||||
POPULAR_ARTISTS,
|
||||
}
|
||||
|
||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
@@ -97,4 +99,16 @@ export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
isBuiltIn: true,
|
||||
order: 11,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_ALBUMS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 12,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_ARTISTS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 13,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum IssueType {
|
||||
AUDIO = 2,
|
||||
SUBTITLES = 3,
|
||||
OTHER = 4,
|
||||
LYRICS = 5,
|
||||
}
|
||||
|
||||
export enum IssueStatus {
|
||||
@@ -15,4 +16,5 @@ export const IssueTypeName = {
|
||||
[IssueType.VIDEO]: 'Video',
|
||||
[IssueType.SUBTITLES]: 'Subtitle',
|
||||
[IssueType.OTHER]: 'Other',
|
||||
[IssueType.LYRICS]: 'Lyrics',
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum MediaRequestStatus {
|
||||
export enum MediaType {
|
||||
MOVIE = 'movie',
|
||||
TV = 'tv',
|
||||
MUSIC = 'music',
|
||||
}
|
||||
|
||||
export enum MediaStatus {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||
|
||||
@Entity()
|
||||
@Unique(['tmdbId'])
|
||||
@Unique(['tmdbId', 'mbId'])
|
||||
export class Blacklist implements BlacklistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
@@ -29,9 +29,13 @@ export class Blacklist implements BlacklistItem {
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
title?: string;
|
||||
|
||||
@Column()
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
public tmdbId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public mbId?: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.id, {
|
||||
eager: true,
|
||||
@@ -61,7 +65,8 @@ export class Blacklist implements BlacklistItem {
|
||||
blacklistRequest: {
|
||||
mediaType: MediaType;
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
tmdbId?: ZodNumber['_output'];
|
||||
mbId?: ZodOptional<ZodString>['_output'];
|
||||
blacklistedTags?: string;
|
||||
};
|
||||
},
|
||||
@@ -74,9 +79,10 @@ export class Blacklist implements BlacklistItem {
|
||||
|
||||
const mediaRepository = em.getRepository(Media);
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
},
|
||||
where:
|
||||
blacklistRequest.mediaType === 'music'
|
||||
? { mbId: blacklistRequest.mbId }
|
||||
: { tmdbId: blacklistRequest.tmdbId },
|
||||
});
|
||||
|
||||
const blacklistRepository = em.getRepository(this);
|
||||
@@ -86,6 +92,7 @@ export class Blacklist implements BlacklistItem {
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
mbId: blacklistRequest.mbId,
|
||||
status: MediaStatus.BLACKLISTED,
|
||||
status4k: MediaStatus.BLACKLISTED,
|
||||
mediaType: blacklistRequest.mediaType,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
@@ -29,16 +30,16 @@ import Season from './Season';
|
||||
class Media {
|
||||
public static async getRelatedMedia(
|
||||
user: User | undefined,
|
||||
tmdbIds: number | number[]
|
||||
ids: number | number[] | string | string[]
|
||||
): Promise<Media[]> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
let finalIds: number[];
|
||||
if (!Array.isArray(tmdbIds)) {
|
||||
finalIds = [tmdbIds];
|
||||
let finalIds: (number | string)[];
|
||||
if (!Array.isArray(ids)) {
|
||||
finalIds = [ids];
|
||||
} else {
|
||||
finalIds = tmdbIds;
|
||||
finalIds = ids;
|
||||
}
|
||||
|
||||
if (finalIds.length === 0) {
|
||||
@@ -50,10 +51,15 @@ class Media {
|
||||
.leftJoinAndSelect(
|
||||
'media.watchlists',
|
||||
'watchlist',
|
||||
'media.id= watchlist.media and watchlist.requestedBy = :userId',
|
||||
'media.id = watchlist.media and watchlist.requestedBy = :userId',
|
||||
{ userId: user?.id }
|
||||
) //,
|
||||
.where(' media.tmdbId in (:...finalIds)', { finalIds })
|
||||
)
|
||||
.where(
|
||||
typeof finalIds[0] === 'string'
|
||||
? 'media.mbId IN (:...finalIds)'
|
||||
: 'media.tmdbId IN (:...finalIds)',
|
||||
{ finalIds }
|
||||
)
|
||||
.getMany();
|
||||
|
||||
return media;
|
||||
@@ -64,14 +70,17 @@ class Media {
|
||||
}
|
||||
|
||||
public static async getMedia(
|
||||
id: number,
|
||||
id: number | string,
|
||||
mediaType: MediaType
|
||||
): Promise<Media | undefined> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id, mediaType: mediaType },
|
||||
where:
|
||||
typeof id === 'string'
|
||||
? { mbId: id, mediaType }
|
||||
: { tmdbId: id, mediaType },
|
||||
relations: { requests: true, issues: true },
|
||||
});
|
||||
|
||||
@@ -88,7 +97,7 @@ class Media {
|
||||
@Column({ type: 'varchar' })
|
||||
public mediaType: MediaType;
|
||||
|
||||
@Column()
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
|
||||
@@ -100,6 +109,10 @@ class Media {
|
||||
@Index()
|
||||
public imdbId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public mbId?: string;
|
||||
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status: MediaStatus;
|
||||
|
||||
@@ -155,7 +168,7 @@ class Media {
|
||||
})
|
||||
public mediaAddedAt: Date;
|
||||
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
@Column({ nullable: false, type: 'int', default: 0 })
|
||||
public serviceId?: number | null;
|
||||
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
@@ -319,6 +332,21 @@ class Media {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaType === MediaType.MUSIC) {
|
||||
if (this.serviceId !== null && this.externalServiceSlug !== null) {
|
||||
const settings = getSettings();
|
||||
const server = settings.lidarr.find(
|
||||
(lidarr) => lidarr.id === this.serviceId
|
||||
);
|
||||
|
||||
if (server) {
|
||||
this.serviceUrl = server.externalUrl
|
||||
? `${server.externalUrl}/album/${this.externalServiceSlug}`
|
||||
: LidarrAPI.buildUrl(server, `/album/${this.externalServiceSlug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
@@ -374,6 +402,20 @@ class Media {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaType === MediaType.MUSIC) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getMusicProgress(
|
||||
this.serviceId,
|
||||
this.externalServiceId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import CoverArtArchive from '@server/api/coverartarchive';
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces';
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
@@ -48,6 +56,7 @@ export class MediaRequest {
|
||||
options: MediaRequestOptions = {}
|
||||
): Promise<MediaRequest> {
|
||||
const tmdb = new TheMovieDb();
|
||||
const listenBrainz = new ListenBrainzAPI();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
@@ -115,25 +124,55 @@ export class MediaRequest {
|
||||
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
||||
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
||||
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||
} else if (
|
||||
requestBody.mediaType === MediaType.MUSIC &&
|
||||
quotas.music.restricted
|
||||
) {
|
||||
throw new QuotaRestrictedError('Music Quota exceeded.');
|
||||
}
|
||||
|
||||
const tmdbMedia =
|
||||
const requestedMedia =
|
||||
requestBody.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
||||
: requestBody.mediaType === MediaType.TV
|
||||
? await tmdb.getTvShow({ tvId: requestBody.mediaId })
|
||||
: await listenBrainz.getAlbum(requestBody.mediaId.toString());
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: requestBody.mediaId,
|
||||
mediaType: requestBody.mediaType,
|
||||
},
|
||||
where:
|
||||
requestBody.mediaType === MediaType.MUSIC
|
||||
? {
|
||||
mbId: requestBody.mediaId.toString(),
|
||||
mediaType: requestBody.mediaType,
|
||||
}
|
||||
: {
|
||||
tmdbId: requestBody.mediaId,
|
||||
mediaType: requestBody.mediaType,
|
||||
},
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
const isTmdbMedia = (
|
||||
media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails
|
||||
): media is TmdbMovieDetails | TmdbTvDetails => {
|
||||
return 'id' in media;
|
||||
};
|
||||
|
||||
const isLbAlbum = (
|
||||
media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails
|
||||
): media is LbAlbumDetails => {
|
||||
return 'release_group_mbid' in media;
|
||||
};
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
||||
tmdbId: isTmdbMedia(requestedMedia) ? requestedMedia.id : undefined,
|
||||
mbId: isLbAlbum(requestedMedia)
|
||||
? requestedMedia.release_group_mbid
|
||||
: undefined,
|
||||
tvdbId: isTmdbMedia(requestedMedia)
|
||||
? requestBody.tvdbId ?? requestedMedia.external_ids?.tvdb_id
|
||||
: undefined,
|
||||
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
mediaType: requestBody.mediaType,
|
||||
@@ -141,7 +180,9 @@ export class MediaRequest {
|
||||
} else {
|
||||
if (media.status === MediaStatus.BLACKLISTED) {
|
||||
logger.warn('Request for media blocked due to being blacklisted', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
id: isLbAlbum(requestedMedia)
|
||||
? requestedMedia.release_group_mbid
|
||||
: requestedMedia.id,
|
||||
mediaType: requestBody.mediaType,
|
||||
label: 'Media Request',
|
||||
});
|
||||
@@ -163,7 +204,21 @@ export class MediaRequest {
|
||||
.leftJoin('request.media', 'media')
|
||||
.leftJoinAndSelect('request.requestedBy', 'user')
|
||||
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
||||
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
||||
.andWhere(
|
||||
requestBody.mediaType === 'music'
|
||||
? 'media.mbId = :mbId'
|
||||
: 'media.tmdbId = :tmdbId',
|
||||
requestBody.mediaType === 'music'
|
||||
? {
|
||||
mbId: (requestedMedia as { release_group_mbid: string })
|
||||
.release_group_mbid,
|
||||
}
|
||||
: {
|
||||
tmdbId: isTmdbMedia(requestedMedia)
|
||||
? requestedMedia.id
|
||||
: undefined,
|
||||
}
|
||||
)
|
||||
.andWhere('media.mediaType = :mediaType', {
|
||||
mediaType: requestBody.mediaType,
|
||||
})
|
||||
@@ -172,12 +227,16 @@ export class MediaRequest {
|
||||
if (existing && existing.length > 0) {
|
||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||
if (
|
||||
requestBody.mediaType === MediaType.MOVIE &&
|
||||
(requestBody.mediaType === MediaType.MOVIE ||
|
||||
requestBody.mediaType === MediaType.MUSIC) &&
|
||||
existing[0].status !== MediaRequestStatus.DECLINED &&
|
||||
existing[0].status !== MediaRequestStatus.COMPLETED
|
||||
) {
|
||||
logger.warn('Duplicate request for media blocked', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
id:
|
||||
requestBody.mediaType === MediaType.MUSIC
|
||||
? media.mbId
|
||||
: (requestedMedia as TmdbMovieDetails | TmdbTvDetails).id,
|
||||
mediaType: requestBody.mediaType,
|
||||
is4k: requestBody.is4k,
|
||||
label: 'Media Request',
|
||||
@@ -217,32 +276,78 @@ export class MediaRequest {
|
||||
const defaultSonarrId = requestBody.is4k
|
||||
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
||||
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
|
||||
const defaultLidarrId = settings.lidarr.findIndex((l) => l.isDefault);
|
||||
|
||||
const overrideRuleRepository = getRepository(OverrideRule);
|
||||
const overrideRules = await overrideRuleRepository.find({
|
||||
where:
|
||||
requestBody.mediaType === MediaType.MOVIE
|
||||
? { radarrServiceId: defaultRadarrId }
|
||||
: { sonarrServiceId: defaultSonarrId },
|
||||
: requestBody.mediaType === MediaType.TV
|
||||
? { sonarrServiceId: defaultSonarrId }
|
||||
: { lidarrServiceId: defaultLidarrId },
|
||||
});
|
||||
|
||||
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||
const hasAnimeKeyword =
|
||||
'results' in tmdbMedia.keywords &&
|
||||
tmdbMedia.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
);
|
||||
// Only apply keyword/genre rules for TMDB media
|
||||
if (isTmdbMedia(requestedMedia)) {
|
||||
const hasAnimeKeyword =
|
||||
'results' in requestedMedia.keywords &&
|
||||
requestedMedia.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
);
|
||||
|
||||
// Skip override rules if the media is an anime TV show as anime TV
|
||||
// is handled by default and override rules do not explicitly include
|
||||
// the anime keyword
|
||||
if (
|
||||
requestBody.mediaType === MediaType.TV &&
|
||||
hasAnimeKeyword &&
|
||||
(!rule.keywords ||
|
||||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
||||
) {
|
||||
return false;
|
||||
if (
|
||||
requestBody.mediaType === MediaType.TV &&
|
||||
hasAnimeKeyword &&
|
||||
(!rule.keywords ||
|
||||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.genre &&
|
||||
!rule.genre
|
||||
.split(',')
|
||||
.some((genreId) =>
|
||||
requestedMedia.genres
|
||||
.map((genre) => genre.id)
|
||||
.includes(Number(genreId))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.language &&
|
||||
!rule.language
|
||||
.split('|')
|
||||
.some(
|
||||
(languageId) => languageId === requestedMedia.original_language
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.keywords &&
|
||||
!rule.keywords.split(',').some((keywordId) => {
|
||||
let keywordList: TmdbKeyword[] = [];
|
||||
|
||||
if ('keywords' in requestedMedia.keywords) {
|
||||
keywordList = requestedMedia.keywords.keywords;
|
||||
} else if ('results' in requestedMedia.keywords) {
|
||||
keywordList = requestedMedia.keywords.results;
|
||||
}
|
||||
|
||||
return keywordList
|
||||
.map((keyword: TmdbKeyword) => keyword.id)
|
||||
.includes(Number(keywordId));
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -253,44 +358,7 @@ export class MediaRequest {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.genre &&
|
||||
!rule.genre
|
||||
.split(',')
|
||||
.some((genreId) =>
|
||||
tmdbMedia.genres
|
||||
.map((genre) => genre.id)
|
||||
.includes(Number(genreId))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.language &&
|
||||
!rule.language
|
||||
.split('|')
|
||||
.some((languageId) => languageId === tmdbMedia.original_language)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.keywords &&
|
||||
!rule.keywords.split(',').some((keywordId) => {
|
||||
let keywordList: TmdbKeyword[] = [];
|
||||
|
||||
if ('keywords' in tmdbMedia.keywords) {
|
||||
keywordList = tmdbMedia.keywords.keywords;
|
||||
} else if ('results' in tmdbMedia.keywords) {
|
||||
keywordList = tmdbMedia.keywords.results;
|
||||
}
|
||||
|
||||
return keywordList
|
||||
.map((keyword: TmdbKeyword) => keyword.id)
|
||||
.includes(Number(keywordId));
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -373,10 +441,47 @@ export class MediaRequest {
|
||||
isAutoRequest: options.isAutoRequest ?? false,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return request;
|
||||
} else if (requestBody.mediaType === MediaType.MUSIC) {
|
||||
await mediaRepository.save(media);
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.MUSIC,
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the music auto approve permission, automatically approve the request
|
||||
status: user.hasPermission(
|
||||
[
|
||||
Permission.AUTO_APPROVE,
|
||||
Permission.AUTO_APPROVE_MUSIC,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: user.hasPermission(
|
||||
[
|
||||
Permission.AUTO_APPROVE,
|
||||
Permission.AUTO_APPROVE_MUSIC,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? user
|
||||
: undefined,
|
||||
serverId: requestBody.serverId,
|
||||
profileId: profileId,
|
||||
rootFolder: rootFolder,
|
||||
tags: tags,
|
||||
isAutoRequest: options.isAutoRequest ?? false,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
return request;
|
||||
} else {
|
||||
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||
const tmdbMediaShow = requestedMedia as Awaited<
|
||||
ReturnType<typeof tmdb.getTvShow>
|
||||
>;
|
||||
let requestedSeasons =
|
||||
@@ -715,9 +820,17 @@ export class MediaRequest {
|
||||
type: Notification
|
||||
) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const coverArt = new CoverArtArchive();
|
||||
const musicbrainz = new MusicBrainz();
|
||||
|
||||
try {
|
||||
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||
const mediaType =
|
||||
entity.type === MediaType.MOVIE
|
||||
? 'Movie'
|
||||
: entity.type === MediaType.TV
|
||||
? 'Series'
|
||||
: 'Album';
|
||||
let event: string | undefined;
|
||||
let notifyAdmin = true;
|
||||
let notifySystem = true;
|
||||
@@ -797,6 +910,34 @@ export class MediaRequest {
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (entity.type === MediaType.MUSIC && media.mbId) {
|
||||
const album = await listenbrainz.getAlbum(media.mbId);
|
||||
const coverArtResponse = await coverArt.getCoverArt(media.mbId);
|
||||
const coverArtUrl =
|
||||
coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||
const artistId =
|
||||
album.release_group_metadata?.artist?.artists[0]?.artist_mbid;
|
||||
const artistWiki = artistId
|
||||
? await musicbrainz.getArtistWikipediaExtract({
|
||||
artistMbid: artistId,
|
||||
})
|
||||
: null;
|
||||
|
||||
notificationManager.sendNotification(type, {
|
||||
media,
|
||||
request: entity,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||
event,
|
||||
subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`,
|
||||
message: truncate(artistWiki?.content ?? '', {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
image: coverArtUrl,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
|
||||
31
server/entity/MetadataAlbum.ts
Normal file
31
server/entity/MetadataAlbum.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
class MetadataAlbum {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
public mbAlbumId: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public caaUrl: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<MetadataAlbum>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default MetadataAlbum;
|
||||
43
server/entity/MetadataArtist.ts
Normal file
43
server/entity/MetadataArtist.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
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;
|
||||
|
||||
@DbAwareColumn({ 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;
|
||||
|
||||
@DbAwareColumn({ nullable: true, type: 'datetime' })
|
||||
public tadbUpdatedAt: Date | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
constructor(init?: Partial<MetadataArtist>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default MetadataArtist;
|
||||
@@ -12,6 +12,9 @@ class OverrideRule {
|
||||
@Column({ type: 'int', nullable: true })
|
||||
public sonarrServiceId?: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
public lidarrServiceId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public users?: string;
|
||||
|
||||
|
||||
@@ -124,6 +124,12 @@ export class User {
|
||||
@Column({ nullable: true })
|
||||
public tvQuotaDays?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public musicQuotaLimit?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public musicQuotaDays?: number;
|
||||
|
||||
@OneToOne(() => UserSettings, (settings) => settings.user, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
@@ -334,6 +340,30 @@ export class User {
|
||||
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
|
||||
: 0;
|
||||
|
||||
const musicQuotaLimit = !canBypass
|
||||
? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit
|
||||
: 0;
|
||||
const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays;
|
||||
|
||||
// Count music requests made during quota period
|
||||
const musicDate = new Date();
|
||||
if (musicQuotaDays) {
|
||||
musicDate.setDate(musicDate.getDate() - musicQuotaDays);
|
||||
}
|
||||
|
||||
const musicQuotaUsed = musicQuotaLimit
|
||||
? await requestRepository.count({
|
||||
where: {
|
||||
requestedBy: {
|
||||
id: this.id,
|
||||
},
|
||||
createdAt: AfterDate(musicDate),
|
||||
type: MediaType.MUSIC,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
|
||||
return {
|
||||
movie: {
|
||||
days: movieQuotaDays,
|
||||
@@ -357,6 +387,18 @@ export class User {
|
||||
restricted:
|
||||
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
|
||||
},
|
||||
music: {
|
||||
days: musicQuotaDays,
|
||||
limit: musicQuotaLimit,
|
||||
used: musicQuotaUsed,
|
||||
remaining: musicQuotaLimit
|
||||
? Math.max(0, musicQuotaLimit - musicQuotaUsed)
|
||||
: undefined,
|
||||
restricted:
|
||||
musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0
|
||||
? true
|
||||
: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export class NotFoundError extends Error {
|
||||
|
||||
@Entity()
|
||||
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
|
||||
@Unique('UNIQUE_USER_FOREIGN', ['mbId', 'requestedBy'])
|
||||
export class Watchlist implements WatchlistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@@ -39,9 +40,13 @@ export class Watchlist implements WatchlistItem {
|
||||
@Column({ type: 'varchar' })
|
||||
title = '';
|
||||
|
||||
@Column()
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
public tmdbId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public mbId?: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.watchlists, {
|
||||
eager: true,
|
||||
@@ -52,6 +57,7 @@ export class Watchlist implements WatchlistItem {
|
||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
nullable: false,
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@@ -77,7 +83,8 @@ export class Watchlist implements WatchlistItem {
|
||||
mediaType: MediaType;
|
||||
ratingKey?: ZodOptional<ZodString>['_output'];
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
tmdbId?: ZodNumber['_output'];
|
||||
mbId?: ZodOptional<ZodString>['_output'];
|
||||
};
|
||||
user: User;
|
||||
}): Promise<Watchlist> {
|
||||
@@ -85,46 +92,88 @@ export class Watchlist implements WatchlistItem {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const tmdbMedia =
|
||||
watchlistRequest.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||
let media: Media | null;
|
||||
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
})
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
if (watchlistRequest.mediaType === MediaType.MUSIC) {
|
||||
if (!watchlistRequest.mbId) {
|
||||
throw new Error('MusicBrainz ID is required for music media type');
|
||||
}
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.mbId = :mbId', { mbId: watchlistRequest.mbId })
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
mbId: watchlistRequest.mbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
|
||||
media = await mediaRepository.findOne({
|
||||
where: { mbId: watchlistRequest.mbId, mediaType: MediaType.MUSIC },
|
||||
});
|
||||
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
mbId: watchlistRequest.mbId,
|
||||
mediaType: MediaType.MUSIC,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For movies/TV, validate tmdbId exists
|
||||
if (!watchlistRequest.tmdbId) {
|
||||
throw new Error('TMDB ID is required for movie/TV media types');
|
||||
}
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
},
|
||||
});
|
||||
const tmdbMedia =
|
||||
watchlistRequest.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
})
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
|
||||
media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const watchlist = new this({
|
||||
@@ -139,14 +188,19 @@ export class Watchlist implements WatchlistItem {
|
||||
}
|
||||
|
||||
public static async deleteWatchlist(
|
||||
tmdbId: Watchlist['tmdbId'],
|
||||
id: Watchlist['tmdbId'] | Watchlist['mbId'],
|
||||
user: User
|
||||
): Promise<Watchlist | null> {
|
||||
const watchlistRepository = getRepository(this);
|
||||
const watchlist = await watchlistRepository.findOneBy({
|
||||
tmdbId,
|
||||
requestedBy: { id: user.id },
|
||||
});
|
||||
|
||||
// Check if the ID is a number (TMDB) or string (MusicBrainz)
|
||||
const whereClause =
|
||||
typeof id === 'number'
|
||||
? { tmdbId: id, requestedBy: { id: user.id } }
|
||||
: { mbId: id, requestedBy: { id: user.id } };
|
||||
|
||||
const watchlist = await watchlistRepository.findOneBy(whereClause);
|
||||
|
||||
if (!watchlist) {
|
||||
throw new NotFoundError('not Found');
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ import logger from '@server/logger';
|
||||
import clearCookies from '@server/middleware/clearcookies';
|
||||
import routes from '@server/routes';
|
||||
import avatarproxy from '@server/routes/avatarproxy';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import caaproxy from '@server/routes/caaproxy';
|
||||
import tadbproxy from '@server/routes/tadbproxy';
|
||||
import tmdbproxy from '@server/routes/tmdbproxy';
|
||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||
@@ -235,8 +237,10 @@ app
|
||||
server.use('/api/v1', routes);
|
||||
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
server.use('/tmdbproxy', clearCookies, tmdbproxy);
|
||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
||||
server.use('/caaproxy', clearCookies, caaproxy);
|
||||
server.use('/tadbproxy', clearCookies, tadbproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
||||
|
||||
export interface BlacklistItem {
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
tmdbId?: number;
|
||||
mbId?: string;
|
||||
mediaType: 'movie' | 'tv' | 'music';
|
||||
title?: string;
|
||||
createdAt?: Date;
|
||||
user?: User;
|
||||
|
||||
@@ -7,8 +7,9 @@ export interface GenreSliderItem {
|
||||
export interface WatchlistItem {
|
||||
id: number;
|
||||
ratingKey: string;
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
tmdbId?: number;
|
||||
mbId?: string;
|
||||
mediaType: 'movie' | 'tv' | 'music';
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { LanguageProfile } from '@server/api/servarr/sonarr';
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
name: string;
|
||||
is4k: boolean;
|
||||
is4k?: boolean;
|
||||
isDefault: boolean;
|
||||
activeProfileId: number;
|
||||
activeDirectory: string;
|
||||
|
||||
@@ -64,7 +64,10 @@ export interface CacheItem {
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
imageCache: Record<
|
||||
'tmdb' | 'avatar' | 'caa' | 'tadb',
|
||||
{ size: number; imageCount: number }
|
||||
>;
|
||||
dnsCache: {
|
||||
stats: DnsStats | undefined;
|
||||
entries: DnsEntries | undefined;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface QuotaStatus {
|
||||
export interface QuotaResponse {
|
||||
movie: QuotaStatus;
|
||||
tv: QuotaStatus;
|
||||
music: QuotaStatus;
|
||||
}
|
||||
|
||||
export interface UserWatchDataResponse {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const watchlistCreate = z.object({
|
||||
ratingKey: z.coerce.string().optional(),
|
||||
tmdbId: z.coerce.number(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
});
|
||||
export const watchlistCreate = z
|
||||
.object({
|
||||
ratingKey: z.coerce.string().optional(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
})
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ tmdbId: z.coerce.number() }),
|
||||
z.object({ mbId: z.coerce.string() }),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
jellyfinFullScanner,
|
||||
jellyfinRecentScanner,
|
||||
} from '@server/lib/scanners/jellyfin';
|
||||
import { lidarrScanner } from '@server/lib/scanners/lidarr';
|
||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||
@@ -172,6 +173,21 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => sonarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full lidarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'lidarr-scan',
|
||||
name: 'Lidarr Scan',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['lidarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['lidarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: lidarr Scan', { label: 'Jobs' });
|
||||
lidarrScanner.run();
|
||||
}),
|
||||
running: () => lidarrScanner.status().running,
|
||||
cancelFn: () => lidarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Checks if media is still available in plex/sonarr/radarr libs
|
||||
scheduledJobs.push({
|
||||
id: 'availability-sync',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import type { PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import LidarrAPI, { type LidarrAlbum } from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
||||
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
@@ -12,7 +13,11 @@ import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import type Season from '@server/entity/Season';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import type {
|
||||
LidarrSettings,
|
||||
RadarrSettings,
|
||||
SonarrSettings,
|
||||
} from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
@@ -28,6 +33,7 @@ class AvailabilitySync {
|
||||
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
||||
private radarrServers: RadarrSettings[];
|
||||
private sonarrServers: SonarrSettings[];
|
||||
private lidarrServers: LidarrSettings[];
|
||||
|
||||
async run() {
|
||||
const settings = getSettings();
|
||||
@@ -38,6 +44,7 @@ class AvailabilitySync {
|
||||
this.sonarrSeasonsCache = {};
|
||||
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||
this.lidarrServers = settings.lidarr.filter((server) => server.syncEnabled);
|
||||
|
||||
try {
|
||||
logger.info(`Starting availability sync...`, {
|
||||
@@ -451,6 +458,47 @@ class AvailabilitySync {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (media.mediaType === 'music') {
|
||||
let musicExists = false;
|
||||
|
||||
const existsInLidarr = await this.mediaExistsInLidarr(media);
|
||||
|
||||
// Check media server existence (Plex/Jellyfin/Emby)
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
||||
if (existsInPlex || existsInLidarr) {
|
||||
musicExists = true;
|
||||
logger.info(
|
||||
`The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
const { existsInJellyfin } = await this.mediaExistsInJellyfin(
|
||||
media,
|
||||
false
|
||||
);
|
||||
if (existsInJellyfin || existsInLidarr) {
|
||||
musicExists = true;
|
||||
logger.info(
|
||||
`The album [Foreign ID ${media.mbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!musicExists && media.status === MediaStatus.AVAILABLE) {
|
||||
await this.mediaUpdater(media, false, mediaServerType);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.error('Failed to complete availability sync.', {
|
||||
@@ -558,11 +606,23 @@ class AvailabilitySync {
|
||||
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||
: null;
|
||||
}
|
||||
|
||||
// Update log message to include music media type
|
||||
logger.info(
|
||||
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'movie' ? 'movie' : 'show'
|
||||
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
||||
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}] was not found in any ${
|
||||
media.mediaType === 'movie'
|
||||
? 'Radarr'
|
||||
: media.mediaType === 'tv'
|
||||
? 'Sonarr'
|
||||
: 'Lidarr'
|
||||
} and ${
|
||||
mediaServerType === MediaServerType.PLEX
|
||||
? 'plex'
|
||||
@@ -577,8 +637,14 @@ class AvailabilitySync {
|
||||
} catch (ex) {
|
||||
logger.debug(
|
||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}].`,
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}].`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
@@ -838,6 +904,51 @@ class AvailabilitySync {
|
||||
return seasonExists;
|
||||
}
|
||||
|
||||
private async mediaExistsInLidarr(media: Media): Promise<boolean> {
|
||||
let existsInLidarr = false;
|
||||
|
||||
// Check for availability in all configured Lidarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.lidarrServers) {
|
||||
const lidarrAPI = new LidarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||
});
|
||||
|
||||
try {
|
||||
let lidarr: LidarrAlbum | undefined;
|
||||
|
||||
if (media.externalServiceId) {
|
||||
lidarr = await lidarrAPI.getAlbum({
|
||||
id: media.externalServiceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
lidarr?.statistics &&
|
||||
lidarr.statistics.totalTrackCount > 0 &&
|
||||
lidarr.statistics.trackFileCount === lidarr.statistics.totalTrackCount
|
||||
) {
|
||||
existsInLidarr = true;
|
||||
break;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInLidarr = true;
|
||||
logger.debug(
|
||||
`Failed to retrieve album [Foreign ID ${media.mbId}] from Lidarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return existsInLidarr;
|
||||
}
|
||||
|
||||
// Plex
|
||||
private async mediaExistsInPlex(
|
||||
media: Media,
|
||||
@@ -881,8 +992,14 @@ class AvailabilitySync {
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}] from Plex.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
@@ -993,13 +1110,19 @@ class AvailabilitySync {
|
||||
existsInJellyfin = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404' || '500')) {
|
||||
if (!ex.message.includes('404') && !ex.message.includes('500')) {
|
||||
existsInJellyfin = false;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] from Jellyfin.`,
|
||||
media.mediaType === 'movie'
|
||||
? 'movie'
|
||||
: media.mediaType === 'tv'
|
||||
? 'show'
|
||||
: 'album'
|
||||
} [${media.mediaType === 'music' ? 'Foreign ID' : 'TMDB ID'} ${
|
||||
media.mediaType === 'music' ? media.mbId : media.tmdbId
|
||||
}] from Jellyfin.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
|
||||
@@ -2,8 +2,13 @@ import NodeCache from 'node-cache';
|
||||
|
||||
export type AvailableCacheIds =
|
||||
| 'tmdb'
|
||||
| 'musicbrainz'
|
||||
| 'listenbrainz'
|
||||
| 'covertartarchive'
|
||||
| 'tadb'
|
||||
| 'radarr'
|
||||
| 'sonarr'
|
||||
| 'lidarr'
|
||||
| 'rt'
|
||||
| 'imdb'
|
||||
| 'github'
|
||||
@@ -48,8 +53,25 @@ class CacheManager {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
musicbrainz: new Cache('musicbrainz', 'MusicBrainz API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
listenbrainz: new Cache('listenbrainz', 'ListenBrainz API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
covertartarchive: new Cache('covertartarchive', 'CovertArtArchive API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
tadb: new Cache('tadb', 'The Audio Database API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
radarr: new Cache('radarr', 'Radarr API'),
|
||||
sonarr: new Cache('sonarr', 'Sonarr API'),
|
||||
lidarr: new Cache('lidarr', 'Lidarr API'),
|
||||
rt: new Cache('rt', 'Rotten Tomatoes API', {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
@@ -27,6 +28,7 @@ export interface DownloadingItem {
|
||||
class DownloadTracker {
|
||||
private radarrServers: Record<number, DownloadingItem[]> = {};
|
||||
private sonarrServers: Record<number, DownloadingItem[]> = {};
|
||||
private lidarrServers: Record<number, DownloadingItem[]> = {};
|
||||
|
||||
public getMovieProgress(
|
||||
serverId: number,
|
||||
@@ -54,6 +56,19 @@ class DownloadTracker {
|
||||
);
|
||||
}
|
||||
|
||||
public getMusicProgress(
|
||||
serverId: number,
|
||||
externalServiceId: number
|
||||
): DownloadingItem[] {
|
||||
if (!this.lidarrServers[serverId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.lidarrServers[serverId].filter(
|
||||
(item) => item.externalId === externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public async resetDownloadTracker() {
|
||||
this.radarrServers = {};
|
||||
this.sonarrServers = {};
|
||||
@@ -62,6 +77,7 @@ class DownloadTracker {
|
||||
public updateDownloads() {
|
||||
this.updateRadarrDownloads();
|
||||
this.updateSonarrDownloads();
|
||||
this.updateLidarrDownloads();
|
||||
}
|
||||
|
||||
private async updateRadarrDownloads() {
|
||||
@@ -220,6 +236,84 @@ class DownloadTracker {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async updateLidarrDownloads() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Remove duplicate servers
|
||||
const filteredServers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
|
||||
return (
|
||||
lidarrA.hostname === lidarrB.hostname &&
|
||||
lidarrA.port === lidarrB.port &&
|
||||
lidarrA.baseUrl === lidarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Lidarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const lidarr = new LidarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||
});
|
||||
|
||||
try {
|
||||
await lidarr.refreshMonitoredDownloads();
|
||||
const queueItems = await lidarr.getQueue();
|
||||
|
||||
this.lidarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.albumId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MUSIC,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
downloadId: item.downloadId,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Lidarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.error(
|
||||
`Unable to get queue from Lidarr server: ${server.name}`,
|
||||
{
|
||||
label: 'Download Tracker',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.lidarr.filter(
|
||||
(ls) =>
|
||||
ls.hostname === server.hostname &&
|
||||
ls.port === server.port &&
|
||||
ls.baseUrl === server.baseUrl &&
|
||||
ls.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
logger.debug(
|
||||
`Matching download data to ${matchingServers.length} other Lidarr server(s)`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
matchingServers.forEach((ms) => {
|
||||
if (ms.syncEnabled) {
|
||||
this.lidarrServers[ms.id] = this.lidarrServers[server.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTracker = new DownloadTracker();
|
||||
|
||||
@@ -71,7 +71,9 @@ class EmailAgent
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: 'series'
|
||||
: payload.media.mediaType === MediaType.TV
|
||||
? 'series'
|
||||
: 'album'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
@@ -113,7 +115,11 @@ class EmailAgent
|
||||
body = `A request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}failed to be added to ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
|
||||
payload.media?.mediaType === MediaType.MOVIE
|
||||
? 'Radarr'
|
||||
: payload.media?.mediaType === MediaType.TV
|
||||
? 'Sonarr'
|
||||
: 'Lidarr'
|
||||
}:`;
|
||||
break;
|
||||
}
|
||||
@@ -135,7 +141,11 @@ class EmailAgent
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.request.requestedBy.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${
|
||||
payload.media?.mediaType === MediaType.MUSIC
|
||||
? payload.media?.mbId
|
||||
: payload.media?.tmdbId
|
||||
}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
|
||||
@@ -47,7 +47,9 @@ class WebPushAgent
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: 'series'
|
||||
: payload.media.mediaType === MediaType.TV
|
||||
? 'series'
|
||||
: 'album'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
@@ -119,7 +121,9 @@ class WebPushAgent
|
||||
const actionUrl = payload.issue
|
||||
? `/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
? payload.media.mediaType === MediaType.MUSIC
|
||||
? `/music/${payload.media.mbId}`
|
||||
: `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
|
||||
const actionUrlTitle = actionUrl
|
||||
|
||||
@@ -29,6 +29,9 @@ export enum Permission {
|
||||
WATCHLIST_VIEW = 134217728,
|
||||
MANAGE_BLACKLIST = 268435456,
|
||||
VIEW_BLACKLIST = 1073741824,
|
||||
AUTO_APPROVE_MUSIC = 2147483648,
|
||||
REQUEST_MUSIC = 4294967296,
|
||||
AUTO_REQUEST_MUSIC = 8589934592,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface MediaIds {
|
||||
imdbId?: string;
|
||||
tvdbId?: number;
|
||||
isHama?: boolean;
|
||||
mbId?: string;
|
||||
}
|
||||
|
||||
interface ProcessOptions {
|
||||
@@ -79,11 +80,24 @@ class BaseScanner<T> {
|
||||
this.updateRate = updateRate ?? UPDATE_RATE;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
private async getExisting(
|
||||
id: number | string,
|
||||
mediaType: MediaType
|
||||
): Promise<Media | null> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const query: Record<string, any> = {
|
||||
mediaType,
|
||||
};
|
||||
|
||||
if (mediaType === MediaType.MUSIC) {
|
||||
query.mbId = id.toString();
|
||||
} else {
|
||||
query.tmdbId = Number(id);
|
||||
}
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
where: query,
|
||||
});
|
||||
|
||||
return existing;
|
||||
@@ -526,6 +540,93 @@ class BaseScanner<T> {
|
||||
});
|
||||
}
|
||||
|
||||
protected async processMusic(
|
||||
mbId: string,
|
||||
{
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
processing = false,
|
||||
title = 'Unknown Title',
|
||||
}: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(mbId, async () => {
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { mbId, mediaType: MediaType.MUSIC },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const newMedia = new Media();
|
||||
newMedia.mbId = mbId;
|
||||
newMedia.status = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MUSIC;
|
||||
newMedia.mediaAddedAt = mediaAddedAt ?? newMedia.mediaAddedAt;
|
||||
newMedia.ratingKey = ratingKey ?? newMedia.ratingKey;
|
||||
newMedia.serviceId = serviceId ?? newMedia.serviceId;
|
||||
newMedia.externalServiceId =
|
||||
externalServiceId ?? newMedia.externalServiceId;
|
||||
newMedia.externalServiceSlug =
|
||||
externalServiceSlug ?? newMedia.externalServiceSlug;
|
||||
|
||||
try {
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
} catch (err) {
|
||||
this.log('Failed to save new media', 'error', {
|
||||
title,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let hasChanges = false;
|
||||
|
||||
if (existing.status !== MediaStatus.AVAILABLE && !processing) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (serviceId && !existing.serviceId) {
|
||||
existing.serviceId = serviceId;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (externalServiceId && !existing.externalServiceId) {
|
||||
existing.externalServiceId = externalServiceId;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (externalServiceSlug && !existing.externalServiceSlug) {
|
||||
existing.externalServiceSlug = externalServiceSlug;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (mediaAddedAt && !existing.mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (ratingKey && !existing.ratingKey) {
|
||||
existing.ratingKey = ratingKey;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
try {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(`Updated existing media: ${title}`);
|
||||
} catch (err) {
|
||||
this.log('Failed to update existing media', 'error', {
|
||||
title,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call startRun from child class whenever a run is starting to
|
||||
* ensure required values are set
|
||||
|
||||
@@ -2,6 +2,7 @@ import animeList from '@server/api/animelist';
|
||||
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type {
|
||||
@@ -676,6 +677,106 @@ class JellyfinScanner {
|
||||
}
|
||||
}
|
||||
|
||||
private async processMusic(jellyfinitem: JellyfinLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const musicBrainz = new MusicBrainz();
|
||||
|
||||
try {
|
||||
const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||
const newMedia = new Media();
|
||||
|
||||
if (!metadata?.Id) {
|
||||
logger.debug('No Id metadata for this title. Skipping', {
|
||||
label: 'Jellyfin Sync',
|
||||
ratingKey: jellyfinitem.Id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
newMedia.mbId = metadata.ProviderIds?.MusicBrainzReleaseGroup;
|
||||
|
||||
if (!newMedia.mbId && metadata.ProviderIds?.MusicBrainzAlbum) {
|
||||
try {
|
||||
const releaseGroupId = await musicBrainz.getReleaseGroup({
|
||||
releaseId: metadata.ProviderIds.MusicBrainzAlbum,
|
||||
});
|
||||
if (releaseGroupId) {
|
||||
newMedia.mbId = releaseGroupId;
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('Failed to get release group ID', 'error', {
|
||||
title: metadata.Name,
|
||||
releaseId: metadata.ProviderIds.MusicBrainzAlbum,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!newMedia.mbId) {
|
||||
this.log(
|
||||
'No MusicBrainz Album ID found for this title. Skipping.',
|
||||
'debug',
|
||||
{
|
||||
title: metadata.Name,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.asyncLock.dispatch(metadata.Id, async () => {
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { mbId: newMedia.mbId, mediaType: MediaType.MUSIC },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!existing.mediaAddedAt && !changedExisting) {
|
||||
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (existing.jellyfinMediaId !== metadata.Id) {
|
||||
existing.jellyfinMediaId = metadata.Id;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.Name} exists. New media set to AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(`Album already exists: ${metadata.Name}`);
|
||||
}
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MUSIC;
|
||||
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
newMedia.jellyfinMediaId = metadata.Id;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new album: ${metadata.Name}`);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
jellyfinitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(slicedItems: JellyfinLibraryItem[]) {
|
||||
this.processedAnidbSeason = new Map();
|
||||
await Promise.all(
|
||||
@@ -684,6 +785,8 @@ class JellyfinScanner {
|
||||
await this.processMovie(item);
|
||||
} else if (item.Type === 'Series') {
|
||||
await this.processShow(item);
|
||||
} else if (item.Type === 'MusicAlbum') {
|
||||
await this.processMusic(item);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
118
server/lib/scanners/lidarr/index.ts
Normal file
118
server/lib/scanners/lidarr/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { LidarrAlbum } from '@server/api/servarr/lidarr';
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import type {
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import type { LidarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: LidarrSettings;
|
||||
servers: LidarrSettings[];
|
||||
};
|
||||
|
||||
class LidarrScanner
|
||||
extends BaseScanner<LidarrAlbum>
|
||||
implements RunnableScanner<SyncStatus>
|
||||
{
|
||||
private servers: LidarrSettings[];
|
||||
private currentServer: LidarrSettings;
|
||||
private lidarrApi: LidarrAPI;
|
||||
|
||||
constructor() {
|
||||
super('Lidarr Scan', { bundleSize: 50 });
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.servers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
|
||||
return (
|
||||
lidarrA.hostname === lidarrB.hostname &&
|
||||
lidarrA.port === lidarrB.port &&
|
||||
lidarrA.baseUrl === lidarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Lidarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.lidarrApi = new LidarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: LidarrAPI.buildUrl(server, '/api/v1'),
|
||||
});
|
||||
|
||||
this.items = await this.lidarrApi.getAlbums();
|
||||
await this.loop(this.processLidarrAlbum.bind(this), { sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Lidarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Lidarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processLidarrAlbum(lidarrAlbum: LidarrAlbum): Promise<void> {
|
||||
try {
|
||||
if (!lidarrAlbum.monitored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mbId = lidarrAlbum.foreignAlbumId;
|
||||
if (!mbId) {
|
||||
this.log(
|
||||
'No MusicBrainz ID found for this title. Skipping item.',
|
||||
'debug',
|
||||
{
|
||||
title: lidarrAlbum.title,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processMusic(mbId, {
|
||||
serviceId: this.currentServer.id,
|
||||
externalServiceId: lidarrAlbum.id,
|
||||
externalServiceSlug: mbId,
|
||||
title: lidarrAlbum.title,
|
||||
processing:
|
||||
lidarrAlbum.monitored &&
|
||||
(!lidarrAlbum.statistics ||
|
||||
lidarrAlbum.statistics.trackFileCount <
|
||||
lidarrAlbum.statistics.totalTrackCount),
|
||||
});
|
||||
} catch (e) {
|
||||
this.log('Failed to process Lidarr media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: lidarrAlbum.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lidarrScanner = new LidarrScanner();
|
||||
@@ -1,5 +1,6 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
@@ -26,6 +27,7 @@ const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const mbRegex = new RegExp(/mbid:\/\/([0-9a-f-]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
// Hama agent uses ASS naming, see details here:
|
||||
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
|
||||
@@ -95,6 +97,7 @@ class PlexScanner
|
||||
'info',
|
||||
{ lastScan: library.lastScan }
|
||||
);
|
||||
const mappedType = library.type === 'music' ? 'album' : library.type;
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id,
|
||||
library.lastScan
|
||||
@@ -103,7 +106,7 @@ class PlexScanner
|
||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||
}
|
||||
: undefined,
|
||||
library.type
|
||||
mappedType
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
@@ -215,6 +218,12 @@ class PlexScanner
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processPlexShow(plexitem);
|
||||
} else if (
|
||||
plexitem.type === 'artist' ||
|
||||
plexitem.type === 'album' ||
|
||||
plexitem.type === 'track'
|
||||
) {
|
||||
await this.processPlexMusic(plexitem);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('Failed to process Plex media', 'error', {
|
||||
@@ -381,6 +390,60 @@ class PlexScanner
|
||||
}
|
||||
}
|
||||
|
||||
private async processPlexMusic(plexitem: PlexLibraryItem) {
|
||||
const ratingKey =
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey;
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await this.plexClient.getMetadata(ratingKey, {
|
||||
includeChildren: true,
|
||||
});
|
||||
|
||||
if (metadata.Children?.Metadata) {
|
||||
const musicBrainz = new MusicBrainz();
|
||||
|
||||
for (const album of metadata.Children.Metadata) {
|
||||
const albumMetadata = await this.plexClient.getMetadata(
|
||||
album.ratingKey
|
||||
);
|
||||
|
||||
const mbReleaseId = albumMetadata.Guid?.find((g) => {
|
||||
const id = g.id.toLowerCase();
|
||||
return id.startsWith('mbid://');
|
||||
})?.id.replace('mbid://', '');
|
||||
|
||||
if (!mbReleaseId) {
|
||||
this.log('No MusicBrainz ID found for album', 'debug', {
|
||||
title: album.title,
|
||||
artist: metadata.title,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const releaseGroupId = await musicBrainz.getReleaseGroup({
|
||||
releaseId: mbReleaseId,
|
||||
});
|
||||
|
||||
if (releaseGroupId) {
|
||||
await this.processMusic(releaseGroupId, {
|
||||
mediaAddedAt: new Date(album.addedAt * 1000),
|
||||
ratingKey: album.ratingKey,
|
||||
title: album.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('Failed to process music media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: metadata?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
|
||||
let mediaIds: Partial<MediaIds> = {};
|
||||
// Check if item is using new plex movie/tv agent
|
||||
@@ -419,6 +482,8 @@ class PlexScanner
|
||||
} else if (ref.id.match(tvdbRegex)) {
|
||||
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
|
||||
mediaIds.tvdbId = Number(tvdbMatch);
|
||||
} else if (ref.id.match(mbRegex)) {
|
||||
mediaIds.mbId = ref.id.match(mbRegex)?.[1] ?? undefined;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -534,6 +599,12 @@ class PlexScanner
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for MusicBrainz
|
||||
} else if (plexitem.guid.match(mbRegex)) {
|
||||
const mbMatch = plexitem.guid.match(mbRegex);
|
||||
if (mbMatch) {
|
||||
mediaIds.mbId = mbMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaIds.tmdbId) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import type {
|
||||
MbAlbumResult,
|
||||
MbArtistResult,
|
||||
} from '@server/api/musicbrainz/interfaces';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
TmdbPersonResult,
|
||||
TmdbSearchMovieResponse,
|
||||
TmdbSearchMultiResponse,
|
||||
TmdbSearchTvResponse,
|
||||
TmdbTvDetails,
|
||||
TmdbTvResult,
|
||||
@@ -21,6 +26,19 @@ import {
|
||||
isTvDetails,
|
||||
} from '@server/utils/typeHelpers';
|
||||
|
||||
export type CombinedSearchResponse = {
|
||||
page: number;
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
results: (
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[];
|
||||
};
|
||||
interface SearchProvider {
|
||||
pattern: RegExp;
|
||||
search: ({
|
||||
@@ -31,7 +49,7 @@ interface SearchProvider {
|
||||
id: string;
|
||||
language?: string;
|
||||
query?: string;
|
||||
}) => Promise<TmdbSearchMultiResponse>;
|
||||
}) => Promise<CombinedSearchResponse>;
|
||||
}
|
||||
|
||||
const searchProviders: SearchProvider[] = [];
|
||||
@@ -214,3 +232,39 @@ searchProviders.push({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
searchProviders.push({
|
||||
pattern: new RegExp(/(?<=musicbrainz:)/),
|
||||
search: async ({ query }) => {
|
||||
const musicbrainz = new MusicBrainz();
|
||||
|
||||
try {
|
||||
const albumResults = await musicbrainz.searchAlbum({
|
||||
query: query || '',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const results: CombinedSearchResponse['results'] = albumResults.map(
|
||||
(album) =>
|
||||
({
|
||||
...album,
|
||||
media_type: 'album',
|
||||
} as MbAlbumResult)
|
||||
);
|
||||
|
||||
return {
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
total_results: results.length,
|
||||
results,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
total_results: 0,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface Library {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: 'show' | 'movie';
|
||||
type: 'show' | 'movie' | 'music';
|
||||
lastScan?: number;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ export interface SonarrSettings extends DVRSettings {
|
||||
enableSeasonFolders: boolean;
|
||||
}
|
||||
|
||||
export interface LidarrSettings extends DVRSettings {
|
||||
activeMetadataProfileId?: number;
|
||||
activeMetadataProfileName?: string;
|
||||
}
|
||||
|
||||
interface Quota {
|
||||
quotaLimit?: number;
|
||||
quotaDays?: number;
|
||||
@@ -130,6 +135,7 @@ export interface MainSettings {
|
||||
defaultQuotas: {
|
||||
movie: Quota;
|
||||
tv: Quota;
|
||||
music: Quota;
|
||||
};
|
||||
hideAvailable: boolean;
|
||||
hideBlacklisted: boolean;
|
||||
@@ -340,6 +346,7 @@ export type JobId =
|
||||
| 'plex-refresh-token'
|
||||
| 'radarr-scan'
|
||||
| 'sonarr-scan'
|
||||
| 'lidarr-scan'
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset'
|
||||
| 'jellyfin-recently-added-scan'
|
||||
@@ -358,6 +365,7 @@ export interface AllSettings {
|
||||
tautulli: TautulliSettings;
|
||||
radarr: RadarrSettings[];
|
||||
sonarr: SonarrSettings[];
|
||||
lidarr: LidarrSettings[];
|
||||
public: PublicSettings;
|
||||
notifications: NotificationSettings;
|
||||
jobs: Record<JobId, JobSettings>;
|
||||
@@ -387,6 +395,7 @@ class Settings {
|
||||
defaultQuotas: {
|
||||
movie: {},
|
||||
tv: {},
|
||||
music: {},
|
||||
},
|
||||
hideAvailable: false,
|
||||
hideBlacklisted: false,
|
||||
@@ -429,6 +438,7 @@ class Settings {
|
||||
anime: MetadataProviderType.TMDB,
|
||||
},
|
||||
radarr: [],
|
||||
lidarr: [],
|
||||
sonarr: [],
|
||||
public: {
|
||||
initialized: false,
|
||||
@@ -552,6 +562,9 @@ class Settings {
|
||||
'sonarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'lidarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'availability-sync': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
@@ -649,6 +662,14 @@ class Settings {
|
||||
this.data.radarr = data;
|
||||
}
|
||||
|
||||
get lidarr(): LidarrSettings[] {
|
||||
return this.data.lidarr;
|
||||
}
|
||||
|
||||
set lidarr(data: LidarrSettings[]) {
|
||||
this.data.lidarr = data;
|
||||
}
|
||||
|
||||
get sonarr(): SonarrSettings[] {
|
||||
return this.data.sonarr;
|
||||
}
|
||||
|
||||
135
server/migration/postgres/1762648503371-AddMusicSupport.ts
Normal file
135
server/migration/postgres/1762648503371-AddMusicSupport.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMusicSupport1762648503371 implements MigrationInterface {
|
||||
name = 'AddMusicSupport1762648503371';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata_album" ("id" SERIAL NOT NULL, "mbAlbumId" character varying NOT NULL, "caaUrl" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_fb8eda254e560f96039f7a0d812" UNIQUE ("mbAlbumId"), CONSTRAINT "PK_02aaaa276bcc3de3ead4bd2b8f3" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata_artist" ("id" SERIAL NOT NULL, "mbArtistId" character varying NOT NULL, "tmdbPersonId" character varying, "tmdbThumb" character varying, "tmdbUpdatedAt" TIMESTAMP WITH TIME ZONE, "tadbThumb" character varying, "tadbCover" character varying, "tadbUpdatedAt" TIMESTAMP WITH TIME ZONE, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_bff8b9448b4a8a3af0f8957d4b7" UNIQUE ("mbArtistId"), CONSTRAINT "PK_06d683fc350297c5aef7f0fe5c4" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD "mbId" character varying`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "override_rule" ADD "lidarrServiceId" integer`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaLimit" integer`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaDays" integer`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ADD "mbId" character varying`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "media" ADD "mbId" character varying`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "mediaId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ALTER COLUMN "tmdbId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" DROP CONSTRAINT "UQ_6bbafa28411e6046421991ea21c"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "tmdbId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" SET DEFAULT '0'`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a40b88a30fc50cf10264e279c9" ON "watchlist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4f7c7041c1792b568be902f097" ON "blacklist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6c866e76dd595ad15b8c5bf9c1" ON "media" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ADD CONSTRAINT "UQ_30a2423945ffaeb135b518d074d" UNIQUE ("tmdbId", "mbId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" DROP CONSTRAINT "UQ_30a2423945ffaeb135b518d074d"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_FOREIGN"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6c866e76dd595ad15b8c5bf9c1"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4f7c7041c1792b568be902f097"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a40b88a30fc50cf10264e279c9"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" DROP DEFAULT`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "serviceId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media" ALTER COLUMN "tmdbId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ADD CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ALTER COLUMN "tmdbId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "mediaId" DROP NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" SET NOT NULL`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "media" DROP COLUMN "mbId"`);
|
||||
await queryRunner.query(`ALTER TABLE "blacklist" DROP COLUMN "mbId"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaDays"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaLimit"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "override_rule" DROP COLUMN "lidarrServiceId"`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "watchlist" DROP COLUMN "mbId"`);
|
||||
await queryRunner.query(`DROP TABLE "metadata_artist"`);
|
||||
await queryRunner.query(`DROP TABLE "metadata_album"`);
|
||||
}
|
||||
}
|
||||
615
server/migration/sqlite/1762648478949-AddMusicSupport.ts
Normal file
615
server/migration/sqlite/1762648478949-AddMusicSupport.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMusicSupport1762648478949 implements MigrationInterface {
|
||||
name = 'AddMusicSupport1762648478949';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata_album" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mbAlbumId" varchar NOT NULL, "caaUrl" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_fb8eda254e560f96039f7a0d812" UNIQUE ("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')), CONSTRAINT "UQ_bff8b9448b4a8a3af0f8957d4b7" UNIQUE ("mbArtistId"))`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, "mbId" varchar, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_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')), "lidarrServiceId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt" FROM "override_rule"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "override_rule"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_override_rule" RENAME TO "override_rule"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, "musicQuotaLimit" integer, "musicQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, "mbId" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, "mbId" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, "mbId" varchar, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, "mbId" varchar)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer NOT NULL, "mbId" varchar)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_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 (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "lidarrServiceId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt", "lidarrServiceId") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt", "lidarrServiceId" FROM "override_rule"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "override_rule"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_override_rule" RENAME TO "override_rule"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_season_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "season_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "season_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_season_request" RENAME TO "season_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, "musicQuotaLimit" integer, "musicQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion", "musicQuotaLimit", "musicQuotaDays") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion", "musicQuotaLimit", "musicQuotaDays" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, "mbId" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k" FROM "season"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "season"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_season" RENAME TO "season"`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer NOT NULL DEFAULT (0), "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, "mbId" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k", "mbId") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k", "mbId" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "issue"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "issueId" integer, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "issue_comment"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_discover_slider"("id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt") SELECT "id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt" FROM "discover_slider"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "discover_slider"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_discover_slider" RENAME TO "discover_slider"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a40b88a30fc50cf10264e279c9" ON "watchlist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4f7c7041c1792b568be902f097" ON "blacklist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6c866e76dd595ad15b8c5bf9c1" ON "media" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_a40b88a30fc50cf10264e279c9"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer NOT NULL, "mbId" varchar, CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById"), CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a40b88a30fc50cf10264e279c9" ON "watchlist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_4f7c7041c1792b568be902f097"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, "mbId" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_30a2423945ffaeb135b518d074d" UNIQUE ("tmdbId", "mbId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4f7c7041c1792b568be902f097" ON "blacklist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_a40b88a30fc50cf10264e279c9"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer NOT NULL, "mbId" varchar, CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "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)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a40b88a30fc50cf10264e279c9" ON "watchlist" ("mbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_a40b88a30fc50cf10264e279c9"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer NOT NULL, "mbId" varchar, CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById"), CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a40b88a30fc50cf10264e279c9" ON "watchlist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_4f7c7041c1792b568be902f097"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, "mbId" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4f7c7041c1792b568be902f097" ON "blacklist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_a40b88a30fc50cf10264e279c9"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer NOT NULL, "mbId" varchar)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a40b88a30fc50cf10264e279c9" ON "watchlist" ("mbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6c866e76dd595ad15b8c5bf9c1"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_4f7c7041c1792b568be902f097"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_a40b88a30fc50cf10264e279c9"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "discover_slider" RENAME TO "temporary_discover_slider"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "discover_slider"("id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt") SELECT "id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt" FROM "temporary_discover_slider"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_discover_slider"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_issue_comment"`);
|
||||
await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_issue"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, "mbId" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k", "mbId") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k", "mbId" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
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(
|
||||
`ALTER TABLE "season" RENAME TO "temporary_season"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k" FROM "temporary_season"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_season"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, "mbId" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags", "mbId" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, "musicQuotaLimit" integer, "musicQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion", "musicQuotaLimit", "musicQuotaDays") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion", "musicQuotaLimit", "musicQuotaDays" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "season_request" RENAME TO "temporary_season_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "season_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "temporary_season_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_season_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "override_rule" RENAME TO "temporary_override_rule"`
|
||||
);
|
||||
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')), "lidarrServiceId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt", "lidarrServiceId") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt", "lidarrServiceId" FROM "temporary_override_rule"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_override_rule"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, "mbId" varchar)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, "mbId" varchar, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, "mbId" varchar, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId", "mbId" FROM "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
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(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "override_rule" RENAME TO "temporary_override_rule"`
|
||||
);
|
||||
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')))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt" FROM "temporary_override_rule"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_override_rule"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "metadata_artist"`);
|
||||
await queryRunner.query(`DROP TABLE "metadata_album"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
}
|
||||
}
|
||||
34
server/models/Artist.ts
Normal file
34
server/models/Artist.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type Media from '@server/entity/Media';
|
||||
|
||||
export interface ArtistDetails {
|
||||
name: string;
|
||||
area?: string;
|
||||
artist: {
|
||||
name: string;
|
||||
artist_mbid: string;
|
||||
begin_year?: number;
|
||||
end_year?: number;
|
||||
area?: string;
|
||||
};
|
||||
alsoKnownAs?: string[];
|
||||
biography?: string;
|
||||
wikipedia?: {
|
||||
content: string;
|
||||
};
|
||||
artistThumb?: string | null;
|
||||
artistBackdrop?: string | null;
|
||||
profilePath?: string;
|
||||
releaseGroups?: {
|
||||
id: string;
|
||||
title: string;
|
||||
'first-release-date': string;
|
||||
'artist-credit': {
|
||||
name: string;
|
||||
}[];
|
||||
'primary-type': string;
|
||||
secondary_types?: string[];
|
||||
total_listen_count?: number;
|
||||
posterPath?: string;
|
||||
mediaInfo?: Media;
|
||||
}[];
|
||||
}
|
||||
125
server/models/Music.ts
Normal file
125
server/models/Music.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces';
|
||||
import type Media from '@server/entity/Media';
|
||||
|
||||
export interface MusicDetails {
|
||||
id: string;
|
||||
mbId: string;
|
||||
title: string;
|
||||
titleSlug?: string;
|
||||
mediaType: 'album';
|
||||
type: string;
|
||||
releaseDate: string;
|
||||
artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
area?: string;
|
||||
beginYear?: number;
|
||||
type?: string;
|
||||
};
|
||||
tracks: {
|
||||
name: string;
|
||||
position: number;
|
||||
length: number;
|
||||
recordingMbid: string;
|
||||
totalListenCount: number;
|
||||
totalUserCount: number;
|
||||
artists: {
|
||||
name: string;
|
||||
mbid: string;
|
||||
tmdbMapping?: {
|
||||
personId: number;
|
||||
profilePath: string;
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
tags?: {
|
||||
artist: {
|
||||
artistMbid: string;
|
||||
count: number;
|
||||
tag: string;
|
||||
}[];
|
||||
releaseGroup: {
|
||||
count: number;
|
||||
genreMbid: string;
|
||||
tag: string;
|
||||
}[];
|
||||
};
|
||||
stats?: {
|
||||
totalListenCount: number;
|
||||
totalUserCount: number;
|
||||
listeners: {
|
||||
userName: string;
|
||||
listenCount: number;
|
||||
}[];
|
||||
};
|
||||
mediaInfo?: Media;
|
||||
onUserWatchlist?: boolean;
|
||||
posterPath?: string;
|
||||
artistWikipedia?: {
|
||||
content: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
tmdbPersonId?: number;
|
||||
artistBackdrop?: string;
|
||||
artistThumb?: string;
|
||||
}
|
||||
|
||||
export const mapMusicDetails = (
|
||||
album: LbAlbumDetails,
|
||||
media?: Media,
|
||||
userWatchlist?: boolean
|
||||
): MusicDetails => ({
|
||||
id: album.release_group_mbid,
|
||||
mbId: album.release_group_mbid,
|
||||
title: album.release_group_metadata.release_group.name,
|
||||
titleSlug: album.release_group_metadata.release_group.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-'),
|
||||
mediaType: 'album',
|
||||
type: album.type,
|
||||
releaseDate: album.release_group_metadata.release_group.date,
|
||||
artist: {
|
||||
id: album.release_group_metadata.artist.artists[0].artist_mbid,
|
||||
name: album.release_group_metadata.artist.name,
|
||||
area: album.release_group_metadata.artist.artists[0].area,
|
||||
beginYear: album.release_group_metadata.artist.artists[0].begin_year,
|
||||
type: album.release_group_metadata.artist.artists[0].type,
|
||||
},
|
||||
tracks: album.mediums.flatMap((medium) =>
|
||||
medium.tracks.map((track) => ({
|
||||
name: track.name,
|
||||
position: track.position,
|
||||
length: track.length,
|
||||
recordingMbid: track.recording_mbid,
|
||||
totalListenCount: track.total_listen_count,
|
||||
totalUserCount: track.total_user_count,
|
||||
artists: track.artists.map((artist) => ({
|
||||
name: artist.artist_credit_name,
|
||||
mbid: artist.artist_mbid,
|
||||
})),
|
||||
}))
|
||||
),
|
||||
tags: {
|
||||
artist: album.release_group_metadata.tag.artist.map((tag) => ({
|
||||
artistMbid: tag.artist_mbid,
|
||||
count: tag.count,
|
||||
tag: tag.tag,
|
||||
})),
|
||||
releaseGroup: album.release_group_metadata.tag.release_group.map((tag) => ({
|
||||
count: tag.count,
|
||||
genreMbid: tag.genre_mbid,
|
||||
tag: tag.tag,
|
||||
})),
|
||||
},
|
||||
stats: {
|
||||
totalListenCount: album.listening_stats.total_listen_count,
|
||||
totalUserCount: album.listening_stats.total_user_count,
|
||||
listeners: album.listening_stats.listeners.map((listener) => ({
|
||||
userName: listener.user_name,
|
||||
listenCount: listener.listen_count,
|
||||
})),
|
||||
},
|
||||
mediaInfo: media,
|
||||
onUserWatchlist: userWatchlist,
|
||||
});
|
||||
@@ -20,6 +20,21 @@ export interface PersonDetails {
|
||||
adult: boolean;
|
||||
imdbId?: string;
|
||||
homepage?: 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 {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
MbAlbumResult,
|
||||
MbArtistResult,
|
||||
} from '@server/api/musicbrainz/interfaces';
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
@@ -9,10 +13,15 @@ import type {
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||
import type Media from '@server/entity/Media';
|
||||
export type MediaType =
|
||||
| 'tv'
|
||||
| 'movie'
|
||||
| 'person'
|
||||
| 'collection'
|
||||
| 'artist'
|
||||
| 'album';
|
||||
|
||||
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
|
||||
|
||||
interface SearchResult {
|
||||
interface TmdbSearchResult {
|
||||
id: number;
|
||||
mediaType: MediaType;
|
||||
popularity: number;
|
||||
@@ -26,7 +35,14 @@ interface SearchResult {
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export interface MovieResult extends SearchResult {
|
||||
interface MbSearchResult {
|
||||
id: string;
|
||||
mediaType: MediaType;
|
||||
score: number;
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export interface MovieResult extends TmdbSearchResult {
|
||||
mediaType: 'movie';
|
||||
title: string;
|
||||
originalTitle: string;
|
||||
@@ -36,7 +52,7 @@ export interface MovieResult extends SearchResult {
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export interface TvResult extends SearchResult {
|
||||
export interface TvResult extends TmdbSearchResult {
|
||||
mediaType: 'tv';
|
||||
name: string;
|
||||
originalName: string;
|
||||
@@ -66,7 +82,45 @@ export interface PersonResult {
|
||||
knownFor: (MovieResult | TvResult)[];
|
||||
}
|
||||
|
||||
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
|
||||
export interface ArtistResult extends MbSearchResult {
|
||||
mediaType: 'artist';
|
||||
tmdbPersonId?: number;
|
||||
name: string;
|
||||
type: 'Group' | 'Person';
|
||||
'sort-name': string;
|
||||
country?: string;
|
||||
disambiguation?: string;
|
||||
artistThumb?: string | null;
|
||||
artistBackdrop?: string | null;
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export interface AlbumResult extends MbSearchResult {
|
||||
mediaType: 'album';
|
||||
title: string;
|
||||
'primary-type': 'Album' | 'Single' | 'EP';
|
||||
'first-release-date': string;
|
||||
releaseDate?: string;
|
||||
'artist-credit': {
|
||||
name: string;
|
||||
artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
'sort-name': string;
|
||||
};
|
||||
}[];
|
||||
posterPath?: string;
|
||||
needsCoverArt?: boolean;
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export type Results =
|
||||
| MovieResult
|
||||
| TvResult
|
||||
| PersonResult
|
||||
| CollectionResult
|
||||
| ArtistResult
|
||||
| AlbumResult;
|
||||
|
||||
export const mapMovieResult = (
|
||||
movieResult: TmdbMovieResult,
|
||||
@@ -144,18 +198,123 @@ export const mapPersonResult = (
|
||||
}),
|
||||
});
|
||||
|
||||
export const mapSearchResults = (
|
||||
export const mapArtistResult = (
|
||||
artistResult: MbArtistResult
|
||||
): ArtistResult => ({
|
||||
id: artistResult.id,
|
||||
score: artistResult.score,
|
||||
mediaType: 'artist',
|
||||
name: artistResult.name,
|
||||
type: artistResult.type,
|
||||
'sort-name': artistResult['sort-name'],
|
||||
country: artistResult.country,
|
||||
disambiguation: artistResult.disambiguation,
|
||||
artistThumb: artistResult.artistThumb,
|
||||
artistBackdrop: artistResult.artistBackdrop,
|
||||
});
|
||||
|
||||
export const mapAlbumResult = (
|
||||
albumResult: MbAlbumResult,
|
||||
media?: Media
|
||||
): AlbumResult => ({
|
||||
id: albumResult.id,
|
||||
score: albumResult.score,
|
||||
mediaType: 'album',
|
||||
title: albumResult.title,
|
||||
'primary-type': albumResult['primary-type'],
|
||||
'first-release-date': albumResult['first-release-date'],
|
||||
'artist-credit': albumResult['artist-credit'],
|
||||
posterPath: albumResult.posterPath,
|
||||
needsCoverArt: !albumResult.posterPath,
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
const isTmdbMovie = (
|
||||
result:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
): result is TmdbMovieResult => {
|
||||
return result.media_type === 'movie';
|
||||
};
|
||||
|
||||
const isTmdbTv = (
|
||||
result:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
): result is TmdbTvResult => {
|
||||
return result.media_type === 'tv';
|
||||
};
|
||||
|
||||
const isTmdbPerson = (
|
||||
result:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
): result is TmdbPersonResult => {
|
||||
return result.media_type === 'person';
|
||||
};
|
||||
|
||||
const isTmdbCollection = (
|
||||
result:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
): result is TmdbCollectionResult => {
|
||||
return result.media_type === 'collection';
|
||||
};
|
||||
|
||||
const isMbArtist = (
|
||||
result:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
): result is MbArtistResult => {
|
||||
return result.media_type === 'artist';
|
||||
};
|
||||
|
||||
const isMbAlbum = (
|
||||
result:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
): result is MbAlbumResult => {
|
||||
return result.media_type === 'album';
|
||||
};
|
||||
|
||||
export const mapSearchResults = async (
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
| MbArtistResult
|
||||
| MbAlbumResult
|
||||
)[],
|
||||
media?: Media[]
|
||||
): Results[] =>
|
||||
results.map((result) => {
|
||||
switch (result.media_type) {
|
||||
case 'movie':
|
||||
): Promise<Results[]> =>
|
||||
Promise.all(
|
||||
results.map(async (result) => {
|
||||
if (isTmdbMovie(result)) {
|
||||
return mapMovieResult(
|
||||
result,
|
||||
media?.find(
|
||||
@@ -163,7 +322,7 @@ export const mapSearchResults = (
|
||||
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
|
||||
)
|
||||
);
|
||||
case 'tv':
|
||||
} else if (isTmdbTv(result)) {
|
||||
return mapTvResult(
|
||||
result,
|
||||
media?.find(
|
||||
@@ -171,12 +330,25 @@ export const mapSearchResults = (
|
||||
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
||||
)
|
||||
);
|
||||
case 'collection':
|
||||
return mapCollectionResult(result);
|
||||
default:
|
||||
} else if (isTmdbPerson(result)) {
|
||||
return mapPersonResult(result);
|
||||
}
|
||||
});
|
||||
} else if (isTmdbCollection(result)) {
|
||||
return mapCollectionResult(result);
|
||||
} else if (isMbArtist(result)) {
|
||||
return mapArtistResult(result);
|
||||
} else if (isMbAlbum(result)) {
|
||||
return mapAlbumResult(
|
||||
result,
|
||||
media?.find(
|
||||
(req) =>
|
||||
req.mbId === result.id && req.mediaType === MainMediaType.MUSIC
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled result type: ${JSON.stringify(result)}`);
|
||||
})
|
||||
);
|
||||
|
||||
export const mapMovieDetailsToResult = (
|
||||
movieDetails: TmdbMovieDetails
|
||||
@@ -221,6 +393,7 @@ export const mapPersonDetailsToResult = (
|
||||
personDetails: TmdbPersonDetails
|
||||
): TmdbPersonResult => ({
|
||||
id: personDetails.id,
|
||||
known_for_department: personDetails.known_for_department,
|
||||
media_type: 'person',
|
||||
name: personDetails.name,
|
||||
popularity: personDetails.popularity,
|
||||
|
||||
177
server/routes/artist.ts
Normal file
177
server/routes/artist.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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 = new TheAudioDb();
|
||||
|
||||
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 responses = await Promise.allSettled([
|
||||
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 artistWikipedia =
|
||||
responses[0].status === 'fulfilled' ? responses[0].value : null;
|
||||
const artistImages =
|
||||
responses[1].status === 'fulfilled' ? responses[1].value : null;
|
||||
const relatedMedia =
|
||||
responses[2].status === 'fulfilled' ? responses[2].value : [];
|
||||
const albumMetadata =
|
||||
responses[3].status === 'fulfilled' ? responses[3].value : [];
|
||||
|
||||
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;
|
||||
@@ -12,12 +12,18 @@ import { z } from 'zod';
|
||||
|
||||
const blacklistRoutes = Router();
|
||||
|
||||
export const blacklistAdd = z.object({
|
||||
tmdbId: z.coerce.number(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
user: z.coerce.number(),
|
||||
});
|
||||
export const blacklistAdd = z
|
||||
.object({
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
user: z.coerce.number(),
|
||||
})
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ tmdbId: z.coerce.number() }),
|
||||
z.object({ mbId: z.coerce.string() }),
|
||||
])
|
||||
);
|
||||
|
||||
const blacklistGet = z.object({
|
||||
take: z.coerce.number().int().positive().default(25),
|
||||
@@ -90,10 +96,12 @@ blacklistRoutes.get(
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const blacklisteRepository = getRepository(Blacklist);
|
||||
const blacklistRepository = getRepository(Blacklist);
|
||||
|
||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||
where: { tmdbId: Number(req.params.id) },
|
||||
const blacklistItem = await blacklistRepository.findOneOrFail({
|
||||
where: !isNaN(Number(req.params.id))
|
||||
? { tmdbId: Number(req.params.id) }
|
||||
: { mbId: req.params.id },
|
||||
});
|
||||
|
||||
return res.status(200).send(blacklistItem);
|
||||
@@ -135,6 +143,7 @@ blacklistRoutes.post(
|
||||
default:
|
||||
logger.warn('Something wrong with data blacklist', {
|
||||
tmdbId: req.body.tmdbId,
|
||||
mbId: req.body.mbId,
|
||||
mediaType: req.body.mediaType,
|
||||
label: 'Blacklist',
|
||||
});
|
||||
@@ -154,18 +163,22 @@ blacklistRoutes.delete(
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const blacklisteRepository = getRepository(Blacklist);
|
||||
const blacklistRepository = getRepository(Blacklist);
|
||||
|
||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||
where: { tmdbId: Number(req.params.id) },
|
||||
const blacklistItem = await blacklistRepository.findOneOrFail({
|
||||
where: !isNaN(Number(req.params.id))
|
||||
? { tmdbId: Number(req.params.id) }
|
||||
: { mbId: req.params.id },
|
||||
});
|
||||
|
||||
await blacklisteRepository.remove(blacklistItem);
|
||||
await blacklistRepository.remove(blacklistItem);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const mediaItem = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: Number(req.params.id) },
|
||||
where: !isNaN(Number(req.params.id))
|
||||
? { tmdbId: Number(req.params.id) }
|
||||
: { mbId: req.params.id },
|
||||
});
|
||||
|
||||
await mediaRepository.remove(mediaItem);
|
||||
|
||||
39
server/routes/caaproxy.ts
Normal file
39
server/routes/caaproxy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
const caaImageProxy = new ImageProxy('caa', 'https://archive.org/download', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Image Proxy
|
||||
*/
|
||||
router.get('/*', async (req, res) => {
|
||||
const imagePath = req.path.replace('/download', '');
|
||||
try {
|
||||
const imageData = await caaImageProxy.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).send();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
28
server/routes/coverart.ts
Normal file
28
server/routes/coverart.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import CoverArtArchive from '@server/api/coverartarchive';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const coverArtRoutes = Router();
|
||||
|
||||
coverArtRoutes.get('/batch/:ids', async (req, res) => {
|
||||
const coverArtArchive = new CoverArtArchive();
|
||||
const ids = (req.params.ids || '').split(',').filter(Boolean);
|
||||
|
||||
if (!ids.length) {
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
try {
|
||||
const coverResults = await coverArtArchive.batchGetCoverArt(ids);
|
||||
return res.status(200).json(coverResults);
|
||||
} catch (e) {
|
||||
logger.error('Error fetching batch cover art', {
|
||||
label: 'CoverArtArchive',
|
||||
errorMessage: e instanceof Error ? e.message : 'Unknown error',
|
||||
count: ids.length,
|
||||
});
|
||||
return res.status(200).json({});
|
||||
}
|
||||
});
|
||||
|
||||
export default coverArtRoutes;
|
||||
@@ -1,10 +1,15 @@
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import TheAudioDb from '@server/api/theaudiodb';
|
||||
import type { SortOptions } from '@server/api/themoviedb';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import TmdbPersonMapper from '@server/api/themoviedb/personMapper';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||
import { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type {
|
||||
@@ -24,6 +29,7 @@ import { mapNetwork } from '@server/models/Tv';
|
||||
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import { sortBy } from 'lodash';
|
||||
import { In } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
@@ -854,6 +860,527 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get('/music', async (req, res, next) => {
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
|
||||
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({
|
||||
days,
|
||||
sort: apiSortField,
|
||||
});
|
||||
|
||||
let filteredReleases = freshReleasesData.payload.releases;
|
||||
|
||||
if (genreFilter) {
|
||||
const genres = genreFilter.split(',');
|
||||
filteredReleases = freshReleasesData.payload.releases.filter(
|
||||
(release) => {
|
||||
let releaseType;
|
||||
|
||||
if (release.release_group_secondary_type) {
|
||||
releaseType = release.release_group_secondary_type;
|
||||
} else if (release.release_tags && release.release_tags.length > 0) {
|
||||
releaseType = release.release_tags[0];
|
||||
} else {
|
||||
releaseType = release.release_group_primary_type || 'Album';
|
||||
}
|
||||
|
||||
return genres.includes(releaseType);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (releaseDateGte || releaseDateLte) {
|
||||
filteredReleases = filteredReleases.filter((release) => {
|
||||
if (!release.release_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const releaseDate = new Date(release.release_date);
|
||||
|
||||
if (releaseDateGte) {
|
||||
const gteDate = new Date(releaseDateGte);
|
||||
if (releaseDate < gteDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseDateLte) {
|
||||
const lteDate = new Date(releaseDateLte);
|
||||
if (releaseDate > lteDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
filteredReleases.sort((a, b) => {
|
||||
const multiplier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
switch (field) {
|
||||
case 'release_date': {
|
||||
const dateA = a.release_date ? new Date(a.release_date).getTime() : 0;
|
||||
const dateB = b.release_date ? new Date(b.release_date).getTime() : 0;
|
||||
return (dateA - dateB) * multiplier;
|
||||
}
|
||||
case 'title': {
|
||||
return (
|
||||
(a.release_name ?? '').localeCompare(b.release_name ?? '') *
|
||||
multiplier
|
||||
);
|
||||
}
|
||||
case 'artist': {
|
||||
return (
|
||||
(a.artist_credit_name ?? '').localeCompare(
|
||||
b.artist_credit_name ?? ''
|
||||
) * multiplier
|
||||
);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const mbIds = filteredReleases
|
||||
.map((release) => release.release_group_mbid)
|
||||
.filter(Boolean);
|
||||
|
||||
const existingMetadata =
|
||||
mbIds.length > 0
|
||||
? await getRepository(MetadataAlbum).find({
|
||||
where: { mbAlbumId: In(mbIds) },
|
||||
select: ['mbAlbumId', 'caaUrl'],
|
||||
cache: true,
|
||||
})
|
||||
: [];
|
||||
|
||||
const metadataMap = new Map(
|
||||
existingMetadata.map((meta) => [meta.mbAlbumId, meta])
|
||||
);
|
||||
|
||||
if (showOnlyWithCovers) {
|
||||
filteredReleases = filteredReleases.filter((release) => {
|
||||
if (!release.release_group_mbid) {
|
||||
return false;
|
||||
}
|
||||
const metadata = metadataMap.get(release.release_group_mbid);
|
||||
return !!metadata?.caaUrl;
|
||||
});
|
||||
}
|
||||
|
||||
const totalResults = filteredReleases.length;
|
||||
const totalPages = Math.ceil(totalResults / pageSize);
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const paginatedReleases = filteredReleases.slice(offset, offset + pageSize);
|
||||
|
||||
const paginatedMbIds = paginatedReleases
|
||||
.map((release) => release.release_group_mbid)
|
||||
.filter(Boolean);
|
||||
|
||||
if (paginatedMbIds.length === 0) {
|
||||
const results = paginatedReleases.map((release) => {
|
||||
let secondaryType;
|
||||
if (release.release_group_secondary_type) {
|
||||
secondaryType = release.release_group_secondary_type;
|
||||
} else if (release.release_tags && release.release_tags.length > 0) {
|
||||
secondaryType = release.release_tags[0];
|
||||
}
|
||||
|
||||
return {
|
||||
id: null,
|
||||
mediaType: 'album',
|
||||
'primary-type': release.release_group_primary_type || 'Album',
|
||||
secondaryType,
|
||||
title: release.release_name,
|
||||
'artist-credit': [{ name: release.artist_credit_name }],
|
||||
releaseDate: release.release_date,
|
||||
posterPath: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages,
|
||||
totalResults,
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
const media = await Media.getRelatedMedia(req.user, paginatedMbIds);
|
||||
|
||||
const mediaMap = new Map(
|
||||
media.map((mediaItem) => [mediaItem.mbId, mediaItem])
|
||||
);
|
||||
|
||||
const results = paginatedReleases.map((release) => {
|
||||
if (!release.release_group_mbid) {
|
||||
let secondaryType;
|
||||
if (release.release_group_secondary_type) {
|
||||
secondaryType = release.release_group_secondary_type;
|
||||
} else if (release.release_tags && release.release_tags.length > 0) {
|
||||
secondaryType = release.release_tags[0];
|
||||
}
|
||||
|
||||
return {
|
||||
id: null,
|
||||
mediaType: 'album',
|
||||
'primary-type': release.release_group_primary_type || 'Album',
|
||||
secondaryType,
|
||||
title: release.release_name,
|
||||
'artist-credit': [{ name: release.artist_credit_name }],
|
||||
releaseDate: release.release_date,
|
||||
posterPath: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = metadataMap.get(release.release_group_mbid);
|
||||
const hasCoverArt = !!metadata?.caaUrl;
|
||||
|
||||
let secondaryType;
|
||||
if (release.release_group_secondary_type) {
|
||||
secondaryType = release.release_group_secondary_type;
|
||||
} else if (release.release_tags && release.release_tags.length > 0) {
|
||||
secondaryType = release.release_tags[0];
|
||||
}
|
||||
|
||||
return {
|
||||
id: release.release_group_mbid,
|
||||
mediaType: 'album',
|
||||
'primary-type': release.release_group_primary_type || 'Album',
|
||||
secondaryType,
|
||||
title: release.release_name,
|
||||
'artist-credit': [{ name: release.artist_credit_name }],
|
||||
artistId: release.artist_mbids?.[0],
|
||||
mediaInfo: mediaMap.get(release.release_group_mbid),
|
||||
releaseDate: release.release_date,
|
||||
posterPath: metadata?.caaUrl || null,
|
||||
needsCoverArt: !hasCoverArt,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages,
|
||||
totalResults,
|
||||
results,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to retrieve fresh music releases', {
|
||||
label: 'API',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve fresh music releases.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
discoverRoutes.get('/music/albums', async (req, res, next) => {
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
|
||||
try {
|
||||
const page = Number(req.query.page) || 1;
|
||||
const pageSize = 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const sortBy = (req.query.sortBy as string) || 'listen_count.desc';
|
||||
|
||||
const topAlbumsData = await listenbrainz.getTopAlbums({
|
||||
offset,
|
||||
count: pageSize,
|
||||
range: 'week',
|
||||
});
|
||||
|
||||
const mbIds = topAlbumsData.payload.release_groups
|
||||
.map((album) => album.release_group_mbid)
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
if (mbIds.length === 0) {
|
||||
const results = topAlbumsData.payload.release_groups.map((album) => ({
|
||||
id: null,
|
||||
mediaType: 'album',
|
||||
'primary-type': 'Album',
|
||||
title: album.release_group_name,
|
||||
'artist-credit': [{ name: album.artist_name }],
|
||||
listenCount: album.listen_count,
|
||||
posterPath: undefined,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(topAlbumsData.payload.count / pageSize),
|
||||
totalResults: topAlbumsData.payload.count,
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
const [existingMetadata, media] = await Promise.all([
|
||||
getRepository(MetadataAlbum).find({
|
||||
where: { mbAlbumId: In(mbIds) },
|
||||
select: ['mbAlbumId', 'caaUrl'],
|
||||
cache: true,
|
||||
}),
|
||||
Media.getRelatedMedia(req.user, mbIds),
|
||||
]);
|
||||
|
||||
const metadataMap = new Map(
|
||||
existingMetadata.map((meta) => [meta.mbAlbumId, meta])
|
||||
);
|
||||
|
||||
const mediaMap = new Map(
|
||||
media.map((mediaItem) => [mediaItem.mbId, mediaItem])
|
||||
);
|
||||
|
||||
const results = topAlbumsData.payload.release_groups.map((album) => {
|
||||
if (!album.release_group_mbid) {
|
||||
return {
|
||||
id: null,
|
||||
mediaType: 'album',
|
||||
'primary-type': 'Album',
|
||||
title: album.release_group_name,
|
||||
'artist-credit': [{ name: album.artist_name }],
|
||||
listenCount: album.listen_count,
|
||||
posterPath: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = metadataMap.get(album.release_group_mbid);
|
||||
const hasCoverArt = !!metadata?.caaUrl;
|
||||
|
||||
return {
|
||||
id: album.release_group_mbid,
|
||||
mediaType: 'album',
|
||||
'primary-type': 'Album',
|
||||
title: album.release_group_name,
|
||||
'artist-credit': [{ name: album.artist_name }],
|
||||
artistId: album.artist_mbids[0],
|
||||
mediaInfo: mediaMap.get(album.release_group_mbid),
|
||||
listenCount: album.listen_count,
|
||||
posterPath: metadata?.caaUrl || null,
|
||||
needsCoverArt: !hasCoverArt,
|
||||
};
|
||||
});
|
||||
|
||||
if (sortBy) {
|
||||
const [field, direction] = sortBy.split('.');
|
||||
const multiplier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
results.sort((a, b) => {
|
||||
switch (field) {
|
||||
case 'listen_count': {
|
||||
return (a.listenCount - b.listenCount) * multiplier;
|
||||
}
|
||||
case 'title': {
|
||||
return (a.title ?? '').localeCompare(b.title ?? '') * multiplier;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(topAlbumsData.payload.count / pageSize),
|
||||
totalResults: topAlbumsData.payload.count,
|
||||
results,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to retrieve popular music', {
|
||||
label: 'API',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve popular music.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
discoverRoutes.get('/music/artists', async (req, res, next) => {
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const personMapper = new TmdbPersonMapper();
|
||||
const theAudioDb = new TheAudioDb();
|
||||
|
||||
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: 'week',
|
||||
});
|
||||
|
||||
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 responses = await Promise.allSettled([
|
||||
artistsNeedingImages.length > 0
|
||||
? theAudioDb.batchGetArtistImages(artistsNeedingImages)
|
||||
: Promise.resolve({} as ArtistImageResults),
|
||||
artistsForPersonMapping.length > 0
|
||||
? personMapper.batchGetMappings(artistsForPersonMapping)
|
||||
: Promise.resolve({}),
|
||||
]);
|
||||
|
||||
const artistImageResults =
|
||||
responses[0].status === 'fulfilled' ? responses[0].value : {};
|
||||
|
||||
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>(
|
||||
'/watchlist',
|
||||
async (req, res) => {
|
||||
|
||||
@@ -27,14 +27,17 @@ import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import artistRoutes from './artist';
|
||||
import authRoutes from './auth';
|
||||
import blacklistRoutes from './blacklist';
|
||||
import collectionRoutes from './collection';
|
||||
import coverArtRoutes from './coverart';
|
||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||
import issueRoutes from './issue';
|
||||
import issueCommentRoutes from './issueComment';
|
||||
import mediaRoutes from './media';
|
||||
import movieRoutes from './movie';
|
||||
import musicRoutes from './music';
|
||||
import personRoutes from './person';
|
||||
import requestRoutes from './request';
|
||||
import searchRoutes from './search';
|
||||
@@ -154,8 +157,10 @@ router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
||||
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
|
||||
router.use('/movie', isAuthenticated(), movieRoutes);
|
||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||
router.use('/music', isAuthenticated(), musicRoutes);
|
||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
router.use('/person', isAuthenticated(), personRoutes);
|
||||
router.use('/artist', isAuthenticated(), artistRoutes);
|
||||
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||
router.use('/service', isAuthenticated(), serviceRoutes);
|
||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||
@@ -166,7 +171,7 @@ router.use(
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
overrideRuleRoutes
|
||||
);
|
||||
|
||||
router.use('/coverart', isAuthenticated(), coverArtRoutes);
|
||||
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
|
||||
@@ -179,6 +179,12 @@ issueRoutes.get('/count', async (req, res, next) => {
|
||||
})
|
||||
.getCount();
|
||||
|
||||
const lyricsCount = await query
|
||||
.where('issue.issueType = :issueType', {
|
||||
issueType: IssueType.LYRICS,
|
||||
})
|
||||
.getCount();
|
||||
|
||||
const openCount = await query
|
||||
.where('issue.status = :issueStatus', {
|
||||
issueStatus: IssueStatus.OPEN,
|
||||
@@ -197,6 +203,7 @@ issueRoutes.get('/count', async (req, res, next) => {
|
||||
audio: audioCount,
|
||||
subtitles: subtitlesCount,
|
||||
others: othersCount,
|
||||
lyrics: lyricsCount,
|
||||
open: openCount,
|
||||
closed: closedCount,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
@@ -5,6 +6,7 @@ import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||
import Season from '@server/entity/Season';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
@@ -23,6 +25,7 @@ const mediaRoutes = Router();
|
||||
|
||||
mediaRoutes.get('/', async (req, res, next) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const metadataAlbumRepository = getRepository(MetadataAlbum);
|
||||
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
@@ -77,6 +80,37 @@ mediaRoutes.get('/', async (req, res, next) => {
|
||||
take: pageSize,
|
||||
skip,
|
||||
});
|
||||
|
||||
const musicMediaItems = media.filter(
|
||||
(item) => item.mediaType === 'music' && item.mbId
|
||||
);
|
||||
|
||||
const mbIds = musicMediaItems.map((item) => item.mbId as string);
|
||||
|
||||
const albumMetadata =
|
||||
mbIds.length > 0
|
||||
? await metadataAlbumRepository.find({
|
||||
where: { mbAlbumId: In(mbIds) },
|
||||
select: ['mbAlbumId', 'caaUrl'],
|
||||
})
|
||||
: [];
|
||||
|
||||
const albumMetadataMap = new Map(
|
||||
albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata])
|
||||
);
|
||||
|
||||
const mediaWithCoverArt = media.map((item) => {
|
||||
if (item.mediaType === 'music' && item.mbId) {
|
||||
const metadata = albumMetadataMap.get(item.mbId);
|
||||
return {
|
||||
...item,
|
||||
posterPath: metadata?.caaUrl || null,
|
||||
needsCoverArt: !metadata?.caaUrl,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(mediaCount / pageSize),
|
||||
@@ -84,10 +118,14 @@ mediaRoutes.get('/', async (req, res, next) => {
|
||||
results: mediaCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: media,
|
||||
results: mediaWithCoverArt,
|
||||
} as MediaResultsResponse);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
logger.error('Something went wrong retrieving media', {
|
||||
label: 'Media',
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
next({ status: 500, message: 'Unable to retrieve media' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -199,17 +237,19 @@ mediaRoutes.delete(
|
||||
});
|
||||
|
||||
const is4k = String(req.query.is4k) === 'true';
|
||||
const isMovie = media.mediaType === MediaType.MOVIE;
|
||||
|
||||
let serviceSettings;
|
||||
if (isMovie) {
|
||||
|
||||
if (media.mediaType === MediaType.MOVIE) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
||||
);
|
||||
} else {
|
||||
} else if (media.mediaType === MediaType.TV) {
|
||||
serviceSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
||||
);
|
||||
} else {
|
||||
serviceSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
|
||||
}
|
||||
|
||||
const specificServiceId = is4k ? media.serviceId4k : media.serviceId;
|
||||
@@ -218,60 +258,82 @@ mediaRoutes.delete(
|
||||
specificServiceId >= 0 &&
|
||||
serviceSettings?.id !== specificServiceId
|
||||
) {
|
||||
if (isMovie) {
|
||||
if (media.mediaType === MediaType.MOVIE) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === specificServiceId
|
||||
);
|
||||
} else {
|
||||
} else if (media.mediaType === MediaType.TV) {
|
||||
serviceSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === specificServiceId
|
||||
);
|
||||
} else {
|
||||
serviceSettings = settings.lidarr.find(
|
||||
(lidarr) => lidarr.id === media.serviceId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!serviceSettings) {
|
||||
const serviceType =
|
||||
media.mediaType === MediaType.MOVIE
|
||||
? 'Radarr'
|
||||
: media.mediaType === MediaType.TV
|
||||
? 'Sonarr'
|
||||
: 'Lidarr';
|
||||
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
}/ server configured. Did you set any of your ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
} servers as default?`,
|
||||
is4k && media.mediaType !== MediaType.MUSIC ? '4K ' : ''
|
||||
}${serviceType} server configured.`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
mediaId: media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: `No default ${serviceType} server configured` });
|
||||
}
|
||||
|
||||
let service;
|
||||
if (isMovie) {
|
||||
|
||||
if (media.mediaType === MediaType.MOVIE) {
|
||||
service = new RadarrAPI({
|
||||
apiKey: serviceSettings?.apiKey,
|
||||
apiKey: serviceSettings.apiKey,
|
||||
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||
});
|
||||
} else {
|
||||
|
||||
await (service as RadarrAPI).removeMovie(media.tmdbId);
|
||||
} else if (media.mediaType === MediaType.TV) {
|
||||
service = new SonarrAPI({
|
||||
apiKey: serviceSettings?.apiKey,
|
||||
url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isMovie) {
|
||||
await (service as RadarrAPI).removeMovie(media.tmdbId);
|
||||
} else {
|
||||
const tmdb = new TheMovieDb();
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
|
||||
if (!tvdbId) {
|
||||
throw new Error('TVDB ID not found');
|
||||
}
|
||||
await (service as SonarrAPI).removeSeries(tvdbId);
|
||||
} else if (media.mediaType == MediaType.MUSIC) {
|
||||
service = new LidarrAPI({
|
||||
apiKey: serviceSettings.apiKey,
|
||||
url: LidarrAPI.buildUrl(serviceSettings, '/api/v1'),
|
||||
});
|
||||
|
||||
await service.removeAlbum(
|
||||
media.externalServiceId
|
||||
? parseInt(media.externalServiceId.toString())
|
||||
: 0
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching media in delete request', {
|
||||
logger.error('Something went wrong deleting media file', {
|
||||
label: 'Media',
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
532
server/routes/music.ts
Normal file
532
server/routes/music.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import TheAudioDb from '@server/api/theaudiodb';
|
||||
import TmdbPersonMapper from '@server/api/themoviedb/personMapper';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import logger from '@server/logger';
|
||||
import { mapMusicDetails } from '@server/models/Music';
|
||||
import { Router } from 'express';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
const musicRoutes = Router();
|
||||
|
||||
musicRoutes.get('/:id', async (req, res, next) => {
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const musicbrainz = new MusicBrainz();
|
||||
const personMapper = new TmdbPersonMapper();
|
||||
const theAudioDb = new TheAudioDb();
|
||||
|
||||
try {
|
||||
const [albumDetails, media, onUserWatchlist] = await Promise.all([
|
||||
listenbrainz.getAlbum(req.params.id),
|
||||
getRepository(Media)
|
||||
.createQueryBuilder('media')
|
||||
.leftJoinAndSelect('media.requests', 'requests')
|
||||
.leftJoinAndSelect('requests.requestedBy', 'requestedBy')
|
||||
.leftJoinAndSelect('requests.modifiedBy', 'modifiedBy')
|
||||
.where({
|
||||
mbId: req.params.id,
|
||||
mediaType: MediaType.MUSIC,
|
||||
})
|
||||
.getOne()
|
||||
.then((media) => media ?? undefined),
|
||||
getRepository(Watchlist).exist({
|
||||
where: {
|
||||
mbId: req.params.id,
|
||||
requestedBy: { id: req.user?.id },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const artistId =
|
||||
albumDetails.release_group_metadata?.artist?.artists[0]?.artist_mbid;
|
||||
const isPerson =
|
||||
albumDetails.release_group_metadata?.artist?.artists[0]?.type ===
|
||||
'Person';
|
||||
const trackArtistIds = albumDetails.mediums
|
||||
.flatMap((medium) => medium.tracks)
|
||||
.flatMap((track) => track.artists)
|
||||
.filter((artist) => artist.artist_mbid)
|
||||
.map((artist) => artist.artist_mbid);
|
||||
|
||||
const [
|
||||
metadataAlbum,
|
||||
metadataArtist,
|
||||
trackArtistMetadata,
|
||||
artistWikipedia,
|
||||
] = await Promise.allSettled([
|
||||
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 resolvedMetadataAlbum =
|
||||
metadataAlbum.status === 'fulfilled' ? metadataAlbum.value : null;
|
||||
const resolvedMetadataArtist =
|
||||
metadataArtist.status === 'fulfilled' ? metadataArtist.value : undefined;
|
||||
const resolvedTrackArtistMetadata =
|
||||
trackArtistMetadata.status === 'fulfilled'
|
||||
? trackArtistMetadata.value
|
||||
: [];
|
||||
const resolvedArtistWikipedia =
|
||||
artistWikipedia.status === 'fulfilled' ? artistWikipedia.value : null;
|
||||
|
||||
const trackArtistsToMap = albumDetails.mediums
|
||||
.flatMap((medium) => medium.tracks)
|
||||
.flatMap((track) =>
|
||||
track.artists
|
||||
.filter((artist) => artist.artist_mbid)
|
||||
.filter(
|
||||
(artist) =>
|
||||
!resolvedTrackArtistMetadata.some(
|
||||
(m) => m.mbArtistId === artist.artist_mbid && m.tmdbPersonId
|
||||
)
|
||||
)
|
||||
.map((artist) => ({
|
||||
artistId: artist.artist_mbid,
|
||||
artistName: artist.artist_credit_name,
|
||||
}))
|
||||
);
|
||||
|
||||
const responses = await Promise.allSettled([
|
||||
artistId &&
|
||||
!resolvedMetadataArtist?.tadbThumb &&
|
||||
!resolvedMetadataArtist?.tadbCover
|
||||
? theAudioDb.getArtistImages(artistId)
|
||||
: Promise.resolve(null),
|
||||
artistId && isPerson && !resolvedMetadataArtist?.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(resolvedTrackArtistMetadata),
|
||||
]);
|
||||
|
||||
const artistImages =
|
||||
responses[0].status === 'fulfilled' ? responses[0].value : null;
|
||||
const personMappingResult =
|
||||
responses[1].status === 'fulfilled' ? responses[1].value : null;
|
||||
const updatedArtistMetadata =
|
||||
responses[2].status === 'fulfilled'
|
||||
? responses[2].value
|
||||
: resolvedTrackArtistMetadata;
|
||||
|
||||
const updatedMetadataArtist =
|
||||
personMappingResult && artistId
|
||||
? await getRepository(MetadataArtist).findOne({
|
||||
where: { mbArtistId: artistId },
|
||||
})
|
||||
: resolvedMetadataArtist;
|
||||
|
||||
const mappedDetails = mapMusicDetails(albumDetails, media, onUserWatchlist);
|
||||
const finalTrackArtistMetadata =
|
||||
updatedArtistMetadata || resolvedTrackArtistMetadata;
|
||||
|
||||
return res.status(200).json({
|
||||
...mappedDetails,
|
||||
posterPath: resolvedMetadataAlbum?.caaUrl ?? null,
|
||||
needsCoverArt: !resolvedMetadataAlbum?.caaUrl,
|
||||
artistWikipedia: resolvedArtistWikipedia,
|
||||
artistThumb:
|
||||
updatedMetadataArtist?.tmdbThumb ??
|
||||
updatedMetadataArtist?.tadbThumb ??
|
||||
artistImages?.artistThumb ??
|
||||
null,
|
||||
artistBackdrop:
|
||||
updatedMetadataArtist?.tadbCover ??
|
||||
artistImages?.artistBackground ??
|
||||
null,
|
||||
tmdbPersonId: updatedMetadataArtist?.tmdbPersonId
|
||||
? Number(updatedMetadataArtist.tmdbPersonId)
|
||||
: null,
|
||||
tracks: mappedDetails.tracks.map((track) => ({
|
||||
...track,
|
||||
artists: track.artists.map((artist) => {
|
||||
const metadata = finalTrackArtistMetadata.find(
|
||||
(m) => m.mbArtistId === artist.mbid
|
||||
);
|
||||
return {
|
||||
...artist,
|
||||
tmdbMapping: metadata?.tmdbPersonId
|
||||
? {
|
||||
personId: Number(metadata.tmdbPersonId),
|
||||
profilePath: metadata.tmdbThumb,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong retrieving album details', {
|
||||
label: 'Music API',
|
||||
errorMessage: e.message,
|
||||
mbId: req.params.id,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve album details.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
musicRoutes.get('/:id/artist', async (req, res, next) => {
|
||||
try {
|
||||
const listenbrainzApi = new ListenBrainzAPI();
|
||||
const theAudioDb = new TheAudioDb();
|
||||
const metadataArtistRepository = getRepository(MetadataArtist);
|
||||
|
||||
const albumData = await listenbrainzApi.getAlbum(req.params.id);
|
||||
const artistData = albumData?.release_group_metadata?.artist?.artists?.[0];
|
||||
const artistType = artistData?.type;
|
||||
|
||||
if (!artistData?.artist_mbid || artistType === 'Other') {
|
||||
return res.status(404).json({
|
||||
status: 404,
|
||||
message: 'Artist details not available for this type',
|
||||
});
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled([
|
||||
listenbrainzApi.getArtist(artistData.artist_mbid),
|
||||
theAudioDb.getArtistImagesFromCache(artistData.artist_mbid),
|
||||
metadataArtistRepository.findOne({
|
||||
where: { mbArtistId: artistData.artist_mbid },
|
||||
}),
|
||||
]);
|
||||
|
||||
const artistDetails =
|
||||
responses[0].status === 'fulfilled' ? responses[0].value : null;
|
||||
const cachedTheAudioDb =
|
||||
responses[1].status === 'fulfilled' ? responses[1].value : null;
|
||||
const metadataArtist =
|
||||
responses[2].status === 'fulfilled' ? responses[2].value : null;
|
||||
|
||||
if (!artistDetails) {
|
||||
return res.status(404).json({ status: 404, message: 'Artist not found' });
|
||||
}
|
||||
|
||||
const [artistImagesResult] = await Promise.allSettled([
|
||||
!cachedTheAudioDb &&
|
||||
!metadataArtist?.tadbThumb &&
|
||||
!metadataArtist?.tadbCover
|
||||
? theAudioDb.getArtistImages(artistData.artist_mbid)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const artistImages =
|
||||
artistImagesResult.status === 'fulfilled'
|
||||
? artistImagesResult.value
|
||||
: null;
|
||||
|
||||
return res.status(200).json({
|
||||
artist: {
|
||||
...artistDetails,
|
||||
artistThumb:
|
||||
cachedTheAudioDb?.artistThumb ??
|
||||
metadataArtist?.tadbThumb ??
|
||||
artistImages?.artistThumb ??
|
||||
null,
|
||||
artistBackdrop:
|
||||
cachedTheAudioDb?.artistBackground ??
|
||||
metadataArtist?.tadbCover ??
|
||||
artistImages?.artistBackground ??
|
||||
null,
|
||||
},
|
||||
});
|
||||
} 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.' });
|
||||
}
|
||||
});
|
||||
|
||||
musicRoutes.get('/:id/artist-discography', async (req, res, next) => {
|
||||
try {
|
||||
const listenbrainzApi = new ListenBrainzAPI();
|
||||
const metadataAlbumRepository = getRepository(MetadataAlbum);
|
||||
|
||||
const page = Number(req.query.page) || 1;
|
||||
const pageSize = Number(req.query.pageSize) || 20;
|
||||
const isSlider = req.query.slider === 'true';
|
||||
|
||||
const albumData = await listenbrainzApi.getAlbum(req.params.id);
|
||||
const artistData = albumData?.release_group_metadata?.artist?.artists?.[0];
|
||||
const artistType = artistData?.type;
|
||||
|
||||
if (!artistData?.artist_mbid || artistType === 'Other') {
|
||||
return res.status(404).json({
|
||||
status: 404,
|
||||
message: 'Artist details not available for this type',
|
||||
});
|
||||
}
|
||||
|
||||
const artistDetails = await listenbrainzApi.getArtist(
|
||||
artistData.artist_mbid
|
||||
);
|
||||
|
||||
if (!artistDetails) {
|
||||
return res.status(404).json({ status: 404, message: 'Artist not found' });
|
||||
}
|
||||
|
||||
const totalReleaseGroups = artistDetails.releaseGroups.length;
|
||||
const paginatedReleaseGroups =
|
||||
isSlider || page === 1
|
||||
? artistDetails.releaseGroups.slice(0, pageSize)
|
||||
: artistDetails.releaseGroups.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize
|
||||
);
|
||||
|
||||
const releaseGroupIds = paginatedReleaseGroups.map((rg) => rg.mbid);
|
||||
|
||||
const mediaResponses = await Promise.allSettled([
|
||||
Media.getRelatedMedia(req.user, releaseGroupIds),
|
||||
metadataAlbumRepository.find({
|
||||
where: { mbAlbumId: In(releaseGroupIds) },
|
||||
}),
|
||||
]);
|
||||
|
||||
const relatedMedia =
|
||||
mediaResponses[0].status === 'fulfilled' ? mediaResponses[0].value : [];
|
||||
const albumMetadata =
|
||||
mediaResponses[1].status === 'fulfilled' ? mediaResponses[1].value : [];
|
||||
|
||||
const albumMetadataMap = new Map(
|
||||
albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata])
|
||||
);
|
||||
|
||||
const relatedMediaMap = new Map(
|
||||
relatedMedia.map((media) => [media.mbId, media])
|
||||
);
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page,
|
||||
totalPages: Math.ceil(totalReleaseGroups / pageSize),
|
||||
totalResults: totalReleaseGroups,
|
||||
results: transformedReleaseGroups,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Something went wrong retrieving artist discography', {
|
||||
label: 'Music API',
|
||||
errorMessage: error.message,
|
||||
artistId: req.params.id,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve artist discography.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
musicRoutes.get('/:id/artist-similar', async (req, res, next) => {
|
||||
try {
|
||||
const listenbrainzApi = new ListenBrainzAPI();
|
||||
const personMapper = new TmdbPersonMapper();
|
||||
const theAudioDb = new TheAudioDb();
|
||||
const metadataArtistRepository = getRepository(MetadataArtist);
|
||||
|
||||
const page = Number(req.query.page) || 1;
|
||||
const pageSize = Number(req.query.pageSize) || 20;
|
||||
|
||||
const albumData = await listenbrainzApi.getAlbum(req.params.id);
|
||||
const artistData = albumData?.release_group_metadata?.artist?.artists?.[0];
|
||||
const artistType = artistData?.type;
|
||||
|
||||
if (!artistData?.artist_mbid || artistType === 'Other') {
|
||||
return res.status(404).json({
|
||||
status: 404,
|
||||
message: 'Artist details not available for this type',
|
||||
});
|
||||
}
|
||||
|
||||
const artistDetails = await listenbrainzApi.getArtist(
|
||||
artistData.artist_mbid
|
||||
);
|
||||
|
||||
if (!artistDetails) {
|
||||
return res.status(404).json({ status: 404, message: 'Artist not found' });
|
||||
}
|
||||
|
||||
const allSimilarArtists =
|
||||
artistDetails.similarArtists?.artists?.sort(
|
||||
(a, b) => b.score - a.score
|
||||
) ?? [];
|
||||
|
||||
const totalResults = allSimilarArtists.length;
|
||||
const totalPages = Math.ceil(totalResults / pageSize);
|
||||
|
||||
const paginatedSimilarArtists = allSimilarArtists.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize
|
||||
);
|
||||
|
||||
const similarArtistIds = paginatedSimilarArtists.map((a) => a.artist_mbid);
|
||||
|
||||
if (similarArtistIds.length === 0) {
|
||||
return res.status(200).json({
|
||||
page,
|
||||
totalPages,
|
||||
totalResults,
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const [similarArtistMetadataResult] = await Promise.allSettled([
|
||||
metadataArtistRepository.find({
|
||||
where: { mbArtistId: In(similarArtistIds) },
|
||||
}),
|
||||
]);
|
||||
|
||||
const similarArtistMetadata =
|
||||
similarArtistMetadataResult.status === 'fulfilled'
|
||||
? similarArtistMetadataResult.value
|
||||
: [];
|
||||
|
||||
const similarArtistMetadataMap = new Map(
|
||||
similarArtistMetadata.map((metadata) => [metadata.mbArtistId, metadata])
|
||||
);
|
||||
|
||||
const artistsNeedingImages = similarArtistIds.filter((id) => {
|
||||
const metadata = similarArtistMetadataMap.get(id);
|
||||
return !metadata?.tadbThumb && !metadata?.tadbCover;
|
||||
});
|
||||
|
||||
const personArtists =
|
||||
paginatedSimilarArtists
|
||||
.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 artistResponses = await Promise.allSettled([
|
||||
artistsNeedingImages.length > 0
|
||||
? theAudioDb.batchGetArtistImages(artistsNeedingImages)
|
||||
: ({} as ArtistImageResults),
|
||||
personArtists.length > 0
|
||||
? personMapper.batchGetMappings(personArtists).then(() =>
|
||||
metadataArtistRepository.find({
|
||||
where: { mbArtistId: In(similarArtistIds) },
|
||||
})
|
||||
)
|
||||
: Promise.resolve(similarArtistMetadata),
|
||||
]);
|
||||
|
||||
const artistImageResults =
|
||||
artistResponses[0].status === 'fulfilled' ? artistResponses[0].value : {};
|
||||
const updatedArtistMetadata =
|
||||
artistResponses[1].status === 'fulfilled'
|
||||
? artistResponses[1].value
|
||||
: similarArtistMetadata;
|
||||
|
||||
const finalArtistMetadataMap = new Map(
|
||||
(updatedArtistMetadata || similarArtistMetadata).map((metadata) => [
|
||||
metadata.mbArtistId,
|
||||
metadata,
|
||||
])
|
||||
);
|
||||
|
||||
const transformedSimilarArtists = paginatedSimilarArtists.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);
|
||||
|
||||
return {
|
||||
id: artist.artist_mbid,
|
||||
mediaType: 'artist',
|
||||
name: artist.name,
|
||||
type: artist.type as 'Group' | 'Person',
|
||||
artistThumb: metadata?.tmdbThumb ?? artistThumb,
|
||||
score: artist.score,
|
||||
tmdbPersonId: metadata?.tmdbPersonId
|
||||
? Number(metadata.tmdbPersonId)
|
||||
: null,
|
||||
'sort-name': artist.name,
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
page,
|
||||
totalPages,
|
||||
totalResults,
|
||||
results: transformedSimilarArtists,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Something went wrong retrieving similar artists', {
|
||||
label: 'Music API',
|
||||
errorMessage: error.message,
|
||||
artistId: req.params.id,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve similar artists.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default musicRoutes;
|
||||
@@ -1,5 +1,10 @@
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import TheAudioDb from '@server/api/theaudiodb';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
mapCastCredits,
|
||||
@@ -7,18 +12,182 @@ import {
|
||||
mapPersonDetails,
|
||||
} from '@server/models/Person';
|
||||
import { Router } from 'express';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
const personRoutes = Router();
|
||||
|
||||
personRoutes.get('/:id', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const theAudioDb = new TheAudioDb();
|
||||
|
||||
const page = Number(req.query.page) || 1;
|
||||
const pageSize = Number(req.query.pageSize) || 20;
|
||||
const initialItemsPerType = 20;
|
||||
const albumType = req.query.albumType as string | undefined;
|
||||
|
||||
try {
|
||||
const person = await tmdb.getPerson({
|
||||
personId: Number(req.params.id),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
});
|
||||
return res.status(200).json(mapPersonDetails(person));
|
||||
const [person, existingMetadata] = await Promise.all([
|
||||
tmdb.getPerson({
|
||||
personId: Number(req.params.id),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
}),
|
||||
getRepository(MetadataArtist).findOne({
|
||||
where: { tmdbPersonId: req.params.id },
|
||||
select: ['mbArtistId', 'tmdbThumb', 'tadbThumb', 'tadbCover'],
|
||||
}),
|
||||
]);
|
||||
|
||||
let artistData = null;
|
||||
|
||||
if (existingMetadata?.mbArtistId) {
|
||||
artistData = await listenbrainz.getArtist(existingMetadata.mbArtistId);
|
||||
|
||||
if (artistData?.releaseGroups) {
|
||||
const groupedReleaseGroups = artistData.releaseGroups.reduce(
|
||||
(acc, rg) => {
|
||||
const type = rg.secondary_types?.length
|
||||
? rg.secondary_types[0]
|
||||
: rg.type || 'Other';
|
||||
|
||||
if (!acc[type]) {
|
||||
acc[type] = [];
|
||||
}
|
||||
acc[type].push(rg);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<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 allReleaseGroupIds = releaseGroupsToProcess.map((rg) => rg.mbid);
|
||||
|
||||
const responses = await Promise.allSettled([
|
||||
!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,
|
||||
}),
|
||||
]);
|
||||
|
||||
const artistImagesPromise =
|
||||
responses[0].status === 'fulfilled' ? responses[0].value : null;
|
||||
const relatedMedia =
|
||||
responses[1].status === 'fulfilled' ? responses[1].value : [];
|
||||
const albumMetadata =
|
||||
responses[2].status === 'fulfilled' ? responses[2].value : [];
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const mappedDetails = {
|
||||
...mapPersonDetails(person),
|
||||
artist:
|
||||
artistData && existingMetadata?.mbArtistId
|
||||
? {
|
||||
mbid: existingMetadata.mbArtistId,
|
||||
profilePath: person.profile_path
|
||||
? `https://image.tmdb.org/t/p/w500${person.profile_path}`
|
||||
: existingMetadata.tadbThumb ?? null,
|
||||
artistThumb: existingMetadata.tadbThumb ?? null,
|
||||
artistBackdrop: existingMetadata.tadbCover ?? null,
|
||||
...artistData,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return res.status(200).json(mappedDetails);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving person', {
|
||||
label: 'API',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import {
|
||||
@@ -213,6 +214,21 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
})
|
||||
);
|
||||
|
||||
// get all quality profiles for every configured lidarr server
|
||||
const lidarrServers = await Promise.all(
|
||||
settings.lidarr.map(async (lidarrSetting) => {
|
||||
const lidarr = new LidarrAPI({
|
||||
apiKey: lidarrSetting.apiKey,
|
||||
url: LidarrAPI.buildUrl(lidarrSetting, '/api/v1'),
|
||||
});
|
||||
|
||||
return {
|
||||
id: lidarrSetting.id,
|
||||
profiles: await lidarr.getProfiles().catch(() => undefined),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// add profile names to the media requests, with undefined if not found
|
||||
let mappedRequests = requests.map((r) => {
|
||||
switch (r.type) {
|
||||
@@ -234,6 +250,14 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
|
||||
};
|
||||
}
|
||||
case MediaType.MUSIC: {
|
||||
return {
|
||||
...r,
|
||||
profileName: lidarrServers
|
||||
.find((serverr) => serverr.id === r.serverId)
|
||||
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -263,6 +287,17 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
),
|
||||
};
|
||||
}
|
||||
case MediaType.MUSIC: {
|
||||
return {
|
||||
...r,
|
||||
// check if the lidarr server for this request is configured
|
||||
canRemove: lidarrServers.some(
|
||||
(server) => server.id === r.media.serviceId
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return r;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,54 +1,315 @@
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import TheAudioDb from '@server/api/theaudiodb';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
|
||||
import TmdbPersonMapper from '@server/api/themoviedb/personMapper';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { findSearchProvider } from '@server/lib/search';
|
||||
import MetadataAlbum from '@server/entity/MetadataAlbum';
|
||||
import MetadataArtist from '@server/entity/MetadataArtist';
|
||||
import {
|
||||
findSearchProvider,
|
||||
type CombinedSearchResponse,
|
||||
} from '@server/lib/search';
|
||||
import logger from '@server/logger';
|
||||
import { mapSearchResults } from '@server/models/Search';
|
||||
import { Router } from 'express';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
const searchRoutes = Router();
|
||||
|
||||
searchRoutes.get('/', async (req, res, next) => {
|
||||
const queryString = req.query.query as string;
|
||||
const searchProvider = findSearchProvider(queryString.toLowerCase());
|
||||
let results: TmdbSearchMultiResponse;
|
||||
const page = Number(req.query.page) || 1;
|
||||
const language = (req.query.language as string) ?? req.locale;
|
||||
|
||||
try {
|
||||
const searchProvider = findSearchProvider(queryString.toLowerCase());
|
||||
let results: CombinedSearchResponse;
|
||||
|
||||
if (searchProvider) {
|
||||
const [id] = queryString
|
||||
.toLowerCase()
|
||||
.match(searchProvider.pattern) as RegExpMatchArray;
|
||||
results = await searchProvider.search({
|
||||
id,
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
language,
|
||||
query: queryString,
|
||||
});
|
||||
} else {
|
||||
const tmdb = new TheMovieDb();
|
||||
const musicbrainz = new MusicBrainz();
|
||||
const theAudioDb = new TheAudioDb();
|
||||
const personMapper = new TmdbPersonMapper();
|
||||
|
||||
results = await tmdb.searchMulti({
|
||||
query: queryString,
|
||||
page: Number(req.query.page),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
const responses = await Promise.allSettled([
|
||||
tmdb.searchMulti({
|
||||
query: queryString,
|
||||
page,
|
||||
language,
|
||||
}),
|
||||
musicbrainz.searchAlbum({
|
||||
query: queryString,
|
||||
limit: 20,
|
||||
}),
|
||||
musicbrainz.searchArtist({
|
||||
query: queryString,
|
||||
limit: 20,
|
||||
}),
|
||||
]);
|
||||
|
||||
const tmdbResults =
|
||||
responses[0].status === 'fulfilled'
|
||||
? responses[0].value
|
||||
: { page: 1, results: [], total_pages: 1, total_results: 0 };
|
||||
const albumResults =
|
||||
responses[1].status === 'fulfilled' ? responses[1].value : [];
|
||||
const artistResults =
|
||||
responses[2].status === 'fulfilled' ? responses[2].value : [];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const artistsNeedingMapping = artistResults
|
||||
.filter(
|
||||
(artist) =>
|
||||
artist.type === 'Person' &&
|
||||
!artistsMetadataMap.get(artist.id)?.tmdbPersonId
|
||||
)
|
||||
.map((artist) => ({
|
||||
artistId: artist.id,
|
||||
artistName: artist.name,
|
||||
}));
|
||||
|
||||
const artistsNeedingImages = artistIds.filter((id) => {
|
||||
const metadata = artistsMetadataMap.get(id);
|
||||
return !metadata?.tadbThumb && !metadata?.tadbCover;
|
||||
});
|
||||
|
||||
type PersonMappingResult = Record<
|
||||
string,
|
||||
{ personId: number | null; profilePath: string | null }
|
||||
>;
|
||||
type ArtistImageResult = Record<
|
||||
string,
|
||||
{ artistThumb: string | null; artistBackground: string | null }
|
||||
>;
|
||||
|
||||
const externalApiResponses = await Promise.allSettled([
|
||||
artistsNeedingMapping.length > 0
|
||||
? personMapper.batchGetMappings(artistsNeedingMapping)
|
||||
: ({} as PersonMappingResult),
|
||||
artistsNeedingImages.length > 0
|
||||
? theAudioDb.batchGetArtistImages(artistsNeedingImages)
|
||||
: ({} as ArtistImageResult),
|
||||
]);
|
||||
|
||||
const personMappingResults =
|
||||
externalApiResponses[0].status === 'fulfilled'
|
||||
? externalApiResponses[0].value
|
||||
: ({} as PersonMappingResult);
|
||||
const artistImageResults =
|
||||
externalApiResponses[1].status === 'fulfilled'
|
||||
? externalApiResponses[1].value
|
||||
: ({} 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'],
|
||||
}
|
||||
);
|
||||
|
||||
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 / 20)
|
||||
);
|
||||
|
||||
const combinedResults =
|
||||
page === 1
|
||||
? [...tmdbResults.results, ...musicResults]
|
||||
: tmdbResults.results;
|
||||
|
||||
results = {
|
||||
page: tmdbResults.page,
|
||||
total_pages: totalPages,
|
||||
total_results: totalItems,
|
||||
results: combinedResults,
|
||||
};
|
||||
}
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
const movieTvIds = results.results
|
||||
.filter(
|
||||
(result) => result.media_type === 'movie' || result.media_type === 'tv'
|
||||
)
|
||||
.map((result) => Number(result.id));
|
||||
|
||||
const musicIds = results.results
|
||||
.filter(
|
||||
(result) =>
|
||||
result.media_type === 'album' || result.media_type === 'artist'
|
||||
)
|
||||
.map((result) => result.id.toString());
|
||||
|
||||
const [movieTvMedia, musicMedia] = await Promise.all([
|
||||
movieTvIds.length > 0 ? Media.getRelatedMedia(req.user, movieTvIds) : [],
|
||||
musicIds.length > 0 ? Media.getRelatedMedia(req.user, musicIds) : [],
|
||||
]);
|
||||
|
||||
const media = [...movieTvMedia, ...musicMedia];
|
||||
|
||||
const mappedResults = await mapSearchResults(results.results, media);
|
||||
|
||||
return res.status(200).json({
|
||||
page: results.page,
|
||||
totalPages: results.total_pages,
|
||||
totalResults: results.total_results,
|
||||
results: mapSearchResults(results.results, media),
|
||||
results: mappedResults,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving search results', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
query: req.query.query,
|
||||
errorMessage: e instanceof Error ? e.message : 'Unknown error',
|
||||
query: queryString,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
@@ -213,4 +214,73 @@ serviceRoutes.get<{ tmdbId: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
serviceRoutes.get('/lidarr', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const filteredLidarrServers: ServiceCommonServer[] = settings.lidarr.map(
|
||||
(lidarr) => ({
|
||||
id: lidarr.id,
|
||||
name: lidarr.name,
|
||||
activeDirectory: lidarr.activeDirectory,
|
||||
activeProfileId: lidarr.activeProfileId,
|
||||
activeTags: lidarr.tags ?? [],
|
||||
isDefault: lidarr.isDefault,
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json(filteredLidarrServers);
|
||||
});
|
||||
|
||||
serviceRoutes.get<{ id: string }>('/lidarr/:id', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const lidarrSettings = settings.lidarr.find(
|
||||
(lidarr) => lidarr.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (!lidarrSettings) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'Lidarr server not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const lidarr = new LidarrAPI({
|
||||
apiKey: lidarrSettings.apiKey,
|
||||
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
|
||||
});
|
||||
|
||||
try {
|
||||
const [profiles, metadataProfiles, rootFolders, tags] = await Promise.all([
|
||||
lidarr.getProfiles(),
|
||||
lidarr.getMetadataProfiles(),
|
||||
lidarr.getRootFolders(),
|
||||
lidarr.getTags(),
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
server: {
|
||||
id: lidarrSettings.id,
|
||||
name: lidarrSettings.name,
|
||||
isDefault: lidarrSettings.isDefault,
|
||||
activeDirectory: lidarrSettings.activeDirectory,
|
||||
activeProfileId: lidarrSettings.activeProfileId,
|
||||
activeMetadataProfileId: lidarrSettings.activeMetadataProfileId,
|
||||
activeTags: lidarrSettings.tags ?? [],
|
||||
},
|
||||
profiles,
|
||||
metadataProfiles,
|
||||
rootFolders: rootFolders.map((folder) => ({
|
||||
id: folder.id,
|
||||
path: folder.path,
|
||||
freeSpace: folder.freeSpace,
|
||||
totalSpace: folder.totalSpace,
|
||||
})),
|
||||
tags,
|
||||
} as ServiceCommonServerWithDetails);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default serviceRoutes;
|
||||
|
||||
@@ -40,6 +40,7 @@ import path from 'path';
|
||||
import semver from 'semver';
|
||||
import { URL } from 'url';
|
||||
import metadataRoutes from './metadata';
|
||||
import lidarrRoutes from './lidarr';
|
||||
import notificationRoutes from './notifications';
|
||||
import radarrRoutes from './radarr';
|
||||
import sonarrRoutes from './sonarr';
|
||||
@@ -49,6 +50,7 @@ const settingsRoutes = Router();
|
||||
settingsRoutes.use('/notifications', notificationRoutes);
|
||||
settingsRoutes.use('/radarr', radarrRoutes);
|
||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||
settingsRoutes.use('/lidarr', lidarrRoutes);
|
||||
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||
settingsRoutes.use('/metadatas', metadataRoutes);
|
||||
|
||||
@@ -758,6 +760,8 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||
const caaImageCache = await ImageProxy.getImageStats('caa');
|
||||
const tadbImageCache = await ImageProxy.getImageStats('tadb');
|
||||
|
||||
const stats: DnsStats | undefined = dnsCache?.getStats();
|
||||
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
|
||||
@@ -767,6 +771,8 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
avatar: avatarImageCache,
|
||||
caa: caaImageCache,
|
||||
tadb: tadbImageCache,
|
||||
},
|
||||
dnsCache: {
|
||||
stats,
|
||||
|
||||
136
server/routes/settings/lidarr.ts
Normal file
136
server/routes/settings/lidarr.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import type { LidarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const lidarrRoutes = Router();
|
||||
|
||||
lidarrRoutes.get('/', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.lidarr);
|
||||
});
|
||||
|
||||
lidarrRoutes.post('/', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const newLidarr = req.body as LidarrSettings;
|
||||
const lastItem = settings.lidarr[settings.lidarr.length - 1];
|
||||
newLidarr.id = lastItem ? lastItem.id + 1 : 0;
|
||||
|
||||
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||
settings.lidarr = [...settings.lidarr, newLidarr];
|
||||
settings.save();
|
||||
|
||||
return res.status(201).json(newLidarr);
|
||||
});
|
||||
|
||||
lidarrRoutes.post<
|
||||
undefined,
|
||||
Record<string, unknown>,
|
||||
LidarrSettings & { tagLabel?: string }
|
||||
>('/test', async (req, res, next) => {
|
||||
try {
|
||||
const lidarr = new LidarrAPI({
|
||||
apiKey: req.body.apiKey,
|
||||
url: LidarrAPI.buildUrl(req.body, '/api/v1'),
|
||||
});
|
||||
|
||||
const urlBase = await lidarr
|
||||
.getSystemStatus()
|
||||
.then((value) => value.urlBase)
|
||||
.catch(() => req.body.baseUrl);
|
||||
const profiles = await lidarr.getProfiles();
|
||||
const metadataProfiles = await lidarr.getMetadataProfiles();
|
||||
const folders = await lidarr.getRootFolders();
|
||||
const tags = await lidarr.getTags();
|
||||
|
||||
return res.status(200).json({
|
||||
profiles,
|
||||
metadataProfiles,
|
||||
rootFolders: folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
path: folder.path,
|
||||
})),
|
||||
tags,
|
||||
urlBase,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to test Lidarr', {
|
||||
label: 'Lidarr',
|
||||
message: e.message,
|
||||
});
|
||||
next({ status: 500, message: 'Failed to connect to Lidarr' });
|
||||
}
|
||||
});
|
||||
|
||||
lidarrRoutes.put<{ id: string }, LidarrSettings, LidarrSettings>(
|
||||
'/:id',
|
||||
(req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const lidarrIndex = settings.lidarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (lidarrIndex === -1) {
|
||||
return next({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||
|
||||
settings.lidarr[lidarrIndex] = {
|
||||
...req.body,
|
||||
id: Number(req.params.id),
|
||||
} as LidarrSettings;
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json(settings.lidarr[lidarrIndex]);
|
||||
}
|
||||
);
|
||||
|
||||
lidarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const lidarrSettings = settings.lidarr.find(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (!lidarrSettings) {
|
||||
return next({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
const lidarr = new LidarrAPI({
|
||||
apiKey: lidarrSettings.apiKey,
|
||||
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
|
||||
});
|
||||
|
||||
const profiles = await lidarr.getProfiles();
|
||||
|
||||
return res.status(200).json(
|
||||
profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
lidarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const lidarrIndex = settings.lidarr.findIndex(
|
||||
(r) => r.id === Number(req.params.id)
|
||||
);
|
||||
|
||||
if (lidarrIndex === -1) {
|
||||
return next({ status: '404', message: 'Settings instance not found' });
|
||||
}
|
||||
|
||||
const removed = settings.lidarr.splice(lidarrIndex, 1);
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json(removed[0]);
|
||||
});
|
||||
|
||||
export default lidarrRoutes;
|
||||
@@ -30,6 +30,13 @@ function initTvdbImageProxy() {
|
||||
return _tvdbImageProxy;
|
||||
}
|
||||
|
||||
const tadbImageProxy = new ImageProxy('tadb', 'https://r2.theaudiodb.com', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
|
||||
router.get('/:type/*', async (req, res) => {
|
||||
const imagePath = req.path.replace(/^\/\w+/, '');
|
||||
try {
|
||||
@@ -38,6 +45,8 @@ router.get('/:type/*', async (req, res) => {
|
||||
imageData = await initTmdbImageProxy().getImage(imagePath);
|
||||
} else if (req.params.type === 'tvdb') {
|
||||
imageData = await initTvdbImageProxy().getImage(imagePath);
|
||||
} else if (req.params.type === 'tabd') {
|
||||
imageData = await tadbImageProxy.getImage(imagePath);
|
||||
} else {
|
||||
logger.error('Unsupported image type', {
|
||||
imagePath,
|
||||
39
server/routes/tmdbproxy.ts
Normal file
39
server/routes/tmdbproxy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Image Proxy
|
||||
*/
|
||||
router.get('/*', async (req, res) => {
|
||||
const imagePath = req.path.replace('/image', '');
|
||||
try {
|
||||
const imageData = await tmdbImageProxy.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).send();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -36,6 +36,7 @@ watchlistRoutes.post<never, Watchlist, Watchlist>(
|
||||
case QueryFailedError:
|
||||
logger.warn('Something wrong with data watchlist', {
|
||||
tmdbId: req.body.tmdbId,
|
||||
mbId: req.body.mbId,
|
||||
mediaType: req.body.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
@@ -49,7 +50,7 @@ watchlistRoutes.post<never, Watchlist, Watchlist>(
|
||||
}
|
||||
);
|
||||
|
||||
watchlistRoutes.delete('/:tmdbId', async (req, res, next) => {
|
||||
watchlistRoutes.delete('/:id', async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 401,
|
||||
@@ -57,7 +58,11 @@ watchlistRoutes.delete('/:tmdbId', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
try {
|
||||
await Watchlist.deleteWatchlist(Number(req.params.tmdbId), req.user);
|
||||
const id = isNaN(Number(req.params.id))
|
||||
? req.params.id
|
||||
: Number(req.params.id);
|
||||
|
||||
await Watchlist.deleteWatchlist(id, req.user);
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import CoverArtArchive from '@server/api/coverartarchive';
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
@@ -21,9 +23,11 @@ export class IssueCommentSubscriber
|
||||
}
|
||||
|
||||
private async sendIssueCommentNotification(entity: IssueComment) {
|
||||
let title: string;
|
||||
let image: string;
|
||||
let title = '';
|
||||
let image = '';
|
||||
const tmdb = new TheMovieDb();
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const coverArt = new CoverArtArchive();
|
||||
|
||||
try {
|
||||
const issue = (
|
||||
@@ -48,13 +52,19 @@ export class IssueCommentSubscriber
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||
} else {
|
||||
} else if (media.mediaType === MediaType.TV) {
|
||||
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
|
||||
title = `${tvshow.name}${
|
||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||
} else if (media.mediaType === MediaType.MUSIC && media.mbId) {
|
||||
const album = await listenbrainz.getAlbum(media.mbId);
|
||||
const coverArtResponse = await coverArt.getCoverArt(media.mbId);
|
||||
|
||||
title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`;
|
||||
image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||
}
|
||||
|
||||
const [firstComment] = sortBy(issue.comments, 'id');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import CoverArtArchive from '@server/api/coverartarchive';
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
@@ -20,9 +22,11 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
||||
}
|
||||
|
||||
private async sendIssueNotification(entity: Issue, type: Notification) {
|
||||
let title: string;
|
||||
let image: string;
|
||||
let title = '';
|
||||
let image = '';
|
||||
const tmdb = new TheMovieDb();
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const coverArt = new CoverArtArchive();
|
||||
|
||||
try {
|
||||
if (entity.media.mediaType === MediaType.MOVIE) {
|
||||
@@ -32,13 +36,22 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||
} else {
|
||||
} else if (entity.media.mediaType === MediaType.TV) {
|
||||
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
title = `${tvshow.name}${
|
||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||
} else if (
|
||||
entity.media.mediaType === MediaType.MUSIC &&
|
||||
entity.media.mbId
|
||||
) {
|
||||
const album = await listenbrainz.getAlbum(entity.media.mbId);
|
||||
const coverArtResponse = await coverArt.getCoverArt(entity.media.mbId);
|
||||
|
||||
title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`;
|
||||
image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||
}
|
||||
|
||||
const [firstComment] = sortBy(entity.comments, 'id');
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import CoverArtArchive from '@server/api/coverartarchive';
|
||||
import ListenBrainzAPI from '@server/api/listenbrainz';
|
||||
import MusicBrainz from '@server/api/musicbrainz';
|
||||
import type { LidarrAlbumOptions } from '@server/api/servarr/lidarr';
|
||||
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import type {
|
||||
@@ -171,6 +176,75 @@ export class MediaRequestSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableMusic(
|
||||
entity: MediaRequest,
|
||||
event?: UpdateEvent<MediaRequest>
|
||||
) {
|
||||
// Get fresh media state using event manager
|
||||
let latestMedia: Media | null = null;
|
||||
if (event?.manager) {
|
||||
latestMedia = await event.manager.findOne(Media, {
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
}
|
||||
if (!latestMedia) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
latestMedia = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
|
||||
if (
|
||||
!latestMedia ||
|
||||
latestMedia.mediaType !== MediaType.MUSIC ||
|
||||
latestMedia['status'] != MediaStatus.AVAILABLE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listenbrainz = new ListenBrainzAPI();
|
||||
const coverArt = new CoverArtArchive();
|
||||
const musicbrainz = new MusicBrainz();
|
||||
|
||||
try {
|
||||
const album = await listenbrainz.getAlbum(latestMedia.mbId ?? '');
|
||||
const coverArtResponse = await coverArt.getCoverArt(
|
||||
latestMedia.mbId ?? ''
|
||||
);
|
||||
const coverArtUrl =
|
||||
coverArtResponse.images[0]?.thumbnails?.['250'] ?? '';
|
||||
const artistId =
|
||||
album.release_group_metadata?.artist?.artists[0]?.artist_mbid;
|
||||
const artistWiki = artistId
|
||||
? await musicbrainz.getArtistWikipediaExtract({
|
||||
artistMbid: artistId,
|
||||
})
|
||||
: null;
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: 'Album Request Now Available',
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`,
|
||||
message: truncate(artistWiki?.content ?? '', {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
media: latestMedia,
|
||||
image: coverArtUrl,
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async sendToRadarr(entity: MediaRequest): Promise<void> {
|
||||
if (
|
||||
entity.status === MediaRequestStatus.APPROVED &&
|
||||
@@ -738,8 +812,279 @@ export class MediaRequestSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
public async sendToLidarr(entity: MediaRequest): Promise<void> {
|
||||
if (
|
||||
entity.status === MediaRequestStatus.APPROVED &&
|
||||
entity.type === MediaType.MUSIC
|
||||
) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const settings = getSettings();
|
||||
|
||||
if (settings.lidarr.length === 0 && !settings.lidarr[0]) {
|
||||
logger.info(
|
||||
'No Lidarr server configured, skipping request processing',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
|
||||
|
||||
if (
|
||||
entity.serverId !== null &&
|
||||
entity.serverId >= 0 &&
|
||||
lidarrSettings?.id !== entity.serverId
|
||||
) {
|
||||
lidarrSettings = settings.lidarr.find(
|
||||
(lidarr) => lidarr.id === entity.serverId
|
||||
);
|
||||
logger.info(
|
||||
`Request has an override server: ${lidarrSettings?.name}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!lidarrSettings) {
|
||||
logger.warn('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) {
|
||||
logger.error('Media data not found', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.status === MediaStatus.AVAILABLE) {
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
|
||||
if (entity.status !== MediaRequestStatus.APPROVED) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
let qualityProfile = lidarrSettings.activeProfileId;
|
||||
const metadataProfile = lidarrSettings.activeMetadataProfileId ?? 1;
|
||||
|
||||
if (entity.profileId && entity.profileId !== qualityProfile) {
|
||||
qualityProfile = entity.profileId;
|
||||
logger.info(
|
||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const tags = entity.tags ?? albumInfo.artist.tags ?? [];
|
||||
|
||||
if (lidarrSettings.tagRequests) {
|
||||
let userTag = (await lidarr.getTags()).find((v) =>
|
||||
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
});
|
||||
userTag = await lidarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
if (!tags.find((v) => v === userTag?.id)) {
|
||||
tags.push(userTag.id);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Requester has no tag and failed to add one`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
lidarrServer: lidarrSettings.hostname + ':' + lidarrSettings.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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: qualityProfile,
|
||||
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: qualityProfile,
|
||||
metadataProfileId: metadataProfile,
|
||||
monitored: true,
|
||||
monitorNewItems: 'none',
|
||||
rootFolderPath: rootFolder,
|
||||
genres: albumInfo.artist.genres || [],
|
||||
cleanName: albumInfo.artist.cleanName,
|
||||
sortName: albumInfo.artist.sortName,
|
||||
tags: tags, // Apply the tags to the artist
|
||||
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 media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
media.externalServiceId = result.id;
|
||||
media.externalServiceSlug = result.titleSlug;
|
||||
media.serviceId = lidarrSettings?.id;
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
entity.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(entity);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending album request to Lidarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
error: error.message,
|
||||
}
|
||||
);
|
||||
|
||||
MediaRequest.sendNotification(
|
||||
entity,
|
||||
media,
|
||||
Notification.MEDIA_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
logger.info('Sent request to Lidarr', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending request to Lidarr', {
|
||||
label: 'Media Request',
|
||||
errorMessage: e.message,
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async updateParentStatus(entity: MediaRequest): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
@@ -840,6 +1185,7 @@ export class MediaRequestSubscriber
|
||||
|
||||
this.sendToRadarr(event.entity as MediaRequest);
|
||||
this.sendToSonarr(event.entity as MediaRequest);
|
||||
this.sendToLidarr(event.entity as MediaRequest);
|
||||
|
||||
this.updateParentStatus(event.entity as MediaRequest);
|
||||
|
||||
@@ -850,6 +1196,9 @@ export class MediaRequestSubscriber
|
||||
if (event.entity.media.mediaType === MediaType.TV) {
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
||||
}
|
||||
if (event.entity.media.mediaType === MediaType.MUSIC) {
|
||||
this.notifyAvailableMusic(event.entity as MediaRequest, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,6 +1209,7 @@ export class MediaRequestSubscriber
|
||||
|
||||
this.sendToRadarr(event.entity as MediaRequest);
|
||||
this.sendToSonarr(event.entity as MediaRequest);
|
||||
this.sendToLidarr(event.entity as MediaRequest);
|
||||
|
||||
this.updateParentStatus(event.entity as MediaRequest);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
|
||||
const allSeasonsReady = allSeasonResults.every((result) => result);
|
||||
shouldComplete = allSeasonsReady;
|
||||
} else if (event.mediaType === MediaType.MUSIC) {
|
||||
if (
|
||||
event['status'] == MediaStatus.AVAILABLE ||
|
||||
event['status'] === MediaStatus.DELETED
|
||||
) {
|
||||
shouldComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldComplete) {
|
||||
|
||||
1
src/assets/services/lidarr.svg
Normal file
1
src/assets/services/lidarr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 flex-shrink-0" viewBox="0 0 1024 1024"><style>.lidarr_svg__a{fill:#989898;stroke-width:24}.lidarr_svg__b{fill:none;stroke-width:16;stroke:#009252}</style><path fill="none" d="M-1-1h1026v1026H-1z"></path><circle cx="512" cy="512" r="410" stroke-width="1.8"></circle><circle cx="512" cy="512" r="460" style="fill: none; stroke-width: 99; stroke: rgb(229, 229, 229);"></circle><circle cx="512" cy="512" r="270" style="fill: rgb(229, 229, 229); stroke-width: 76; stroke: rgb(229, 229, 229);"></circle><circle cy="512" cx="512" r="410" style="fill: none; stroke-width: 12; stroke: rgb(0, 146, 82);"></circle><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z"></path><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z" class="lidarr_svg__b"></path><circle cx="512" cy="512" r="150" style="fill: rgb(0, 146, 82);"></circle></svg>
|
||||
|
After Width: | Height: | Size: 897 B |
163
src/components/AddedCard/index.tsx
Normal file
163
src/components/AddedCard/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import TitleCard from '@app/components/TitleCard';
|
||||
import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { MusicDetails } from '@server/models/Music';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface AddedCardProps {
|
||||
id?: number | string;
|
||||
tmdbId?: number;
|
||||
tvdbId?: number;
|
||||
mbId?: string;
|
||||
type: 'movie' | 'tv' | 'music';
|
||||
canExpand?: boolean;
|
||||
isAddedToWatchlist?: boolean;
|
||||
mutateParent?: () => void;
|
||||
posterPath?: string | null;
|
||||
needsCoverArt?: boolean;
|
||||
}
|
||||
|
||||
const isMovie = (
|
||||
media: MovieDetails | TvDetails | MusicDetails
|
||||
): media is MovieDetails => {
|
||||
return (media as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
const isMusic = (
|
||||
media: MovieDetails | TvDetails | MusicDetails
|
||||
): media is MusicDetails => {
|
||||
return (media as MusicDetails).artist !== undefined;
|
||||
};
|
||||
|
||||
const AddedCard = ({
|
||||
id,
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
mbId,
|
||||
type,
|
||||
canExpand,
|
||||
isAddedToWatchlist = false,
|
||||
mutateParent,
|
||||
posterPath: initialPosterPath,
|
||||
needsCoverArt: initialNeedsCoverArt,
|
||||
}: AddedCardProps) => {
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
});
|
||||
|
||||
const url =
|
||||
type === 'music'
|
||||
? `/api/v1/music/${mbId}`
|
||||
: type === 'movie'
|
||||
? `/api/v1/movie/${tmdbId}`
|
||||
: `/api/v1/tv/${tmdbId}`;
|
||||
|
||||
const { data: titleData, error } = useSWR<
|
||||
MovieDetails | TvDetails | MusicDetails
|
||||
>(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) {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<TitleCard.Placeholder canExpand={canExpand} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return hasPermission(Permission.ADMIN) && id ? (
|
||||
<TitleCard.ErrorCard
|
||||
id={id}
|
||||
tmdbId={tmdbId}
|
||||
tvdbId={tvdbId}
|
||||
mbId={mbId}
|
||||
type={type}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (isMusic(title)) {
|
||||
return (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={
|
||||
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
|
||||
}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
title={title.title}
|
||||
artist={title.artist.name}
|
||||
type={title.type}
|
||||
year={title.releaseDate}
|
||||
mediaType={'album'}
|
||||
canExpand={canExpand}
|
||||
mutateParent={mutateParent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 AddedCard;
|
||||
99
src/components/ArtistCard/index.tsx
Normal file
99
src/components/ArtistCard/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import { UserCircleIcon } from '@heroicons/react/24/solid';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ArtistCardProps {
|
||||
artistId: string;
|
||||
name: string;
|
||||
subName?: string;
|
||||
profilePath?: string | null;
|
||||
artistThumb?: string | null;
|
||||
type?: string;
|
||||
canExpand?: boolean;
|
||||
}
|
||||
|
||||
const ArtistCard = ({
|
||||
artistId,
|
||||
name,
|
||||
subName,
|
||||
profilePath,
|
||||
artistThumb,
|
||||
type,
|
||||
canExpand = false,
|
||||
}: ArtistCardProps) => {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/artist/${artistId}`}
|
||||
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHovered(true);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} transform-gpu cursor-pointer rounded-xl text-white shadow ring-1 transition duration-150 ease-in-out ${
|
||||
isHovered
|
||||
? 'scale-105 bg-gray-700 ring-gray-500'
|
||||
: 'scale-100 bg-gray-800 ring-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<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">
|
||||
{artistThumb || profilePath ? (
|
||||
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||
<CachedImage
|
||||
type="music"
|
||||
src={artistThumb || profilePath || ''}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<UserCircleIcon className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full truncate text-center font-bold">{name}</div>
|
||||
{(subName || type) && (
|
||||
<div
|
||||
className="overflow-hidden whitespace-normal text-center text-sm text-gray-300"
|
||||
style={{
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{subName || type}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t ${
|
||||
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtistCard;
|
||||
569
src/components/ArtistDetails/index.tsx
Normal file
569
src/components/ArtistDetails/index.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
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 axios from 'axios';
|
||||
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;
|
||||
|
||||
if (
|
||||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(
|
||||
artistId
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validAlbumTypes = [
|
||||
'Album',
|
||||
'EP',
|
||||
'Single',
|
||||
'Live',
|
||||
'Compilation',
|
||||
'Remix',
|
||||
'Soundtrack',
|
||||
'Broadcast',
|
||||
'Demo',
|
||||
'Other',
|
||||
];
|
||||
if (!validAlbumTypes.includes(albumType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAlbumTypes((prev) => ({
|
||||
...prev,
|
||||
[albumType]: {
|
||||
...prev[albumType],
|
||||
isLoading: true,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const pageSize = Math.min(data?.typeCounts?.[albumType] || 100, 1000);
|
||||
const response = await axios.get(`/api/v1/artist/${artistId}`, {
|
||||
params: {
|
||||
albumType,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const responseData = response.data;
|
||||
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;
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
BlacklistResultsResponse,
|
||||
} from '@server/interfaces/api/blacklistInterfaces';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { MusicDetails } from '@server/models/Music';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
@@ -59,6 +60,12 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
const isMusic = (
|
||||
media: MovieDetails | TvDetails | MusicDetails
|
||||
): media is MusicDetails => {
|
||||
return (media as MusicDetails).artist.id !== undefined;
|
||||
};
|
||||
|
||||
const Blacklist = () => {
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
||||
@@ -277,12 +284,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
const url =
|
||||
item.mediaType === 'movie'
|
||||
item.mediaType === 'music'
|
||||
? `/api/v1/music/${item.mbId}`
|
||||
: item.mediaType === 'movie'
|
||||
? `/api/v1/movie/${item.tmdbId}`
|
||||
: `/api/v1/tv/${item.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? url : null
|
||||
);
|
||||
|
||||
const { data: title, error } = useSWR<
|
||||
MovieDetails | TvDetails | MusicDetails
|
||||
>(inView ? url : null);
|
||||
|
||||
if (!title && !error) {
|
||||
return (
|
||||
@@ -293,11 +303,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||
const removeFromBlacklist = async (
|
||||
tmdbId?: number,
|
||||
mbId?: string,
|
||||
title?: string
|
||||
) => {
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
|
||||
await axios.delete(`/api/v1/blacklist/${mbId ?? tmdbId}`);
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
@@ -321,11 +335,20 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
||||
{title && title.backdropPath && (
|
||||
{title && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||
src={
|
||||
isMusic(title)
|
||||
? title.artistBackdrop ||
|
||||
title.artistThumb ||
|
||||
title.posterPath ||
|
||||
''
|
||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
||||
title.backdropPath ?? ''
|
||||
}`
|
||||
}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
fill
|
||||
@@ -343,43 +366,58 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
|
||||
<Link
|
||||
href={
|
||||
item.mediaType === 'movie'
|
||||
item.mediaType === 'music'
|
||||
? `/music/${item.mbId}`
|
||||
: item.mediaType === 'movie'
|
||||
? `/movie/${item.tmdbId}`
|
||||
: `/tv/${item.tmdbId}`
|
||||
}
|
||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
type={title && isMusic(title) ? 'music' : 'tmdb'}
|
||||
src={
|
||||
title?.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
title
|
||||
? isMusic(title)
|
||||
? title.posterPath ||
|
||||
'/images/jellyseerr_poster_not_found_square.png'
|
||||
: title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
|
||||
width={600}
|
||||
height={900}
|
||||
height={title && isMusic(title) ? 600 : 900}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
|
||||
{title &&
|
||||
(isMovie(title)
|
||||
? title.releaseDate
|
||||
: title.firstAirDate
|
||||
)?.slice(0, 4)}
|
||||
(isMusic(title)
|
||||
? title.releaseDate?.slice(0, 4)
|
||||
: isMovie(title)
|
||||
? title.releaseDate?.slice(0, 4)
|
||||
: title.firstAirDate?.slice(0, 4))}
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
item.mediaType === 'movie'
|
||||
item.mediaType === 'music'
|
||||
? `/music/${item.mbId}`
|
||||
: item.mediaType === 'movie'
|
||||
? `/movie/${item.tmdbId}`
|
||||
: `/tv/${item.tmdbId}`
|
||||
}
|
||||
>
|
||||
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
|
||||
{title && (isMovie(title) ? title.title : title.name)}
|
||||
{title &&
|
||||
(isMusic(title)
|
||||
? `${title.artist.name} - ${title.title}`
|
||||
: isMovie(title)
|
||||
? title.title
|
||||
: title.name)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -446,12 +484,18 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
{intl.formatMessage(globalMessages.movie)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : item.mediaType === 'tv' ? (
|
||||
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
|
||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
||||
{intl.formatMessage(globalMessages.tvshow)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none z-40 self-start rounded-full border border-green-600 bg-green-600 bg-opacity-80 shadow-md">
|
||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
||||
{intl.formatMessage(globalMessages.music)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,7 +506,13 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
onClick={() =>
|
||||
removeFromBlacklist(
|
||||
item.tmdbId,
|
||||
title && (isMovie(title) ? title.title : title.name)
|
||||
item.mbId,
|
||||
title &&
|
||||
(isMusic(title)
|
||||
? `${title.artist.name} - ${title.title}`
|
||||
: isMovie(title)
|
||||
? title.title
|
||||
: title.name)
|
||||
)
|
||||
}
|
||||
confirmText={intl.formatMessage(
|
||||
|
||||
@@ -21,13 +21,15 @@ const messages = defineMessages('component.BlacklistBlock', {
|
||||
});
|
||||
|
||||
interface BlacklistBlockProps {
|
||||
tmdbId: number;
|
||||
tmdbId?: number;
|
||||
mbId?: string;
|
||||
onUpdate?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const BlacklistBlock = ({
|
||||
tmdbId,
|
||||
mbId,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: BlacklistBlockProps) => {
|
||||
@@ -35,13 +37,28 @@ const BlacklistBlock = ({
|
||||
const intl = useIntl();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const { addToast } = useToasts();
|
||||
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
|
||||
|
||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||
const { data } = useSWR<Blacklist>(
|
||||
mbId
|
||||
? `/api/v1/blacklist/music/${mbId}`
|
||||
: tmdbId
|
||||
? `/api/v1/blacklist/${tmdbId}`
|
||||
: null
|
||||
);
|
||||
|
||||
const removeFromBlacklist = async (
|
||||
tmdbId?: number,
|
||||
mbId?: string,
|
||||
title?: string
|
||||
) => {
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
await axios.delete('/api/v1/blacklist/' + tmdbId);
|
||||
const url = mbId
|
||||
? `/api/v1/blacklist/music/${mbId}`
|
||||
: `/api/v1/blacklist/${tmdbId}`;
|
||||
|
||||
await axios.delete(url);
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
@@ -113,7 +130,9 @@ const BlacklistBlock = ({
|
||||
>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
|
||||
onClick={() =>
|
||||
removeFromBlacklist(data.tmdbId, data.mbId, data.title)
|
||||
}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<TrashIcon className="icon-sm" />
|
||||
|
||||
@@ -3,14 +3,16 @@ import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { MusicDetails } from '@server/models/Music';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
interface BlacklistModalProps {
|
||||
tmdbId: number;
|
||||
type: 'movie' | 'tv' | 'collection';
|
||||
tmdbId?: number;
|
||||
mbId?: string;
|
||||
type: 'movie' | 'tv' | 'collection' | 'music';
|
||||
show: boolean;
|
||||
onComplete?: () => void;
|
||||
onCancel?: () => void;
|
||||
@@ -22,14 +24,22 @@ const messages = defineMessages('component.BlacklistModal', {
|
||||
});
|
||||
|
||||
const isMovie = (
|
||||
movie: MovieDetails | TvDetails | null
|
||||
): movie is MovieDetails => {
|
||||
if (!movie) return false;
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
media: MovieDetails | TvDetails | MusicDetails | null
|
||||
): media is MovieDetails => {
|
||||
if (!media) return false;
|
||||
return 'title' in media && !('artist' in media);
|
||||
};
|
||||
|
||||
const isMusic = (
|
||||
media: MovieDetails | TvDetails | MusicDetails | null
|
||||
): media is MusicDetails => {
|
||||
if (!media) return false;
|
||||
return 'artist' in media && typeof media.artist?.name === 'string';
|
||||
};
|
||||
|
||||
const BlacklistModal = ({
|
||||
tmdbId,
|
||||
mbId,
|
||||
type,
|
||||
show,
|
||||
onComplete,
|
||||
@@ -37,7 +47,9 @@ const BlacklistModal = ({
|
||||
isUpdating,
|
||||
}: BlacklistModalProps) => {
|
||||
const intl = useIntl();
|
||||
const [data, setData] = useState<TvDetails | MovieDetails | null>(null);
|
||||
const [data, setData] = useState<
|
||||
MovieDetails | TvDetails | MusicDetails | null
|
||||
>(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,13 +57,38 @@ const BlacklistModal = ({
|
||||
if (!show) return;
|
||||
try {
|
||||
setError(null);
|
||||
const response = await axios.get(`/api/v1/${type}/${tmdbId}`);
|
||||
const response = await axios.get(
|
||||
`/api/v1/${type}/${type === 'music' ? mbId : tmdbId}`
|
||||
);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
})();
|
||||
}, [show, tmdbId, type]);
|
||||
}, [show, tmdbId, mbId, type]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (isMusic(data)) {
|
||||
return `${data.artist.name} - ${data.title}`;
|
||||
}
|
||||
return isMovie(data) ? data.title : data?.name;
|
||||
};
|
||||
|
||||
const getMediaType = () => {
|
||||
if (isMusic(data)) {
|
||||
return intl.formatMessage(globalMessages.music);
|
||||
}
|
||||
return isMovie(data)
|
||||
? intl.formatMessage(globalMessages.movie)
|
||||
: intl.formatMessage(globalMessages.tvshow);
|
||||
};
|
||||
|
||||
const getBackdrop = () => {
|
||||
if (isMusic(data)) {
|
||||
return data.artistBackdrop;
|
||||
}
|
||||
return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
@@ -67,12 +104,10 @@ const BlacklistModal = ({
|
||||
<Modal
|
||||
loading={!data && !error}
|
||||
backgroundClickable
|
||||
title={`${intl.formatMessage(globalMessages.blacklist)} ${
|
||||
isMovie(data)
|
||||
? intl.formatMessage(globalMessages.movie)
|
||||
: intl.formatMessage(globalMessages.tvshow)
|
||||
}`}
|
||||
subTitle={`${isMovie(data) ? data.title : data?.name}`}
|
||||
title={`${intl.formatMessage(
|
||||
globalMessages.blacklist
|
||||
)} ${getMediaType()}`}
|
||||
subTitle={getTitle()}
|
||||
onCancel={onCancel}
|
||||
onOk={onComplete}
|
||||
okText={
|
||||
@@ -82,7 +117,7 @@ const BlacklistModal = ({
|
||||
}
|
||||
okButtonType="danger"
|
||||
okDisabled={isUpdating}
|
||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
||||
backdrop={getBackdrop()}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import type { ImageLoader, ImageProps } from 'next/image';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
const imageLoader: ImageLoader = ({ src }) => src;
|
||||
|
||||
export type CachedImageProps = ImageProps & {
|
||||
src: string;
|
||||
type: 'tmdb' | 'avatar' | 'tvdb';
|
||||
type: 'tmdb' | 'avatar' | 'tvdb' | 'music';
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -15,8 +16,11 @@ export type CachedImageProps = ImageProps & {
|
||||
**/
|
||||
const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
||||
const { currentSettings } = useSettings();
|
||||
const [, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
let imageUrl: string;
|
||||
let fallbackImage = '';
|
||||
|
||||
if (type === 'tmdb') {
|
||||
// tmdb stuff
|
||||
@@ -32,14 +36,39 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
||||
'/imageproxy/tvdb/'
|
||||
)
|
||||
: src;
|
||||
fallbackImage = '/images/jellyseerr_poster_not_found.png';
|
||||
} else if (type === 'music') {
|
||||
// Cover Art Archive and TheAudioDB images
|
||||
imageUrl = src.startsWith('https://archive.org/')
|
||||
? src.replace(/^https:\/\/archive\.org\//, '/caaproxy/')
|
||||
: currentSettings.cacheImages &&
|
||||
!src.startsWith('/') &&
|
||||
src.startsWith('https://r2.theaudiodb.com/')
|
||||
? src.replace(/^https:\/\/r2\.theaudiodb\.com\//, '/tadbproxy/')
|
||||
: src;
|
||||
fallbackImage = '/images/jellyseerr_poster_not_found_square.png';
|
||||
} else if (type === 'avatar') {
|
||||
// jellyfin avatar (if any)
|
||||
imageUrl = src;
|
||||
fallbackImage = '/images/user_placeholder.png';
|
||||
} else {
|
||||
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;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import AddedCard from '@app/components/AddedCard';
|
||||
import ArtistCard from '@app/components/ArtistCard';
|
||||
import PersonCard from '@app/components/PersonCard';
|
||||
import TitleCard from '@app/components/TitleCard';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
AlbumResult,
|
||||
ArtistResult,
|
||||
CollectionResult,
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
@@ -15,7 +18,14 @@ import type {
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
type ListViewProps = {
|
||||
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
|
||||
items?: (
|
||||
| TvResult
|
||||
| MovieResult
|
||||
| PersonResult
|
||||
| CollectionResult
|
||||
| ArtistResult
|
||||
| AlbumResult
|
||||
)[];
|
||||
plexItems?: WatchlistItem[];
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
@@ -53,9 +63,10 @@ const ListView = ({
|
||||
{plexItems?.map((title, index) => {
|
||||
return (
|
||||
<li key={`${title.ratingKey}-${index}`}>
|
||||
<TmdbTitleCard
|
||||
id={title.tmdbId}
|
||||
tmdbId={title.tmdbId}
|
||||
<AddedCard
|
||||
id={title.tmdbId ?? 0}
|
||||
tmdbId={title.tmdbId ?? 0}
|
||||
mbId={title.mbId}
|
||||
type={title.mediaType}
|
||||
isAddedToWatchlist={true}
|
||||
canExpand
|
||||
@@ -68,8 +79,8 @@ const ListView = ({
|
||||
?.filter((title) => {
|
||||
if (!blacklistVisibility)
|
||||
return (
|
||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
||||
MediaStatus.BLACKLISTED
|
||||
(title as TvResult | MovieResult | AlbumResult).mediaInfo
|
||||
?.status !== MediaStatus.BLACKLISTED
|
||||
);
|
||||
return title;
|
||||
})
|
||||
@@ -143,6 +154,51 @@ const ListView = ({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'album':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={
|
||||
title.mediaInfo?.watchlists?.length ?? 0
|
||||
}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
title={title.title}
|
||||
artist={title['artist-credit']?.[0]?.name}
|
||||
type={title['primary-type']}
|
||||
year={
|
||||
title.releaseDate
|
||||
? title.releaseDate.split('-')[0]
|
||||
: title['first-release-date']?.split('-')[0]
|
||||
}
|
||||
mediaType={title.mediaType}
|
||||
inProgress={
|
||||
(title.mediaInfo?.downloadStatus ?? []).length > 0
|
||||
}
|
||||
needsCoverArt={title.needsCoverArt}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'artist':
|
||||
return title.tmdbPersonId ? (
|
||||
<PersonCard
|
||||
key={title.id}
|
||||
personId={title.tmdbPersonId}
|
||||
name={title.name}
|
||||
profilePath={title.artistThumb ?? undefined}
|
||||
canExpand
|
||||
/>
|
||||
) : (
|
||||
<ArtistCard
|
||||
key={title.id}
|
||||
artistId={title.id}
|
||||
name={title.name}
|
||||
artistThumb={title.artistThumb}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
||||
|
||||
29
src/components/Common/Toggle/index.tsx
Normal file
29
src/components/Common/Toggle/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Switch } from '@headlessui/react';
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Toggle = ({ checked, onChange, disabled = false }: ToggleProps) => {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full ${
|
||||
checked ? 'bg-indigo-600' : 'bg-gray-700'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<span className="sr-only">Toggle</span>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition ${
|
||||
checked ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toggle;
|
||||
141
src/components/Discover/DiscoverMusic/index.tsx
Normal file
141
src/components/Discover/DiscoverMusic/index.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import {
|
||||
countActiveFilters,
|
||||
prepareFilterValues,
|
||||
type FilterOptions,
|
||||
} from '@app/components/Discover/constants';
|
||||
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||
import type { AlbumResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Discover.DiscoverMusic', {
|
||||
discovermusic: 'Music',
|
||||
sortReleaseDateAsc: 'Release Date Ascending',
|
||||
sortReleaseDateDesc: 'Release Date Descending',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
sortArtistAsc: 'Artist Name (A-Z) Ascending',
|
||||
sortArtistDesc: 'Artist Name (Z-A) Descending',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
filters: 'Filters',
|
||||
});
|
||||
|
||||
const SortOptions = {
|
||||
ReleaseDateDesc: 'release_date.desc',
|
||||
ReleaseDateAsc: 'release_date.asc',
|
||||
TitleAsc: 'title.asc',
|
||||
TitleDesc: 'title.desc',
|
||||
ArtistAsc: 'artist.asc',
|
||||
ArtistDesc: 'artist.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverMusic = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<AlbumResult, unknown, FilterOptions>(
|
||||
'/api/v1/discover/music',
|
||||
{
|
||||
...preparedFilters,
|
||||
days: preparedFilters.days ?? '7',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovermusic);
|
||||
|
||||
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.ReleaseDateDesc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateAsc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.ArtistAsc}>
|
||||
{intl.formatMessage(messages.sortArtistAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.ArtistDesc}>
|
||||
{intl.formatMessage(messages.sortArtistDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</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>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMusic;
|
||||
101
src/components/Discover/DiscoverMusicAlbums/index.tsx
Normal file
101
src/components/Discover/DiscoverMusicAlbums/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import { prepareFilterValues } from '@app/components/Discover/constants';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { BarsArrowDownIcon } from '@heroicons/react/24/solid';
|
||||
import type { AlbumResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Discover.DiscoverMusicAlbums', {
|
||||
discoveralbums: 'Albums',
|
||||
sortPopularityDesc: 'Most Listened',
|
||||
sortPopularityAsc: 'Least Listened',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions = {
|
||||
PopularityDesc: 'listen_count.desc',
|
||||
PopularityAsc: 'listen_count.asc',
|
||||
TitleAsc: 'title.asc',
|
||||
TitleDesc: 'title.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverMusicAlbums = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<AlbumResult>(
|
||||
'/api/v1/discover/music/albums',
|
||||
preparedFilters
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discoveralbums);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMusicAlbums;
|
||||
101
src/components/Discover/DiscoverMusicArtists/index.tsx
Normal file
101
src/components/Discover/DiscoverMusicArtists/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import { prepareFilterValues } from '@app/components/Discover/constants';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { BarsArrowDownIcon } from '@heroicons/react/24/solid';
|
||||
import type { ArtistResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Discover.DiscoverMusicArtists', {
|
||||
discoverartists: 'Artists',
|
||||
sortPopularityDesc: 'Most Listened',
|
||||
sortPopularityAsc: 'Least Listened',
|
||||
sortNameAsc: 'Name (A-Z) Ascending',
|
||||
sortNameDesc: 'Name (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions = {
|
||||
PopularityDesc: 'listen_count.desc',
|
||||
PopularityAsc: 'listen_count.asc',
|
||||
NameAsc: 'name.asc',
|
||||
NameDesc: 'name.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverMusicArtists = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<ArtistResult>(
|
||||
'/api/v1/discover/music/artists',
|
||||
preparedFilters
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discoverartists);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.NameAsc}>
|
||||
{intl.formatMessage(messages.sortNameAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.NameDesc}>
|
||||
{intl.formatMessage(messages.sortNameDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMusicArtists;
|
||||
@@ -169,6 +169,10 @@ const DiscoverSliderEdit = ({
|
||||
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
|
||||
case DiscoverSliderType.POPULAR_ALBUMS:
|
||||
return intl.formatMessage(sliderTitles.popularalbums);
|
||||
case DiscoverSliderType.POPULAR_ARTISTS:
|
||||
return intl.formatMessage(sliderTitles.popularartists);
|
||||
default:
|
||||
return 'Unknown Slider';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
import Toggle from '@app/components/Common/Toggle';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import { countActiveFilters } from '@app/components/Discover/constants';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
@@ -19,7 +20,10 @@ import {
|
||||
} from '@app/hooks/useUpdateQueryParams';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import type { MultiValue } from 'react-select';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
||||
|
||||
const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||
@@ -45,12 +49,13 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
status: 'Status',
|
||||
certification: 'Content Rating',
|
||||
onlyWithCoverArt: 'Only show releases with cover art',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
type: 'movie' | 'tv';
|
||||
type: 'movie' | 'tv' | 'music';
|
||||
currentFilters: FilterOptions;
|
||||
};
|
||||
|
||||
@@ -64,12 +69,188 @@ const FilterSlideover = ({
|
||||
const { currentSettings } = useSettings();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
|
||||
const [defaultSelectedGenres, setDefaultSelectedGenres] = useState<
|
||||
{ label: string; value: string }[] | null
|
||||
>(null);
|
||||
|
||||
const dateGte =
|
||||
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
|
||||
const dateLte =
|
||||
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'music' && currentFilters.genre) {
|
||||
const genres = currentFilters.genre.split(',');
|
||||
|
||||
setDefaultSelectedGenres(
|
||||
genres.map((genre) => ({
|
||||
label: genre,
|
||||
value: genre,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setDefaultSelectedGenres(null);
|
||||
}
|
||||
}, [type, currentFilters.genre]);
|
||||
|
||||
const musicGenreOptions = [
|
||||
{ label: 'Album', value: 'Album' },
|
||||
{ label: 'EP', value: 'EP' },
|
||||
{ label: 'Single', value: 'Single' },
|
||||
{ label: 'Soundtrack', value: 'Soundtrack' },
|
||||
{ label: 'Remix', value: 'Remix' },
|
||||
{ label: 'Live', value: 'Live' },
|
||||
{ label: 'Demo', value: 'Demo' },
|
||||
{ label: 'DJ-mix', value: 'DJ-mix' },
|
||||
{ label: 'Compilation', value: 'Compilation' },
|
||||
{ label: 'Audio drama', value: 'Audio drama' },
|
||||
{ label: 'Mixtape/Street', value: 'Mixtape/Street' },
|
||||
{ label: 'Field recording', value: 'Field recording' },
|
||||
{ label: 'Other', value: 'Other' },
|
||||
];
|
||||
|
||||
const loadMusicGenreOptions = async (inputValue: string) => {
|
||||
return musicGenreOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
if (type === 'music') {
|
||||
return (
|
||||
<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 (
|
||||
<SlideOver
|
||||
show={show}
|
||||
@@ -96,16 +277,26 @@ const FilterSlideover = ({
|
||||
endDate: currentFilters[dateGte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateGte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
// Format the date as YYYY-MM-DD before setting it
|
||||
let formattedDate: string | undefined = undefined;
|
||||
if (value?.startDate) {
|
||||
try {
|
||||
const date = new Date(value.startDate as string);
|
||||
if (!isNaN(date.getTime())) {
|
||||
formattedDate = date.toISOString().split('T')[0];
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid date, use undefined
|
||||
}
|
||||
}
|
||||
updateQueryParams(dateGte, formattedDate);
|
||||
}}
|
||||
inputName="fromdate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||
displayFormat="YYYY-MM-DD" // Add this to enforce the correct format
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -117,16 +308,25 @@ const FilterSlideover = ({
|
||||
endDate: currentFilters[dateLte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateLte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
let formattedDate: string | undefined = undefined;
|
||||
if (value?.startDate) {
|
||||
try {
|
||||
const date = new Date(value.startDate as string);
|
||||
if (!isNaN(date.getTime())) {
|
||||
formattedDate = date.toISOString().split('T')[0];
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid date, use undefined
|
||||
}
|
||||
}
|
||||
updateQueryParams(dateLte, formattedDate);
|
||||
}}
|
||||
inputName="todate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||
displayFormat="YYYY-MM-DD" // Add this to enforce the correct format
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AddedCard from '@app/components/AddedCard';
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||
@@ -62,10 +62,11 @@ const PlexWatchlistSlider = () => {
|
||||
),
|
||||
})}
|
||||
items={watchlistItems?.results.map((item) => (
|
||||
<TmdbTitleCard
|
||||
id={item.tmdbId}
|
||||
<AddedCard
|
||||
id={item.mediaType === 'music' ? item.mbId : item.tmdbId}
|
||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||
tmdbId={item.tmdbId}
|
||||
mbId={item.mbId}
|
||||
type={item.mediaType}
|
||||
isAddedToWatchlist={true}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AddedCard from '@app/components/AddedCard';
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||
@@ -37,12 +37,13 @@ const RecentlyAddedSlider = () => {
|
||||
<Slider
|
||||
sliderKey="media"
|
||||
isLoading={!media}
|
||||
items={(media?.results ?? []).map((item) => (
|
||||
<TmdbTitleCard
|
||||
items={media?.results.map((item) => (
|
||||
<AddedCard
|
||||
key={`media-slider-item-${item.id}`}
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
mbId={item.mbId}
|
||||
type={item.mediaType}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -88,6 +88,8 @@ export const sliderTitles = defineMessages('components.Discover', {
|
||||
tmdbsearch: 'TMDB Search',
|
||||
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
|
||||
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
|
||||
popularalbums: 'Popular Albums',
|
||||
popularartists: 'Popular Artists',
|
||||
});
|
||||
|
||||
export const QueryFilterOptions = z.object({
|
||||
@@ -115,6 +117,10 @@ export const QueryFilterOptions = z.object({
|
||||
certificationLte: z.string().optional(),
|
||||
certificationCountry: z.string().optional(),
|
||||
certificationMode: z.enum(['exact', 'range']).optional(),
|
||||
onlyWithCoverArt: z.string().optional(),
|
||||
releaseDateGte: z.string().optional(),
|
||||
releaseDateLte: z.string().optional(),
|
||||
days: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -226,6 +232,18 @@ export const prepareFilterValues = (
|
||||
filterValues.certificationMode = 'range';
|
||||
}
|
||||
|
||||
if (values.onlyWithCoverArt === 'true') {
|
||||
filterValues.onlyWithCoverArt = values.onlyWithCoverArt;
|
||||
}
|
||||
|
||||
if (values.releaseDateGte) {
|
||||
filterValues.releaseDateGte = values.releaseDateGte;
|
||||
}
|
||||
|
||||
if (values.releaseDateLte) {
|
||||
filterValues.releaseDateLte = values.releaseDateLte;
|
||||
}
|
||||
|
||||
return filterValues;
|
||||
};
|
||||
|
||||
@@ -271,6 +289,17 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
}
|
||||
|
||||
delete clonedFilters.certificationMode;
|
||||
if (clonedFilters.onlyWithCoverArt === 'true') {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.onlyWithCoverArt;
|
||||
}
|
||||
|
||||
if (clonedFilters.releaseDateGte || filterValues.releaseDateLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.releaseDateGte;
|
||||
delete clonedFilters.releaseDateLte;
|
||||
}
|
||||
|
||||
totalCount += Object.keys(clonedFilters).length;
|
||||
|
||||
return totalCount;
|
||||
|
||||
@@ -396,6 +396,26 @@ const Discover = () => {
|
||||
/>
|
||||
);
|
||||
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) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
mediaType: 'movie' | 'tv' | 'music';
|
||||
tmdbId?: number;
|
||||
tvdbId?: number;
|
||||
imdbId?: string;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { MusicDetails } from '@server/models/Music';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
@@ -73,8 +74,19 @@ const messages = defineMessages('components.IssueDetails', {
|
||||
commentplaceholder: 'Add a comment…',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
const isMovie = (
|
||||
media: MovieDetails | TvDetails | MusicDetails
|
||||
): media is MovieDetails => {
|
||||
return (
|
||||
(media as MovieDetails).title !== undefined &&
|
||||
(media as MovieDetails).releaseDate !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
const isMusic = (
|
||||
media: MovieDetails | TvDetails | MusicDetails
|
||||
): media is MusicDetails => {
|
||||
return (media as MusicDetails).artist !== undefined;
|
||||
};
|
||||
|
||||
const IssueDetails = () => {
|
||||
@@ -86,9 +98,13 @@ const IssueDetails = () => {
|
||||
const { data: issueData, mutate: revalidateIssue } = useSWR<Issue>(
|
||||
`/api/v1/issue/${router.query.issueId}`
|
||||
);
|
||||
const { data, error } = useSWR<MovieDetails | TvDetails>(
|
||||
issueData?.media.tmdbId
|
||||
? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}`
|
||||
const { data, error } = useSWR<MovieDetails | TvDetails | MusicDetails>(
|
||||
issueData?.media.tmdbId || issueData?.media.mbId
|
||||
? `/api/v1/${issueData.media.mediaType}/${
|
||||
issueData.media.mediaType === MediaType.MUSIC
|
||||
? issueData.media.mbId
|
||||
: issueData.media.tmdbId
|
||||
}`
|
||||
: null
|
||||
);
|
||||
|
||||
@@ -175,8 +191,17 @@ const IssueDetails = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const title = isMovie(data) ? data.title : data.name;
|
||||
const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate;
|
||||
const title = isMusic(data)
|
||||
? `${data.artist.name} - ${data.title}`
|
||||
: isMovie(data)
|
||||
? data.title
|
||||
: data.name;
|
||||
|
||||
const releaseYear = isMusic(data)
|
||||
? data.releaseDate
|
||||
: isMovie(data)
|
||||
? data.releaseDate
|
||||
: data.firstAirDate;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -206,12 +231,16 @@ const IssueDetails = () => {
|
||||
{intl.formatMessage(messages.deleteissueconfirm)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
{data.backdropPath && (
|
||||
{((!isMusic(data) && data.backdropPath) || isMusic(data)) && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
type={isMusic(data) ? 'music' : 'tmdb'}
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
src={
|
||||
isMusic(data)
|
||||
? data?.artistBackdrop || data?.artistThumb || ''
|
||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`
|
||||
}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
fill
|
||||
priority
|
||||
@@ -228,9 +257,12 @@ const IssueDetails = () => {
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
type={isMusic(data) ? 'music' : 'tmdb'}
|
||||
src={
|
||||
data.posterPath
|
||||
isMusic(data)
|
||||
? data.posterPath ||
|
||||
'/images/jellyseerr_poster_not_found_square.png'
|
||||
: data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
}
|
||||
@@ -258,8 +290,18 @@ const IssueDetails = () => {
|
||||
<h1>
|
||||
<Link
|
||||
href={`/${
|
||||
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
||||
}/${data.id}`}
|
||||
issueData.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: issueData.media.mediaType === MediaType.TV
|
||||
? 'tv'
|
||||
: 'music'
|
||||
}/${
|
||||
issueData.media.mediaType === MediaType.MUSIC
|
||||
? isMusic(data)
|
||||
? data.mbId
|
||||
: data.id
|
||||
: data.id
|
||||
}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IssueStatus } from '@server/constants/issue';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { MusicDetails } from '@server/models/Music';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Link from 'next/link';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
@@ -30,8 +31,19 @@ const messages = defineMessages('components.IssueList.IssueItem', {
|
||||
descriptionpreview: 'Issue Description',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
const isMovie = (
|
||||
media: MovieDetails | TvDetails | MusicDetails
|
||||
): media is MovieDetails => {
|
||||
return (
|
||||
(media as MovieDetails).title !== undefined &&
|
||||
(media as MovieDetails).releaseDate !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
const isMusic = (
|
||||
media: MovieDetails | TvDetails | MusicDetails
|
||||
): media is MusicDetails => {
|
||||
return (media as MusicDetails).artist !== undefined;
|
||||
};
|
||||
|
||||
interface IssueItemProps {
|
||||
@@ -45,12 +57,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
triggerOnce: true,
|
||||
});
|
||||
const url =
|
||||
issue.media.mediaType === 'movie'
|
||||
issue.media.mediaType === MediaType.MOVIE
|
||||
? `/api/v1/movie/${issue.media.tmdbId}`
|
||||
: `/api/v1/tv/${issue.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? url : null
|
||||
);
|
||||
: issue.media.mediaType === MediaType.TV
|
||||
? `/api/v1/tv/${issue.media.tmdbId}`
|
||||
: `/api/v1/music/${issue.media.mbId}`;
|
||||
|
||||
const { data: title, error } = useSWR<
|
||||
MovieDetails | TvDetails | MusicDetails
|
||||
>(inView ? url : null);
|
||||
|
||||
if (!title && !error) {
|
||||
return (
|
||||
@@ -118,11 +133,17 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
|
||||
{title.backdropPath && (
|
||||
{((!isMusic(title) && title.backdropPath) || isMusic(title)) && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||
src={
|
||||
isMusic(title)
|
||||
? title.artistBackdrop ?? title.artistThumb ?? ''
|
||||
: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${
|
||||
title.backdropPath ?? ''
|
||||
}`
|
||||
}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
fill
|
||||
@@ -142,14 +163,19 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
href={
|
||||
issue.media.mediaType === MediaType.MOVIE
|
||||
? `/movie/${issue.media.tmdbId}`
|
||||
: `/tv/${issue.media.tmdbId}`
|
||||
: issue.media.mediaType === MediaType.TV
|
||||
? `/tv/${issue.media.tmdbId}`
|
||||
: `/music/${issue.media.mbId}`
|
||||
}
|
||||
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
type={isMusic(title) ? 'music' : 'tmdb'}
|
||||
src={
|
||||
title.posterPath
|
||||
isMusic(title)
|
||||
? title.posterPath ??
|
||||
'/images/jellyseerr_poster_not_found_square.png'
|
||||
: title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
}
|
||||
@@ -162,20 +188,28 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
</Link>
|
||||
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div className="pt-0.5 text-xs text-white sm:pt-1">
|
||||
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
|
||||
0,
|
||||
4
|
||||
)}
|
||||
{isMusic(title)
|
||||
? title.releaseDate?.slice(0, 4)
|
||||
: (isMovie(title)
|
||||
? title.releaseDate
|
||||
: title.firstAirDate
|
||||
)?.slice(0, 4)}
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
issue.media.mediaType === MediaType.MOVIE
|
||||
? `/movie/${issue.media.tmdbId}`
|
||||
: `/tv/${issue.media.tmdbId}`
|
||||
: issue.media.mediaType === MediaType.TV
|
||||
? `/tv/${issue.media.tmdbId}`
|
||||
: `/music/${issue.media.mbId}`
|
||||
}
|
||||
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
|
||||
>
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
{isMusic(title)
|
||||
? `${title.artist.name} - ${title.title}`
|
||||
: isMovie(title)
|
||||
? title.title
|
||||
: title.name}
|
||||
</Link>
|
||||
{description && (
|
||||
<div className="mt-1 max-w-full">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user