From cdb9d2450ae94e015e21db630e7c8468a1261f45 Mon Sep 17 00:00:00 2001 From: Pierre <63404022+0-Pierre@users.noreply.github.com> Date: Thu, 6 Mar 2025 17:30:50 +0100 Subject: [PATCH] refactor(person details): merging Person Details --- next.config.js | 2 + package.json | 15 +- pnpm-lock.yaml | 330 +++++- .../jellyseerr_poster_not_found_square.png | Bin 0 -> 41343 bytes .../overseerr_poster_not_found_square.png | Bin 0 -> 41343 bytes public/os_logo_filled.png | Bin 7377 -> 0 bytes public/preview.jpg | Bin 140584 -> 0 bytes seerr-api.yml | 717 +++++------ server/api/coverartarchive/index.ts | 169 ++- server/api/coverartarchive/interfaces.ts | 9 - server/api/listenbrainz/index.ts | 122 +- server/api/listenbrainz/interfaces.ts | 212 ++++ server/api/musicbrainz/index.ts | 316 ++--- server/api/musicbrainz/interfaces.ts | 219 ++-- server/api/servarr/lidarr.ts | 330 ++---- server/api/theaudiodb/index.ts | 227 ++++ server/api/theaudiodb/interfaces.ts | 8 + server/api/themoviedb/index.ts | 26 - server/api/themoviedb/interfaces.ts | 17 +- server/api/themoviedb/personMapper.ts | 348 ++++++ server/constants/discover.ts | 27 +- server/constants/issue.ts | 6 +- server/entity/Media.ts | 47 +- server/entity/MediaRequest.ts | 485 ++++---- server/entity/MetadataAlbum.ts | 31 + server/entity/MetadataArtist.ts | 42 + server/entity/OverrideRule.ts | 3 + server/index.ts | 6 +- server/interfaces/api/settingsInterfaces.ts | 5 +- server/lib/cache.ts | 5 + server/lib/imageproxy.ts | 6 +- server/lib/notifications/agents/email.ts | 4 +- server/lib/search.ts | 27 +- .../postgres/1714310036946-AddMusicSupport.ts | 125 ++ .../1734805738349-AddOverrideRules.ts | 2 +- .../sqlite/1714310036946-AddMusicSupport.ts | 49 +- .../sqlite/1734805733535-AddOverrideRules.ts | 2 +- server/models/Artist.ts | 69 +- server/models/Music.ts | 203 ++-- server/models/Person.ts | 16 +- server/models/Search.ts | 121 +- server/routes/artist.ts | 169 +++ server/routes/caaproxy.ts | 9 +- server/routes/coverart.ts | 28 + server/routes/discover.ts | 593 ++++++++-- server/routes/fanartproxy.ts | 35 - server/routes/group.ts | 165 --- server/routes/index.ts | 7 +- server/routes/issue.ts | 14 +- server/routes/lidarrproxy.ts | 39 - server/routes/media.ts | 95 +- server/routes/music.ts | 492 +++++--- server/routes/person.ts | 334 +++--- server/routes/search.ts | 280 ++++- server/routes/settings/index.ts | 6 +- server/routes/{imageproxy.ts => tadbproxy.ts} | 9 + server/subscriber/IssueCommentSubscriber.ts | 30 +- server/subscriber/IssueSubscriber.ts | 33 +- server/subscriber/MediaRequestSubscriber.ts | 491 ++++---- server/subscriber/MediaSubscriber.ts | 8 +- server/utils/typeHelpers.ts | 30 - src/components/AddedCard/index.tsx | 34 +- .../{GroupCard => ArtistCard}/index.tsx | 30 +- src/components/ArtistDetails/index.tsx | 542 +++++++++ src/components/Blacklist/index.tsx | 24 +- src/components/BlacklistModal/index.tsx | 10 +- src/components/Common/CachedImage/index.tsx | 42 +- src/components/Common/ListView/index.tsx | 40 +- src/components/Common/Toggle/index.tsx | 29 + .../Discover/DiscoverMusic/index.tsx | 71 +- .../Discover/DiscoverMusicAlbums/index.tsx | 101 ++ .../Discover/DiscoverMusicArtists/index.tsx | 101 ++ .../Discover/DiscoverSliderEdit/index.tsx | 6 +- .../Discover/FilterSlideover/index.tsx | 218 +++- .../Discover/RecentlyAddedSlider/index.tsx | 2 +- src/components/Discover/constants.ts | 30 +- src/components/Discover/index.tsx | 30 +- src/components/GroupDetails/index.tsx | 318 ----- src/components/IssueDetails/index.tsx | 16 +- src/components/IssueList/IssueItem/index.tsx | 9 +- .../IssueModal/CreateIssueModal/index.tsx | 10 +- src/components/IssueModal/constants.ts | 10 +- src/components/Layout/SearchInput/index.tsx | 5 +- .../UserDropdown/MiniQuotaDisplay/index.tsx | 2 +- src/components/ManageSlideOver/index.tsx | 2 +- src/components/MediaSlider/index.tsx | 112 +- src/components/MovieDetails/index.tsx | 22 +- .../MusicDetails/MusicArtistDiscography.tsx | 137 ++- .../MusicDetails/MusicArtistSimilar.tsx | 76 -- src/components/MusicDetails/index.tsx | 361 ++++-- src/components/PersonCard/index.tsx | 13 +- src/components/PersonDetails/index.tsx | 1052 ++++++++++------- src/components/RequestButton/index.tsx | 25 +- src/components/RequestCard/index.tsx | 152 ++- .../RequestList/RequestItem/index.tsx | 60 +- .../RequestModal/MusicRequestModal.tsx | 17 +- src/components/Search/index.tsx | 3 +- .../Settings/SettingsJobsCache/index.tsx | 19 +- src/components/TitleCard/TmdbTitleCard.tsx | 98 -- src/components/TitleCard/index.tsx | 44 +- src/components/TvDetails/index.tsx | 22 +- src/components/UserProfile/index.tsx | 24 +- src/hooks/useDiscover.ts | 4 +- src/hooks/useProgressiveCovers.ts | 190 +++ src/i18n/locale/ar.json | 2 +- src/i18n/locale/bg.json | 2 +- src/i18n/locale/ca.json | 2 +- src/i18n/locale/cs.json | 2 +- src/i18n/locale/da.json | 2 +- src/i18n/locale/el.json | 2 +- src/i18n/locale/en.json | 190 ++- src/i18n/locale/es.json | 2 +- src/i18n/locale/es_MX.json | 2 +- src/i18n/locale/fr.json | 2 +- src/i18n/locale/hr.json | 2 +- src/i18n/locale/hu.json | 2 +- src/i18n/locale/it.json | 2 +- src/i18n/locale/ko.json | 2 +- src/i18n/locale/nb_NO.json | 2 +- src/i18n/locale/pl.json | 2 +- src/i18n/locale/pt_BR.json | 2 +- src/i18n/locale/pt_PT.json | 2 +- src/i18n/locale/ru.json | 2 +- src/i18n/locale/sq.json | 2 +- src/i18n/locale/tr.json | 2 +- src/i18n/locale/uk.json | 2 +- src/i18n/locale/zh_Hans.json | 2 +- src/i18n/locale/zh_Hant.json | 2 +- src/pages/artist/[artistId]/index.tsx | 8 + src/pages/discover/albums/index.tsx | 8 + src/pages/discover/artists/index.tsx | 8 + src/pages/group/[groupId]/index.tsx | 8 - src/pages/music/[musicId]/index.tsx | 34 +- src/pages/music/[musicId]/similar.tsx | 8 - 134 files changed, 7519 insertions(+), 4119 deletions(-) create mode 100644 public/images/jellyseerr_poster_not_found_square.png create mode 100644 public/images/overseerr_poster_not_found_square.png delete mode 100644 public/os_logo_filled.png delete mode 100644 public/preview.jpg create mode 100644 server/api/theaudiodb/index.ts create mode 100644 server/api/theaudiodb/interfaces.ts create mode 100644 server/api/themoviedb/personMapper.ts create mode 100644 server/entity/MetadataAlbum.ts create mode 100644 server/entity/MetadataArtist.ts create mode 100644 server/migration/postgres/1714310036946-AddMusicSupport.ts create mode 100644 server/routes/artist.ts create mode 100644 server/routes/coverart.ts delete mode 100644 server/routes/fanartproxy.ts delete mode 100644 server/routes/group.ts delete mode 100644 server/routes/lidarrproxy.ts rename server/routes/{imageproxy.ts => tadbproxy.ts} (88%) rename src/components/{GroupCard => ArtistCard}/index.tsx (84%) create mode 100644 src/components/ArtistDetails/index.tsx create mode 100644 src/components/Common/Toggle/index.tsx create mode 100644 src/components/Discover/DiscoverMusicAlbums/index.tsx create mode 100644 src/components/Discover/DiscoverMusicArtists/index.tsx delete mode 100644 src/components/GroupDetails/index.tsx delete mode 100644 src/components/MusicDetails/MusicArtistSimilar.tsx delete mode 100644 src/components/TitleCard/TmdbTitleCard.tsx create mode 100644 src/hooks/useProgressiveCovers.ts create mode 100644 src/pages/artist/[artistId]/index.tsx create mode 100644 src/pages/discover/albums/index.tsx create mode 100644 src/pages/discover/artists/index.tsx delete mode 100644 src/pages/group/[groupId]/index.tsx delete mode 100644 src/pages/music/[musicId]/similar.tsx diff --git a/next.config.js b/next.config.js index deacbda3..702c8716 100644 --- a/next.config.js +++ b/next.config.js @@ -11,6 +11,8 @@ module.exports = { { hostname: 'image.tmdb.org' }, { hostname: 'artworks.thetvdb.com' }, { hostname: 'plex.tv' }, + { hostname: 'archive.org' }, + { hostname: 'r2.theaudiodb.com' }, ], }, webpack(config) { diff --git a/package.json b/package.json index 893a3d15..4719f545 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "date-fns": "2.29.3", "dayjs": "1.11.19", "dns-caching": "^0.2.7", - "dompurify": "^3.2.3", + "dompurify": "^3.2.4", "email-templates": "12.0.1", "express": "4.21.2", "express-openapi-validator": "4.13.8", @@ -69,6 +69,7 @@ "gravatar-url": "3.1.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", + "jsdom": "^26.0.0", "lodash": "4.17.21", "mime": "3", "next": "^14.2.25", @@ -124,13 +125,15 @@ "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", "@types/bcrypt": "5.0.0", - "@types/cookie-parser": "1.4.10", - "@types/country-flag-icons": "1.2.2", - "@types/csurf": "1.11.5", + "@types/cookie-parser": "1.4.3", + "@types/country-flag-icons": "1.2.0", + "@types/csurf": "1.11.2", + "@types/dompurify": "^3.2.0", "@types/email-templates": "8.0.4", "@types/express": "4.17.17", - "@types/express-session": "1.18.2", - "@types/lodash": "4.17.21", + "@types/express-session": "1.17.6", + "@types/jsdom": "^21.1.7", + "@types/lodash": "4.14.191", "@types/mime": "3", "@types/node": "22.10.5", "@types/node-schedule": "2.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3c731a8..4a362baa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,8 +87,8 @@ importers: specifier: ^0.2.7 version: 0.2.7 dompurify: - specifier: ^3.2.3 - version: 3.2.3 + specifier: ^3.2.4 + version: 3.2.4 email-templates: specifier: 12.0.3 version: 12.0.3(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7) @@ -116,6 +116,9 @@ importers: https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 + jsdom: + specifier: ^26.0.0 + version: 26.0.0 lodash: specifier: 4.17.21 version: 4.17.21 @@ -283,8 +286,11 @@ importers: specifier: 1.2.2 version: 1.2.2 '@types/csurf': - specifier: 1.11.5 - version: 1.11.5 + specifier: 1.11.2 + version: 1.11.2 + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 '@types/email-templates': specifier: 8.0.4 version: 8.0.4(encoding@0.1.13) @@ -292,8 +298,11 @@ importers: specifier: 4.17.17 version: 4.17.17 '@types/express-session': - specifier: 1.18.2 - version: 1.18.2 + specifier: 1.17.6 + version: 1.17.6 + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 '@types/lodash': specifier: 4.17.21 version: 4.17.21 @@ -442,6 +451,9 @@ packages: '@apidevtools/json-schema-ref-parser@9.0.9': resolution: {integrity: sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==} + '@asamuzakjp/css-color@2.8.3': + resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -1572,6 +1584,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.0.1': + resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.1': + resolution: {integrity: sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-color-parser@3.0.7': + resolution: {integrity: sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-parser-algorithms@3.0.4': + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-tokenizer@3.0.3': + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + '@cypress/request@3.0.7': resolution: {integrity: sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==} engines: {node: '>= 6'} @@ -3258,6 +3298,10 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/email-templates@8.0.4': resolution: {integrity: sha512-HYvVoyG8qS6PrimZZOS4wMrtQ9MelKEl0sOpi4zVpz2Ds74v+UvWckIFz3NyGyTwAR1okMbwJkApgR2GL/ALjg==} @@ -3297,6 +3341,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3421,6 +3468,9 @@ packages: '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -4544,6 +4594,10 @@ packages: resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} engines: {node: '>=8.0.0'} + cssstyle@4.2.1: + resolution: {integrity: sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==} + engines: {node: '>=18'} + csstype@2.6.21: resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==} @@ -4573,6 +4627,10 @@ packages: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -4803,8 +4861,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.2.3: - resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==} + dompurify@3.2.4: + resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==} domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -5386,8 +5444,12 @@ packages: resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} engines: {node: '>= 0.12'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + + form-data@4.0.3: + resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} engines: {node: '>= 6'} form-data@4.0.5: @@ -5703,6 +5765,10 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -5755,6 +5821,14 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@1.1.1: resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} engines: {node: '>=8.12.0'} @@ -6037,6 +6111,9 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} @@ -6239,6 +6316,15 @@ packages: peerDependencies: '@babel/preset-env': ^7.1.6 + jsdom@26.0.0: + resolution: {integrity: sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -6555,6 +6641,9 @@ packages: resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==} engines: {node: 20 || >=22} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -7167,6 +7256,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + nwsapi@2.2.16: + resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} + oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} @@ -7362,6 +7454,9 @@ packages: parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + parse5@7.2.1: + resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -8203,6 +8298,9 @@ packages: rndm@1.2.0: resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@3.2.0: resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==} engines: {node: '>=4'} @@ -8263,6 +8361,10 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.21.0: resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==} @@ -8718,6 +8820,9 @@ packages: tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@3.2.7: resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} engines: {node: '>=12.13.0'} @@ -8806,6 +8911,13 @@ packages: resolution: {integrity: sha512-fSgYrW0ITH0SR/CqKMXIruYIPpNu5aDgUp22UhYoSrnUQwc7SBqifEBFNce7AAcygUPBo6a/gbtcguWdmko4RQ==} hasBin: true + tldts-core@6.1.75: + resolution: {integrity: sha512-AOvV5YYIAFFBfransBzSTyztkc3IMfz5Eq3YluaRiEu55nn43Fzaufx70UqEKYr8BoLCach4q8g/bg6e5+/aFw==} + + tldts@6.1.75: + resolution: {integrity: sha512-+lFzEXhpl7JXgWYaXcB6DqTYXbUArvrWAE/5ioq/X3CdWLbDjpPP4XTrQBmEJ91y3xbe4Fkw7Lxv4P3GWeJaNg==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -8850,9 +8962,17 @@ packages: resolution: {integrity: sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==} engines: {node: '>=16'} + tough-cookie@5.1.0: + resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + traverse@0.6.9: + resolution: {integrity: sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==} + engines: {node: '>= 0.4'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -9278,6 +9398,10 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -9303,9 +9427,25 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.1.0: + resolution: {integrity: sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -9424,6 +9564,22 @@ packages: utf-8-validate: optional: true + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.4.16: resolution: {integrity: sha512-9rH7UTUNphxeDRCeJBi4Fxp/z0fd92WeXNQ1dtUYMpqO3PaK59hVDCuUmOGHRZvufJDzcX8TG+Kdty7ylM0t2w==} @@ -9446,6 +9602,9 @@ packages: resolution: {integrity: sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9549,6 +9708,14 @@ snapshots: call-me-maybe: 1.0.2 js-yaml: 4.1.0 + '@asamuzakjp/css-color@2.8.3': + dependencies: + '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-color-parser': 3.0.7(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + lru-cache: 10.4.3 + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -11151,6 +11318,26 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.0.1': {} + + '@csstools/css-calc@2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-color-parser@3.0.7(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/color-helpers': 5.0.1 + '@csstools/css-calc': 2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-tokenizer@3.0.3': {} + '@cypress/request@3.0.7': dependencies: aws-sign2: 0.7.0 @@ -13441,6 +13628,10 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.2.4 + '@types/email-templates@8.0.4(encoding@0.1.13)': dependencies: '@types/html-to-text': 9.0.4 @@ -13497,6 +13688,12 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 22.10.5 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + '@types/json-schema@7.0.15': {} '@types/json-stable-stringify@1.0.36': {} @@ -13617,6 +13814,8 @@ snapshots: '@types/express': 4.17.17 '@types/serve-static': 1.15.7 + '@types/tough-cookie@4.0.5': {} + '@types/triple-beam@1.3.5': {} '@types/ua-parser-js@0.7.39': {} @@ -14930,6 +15129,11 @@ snapshots: dependencies: css-tree: 1.1.3 + cssstyle@4.2.1: + dependencies: + '@asamuzakjp/css-color': 2.8.3 + rrweb-cssom: 0.8.0 + csstype@2.6.21: {} csstype@3.1.3: {} @@ -15004,6 +15208,11 @@ snapshots: dependencies: assert-plus: 1.0.0 + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.1.0 + data-view-buffer@1.0.1: dependencies: call-bind: 1.0.7 @@ -15207,7 +15416,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.2.3: + dompurify@3.2.4: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16149,11 +16358,10 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 - form-data@4.0.2: + form-data@4.0.1: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 mime-types: 2.1.35 form-data@4.0.5: @@ -16520,6 +16728,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -16611,6 +16823,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + human-signals@1.1.1: {} human-signals@2.1.0: {} @@ -16881,6 +17100,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-plain-object@5.0.0: {} + is-promise@2.2.2: {} is-regex@1.1.4: @@ -17119,6 +17340,34 @@ snapshots: transitivePeerDependencies: - supports-color + jsdom@26.0.0: + dependencies: + cssstyle: 4.2.1 + data-urls: 5.0.0 + decimal.js: 10.5.0 + form-data: 4.0.1 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.16 + parse5: 7.2.1 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.1.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@0.5.0: {} jsesc@2.5.2: {} @@ -17442,9 +17691,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@10.4.3: {} - - lru-cache@11.2.1: {} + lru-cache@10.2.2: {} lru-cache@5.1.1: dependencies: @@ -18302,6 +18549,8 @@ snapshots: nullthrows@1.1.1: {} + nwsapi@2.2.16: {} + oauth-sign@0.9.0: {} ob1@0.80.12: @@ -18522,6 +18771,10 @@ snapshots: dependencies: entities: 4.5.0 + parse5@7.2.1: + dependencies: + entities: 4.5.0 + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -19515,6 +19768,8 @@ snapshots: rndm@1.2.0: {} + rrweb-cssom@0.8.0: {} + run-applescript@3.2.0: dependencies: execa: 0.10.0 @@ -19582,6 +19837,10 @@ snapshots: sax@1.4.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.21.0: dependencies: loose-envify: 1.4.0 @@ -20223,6 +20482,12 @@ snapshots: dependencies: tldts-core: 6.1.78 + tldts-core@6.1.75: {} + + tldts@6.1.75: + dependencies: + tldts-core: 6.1.75 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -20256,8 +20521,18 @@ snapshots: dependencies: tldts: 6.1.78 + tough-cookie@5.1.0: + dependencies: + tldts: 6.1.75 + tr46@0.0.3: {} + traverse@0.6.9: + dependencies: + gopd: 1.0.1 + typedarray.prototype.slice: 1.0.3 + which-typed-array: 1.1.15 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -20672,6 +20947,10 @@ snapshots: void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -20718,8 +20997,21 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.1.0: + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -20891,6 +21183,10 @@ snapshots: ws@7.5.10: {} + ws@8.18.0: {} + + xml-name-validator@5.0.0: {} + xml2js@0.4.16: dependencies: sax: 1.4.1 @@ -20914,6 +21210,8 @@ snapshots: xmlbuilder@9.0.7: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@4.0.3: {} diff --git a/public/images/jellyseerr_poster_not_found_square.png b/public/images/jellyseerr_poster_not_found_square.png new file mode 100644 index 0000000000000000000000000000000000000000..c116bd1cd19a04bc8b59b4f358ce3ff8759258f5 GIT binary patch literal 41343 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 MI=LPzI^q}i9|k-j?EnA( literal 0 HcmV?d00001 diff --git a/public/images/overseerr_poster_not_found_square.png b/public/images/overseerr_poster_not_found_square.png new file mode 100644 index 0000000000000000000000000000000000000000..c116bd1cd19a04bc8b59b4f358ce3ff8759258f5 GIT binary patch literal 41343 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 MI=LPzI^q}i9|k-j?EnA( literal 0 HcmV?d00001 diff --git a/public/os_logo_filled.png b/public/os_logo_filled.png deleted file mode 100644 index 37ad421d7492b87fda8306dfdb76b80848e8b3fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7377 zcmV;?94_ODP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H199~I8 zK~#90<(+wy9L1gIKarVL)m_!6)I#05k<^kg5)ue;7{nkj<}?^HFkrLD3_cc*XY3i< zGvi$hW(Mr_KJWMd&%-;$^U&H}SYI5rxh;edBftWIgi#+7x}|RQU3XPx{`QZovn#W% zZVB((-+TEgD>EZ9;`@u=nURr7MEFxArB?U}CQn6ar-8i^R3|VCm<@CQ&7iD&2}yv2 zK^+F!k5c=v)qZsK4&mfBk#`gCO;5^``cq+mQmaA)V;7^TD-g~FP62$=RDBwF1K5gE zFJq*hmeF^|rm5~J(R<1O)!aP`Q6B@Y1TFvqC+OBG;5kHofo?n|6YmV4p!=5431@&( zO3}3BLPYyCa4jbebT2Yd@@wF`67GB9MBn^`F+i#H2El_HQ0mLT@)L5?+UX5Y-z924 zEYg=e5jQ;U3{XmGG%dXj;T}-Sj_b{)j}9X0pNaMUP(@Z0l-??cP7til)o})v|mYw)_kyMezR<9YW-4iT1rdl}fa!6jq^S zS7OUEyhq>%10g~U&F{sn0p_CAiz>MM+No4hw*;tA_lHsHXB_89c2;XAt4?0S;?89( z=~~8ulTM~J*hW)B3reZnbHTRv zh3jH~YF>ULBL538>bYqpI%UC1E;#jrocq2DS<*GN*llRy7_ard%!_Y7%hp|+F)eeN zT3JLEO5H5so`-8KSTh4uX!#W=d5mcWztJD$k~6O2BWK@8_uNxT@2fx$fDr&q1Enjh zl#rP4u2TS!{O?F)h^Mzb#uKkR!jX{!)7gB0G$PkawC}N63f05_6{KDI4Zfq?&=MRFvNfBrQUxR!3=rDu_O8bxlo?<2lkhV;od*g9_vguzr zI&x^bx&n~ImJ21;x3$^=RWm>Zm(N9$t-!o`TUSV$()jS%*Yk-FewL1wlXAy(A2hZr z8e5(HN@S(sSYL4;CWCJm9uvu(OPeOYe)VBCKJ#7Tspxcc3s6U~Ia6XiRdKc|1}HVZ zkx;`cpjJ+~eZZWV^ZCc?@8R^tXXTC=0SI+Cz}G_YaTq_8??Vo7N=JSn_=V?&CkA=o z*Z1=BAD^9a-vCf-B_bDAlTodX0io{uQR=Uz+%BcK{=8fG^yOb5kkKic0Zp?M!S?Fv zFq6)x7You!h>ZwhdjO6L_+?bnZ(n_w2cP_Jq|MY+y9!EvDB-?ORlckO15{J@rJ$Y! zRM`tFklz>J@2|U?OV79_cdW4;T4pO$nP?;xgJc9!5h0z#L243B2l(0F-G~3kHy`;M z21gH1rMsDiscR+L^JuwC$}+%tWT?G0)x=uYjCp*0!~HDna-PFz2DHvm_&rRyZ9#kt z;$wnUVK7b?_=R?2G;)l8-uNl@4fIZ>+Wt}zG z1K%Hn84DG@MjX{quvuvc9~FlBg~(u?;7b|s3(naY(Cta!3(y*x#n*1#$g=sXr_yzR zu1s3qlRPCLljGUU6jaZWu4R1tQ$M1;X(j-TZH}Q;lWG)!#5hEU9erL6l~W^vc#rb3 z<+A%u`FJ|UJsWSM|KMBoLXf2oNu<}+!n;aV+Xo-%`rm(z&ak;L=+`omubQ zX12`Yzu)=*t-&?`LNgT&Eskz4+~VUfeo!!Lc>Gd~N~Edzdb^jl8UT9dw~5{T6YSks}l zbj`=jmwk0As^}D&y4MvRm@EN*U2n}_x#3=qxN9`I4%3qqht@P{OS zt=sj5fP1N}N|H0_*y1^-bIyvZ>xBt~+YA5Z;sDjUVg^>~2;i@^&WldFoV#y!;tkyg zjjhEaB%KtJ;ab{Q+;O8pXkQ4eT|#h%p!*ytQyOTRMr6chc)#MLgGu~5RE|AEc0VtFJGyPHO06*Ys;PxxNnEUT*1l!JUmMNraFt~*sRyxc+ z#io0`a8kETL#s1b95+@#6t>tjb-?1aaLx@jXI*R4-sJ_NOi^t)Zfo(9i!*QB4!x@!gOKwX=soK>n?7Yz+ELdyPHs3iRq7YFz#yb0% zOsK7}EWx&+tqacnsLk3B3xSZh23VCmBX_k`T>j#-{}NrV^P!;P!`WR8K(#FI04}Vx zo`A1`8`j;D`>*>b$?aI_>gp;4L-)a)RbVtY_2eYV62w*oF0B;Z4^g>jde%Z=-AzKr z0`UePN?gy$c_A<6wYPL}`tr+ag)B=KDYYsDSqTt(J#{?ey?o8Jw1qMeq5@4XWOCZMp^?_Pz(3HksT#z6Annp>$Wmho)p)kO*Y+>dS!3bnPv?&5rEayL>uq|=! zVN>MpVk^h<$}mAGShXJJzt26u^0Wcvz{lCT=bue$)2v#d%K#>qLCyxKb>*&gqt#2+ zGOuG%{(verzLmf)9EsTuH_cK6TENbrWZ9D0&+C2%e22k>Ho>y&B3qDUOqSMMB6KbG z8s%;PS5{kSI;-kcL*nGxG)EW7s#?omvihphIwwhh=e;TIl|;K#>LTEbgkCT9~{VIvN1y2?m^!wAcAxX^QWlyCg} zRicT6V-##B)b4C+;N}mW%HRC?S+q3!ow}6Mh%93OW-kyHuTb>woZJ@4k8F>2x}Ed; zLd@%2!M?-WYlRWu3=O3g)mrNGC1;f!D>Q^|4_3-`Dl}8TE;g*T1y)9#SXN%8-Eht_ zzW>Sf7)o;BBNj)8C-~OGFLB0g-{+aF{aAKJlrlD>a6qg)Ptdf|0IS#ld;Gc_>+Gm` z3_GOMat&c|t>sQzRNJ$fa^MGAvM#4!i*mNC5p~h|wk2Hf{smmUc1aHWLPiXYgt_Uf zKjq(k`6{+8Iip-SD1uFJ@{0V(ubh4;cTVVNKe<*2Go(5-#IC7ygTIkwbE|*nuIz|H z2s8^;X0&Gk&iej&MyQ-rWIdlh|G+6F;5+*^mYL=s?|+;pHou)Q$OT?hhL*1M2+-Wx zSq6Mtprd_JEf5x>)2V@~X>R}0uI2cQ>Q)f&&Z#)5V1E;!@}TBZ3|ZGQ^P9;j<%2EZ zTF~2P6ZnclK0)SU|WvbD9Zh0N4}Mph;-T;{Oo=t8snQkdaB5+6+7=K%vn@< zlot(}+__@O@q0~hsz|C+gIXC-er~QnNq|+{DarZ$N~w(MnGA3~_r}`$k~4U#7l&uNr%ajXEs3B!cy?-$qV^?bx#&=(WvnampL=%d!DPTH}g3e%_k1{ zS=C}X#`5`|eT+qujyZI-}W=A~n}O%R9hw zS55BGvELW02Ydiu%?!xS^O*|x0_cY34B)JrnNck-_V4Ge-)ttHvU1N8Bohs10sPKm zFR*?0zKY;y_y4eScY#FZ^Gi)F3d8TMeu_rD2lxUgt?qYK(i+ZMI#rY3DCJlAQBw-O z(()i!R(4-uTEfsoge|+@7)=sPmR=YRNrc<`AQs{=oyqK*!XI5wcr zresnVZ8Ix97j~K6IO*3amv<>oVFMVZhBsb(dhtWEvW@c^H7b+Lva;C%g(y%0rfq_4 z@y?+meEz4uBxvZ&X=`FU7U9^~cok~LE;$y9#|v^(;)KPSbVaa9m31@S&P`TT=}MDW zy(w$}Bfx@cOZbgE1F8joa)XsPss$Z1l#{^_K{FKXfj|+kv1E$=qmk-Hdo~X}>3rwR zb|(feP#;`)QY)pWihLE1RoQ4!BA=sx2OO2fwPk&{QQrB&>l}({f$M#s?|}SXDJV z=l>Vmf^!xwngaOl`|HnI?dbgMVaa+^!pP9%KGcvG%Jegpu5;N4r4DEy@77Z0$nb%w zO%bFp%FVi#`9Q3DMkjMy%Sb^LzJKBViX|traL!yOz$*yIEE~e(8j*;1fK^RjL?9aT zeE*^n19~)&owbyCbKmP_1FVXpCbOT-a9TNkKa&#@;g+-4)jirL@BiJcAInA9QV>Vj zHuUc*Ry*ZAIq!3k*s=N;Akw2D)Wm?d_H4tnEpPCtZq!&dm>DT>(hvDy>jR6Hvv$$q znn(NO{i`lq$2qH33mbV%V03&Kh0V;?R+h|}$9b#Qa?WY1X=v~}HpX&BDM|xV5Z!6}_Sb~Lqxtq$ zo|xqgz5sjnZ^5kPg@-JO+60-|(Mqif5sD8}Lt?JcALNIB_atpivuZHF<@GI^p!=M+ zD3$I6XG#+^-I;Z#G;wA`>l7W+b>;lmocXC*<{G_V9~5tIw>f;Mmcilz{-kq{KJ|I} z-`!Lz{3ZdJDZ_me8i2@~QIIXQ)DTa_c<8zBO&R>cn8LCY>7+CLB=4f;gEYsRp(rh# zecYJM_vaq!XUdtTFmM>QydZh-WqQCL8tG^E-p#dw52)YEaNh(J&5qW7RckfRZu=Fz z2j8w@M7h6%<@V6VvLTgFB$HsK1=~_NV{O|C%go0P*~gWpnTaJ#8`A>IRwUwzeS6^5 zEs7o6g;;cI?*tUPp3d*)t>2-pg>}XLaSry}3mWYww~_W81{!OrYsI3q-20agd3I{5 zZ!cINo6oyX<)Q`%kB;9ZloawX>k0~Z5U}A!dfv?ZUF9hGsIKR{T8=idrE470U z3H(;4jO-qVf*H{=`tF!&TJ|V#Q!Taa-1{0Y?)VMoue>tv2^Eg~DS$5`m}y9-L1L0i zrYQ|jHkz(7^G6k_lp`V4sJ%+}t(2MwM+VvS(l_b>AK=j}_)zkWn0m0D8XtV}n}p*N z)AA0M8}qUvpN*bVDM%(2u^1%cj=Hk#Nh+fT@ZAF}ue^}(?B=hLNS0YEtt{CO75*z` zK(u!gQ1dc_W04^q+4OyP@bLoJ>zvf|>Zn`!@={fOfaNw=w(oqHz58FP6{0M?MRfPm zg$Ifl;4Iay?yaZh=U@K?rft>*eqE_xFYsN?RljCLdFCb45w^T?Up??Rq}11C(&{Ls zFKS17e+=~0T6Z!Xcis!5GEi=Lq3M{VR(Q^H%?QsrD@YE|+y4xybd9h0XGz#ir1$5O z50@}NL@bbdYOVj21!tn^hHJy?nQK!k_*Tjy9Zh0eb`{f0s*{|_YAX_(!lz~w_c+)V zlly#%X#f8v)cqBz{P2R$@N>(h|4=f@-8z$)NDw_1CK*m(Cemyje4Hg6t7)Dwi;m8D zG&Z$V#e_sUM(^IO^z8jTt%)|)w0+2FjILpHv|x0$LoiUYrMG7FC6HDXDEl zdpEkBab_X_CyO#E@IRrYIF+vTwx-7tsrPzD$EuMSpf3oMmA22h|V$Yv@J+x&iwQS6R zF|2_xRBH&WYX+(%h$vMS{7MhrMPR}F^9cA`NTzD?fNX60b6%f9@tjd4;ocWj)AApI z+*Ypkb56aCb*ny7((rJ6j4eB!U}*nQ*0fydT6Q!!K&!6}zvhPw7*;e*c<&J5y+bHN zM-Mnnw2~%bTD-XXNAwQAT-?J_$#&W?9>pGyvOlqlP}f3Q=Pbq-YIW{orBO)>JDG+= z3S|S9iBcN2-$%@!BG5dmhzW)sWW|b`*!KDl%0W_0N**k?kZbuVT}I-|n1QP(vpV|n z`Df%nPo(0!(YKjb{`eHz`!{2nDb~;bIJCHzJr>{3jKE0*G+)L9g0AVg^I}?nF4Z zPVy)TL$L#NH!nli9OLbbv+^2mB!0BCos?&QDyY*+B?#Dl22um)hWDC<0i%(T(cL`1 z`A&BA{hEs}{5rwlEDT?W#>Sb|FhCHsK_+_2KCi4esZqjxKUbk;_o3WZQW=>T;K^4W zaytb;v%do`P{XMaf<`dgT89auJg<+X$4OYF9d}&o>gP83v;dLxD4MQ&UK1#oaC_+R zt32|Hk8<*&iy0Vvi}8tl)!gP=67BhIrHiVYRT^vgBEgB%QJi1Z^#v3(JoFEcwxf_4 zg-V>KVoLJ{Wh8mT^|+f2C?+X65>HQ{8RZyIoRdr@C)m|fb;9^HqW(Loxv;7)Xo&nF zji?)e!D?HOrI62kF=$&XXJHRR%WMkq>FCOPOpL_cSF^2@5*Ag;0EI@}3Zv<|>lUd0 zrHi9j=Jg^wtC}uX^(Rau)^iBkz80u@nQ>h+2+_NT zaT(!-WV);v(x8VJO&&ovbl1)2t9j)ffG}cTDe*m3=_si28!{5>e;L#@Kvm0dIO@k; zHy?{1pfz|B7!LRt8b_lC%horMbhl@A1tXlaOQ#M7^dMu&K~D+L%cw#RN`qWq>qmWR z{)mr6dY(t|F`U?_ECmhkwdjZ92WSem<-o_#7)h3TIE6Hxbf1A%Nd+a%Qpsx6n;B0G zp&7dSR@+)Y7Vrs)^gUhi%1Kn$SCk~&`%92dP+>w>(AScCh%$LQ7CS&F*j``*G{zFc zp4X#bBJG}w5$v+%;#O(o8Zd%PB#(JZKs8^Fa43nAo9g|DPu-ufl1Sf=L0t}b#^qiH z7+^SYkj7vO=vfI+OvJriW)Yi6%AEm~01z*nsD~BI!|2pvxIy5u0$jboR2Y z`JMau@YpKG6@!R&VLfW6kfy`{0Eza!j%A*U^Ud}mYSi5sFq}9~}MYdqe8j0@ugZD+%(UchgAn`r> zh_;-aS#vr!O#O!YE679fLj)UJiwy8sd=OhKPlFmay_sW+w*&=Ai(#C1ul|UUwN0UN}3i1fD^1qxaVt#x{6G|W+-=GdMt62P_QlM|2yi(@qkR* z{iV&gO9~=?Z*K{RrzeQHFhEy2wphd^iuhE?mM(pHbP$niCDQx(YJ;q5XxbQ%C5iSt z3Fc`a8x76flNd`45)2i1K)S|g#0j!ZaqnAM2`FO&;%0=D6~~&ySS^!S2bzdJNPc=hQ=+20i|k4p(%}&Buc;^jvPw8du02a zMEllAtnWXn>TI2KTt3EGNSij@Wda-4ufO}YhJoN+^P1oPx6$-yDA3fL$%G14+9VQj zKG*K%u>hF1x7wYoUJ5BI0SOPrrmPg%1yqJc4kq6{u>B#dy?_1E3*SgV-Lv1SrsK|l zEM*w+Ri*B}i%{%C;l#*&rZSi6nvZZ~lx)h_OV@iDXQ1qYdD;4s)?^hX*kX}LN35QA zUpp`~vhRWMp`-sJ)6Nq{CyW7E61mHUyPg8N&%5%Dg#n}ePSLm+=Npx}o5up)#<|kD zc-l$cCaruv$QFyS)Ugpn{lo41qevPqO>9)T?;5ENDD~EO6RgPEUAJbQo_=;(o##aG}5tzbc1v( z&C+!j^?kqh{p$C-_rE*y?3teDoHH|L&U0q=eE9q);I6Ecj1&M54-fDL=K<$q07<}= z%a<>nE0?cay>jL1wOiM(;T-cMb2-m8;h;-@uhp;|eb0X!%Dm-X$Cr*RG$B0`6SK16;a$`7Qu} zH|N5!)I89pUG{IUW0n^)lUEEPElV5JjV<^nM`2u$Wz5wqNrM9yiC;ON+Klc*KKTQf z!kQ@`8^Cp6b4RB<{QOUlX~HF?N_r=qwcRim*+S5oyR>6Hawc3_w^jL7dmiNEBuC% zcCtYMyOlT)kJg6#Cg1_+2$@N92H1o9ihh?sI^LJ{CoKY~t0eWu4Wq@p675TM2;2Qz z>vqaE!=?Nu%%mSPm2DL~>R&l&JBt;PP+lgfn$V-G3UxKVnsmYKD{s}`XFX86`kGYb zD=zsG_xqn5o(H#^fMDbz!Y-XX5o#tj-&=-AYw^xV6x%=IkxR{}twAB6d_WuOMAYTp z!=e#~a%32@0mB@L!`xi2@bqnJ|hk5Qn0jd}Gc=>)mK+kbl^w>r1Fr5(wC)PGa`+Z$@+kPmedN=IX1X-l|nxt8+v z|En*!J;?OCkN=^D+OwLizQHs$>uQ_AW$X0;K6jXM>~Ai;QhRp6tgYQ`Q26tKUvcF} z-yS8C;tI6KRI-S0rLtKh7j=|xeT#UVb-~TQSp2)OY}T!BkG^Jo!u9kB0J!zzcaQ%p z%Y~4$Xa4EpQL-AYBOLr)ffR5{eb`+O())Xq@MhO$pl&Q|W9r6kX1+y5y!+3O(dEsD z6tR?zm-Os!hZ@VnNpymP5*@*&&uzK6dP(r^1Ae{w0?l7|M7;gg6%OG)Z}qBNwLMXh93*?XMnTX^kT}W6tfGf#BB^Kz zehUSLChJslLOQ0qx(gHIB|6zGih6@pmrDTDBIf1~Jx~*ij^j4o1!$z54_*6qIV#>S z+kUm0_0{6;cfbYae;nTZi<$Im(Ebh{y!xvY7yeDs105?#W+jTfwc=TWZeUm6J>k;g zCIV(dKN4<9Ir%TWrsf7byiZ~>0;SqPAkQit(h3b-Gp0;R9FGz7)ZA6m!XH0OO>nKJarJGM2Yr)~GTI z`VKjH^n3&60-imol<Z3o} zE5_1qe!e^F$$m>R`y&Z-&g%JF000O)yV9@(O*o4%x!SxxvN3BaZRf1aUUS8GNx9qe zwf><8`6G+_0c{3wTIq#zK#G@b%kbW+heP3!?%+rvB#Lu4VQiq`_SSCxkjX0c$U(=^ z$4TL5rTj4)awu|-L!@EiCJ>y8!{>sx&(!Yzo=tx*#=r1;jjQk!_hZEUa9h!(r?~JU z=U(oe?A!{YEFtYe#CH;s5KUrcm?p7NU&p1VfXgjojgfm@4~I$?#=)uF9qs+hl^e98 zWTW8p3qrEAzT{ z4j@O^xU{&Nk*qHIh6q&sdiyW%ACm`gi|O6);MCN?RGGh4pTSM;(|8|*D?f&y3^6DdLCpeL2{vq zrig%%N}lUhI<6z|ppp5~*wT1v-N%j!f*VG&(HI<0zy5l~{}HfP44dki^brrVcSu>? zoI+c91^2nr?KSv+SGFAZJvTI3hFW8sZFol3x=m_IbwoW%N%$A0LONJ_Y!u6A{IrzLa@rl zA3Isg9Pd!D)pCUq=Jodfi0Q8ir5O3j-10wlLDe4qc@ER3qvd0e-I6EMs*`$ru?q$- zVWSwUWeVVVTCPq6Z#-9Tu3!Cb{+b>S} z#b7-47NXA*r6)B=L^OI94!62mT7=K^i9xkb|L<_qaZ1-491jYxh|v0EVz`?bEV}}1 zdFY5Sgry0q<6R+p3&5Ml%*h{X++CjH8><*@;3?ZE9ay(LofKle=VInO1iOhXb+}QR zm)O7AG>oZAjIUuEIyhZljfWynq41Ast_Q#xExQ4X&1_e#T59`dO^xS>Cf^~nWPdBI zv}G!I&xUJXJW;|gZuI{SVfKnXj?!^!?YT<^F&|&v$2Gc+$OtpuS(1Kps6V_uB+$}6 z#X=R4n!PxiPFW+CSRkfjEfTrkvmpv6SBsdKK5I zk?zhPTbSw-fKzrR1!0S zQo}G7qWt{&8@P5bw?p|(cj)z%TBecvn+=udq0t&Lb5Rn5{XExO)2PEw-eZUvc29>JzE+0d2x-2FJ4a|41`3{A| z+WNkVzrUo&@owT_JpnVS?nm6V=!Vex(k2jL`wuMsmnd$@#O0R#u7J0=ya4-O>i$nf zI3g7WCvGl~ar72li>*)P0am`;FVFXfIE9Ky{J^!^!S3*Ld*10~CY|r)bs(aXDSa(Z zycY4PyI0}l2ElVo$J=8^Dwc#bR5DZ23eC?Ep4{jF|NrT2|0Vx+Zl)aWu_x9s#mP_4$Sk$OMj6$hC6J zw|3ym=^zJt3z@nhXjPD&b3`OEj+2Mrq)O}a%rR_~YuRQ|#FDsE+p)QorTO>Nq8%Lck)q(W+w>T0f2<7<_zzQaKr$v%VJ! zYxtzMhdTbXF0P`qjrAZuM)Ni;SnE?p_y;iQxHBKQ~iv;j@tQ_)F4ran#2=gLLh#|IpO3XqL?w@;q zZ*2MF8MzxwfupckwPA_lde1Cy19iCJi4J))WDn88viOni#H^i-^Yni?L1WFjJO!|E z5@!51$%PNqSYoV9V`qfS6bJ6gDtg|)FT&3$@2nqgkmrDD1{Iw?>zCTgmMm z`LC-79Co2dQS2y(M?z}};uMSIN{%f2xhO}IH(&laLvGs?w^pE&aGLSoYUPEBmK&w* zzF=Ug;!;EGI!WsyTizl$6s5p6U_Lkg9hF-65RuMbSz{6AsLAW}@LI+tI5pc7(QaMq zBVrG6U2ER$N%Ly_`Pjf6x)zsoD!rQY-6m?431o#1t)R}S?={qjnN()F2&kHLFNT>v zN(7Pq0&}aaTZS+m;^m$V4Tmg-&U^H=$y%VH&lV^09;VB8lLQ-#1*MH{x42YzPUO-J zDikFfOo^S6?&Qf&6%MX*l8An+c+ous46-Q^c36$Q*N9f|hlN)BaBDx27Z-$7rI5Wd zXT5i4L{~*Rq5`;)$Oge_gb4q0K%Q7q40UVs1M(4XMfHC&27sGC?h}v;34(KYkU{hq z3pq1w*2;)f`Vgl}csG!s2Oy%N?_iIA|hof2nIc3PHuJksNhN^{+<3z22_C7DEf-r;(v z110@x0vr7BxMsxcWs1EAWKqdmc8n0s(7L^~-$D*^BNADy&0zb@fuZSV0c75n{!&|a!qd;|Siqc&5@bUX7vHv(sd4yRx4^)0_q+~fEIO= zL;?fv)N2(lhzjp6cyr_U!Z9+%_d$l8_++ViH-_`$`vxPS1$RQ0?97oayDH#h=5#x^ zspz!kB%Si2v0*|CYm(#Ti zc9i|*6F$pSt?euh4drgL!$!(cI!%Jivwe5F23B{YkQAd)YbDf`YxDBmFD*I7EbUMR z`j_UT7 zU&*C`mXA&Am>W}g^%uJ@fYWPCmYWadwe`%=l~e7d84(eWciOgiM^~#1Dtb*j474Hw zVN-Iqmr}DW-zM{9H60=LNs(RTe3i_102k!f0X8FS!oGeKk z(sBHg(s3&6=xQDrbP|JU_VDgE7dPy5G)EVIGk&$1$z-t-xWP#+hur$6@==wn96Zz> z0jyydnc+FHTQ;vsk5Kw5G~%#J$a+->fj{(7jel0!Byf?$?AOKwdK@j)qN4 zUbY+0p-Cg7>j>t^!2~g#B~AqYU^nJ^=j~Z+<-%vWXLBcuJ|E8kMh=s-A2=#^7RFT5 zpp#2hI*O^fQIM87tX%nk{oG1fRj0tR>&bcnSYcd%MX4xCi_B7c7W%Vdij8YX*fbO5d@0qUrnpZvVfhbhWL6ASi9O3CQ=~6>q8g#n;>y zhQL14I;eGBSFeRX=;PC7H+0dtVgKgBsPKR7*q(OP;a1!(ZpGnDC>Ktp7_{x25Mtv+ zwMOnV_PJ6iR&YS%1J}$9!GLuOnRAFx9<}o=8V9-(`*C6HS>m?1_+$Xnq$l5JRd186 zp47w|@mw-_E<>Zor%(-A?9TQOf_dCJS7>=7e_4BLJ4(gL0IesRKObB=SIg*>)TU?S z$K;gM>LJ3yZS}2l>XbG_1I>3t$Ce@mdo?^V}4#=mEL1Bb^Z1by~4=P z=a?SIQa3_afxroB)ckX4&S^QujJ2}(NJEiHvCY?BiJ-f~F+GmKY(TZeqE{epC`1+M zrtZ2X49x3368uo@-ZQr~y4UeCkIpGjPMUplohHuOiBhC1WopLG&ukkLEWSK~qI9DZ z*BjkB*qYVYOGp*GTJ6(rKd0D3g%am)#+;n({xEG7gLe-fDqwxGIpgVGx-~3h3=P?> zZJiUwjk@uP)$hyb@+{b>Cjc4RU@JGxN_nj*trtPSao znKjEpNHYawvIs4W&jLGNmp7s_R`u;YE7V_vp?O}+{i*8}x+fiO=L3G<2B{dN> zRs42h=YR%w&A>vY`xz5T8*gc%E;loq9)$G9vj}ts5W&i zvItL>MV=4}=GvZxWeOr0n(T;WSJT*wIfzyP(l2QtmD( z3ypRtvSSccpbTNoAZ6+j~g+AnuJy1VL; znoi#qg$X>!abG&qLCTkjjYh6i zfm6QcuZ%g}kU#EN>yZ{X(%yZ+M?Bo!e?W~4?Q+v7+5$plo5i%(itRZ&L^t_qCi@YY zTNY~%H{xjFR!!0=DFNMa*e(kZGwh_%z@ZxwJGkIr@hwK$V`hRoU*@)Xo*=x1cT&wi zssgjJ(~C(hSsdR6&Nn!1&$zE$!!(~>S^=BI?2)E5Er)yy+4m@3WJr`r9>YLUJ}WsD z;iVcDF4|zZxgs+nWZSX_BQ$QGR!Z2md>^zNzxTzsPS^#}cYT?vi!a9|{E|g($^M$o z;@7$AmOw{?!XuK!jie)w?%Vc|bU|$Ez+{bXcd7*2NYoGZ`L>mNb>mfV}AA}_iz zL1@TIYw9LGqGWN8K;cJKRjmCF2j958cHIxgOUf27_er-`x<*9HYK zN3q*2Jp)2}cQpKJv!HLZ1R;g~%NENpRZrfJY-tPSRDqb;;(76>wv&m1b9;2XXL9S@ zhGS9iq4tu(iK!pkPxrJaj(3JU>Gsnqe7yt^v(7vWJo>}E^=5aGv5*QE?iH}Y;bcan z)5|;oaH2H}EX~kCQxOB~@{hXrW!~D{7ZZ)O>rPX)aGVb5a6_<9i7x&C-Cn8{9}HpUaYFk_^;=2=ZC94JOVOZ{?*3EuhW4T6TDhnXOD7$bRhwedsBUls{-i-3 z*IcdINn76sF1E7hl~>=?!F12h0hzX4YZ0aTTEnqiWf^uew3-uGfv$uUM6B6zw+jgj zoB&yxt6K~+cS-q(zQrE2c#5!_vL|R#A|CU6lHC%rGh{j)J=&QF${0U9NKDq@o8LR| zmfD}c{W72XD4eG}v6)h9#FKCbTdya#z?oKKn$uprmAkrK?XHBsI)HuLXUQ95QI^3T z((7C_saBLB{bQVmhexC_8!{Pka;ucgT`K3s(vdnsl#c9aNGtsnFn2 z0ZYA`lUTiE>wrdWtqKcN-y$5@tVLbfs3O$mn*hVH-tkA)6p`Ka&gc=tCL7_&G+ML~j@>q(9R!-c&J+m7^dmGY2^YZRs0*pX|Nodx9!`jPK?-xjlv$GgsRUPMU z=2FyGhYU2r)_GT*K%{^UqpcyxRDVXB!9A`Kg_KXPr7PVh5kam;BMhIrjH zw+ll^FHoKWz0GIz+^$FQZ{zg~PHF3j`C-K+7x`&4^6kBpT)H?5zYirIM&utrs%FcT zxer&ZdfBfwhrVdrEk=(e^7I^rPl@H~Uh=%$ip1Dp?S6|+ zJnh#@o(=EzporbmH=bfFnQ6juSy}PipK+HzdI9#inPgz_WQF*Glng zYaToqQP(WG=1;`Vr1GS{6+0p!8*3$eW8N0{!cN4R`zWy*DxoXlC_gGsnhD+r31%Xo zh^dp9UT;m&PlMsOhtiy2j^Wh;c9I-c-Plr}#jgCFQbsFb%4$((ZN&t>_7AN_#||2$ zo1MGP7!YSlv*6K;zGiIT9v0oYDl*+w?G?SBd#3efwz1QtAVxso^&-6cup5lHF}{*| z;8u~nM{y!nnj*s(ylYqV1kwkRs|Hnost}&)^$9@tATc$kQ7ci%P@>vE**QR`A&NZO zDTm_@EF?r>V2Ty^x(i!xkM&GH+OF=H6c2(R4(9D~rw{IQRJ%uG!{o0sGH>;-pH}ij zHyW#WG4hBHeOVkRTG_(cD6(e`w`0PID|gXvRKes)t!YPM)!9R3CsUIL3cGJAIOvl5 zF)n55-M5r~oU)aUeLCgY+0%yn)SEmlT6h(cO3Tv`0`i2-s(wPKqtm%U?$@`UWR2{- zlV?{PY+wGcnD8N8b6-clWVJm-Xx_Li%>49}`T`iz-3RD`z{Au_zn{jEJl(J2lJ z-q-K2NcL2n;wSOKk0HX}!0HPVHA<=HfCOw_{(<@6tb$)~uX%7YwRuPQ8<+!jEnDVR z#hDH>9O?&J+AJ>ygUJv(>~>$AfY183S3b7Ai3Y{Vdd$bJ3a7df--s-4H}&RgpW*r% zx4{9mvgi>SKVIC7vaLb2iUqX!$Ruk`RCC8U4cD0Et)*>aR~83VTFfHAbn@;dO5V5E zfHjFOswo0}JGC?L7F(j@X?8M0k zxu~M$6-7tOx>E+et$NriLnUv82KfzoZ0JWyI(y$-m;R||6c0(?ASb`RDkkylc3aa{ zb%j&#!vWBS%8PapZ_-jPQSXqwbAakBpIdcC6dW6m@P07A=c4(Xc(TIUFs$>Qw%n~r z3oGk2mx!pu05A4RTfGTEGi5Yt$RW=Gv$%7;k4{X#BH+X$H*vwBJMH9DIXkzqZ|<)p zs7Bq^!nMEUY3=`-JlP%^b7@(*VoHHx(%j3ZVG}z75#nzfc`*;7t+9vj3Qy)lDZu^2 zydJOL=APd_1wFBKt&UyV*xao`K*`kO4@q1(Lx9z1>l(vi>an^qgnnYtRVm1=DQHLQ ziU%sybE;WlrzqX&(>QrvJ;sBTafl9qeE5Sm4D(##1;Y@glkAJE0hL+)f}R|rxMS%H zUD81{@FsHAwg&huxL(LFayv#^q*mLBYKd!2Zud;2dinCo0SqWm&OTH7)Z-kmf;)8= zZba8%4^b4O$K~#;Dihol8*Gcz9>~&uJxH^H=VNqU9}hSVCDsML8z!Ya>@BlcU@d1~ z&8aJHJ!Z(Yv0st3R-)zPD3m9@L36Zn0QUN+J#@R!UVoWZTy#dhlveC{k@pAqcI~t4 zU3omUP^Ime%Diuzr(A^(N`}OfEQQGvrr;gIV2)CaJ)r1_C)97>XP2TS06P-D07ss< zvg0hi(xRJtUvf#lEQv;9JX9-eXY`!)s=dX59RsU}UVcgw3@!@^c0r{d9s8|JG)Ej) znGf_QHBwqcK^sTlc+s7tEXdq*${)5b4HXh4B7fafajWy3)CkXgT|507gmMj}~#AJq6^2m~_w8fzHX#YXnqH_%s_Bvrf+m4%AZCqdTlS=iJ-kn`- z-O5}deWwvvdQ6kBBfoQT3pZVU_rvbn>nAJ(>n`seL|Alofy6$yZXS7A=G0Sf%**8< zN0i~L%?6#mzMqa@U}WN9nb1(T9qUIMD=o63E}?{7mobIHjEX7+v+uN%ra(RmJDlXY@d)qGC-EwbQted9q%*1j7s+tgnJtG(L-BUo( zfeGp(jE=P|osJJid%OI)MWC5mn=-*ZFX}d=x15C&18t4%WK5igERBzIO6JA#6gb!5 zalK1@<~-Qy#Cw)Xiff%%JN#>73Yr0WrW+Xv=`A%;VTa2sK*H*#ydCTjU1HeJWhv=A zzxWo--X#zQsz6|R<^3A~0BkbP25HGnRJX-0^r2azyqdCyY)-@yA~(R&4{zPhEf01X$^4zq@ zU^7Q)7!Y#b|5)GVID6ZxY1f;Ybi&k7h3`xRD%+tU4)x)g_o`9FC(!Q$;+rlU>a>Ym zr>!nJUc9<~B8CK)Bu0ZTi3W9*ZS>m<@#{OUgnmPcVr;U_`YR7$;Vw%Ak`rwhk}r+> z=pm$Sc~l#2?@Hz7oQIB{l=Dx;PKZcmd~C=+v^xiQvJK=P1}h@{DrRG1x}|4z%;|nO zn7uU80VgelOYRrQmkiDu>L68U!|9Kn&vN6Jb-6hPUq^HffFx|KQa6HR9wcb4y5%t~ zMf53^_$$DwEs#-{_%DR#-dwi7osmIsNTGgY zlqTw-Zl)I!A_swN?&KWLz+hI14aAKN47aB|3Zq?-35&%}kz=|+cf&0UGVfGX1D`h! zk-wJDItVK&u`G0{cN#cwL@V)oy*+9&8+16q%G^(^SgIO)(Oqo{N4(@`Y&@&l&R+sW zncOW|(3@RYFKWOzwsvJxGm1>=Ijj4)$-ePGeL0wsbLeH8UMF5zx?;;w&Q z23*_-y+%Y#a_=tb11>UpZod0ZczB<_VR*&(Ov3g$lO+Fh+!cZg9ac&FVS|2{zM2!#HtUkFE0)>wse-Sco zC#T{RHfj3EC@MUC@6jg##s>xNc6>D*cWvq)JWtZTN^1I1g9FIOG}1`!LuGh2jnm#h z4@qB#+vF=yr}Xb&SoT5+GMe&7t)GzQ@X1I%436Yza62&BD#@Dm`&jd4IS1n5n={>b zK>68-&2YUsz|mScI)A?lNId5udQ?YC&3tc5Lu$_=F+Ti+sw0& zfzzi{txl&;<-#ipLd=`6wjtfh9eBG*jQaZOHq6^L5XR#4uFaX>Ziw+&_3~?viL<5d zH9E_Jz-^U+u~IgE-E>LiwMX=)BGG8x`%eQ)3dysj8Cmi`xqV%wQIStzk2~3)ZkH;P z&9v!d-}z+la`f{@i41oUh2Xvl`>p9uqHyh4#I%Jq%A`ttOQ#7+`j4P+B61aFQprAI zgLeK;MgOk&&(wdk_*e6PCnnikb7%hU`rl;}kz3$LhZ^lQYTJ1c9@+E%-x64zjaelw z=l3P>5@(sylvQ;nFz4XiZRAVSHBlcP>wZ4ISf@fEDs=NE78IKcGe3o?5*5$Awg-sZ=T_L=njoSNxFJHS6l z&H*39i*ft7(%HSU&b1SX$M+aO)$P4&FVbL7H9t>hs^Z-yf$))4KGx$$_AiI1gb-4b zZumN_!^}@AYB*}6jO`v2!Px!)w~1^Nul7n>y%A9oN6jGztkf zpKbm^$$K+n4{Bbos&@(bPUC4d>d{(zr9E=|e~6?$tP8zoxbuZKC|UAnU!IR*4ZiRJ zFXq3EE*(h?PqS7rQB=**a+=y6yh2Y)a(=i|bC3JKkMc(q-H_$H>22X{x|edqP9C+xK%?Q+n-`?Tw7dQr z$6GGVw(o;u56Ig0ZrB>t4Ze>*Z3mayzlnq?Wr{Z|^*z_tSl6Nuf8}!h3S0i2SsPlR zMB=;vpL!A8o1H2q#JQ?H4_ETaEkerleF~(l?Os&^LXi!=oj28U-PF>|=;iKwG`vdO z7FbnjLfx1?Q_|^Eg-|_hIhH#XC?V{z04YOhex7U}-6XOQ*NqEG;OFO7Y39{1-&NIAkvhH?Cc;^y)Nh)y7F(uKtchxG?FpY1pfc|2y(JP^OT+H?LGS6_zsAO8!9U zp|Q)IhxV4eDo81XOO+*t8*Jf{^w${QV!q_;%Jf^_9sc=+PSvwP?zE-u^e*ywWOmyJ zMTN^TZy$>6!k&{(llw{ZwY7!a8%`FH;x%H1TZZ6_1Q!E)_JAzvIEddXD6BcrvwcLj zMv`evnqDqXw&d=hM7so2hI<8T_LcH@gCrHKJ)eHHN29#1mt;{AYx%oOZ|}6T*=gpv z-WJWkBeLcC7;wg97243FT_oPXw8b9VHPAsE6d*u^=`pjcNLd#j1&>sR7Lcx}5ztpx zWDPRzrWxUzYiP$zoRsxOZ|wzeqUrm@ZGBL84rV8{DGKJ>+WbX!O~3@g4{`&(q`G{E z=YrBKhS6$oE2-@^=tu7B^4;i4)y=Uy3fUh8ud`FiNQKVd=mmXEBMVmu5scR?>Qo|F z?Urp2)zx|7TsJ%%+IgLXqU~?e)gPDJ?XH#;V7k8d@Jr>EEWQu_5S7;p)UN6lFhk4} zSsBLK~>&I{J3kcXR)hXfc~^AOS-q$D~s|FbX8mgYmZXDK11&kzj4DeSg- z*96rv1h6LrZIkjPEV-wg`*e8FcI8h-Y-9kzyI%`lag$2$@OqQ|nY$*=%UM(reLCE3 z17?cNhzQ#nDr&gWCi{_RXzk+w*;8~=3bAoCjZ$`=$#V~(m!l3y;euwm+>ixy9Q#<^ zm{X?LblTa>XU(fgY>YPbjIWZ9k=7fchfn6#SV5FYOFb@I19Wz0y=R@PS#v8DCGf0C zpsiOZ_s7Se-WH+xo7~#vBG5x)+``x4xH*$2A>Y3y@e|0-j24i}6<)Jm7kp-?t3lc{ zq$u}VfCk5v&vYyFBE>IQ!pw`9Ju?L6xr&W<%tpN#T^$1B0we#>1f9XH@bY4Hv;_(JNlaT=B|iodiB|VS%bhmKdfaVqG+F9cBj#NM)KzQ6 zxj)hjySIxS{G@1o<= zAD49tDRi)RIMYv(=iFFlAtOc#o)=9RR&lvTBNyP`oCr+Fj=YR`w!U69PWqJFWJ0kVjoQ2~_dxq?S zxs*q4)}Ya$gSPwrHs>K*RYC@(?K(uHX`Z#YsP3trfRB=C;NG&GJnu^o{T6rbC3b}( zW5=-~V^Q3_|5{(?sRk(R%hf}|3mT>xV{c~YrgS}%RMQ9qt7$lGiJEM&t8MYU9*xa9 zNKS^RDMvLqkvEl4dgv*8=qOOD`@QQHVZyaZ?w@t_P|~`1RPbw9a{fHbe?@-hup*Z# zzIw=840B#=q|h0q3tbK;B#FIG9^%%A!5f)NO>TBAtgn!VQH`jmD1}x<8rEQo7GK3~n**Ddm3dR?n!azFJo zEIMfTFn~<|a%W!0-8i3XWT&B(Aia-Nou}yMu zglu|w;);>P(kZu%+SH#c>D~0b&8y!f)~S(gGR=0G>(+iw+t_jTu>fj|$V&ZH#vxP8OvmB z!={=3+ay7m@XR@2d1|COLY9nEcH;Iu;|j)QB)17ytIA1RjxALaoWzu|Kdr(=ae2yw zd*d)4DZzF9!ArjJ>_TJ9EgIbMJJ|}DS7%^i|Ky_?{JVYFaywfF`#~)MiK(kNxlf^M zC5Jdqz3eMjWPFsu5V?-{9Jjah6)|v2?L2nOCLXoiX6t=Zdf-%?rnV0^XC8?vah?ON ze_;voQK`jggf8ha#Qm4GZc|ZceRvQSGOu97^t~~5dxR9`j?KCG9VV093rBA?s>ZG= zCK3l`slH?A0ffJQSWwr)DlWT$kpNM{gu|(<2YzCb`FcJtaZi)!-cKJlIR`|0%BG8H zTmuow21rQc9pG_NK$ka6S5UDZ_dD!`m zTuEC5`zEn$;@A#3EQcaqYfSt$5nf%}i(C7TqE?k7D)%_$Hv3zN<3y?u+v(qG8X3RX zOCd8n!ftjfJf3gs;&6%koP2BHssj&qJN_ z<}9Im&^23Frz9MEFk|;dVc|({uVKhdo^L;swKMu6>f9fMY#*~=8QdjTo)tZLvEv;7 zp+@y`Gy4+P-N1oc=+Z~$fRdGsp4jDwQm^#5g$DUfXSLVsy^k~k=5C# z2u-^AhJIoWBjP?8n^8V) zuHvX8jSJbJ@cJnAIK?2dIkdP=QoZ?BS>~%gU;o*3ddrC72Ed^xsHc=&m{lj3>Elx) zX(QGHx4t3}>ou~zoU2ji0NRz>baz@Iv|+u3-92yybT_T?;d~w?zuEofRzqu}FIz)( zU)-EWJJA`wX|c8R_uvI0;H=3XN2QuHZ*q+w7VSYcRECeUmq{q-2)zSX(piGCr3s&Y zO(b2fk-jbbXpbMRasWF(T%-R^!Wl|KD5l@M+Qd*=71YzgZz!Q z<|;VgmnmP#G%E;-*i*SsC)MmU zs%Cq9X?VZcl#%UZr5-fyYdgSBznotnlaV5#kWo9g@KBP@?r+v=>?_3S~s*7l%p))n28mVP*F7tBGZ-9V_?Q!;CvkbKl?fi}i+Le@X1v>y=qO1CuwW7=}(?k{Gb@O2B;A@N=&6l%Z1qu|o#9z~(3N>M>Jov6czeX#np~S~@4)BVT z9?1M8RT9=hP6Is(u+H^L!L2TnPYs7~-QH8G*_^q1Y+r(9=V+2N;c>StLCpdB3M?++ zsS`w{Kkk_fLJmJ9RXQ08oCD7M3lgMzYR_CL?fS0eQ4J(AnI?tbu-h%vBR2C{jOesX znOWj_YTQzWpPkD$@dU@5+|FBeTvGtUX4iH8^=lCBq3D=wr4Xw=rR(!VZJzi;QBF>^ z%-P;><2xl4^}*`nbR9W`K$T$=n$88G!hM1e^TG5T<``N(4 zcm`bUE|O2+tBQGQ*RT1|-gnQ*Y?peJPNfb^Y7*A{He%YMMXQiLkd6h&)z2MDfEx(&^^_<7W==58V;wFQUja#2etH z&or`V*YpyE`_NXOp1WQZ&q|$mr|I*q>gU~n@qjVPF(17^Rq&iTud|Va5?haR*~yH? z;XF&6*9{RVkrxo!Q&PQ(oGJeyrdpq4@3f1LY{nm|2htP{tUvf*Cbin(vk$6>wouYkRS5_N9=vsD4<5fi}g*yo~khOl*fitJ2o@EZU#nqku+GV_n z%^waaa)`z26JIfw|MuqTjQDfg9NGL7ebrUW*TG!!wP($%REz{;Q)&x2gqeU3Ba0kd z#Gc`wdQ>)3pWc}H=_cV!Qz%%es4xz-t6HS{($ozc^j5A;+I_4m6o77=-u}L86igEI zO(VROpEN7pJuc3XEAG)|h?PT#^tY|e+4ff?ATuDbazLlN5@~UaIW+F7FmrPk>@%|; zLPfHOrf!MQcV06;TN1TK_IqPF{pzi5Yo>SO#j$HPkwF-OT;bNd{N0NC-#_Cu3e`1R zVA_(MhZ8{vn-pXU(k8QJapKLMn^tUOP9nj2SE&wrJ_4`Xv|xsS{F15R{iqprfs%5x;nnT-P^9XW5J{^b|uzs4t7QL#OHE(|C z)a`v8cT+(2!bBh61M;p5`yCWB_o`YrvP0EJwTAuFXH<*18lT<6IH0;}W| zN!J5~i4%Sk)mI(GIhHD;o{|%MOYc=AeIzdXKp}}tIND^Z1q2iQM=D9Mv#{W z1xeGx=}8q41dqA|RRd6#8iz5-n6dALSik;ic=#63&8?=e_o2w0l|5_e^Hyda zeqLVkDd+vFuDI86m_lmL^kI^U=39 zqmBS9G8vG$G?u6tx*`4vg)_|-rzRyhpVdqr0Y64ej6N;6eObLNA$d#5Qlw!){T!f% zo4t=Tw39zda6x&;L))buB_#F^SbwSTG6Ra4;Kw}#x)kzBlf-m*q3&%EA#?5pmb&N& zTWtJXD_T-2SS?lGa@-&;I#kPcwTyz8*7cJ|XeiLSZq@rh>x&jDK~6;n8fHmWki z5eD-AN$HUR-DcUfC&-oc}SBS!Wwdh_tR)<4ir_$5n&enr6XVt*+&lRyqralR7pYod;sRmzZC3$}NBY~E4fnKVH1?fng^An|~ z_9_h@wDZQONExokro^s(tbBlMl`EiH^$isB2R+*@${~lC(e{RGCC_<$8Fq|@kTV{4 zTxn_J64?BE`_0{zsj^s`hRp(+p`_z*XcH|e+^~oG#c&+7=wN5WrYmzEL1lgpIJ|s9 zw*9u+@{^C@KNE-L64da*y*dB#;XP{*FSQbnZF88*pgbXRV;vd zEjEg4PC0$951_r1R;Yq6s>G&#U;WcAv-*dCSg(FUA$ywi4k@L-He5Aom`r#Tl@g9* zi*}W$Ie{vKd9zL)PH-oP=lN7vSf_R})j9SXms=lylPMM1;K!+7I6^5+StQDyh2}+k z9sG>0Is~E4L z0!o+1pg@0eDereNGx3$_i5(ji^9KSa?gs+JUa8bCkX2p1J92WJ?3}#CRlU8|5#=J< z`CBew5dlPcTW}r5SoPK(+J=gaTN&nIX1G6$!%@NDdnOu9lxka2Y92}vPX{QqzX#i3 z*>0a0%e%y%$%(xj9y$&kBS-9H?x2xq%AoAjw;$SrdapbVA0jW8;X^E zNGzsC*aas3bEVVCw4zVX0$EP38-RQ%nm!;Ff((~FmoJcW@c=|%;aFh%uMa3|8Kwd_PyP+$LEZGo{?t8 zmSsuzUfpro52{htEohD0q*D+W0 zHc|_oH45(j-}C$@{Kr%OFITb|pXIy!lm4Ia++T=o#sAhba4)K9Jtb4>E=jB#x2tg2?L?<1P(JEv!yIc?%SN9h!+nK>~aS^JewrAJef z{DFI*{KGhcYW|aaARaB(;qaX2fKJt#h0|u<|2>F@|9pih2W!vJ*41!w(^)xoQA^kH zFC4ENXZ&Bng8917SB6t^1roF0j=s9Ub$rr;Vhq3*j>?mmp*_NMK`5db9t@*ljKH>2 zU&D19(#v1AGF=0G1of#FuB(J@B`WYFn_nT#iKhCj+z8z`Q;kjC+^|0mW&bCSmHEFC zz6WTiLzvOPGn<;BF&p33?^>>&zD+A@v0WXTf3#fZcdG|E1=s%-1*)B^cDd^~&|=`A6z~@QS7Pz_57+!+SVxuNAoGM%I18Fh*9SU2?s4?kz{e z1FH&Ui*H`7m~$Hqu7$uQ$}XGr>8IUl zAHq^2VaRoK@X+E6wRuI~r5q=JLL(*_;38{Sj4D~+hjITkgvJ1*{udZjzVA-LeT7nz6MiL+RF+4|32HrL9AOXwrQAUmag+TFsO!il@y+Pt|OBRD{vyL#O zn6E$lRO}S}%ZpwBRH=SKioscE^&@c0E|Kk%*j!1T5y> zP$W1ZnH9O3N7y48cY=5fF-RRqY7}mCl=$UL@rg^PdYPi5`z2XOg}4N6!BO5A=ilX& zELQM>Y|aT645ltNlgwRbecPxar@eIhF4tEeKV4&*TOu*zFV}FyV;Fh6Xf~J6#sd&U z$pooUx)NJ1gUY%xdVUW=)x)7!E1?*57mzNDAN=kggA;Xu3yCC+Dv%bd?=%ilkN-B; z+4WKvtUz0b6T_8?=15)qdEOzpVYPq>DK_G@$2rv%nmZvy&tDc9x}M73P{c$6ly_zbPl7K)x(^X$4qW+`mUO2buF4z zk@r2M?GMu7#a-Gdofl@x(YYAs^ekb+H!0bd&XRcQFb! z41hG-gYDz*OpwO8x4V&pGzKWxVqfxp%wW4%D(_qHNue7x>Pn&Z@L+G)$qFB!6ggSj zeAvWiNW~nZU+py?J<{tsRP8EErAO+xHC@;+wnjM5meGaonLL$el&V*M{B=CmPIGt) z97=Rf#`klYU()rQ)H4D)dC@$KuUF9--TbmaazUgVfJa2anPM1X%`%!HH(UO|B``lm z$sYvDP8)CSkygARy4kUGPi&4&^eo#raR=cmW%ya-XarHZ58GDPz^_unR19vaG=mbQ zM@;}IEbc8c9p^F*qHqCclBUU=1dDb6cUC-njUM$9YuvPzA*Mwt6Us9 z=FH((KW-AU0c_suS!^Tu!ErNaf;7?=Lj#QZZLfXA^SLL_*oz1j8-_yJ8?uj3T@O@C zr1vEqC(Z_|ji^FvZ5xvRbpM6Gh~?%-;pyG!eo6DVsYkdKDW~By|MGPDHS^bzvDK8y z)DpZ%RF!;=yN{X03`{9TkBTjZ^d)u7tVF|fD}Dyu#VbR~$L+N%S4WXX*Rfbm=vqz| zc7Sa%=mNX34nIiFe8KzdAaowWG_5jO;O{wCe?m3QQJ<;xXS`Y^H4(nh=^aA}EvFTp zjpGbOB5mi#mkNGXUbc%lJELfwmTJbd&E1NYXDq5co-}aukBXch6vk~sA0q_vrq&wC zufFeZY%R5bqT%FYLq^jGRwp;hx|zl$L=V`~@0y25v}@)(gPin0|G5KfyW{;vQB;bQ zMzbo#2)tqxb{ccSgz&4F4+cMz2 z02Js7?;smLm1%Q-I=Z5;*?&j=LUhgbo$SfN1{=uatJi7mAEl`~{^q6~NJRSOpRqVc z`n_rdeVH&Y-eYvch|HL^K3hw++i^*|>#7k{djA(<`FF47PP(@MT`kueSFftPSdLg} zLtMqUD(EFw?%2BDW3gE!X(wNDz?t&L#@^xJXsE2 z5Uw@xHzS+C6q=o9gas?d?sy-7LCe`A&O0A{I2*5=MbGaK4(z1?6)7e1wd)~3dflSm z{nJJvZB7(VnPPN^^yj)KUdYIQ>f%G#Ds2{ytvMoYN{Uo+(qg2;$?lmzmoQ{-zU4#AsLn-@l9-DW z4o}4R%Ek)j_vG;K$*72v#F2a7!2b1nlpjdhLQMc1U2`#y1~w!qGL-cCm(vZNsWvl? zJ;*NZ>(1Abn8$%W>yvoz4GYKFe=VcE8Z+6or{iX+*g0!W(Pm!fq3NnQc61&;6}f9z z6?qu@vf+rb1!QV9wtOlFBWr`LZ9VR4evUmTwiHH%X;6NUWr?%4Js(R(+tLYD##f5u zo*T4@fufq=sAkLp9lH6(u^wR%F?wQz&EAC*2&q(0HCJVL1bc~ToUSx~p>yq8omD~W z7W%XR=qaGbKO8gGFf@+d-!|q~UnN?1YF45mKikCGX~!Z-#@{X|cJFd|*#ev-37++) z3EZjosPjbQKh!7L{ZZ@F)`vNsu{B<);hybnQb{v9&X7{@{t)sy^`~|Fn|d?=sYW$n zy9h+*tr4@N+NvIyqb59}bSAD*7#CmVE^M_3c)Df(Zky_R7Ghj3JU2RXJ}-D8 z5v<`vrNl4c)pq?rf5;5+ZB+H#_--%?w>#)g2RFheqZz2#xazxKOl*^GGaB|6f@Giy z4S~u%jd4C3|Eoors%8J(K0Ztmkj3#7@faZc{S@dsE&DzN^MeBau{IHF40<4jGdY8v ze~~STCDAAFw2gEUY{=#WSxA|s2Trbza@IXnR2@$dlldBwI4TmYxoxv1G3RmW*jZ$# zQYFXSW*7fumSY=yNJ5AG0+W8a0IGDYerAYRu2m$kb4tnDns7DA(PCg?D_ zC`^|=`7LJ8%Toyj4dd*9Zs+BY`T#qV6HL4`pWCBn4hqc$Amb!VW5xgc+Et_ObwNqt z8N|gM)@BoKCiB^-oi(tyd$eJ}zh~wzgvb5&aSGY%>L~fY5OH5sdU6Tmk;)7lw@uxO z*p!jz2C!`whCyQwq(4hJs&Gk!cP589^jjJzVozKy5I&(c3W#bq^WN4x;))p!X@q~; z^9IUqNOg<04SO#ATgmh8GovBA(XQmamC-OQEed`46WT8GL1m?lr)n%jIy{q`4vdcBGu zq&x^+b^?rd2=u_)A2DtIjL;9Re|YRXGX_qD(YVhZrB5giYy!Op|G>`Z^cPOUsU zQi(5ujfzh4%luc1;_vHu@MN}83W8Rrd|)nDi(2YfdupQH6he4e&2{N|&?l824?H>} zYV|}dg0)tNFvrsxGW$vMe<8Xdads!1NLkV(JI-T3s(W(0sXjwy-d}O0l!IWK6#|8I z9BCwyommKWQ)331)DvxEKG1zXeUZ@-OT6|C$y!WN$4~1VjS%%}lM;oe+C^Du&&wT+ zBFB3HJdn`Trco@Ygfg%1D6eKbgYzBjImOBbH@7KbJXy%|{v*}2czMvqCz11E{?^^C zc%IzKe$mGpK`9TWeYAZMm8Z($T3)^Casp5U=Xg`E0^S1kjQXctCd0TT!ibp?KQtmr zfYFoAZIZc4Pp{pO`}Si4y|<eIRinfy zZNKaiQgLHCrpMJk6iCB4Qf%gx?wE;jwh3e5b3fCk=ng_0>PyqR;g_v28<_b=aOAELBG`GPK%a_U2gkb{iHCO_+|xeC963ofpytk{Sg-;`ogDMPU*hxFgR7 z{i{m}-_2`}Pq%5A%9`PaR0)XY>EGabRWgNH&vtQ5QK43+Ulu#ViP(sNBN-t2L8l?d zeFlRyQ)Z%68X@7C{z@DrvQc)eHTHE| z%i66J9~~0GCEun!!hcC z6Hc2J=-XDFF0u+unrt?bb+?$89ZWT~5D_-;1Af%d1VKbRRTL$|&?<27rB!5n$2 zg~XCdE3rRB`&Dsf=K!65?gk=)I<21weA5AoqI0dW^Gi39Y0@{dy?-I{mUTR*7>W*# zGY!LoCG$5UPGprs8td)VV%#0D9H5z(lv~LD)tyXwqF6HtlwOrwv;pk^j0hc}mes&m?-d-37)Iz6VM$HS*m zbE{N(Y662xx{jgY8UCSsps(};i3!u=IM^IsG)=D~3dS^Qfh)9(W#6T$l!56bKE*#s zu_k{$G-4*rGgzhS7Ab$UF z3QTfLIgy!v63(Rp0y1o_Gt248bFk`X_d?>64gEiPtXZxS-bc5j4u4V5tK{gEYByG_ zD=RaBP*1(LQVuWtk=Zx7eFgE&I7#*F8JX=7DTNgngJV*z(PmALM?V!6RY&@}>Gvc+ zgKpPZxn_r`icF3#xC;StWUa!<#1$Z`NN2)!Ov=s1J8)@C7ifEq8c!+hR?Do(jBw_l z6PLPIY@WPpZlsOPfpq!g)78sDl;jZNqiS^Whk86CW*-) zQsE_vFId@WT7E|<;qtCAB{c_bWorXUX2(wIKPDkR(-*_>+d!tv3k(|W zWaqX-Q58%Db6FhpI)6x3sqf)!+lE5g0iNMqqdIg%&6cu*l?<$~X!GUVTAci;!h?u? zq9l~F6n<%`L`-E=3++WH_c!fmv2S8umgqaEfOp~~4#&))M&TAA_sr?1=6O;u;h<)J zOJkCIB8N(aX`QwzuckC;l$O^vfj-G3i8iq$-lS`1sf-WsNAWk8j!e?HdvrJP8s=*x zmnPfMQeMa7UCK@AYbwmcP)QETGtShz>dOAjRo@t$GHeW#c> z^F!*0z)3L`Z^S1VT+@|dTf9kH^Z1(fJ(+yIg7#tUo0UwhX1!6{#&ptsqww6<^gYR7 z#My=E9x?g%KCyjVCkw5@<#H)vb84R%t6f^7TXm~#rzMMnh|QH?Ji8m>yx&p;lWr0< zueJr*U60znTQZ%T@(+bDJOe3GEGW2I^H~%l^B$yVhJZa!qyp--wWmZ)Y>SPK_n!?# zn1*Tm{qIAK6(g#9oqw3pY$Wj>j1O=U)i!s#Bp(NnLGA{|GjP1V zv*ssjr=C8;NwrpVQ$N@nL3a!){fdMl~`~({}e0yG&R-1^5VY4`)*rdo3<~z z;&rMVN>kzNqHjv$yz1!JK)Y^USDltpKZ(qofTm}B&6nm4SXwJql#)^kTAa6XWD^gA zm5HBaVznU4+fri)4erz62FAwM#&=|@sOYu&@TnXB?{>Y%-MNFwQk^!}j^|wuW_hnz zhz#1Qibp-B4Hg}-XOqWRcNU3`qAfrWq9b=*lZNyb-X-?}s4s0K;(GGTZo zU~mV1ID;c`SAMA2PoNrBHD4ArIZ9&$n{1x6%9SkiR2j2xMLU1w8JXktRh+pj6Lr%# zX#G>lCsK4a^${vd8F>^AK34Y+QyDJoVL;=OjXKV8U(0lfA&u@rZ|_i|F^8ol`f8b` z*3Z6K>OIHiwAOI^6cj-ghhZOS0}c?Thw;BABSIJAncQm3gRCAn>m(fGU;IVpF}T}Y zrESVswTd#&QqskM5t;dxuS-sJ9)-t}Juc2SdR`aDPOBq1IX6m_KZ)NUeVBb0Y{=T( zm4Da8rNbtjpY*S}fL=i}^&?2uU-D~sC*0Q^gCxnr@=4HyF@I0TqFUoa#f0u{{Q5yx zNGa$FAD4u-RQZ|`_BxO|vxrxjj100F!f6{+qy=Qk4mK00=V)FWH14r=*Iyi3$xh`o zBQ%qnj{h?GnWiz@(u!vywQ2cs(zKJnH>DXkNR@D7lI`tUX-pPb_(fynWJO#< zj0P?}%hzk>rBJ)v4qF3@{o2f5fllD!+Q_aGzLWZp%<6uS!GV1KWyFHkY)BJ( z%Em**!Jxgp+1_bV4WOO(|C}7>x~qsq$w(96X87d$FW!W>E`uT_v1ei0Xf7;gs9?$D zvgPcxsXVcKw5b#Ke&)hjqa~j3m3x&-*xV-fXpU}q+?eWpc;!WuS4rd?6u#9r`T?{K z=qDQhH=HT25p?(Q!(T9nW}nD zJ}j{FmN?SKScCqkGQAm_4$Nfftwr{TzzKeh1&evO@gIU+)S*K8-g`-^@)O8{^ zh_Fqm%ABSu_S?Gwb_|a07SfP%w*koat+SMi=PO%A)Xr8yA_up5lQ37*7>sk8xG>GE z9*FE-%rCHlsHL6am(%e;{Milq?GTR~Ln_E>)!*NyKG*W;=Cy*6ZsD2s&h<6lY#Joz z7xMM)T?*+zzB6^tPOzwm>MX1I^(kj!79$)b#@YtU__o9HiKJsz&Z2`$4~bs4;S5Ug z`q*Elz-4n;`si}mw0DoClI^FP#lXD35R}9!iPcB9S=kSXiF3+U_c9gLCd1bFiDh`r zFPgUEbh!MjW)>XUoX@|nwr;BJbZR-1%_heGLVUZIAEPt5)_mCw?VU%nu?fFx2d19&Gu6f$zy$0c_0LIDIO77-jdK%(9{QMH>G-gC&Bs{s( zaid^1YpUO-U3;O;&^qqTIhs)Mc}|d32OU`;6BHw%bhQWOUj*lus3R)nwO4zy>kBE%_GX zmQ5~66fKe63RMaomrR6eHoynp^ydbom5&@BTwH%DdTC)m%E?H@e|yIx5G(D8O&&~l z1mF@($&C%+XiP1pIK_`SdfM@!dwI$xA#*Ww7PmVog`4R}WWk>_O<%6+U!OrK(z!`5 z$Z6ZF8X@!bT=mzpV1zy^5i8ss$LoCqu5vo5ZS$=Jh!)lFb7ffRZyg80;qtXt+|#D2 zgPG6N0)SWS!}j@ld}k{M&u50E3Bz1X-N6oP+;+@q&?iaF^g0wJAu&V0nIXr6s6^@? z%z4oClTgd$R&-NY`EG)^PztK<>v_HLIYEbz5+UG-l=f^3jbghVz#u;T0A_*8oFjQD zPp|SuuG|m_LCze%QtvJ=v32NwBl4bQ=*d&cQBjBPN#G%Vr@H< zmeQlOh~zCRNS57jAIFwvc58Q`$~GM$fI3!I9c%SlVSLh1r@7qP=#6i7L)6pH%2i3q zRbqJA5gJoyH5=7i(`M3JCX&i+Qz?_51B!;BrQ&4^B|pV{jqM_FGesM8-w9;6P{^SI zUgRc8?r~-6tWrfiteor{!=lE=RC$|NnnK33Dmg!S)OcoL>5Xfk%Lz4meYxX^WU-pd zbf{-kETZeqDk{q?HOG@_JRZ$y$;q>FxtK$RJ`N|yn-8v-p9P=+hf>sg5G&ak@S_4_ zbBodDy->7vtVcS2qUzHX)1fR$b6fq$DhDQQ%@sCUHdQ-Bx9J3?66=uLsq=QzJL+BA zp8eT`B8&9Nbv9>O!C`$UZ;lFSes+9G?#`Y2VkcK}t^RwDv#J>iE!FPDN~&@*agQu^ zbgDhf7MpC4(r18C}?m~TXnQ*zuaqk#=f>82xRFOo4E2D!5;N&)%Xn&xxHEA-2 zJ$6o?T*$=cig=qjO}_ix>UD2&SrGIZ)$jmV>y%(u%d1Xmu8qQvXR%L>^sq_qB0FkU zTz6627&{YDUgGH#Ndp}M*L1L^hYm6u(1H}#$qr8h zJ``jDj$tXSbPQB(ED{T`NRLCqnj65!S~FK2W1BGT6++`vK8m{4g=!=1iMd(^Cr`jo z6GK419=?G!AG7~k&?dSI8QuK8wQI*gh7~0k*gYTbB+zFKU)3}B&8aH4yv}}TZG}J^ zm~%f0#W}?*g%c25WjvcNx^8W3eJ2giEEiF^9tI7_kBX}G;_E^+r(hVTJegh-c|U%~ zI?G(NTYI6p`;b1QoQ=C}_&=kQSS>nhYt?D4V~*~z@ZMf(vt4|ws&4pD(l>CF#}?-(Qz=HB{{NelKWGnTn?kbt47n+q&mTt1klSZx}N9$Cq8T*FAJwrvUCMsg(07+kR3z zXJ#22Hm~-Sz^b-TXTUWJ+qQC1$3%Ng$#^Bkp?0F~#{%8)B> zNPZqI#^G`sk3UF*O!V2us8 zqs)MjLErKVMM`N#(GZX5#KLp;O614@+!>C1|2+a^{UzZ4PRT9=jHl-3Y6V z>C_NJIZ2UX0|^?#+DQyq5#UmpE1h(tF!J*scoGP7Vj|X1iJTC{>MO=!7!c#v3f`Ac zQ17|(CtI(GL;Ma~lab{hzk!p(e|p7vn~{H$z!^p#b#|Rx=F;8xza{(|>|1trIXZkj z*HJfSNqyp}+;JN*REkWXN%HZ;y>{M%H&d?N|Eg1&^z3)S+}6+jFtzNGECC@-W^!>-~k` z$aZ=*Kjbh4J4f^X8(MoichL6~bterQI+<2RE~Xrs)08UoJ-QWo?))*PE;mD(4b`05 zv@!F>-dy0svkCAb{&3F_{`${70}2ZE6YOUg7|0JNsE>!FL4W;(MvBe?^9AD@tB5iQ zvx*@mnNmPP{_JN_RRhN>a&{w!Usx1uPW9LSvC;Uj+z^8N1K8Qy`oqv$s~NudF+|}T zc_AY~Uht&6w7Wlv=h^$x^-^20Sos&iVpy>L{SV!E{dZ9IOBJ^y)4-eSiZS+EILNKZPEV2jnTx`C5 z5%VTnFsX9ij5aUH4(fCd?Jk=$TtI_XyBv<0M{VbKdigz3O^vf|DPD{V)8bBG-HzPO z_%XYRtyy;vnZh-mS`&ykLUC78BgfQOp@#C9zZw5Pa`*7|7H~A2>aL6W7sBe~SaDV& ztmB0EWNoTfh$2XN;3#K}ca`q?IfRjnDrWxVQrY$VbgaF~P_j2(F$SwsXVr&fHBoA` zb_R9P+6TfC{*oyqk?>Jz>}hxHW7Xlc4B0(rF9k<~^pNL*1MU{zd zcl+C8^T1fB7|DU(-LH5v+)t0~WkngBY3nm0f1YnO`(k`+trL44DZ=QUX$i`;R}hL& za^zIP*OIpS6PK-sat4%|cSZrjX4%Q#ivEZs<*MqGJNP8Wr^OJpy~+CH4aPLe<6KDJ z`S>s|+t^CY)@?pCUW4N(SYWYkBrd*1D0%!@Ay`gaTk{tz8;fY!pWCkX7QQ=;8z{Z5 zi3)cZUq({Dmxx1pW`#$v!}!Vv1Z9Sj|nTCCR17*c0j1Swq=#CMnklVl20YY9Ux0dy>5lUL5mL2BNRsWcUQ7 zLnc>gaTzfgX2M(DN zlm{Z%u9tef*iQl6^v{R=Y1;ulqc={+ZXnSBlXmbDC;&xt#uHLP(82MOAL zAxK|HF4YIB0~r|)>fiir^;-!tB)&Z)6U-h|SFAL15Te{FI#eK7WF2bi;tF8H{D}&o zD%h4eYGd_cM105i%2P7^irL!2b07DhkL2QGlgugM7ksQJY#1LrxgLGtsJ7n@>wU|p()^^9ISn0Nn z%FX%Ekx2PFKt+*Pa1HWY*3O|7O*JF?B6$B!FGpWOeHu(`9B-?vrbM*&%4F#@-+&}I zBAn2$1Q>gXtN#?pYU{Xruj?_K0eX{4MK$p)@eN&!k$a(|qf$WCH*xj6w`9OCyo6mf zujJJjM6P%1ii>c(dDpNQzt=-}G?^~YdxYCIg4X^z*!MX~!|d0S=&`Oxp0kHhZN;&b zc9JI*kKVo6p6sYgQ+`sS0xYATMgGhidI6MW8O@NSvI;Y|=~I<%JkDwcpi;`<%p`pW=WJ`U_)J#0u**aWBQ) z5Lf6XkJ>`4!3;7Refkop{M^tiJNiXLFY(rIqr1Uab{pE8nA%fm^LvITVK z@^eLN@yh`!8h-A2pY%YfyN0(w8@Sqx$%qb8Y^RALk5{+lf^W`thzd zy)$rvAtT!%eeR=cTi5hdy+zeA`MU zjtXDu>jvu6zn?R}4Trqxk6I2<28X-QPf6vQ!asc$LgT(a$i=lgk#S*Mz_vn+yV12z z-1SMt-%F>gPSU1RjrpVx@xDJ8wWEy{lK*Atxs*-1Ibb%sI#h~J8u7?Sf7eg$$mU}! z?Ke5JZKOErKTrRMYOnV=)F1su>cL28$4{=PgZy_BKX2cPv!unZgSOe1YC75N8a!wA zl3`p##OCYh+ zz!Op*id){|6v23jJXdA)L zSoKCM;yCJL4OFJ{2iJo4^k$Muy0|CpuBsvkjJHEJYot$4b5D4^d& zyzh56v?5WS>{-H7QMQ(VRW8q0e%=tJY!$|7V`fR#(csskw0n?ETwtvpgHM{Wxd^WG zYA)hwU2Idku+vy9c>$h2dIc7Tt&@Z1(4_eAH%g%|p*cQH0J}R07ZlF=0x3FFbA09r z2qxty1#H1^;@&l(-jWQ&>I3mTaTN4|)}e%CIwwEA$X<|w*+^sY>D{=$+<4ZcV@5od zcgf;BG#wp31SRaLnMbs%Hp+$#6!1}EHLzN;O4%(VC$(sJlObQI_Ec9Ox}2?(7X|lkoaFw@m=xi|oWN42*(mN3;s}{xaDf>63-QbJcPH|TB1OS$G)qMW zugay<0*8}M$RwH)T%@j~+azYU^T}Mb==K0sgP7~FWv^#kDd0H%*fp?d^B&_Z=a78v zI9s|^IxqBW%m5qz~Np#sO!6RiO|^l ziHN<(LVH0|YA}AkD-1;&b%=nZ1toYdZD>z=ma>1*{O6Hr1=#q`po{}%Aclg4pk z)B1-or|eI=@c!v$qMRFIYUi08dAK49-7L34-z?;TP=3Pd+B0(#Wnm*sFGx{$FZ8eYwp5^!@ zEm|xBRSTj=KTw$e6f1BoYb3faMcychiz%<1H6n&zfFfYw^Oq-G+W$hpKbAht5Uz== zU1Z-V_+>}>`<&%aQ+mugXL#DwZ_c|ZXO1ucB&8C>%hqk>(9^bCHf3I_R{MoCdP&mB zc5R0m$^FEF)(f*|BoAVxDyTLG$~H<9$9ZFFs6!cKWlF5&vKE}AVQnAY=2_wWGHz+L zo&bA=ql?oZ(zG2;#Hhhw9>chbCJN^g7rPrR`Suv9krh(tj3|aPw_+lZv5aSx;IGVj zq=tEPH7OidH9XbJ?64I{hD*nqM7Rg4gd*75P?2t_gei%rU47QUcx#jVV6e2D^&(Gx zrdL^Z-dR1-ca(x6;~ZhDW7v}b7e2A8rd*XyI#_m6WVlA#WG60l3@!ASJiHFQt?eVP z(x;^FV(7@X@(6k%)!GP&!rFz{9Z-WrG6}3q2sWIPYMI5l_QV`fSbQrhBbS(Syw~Ff zH}@(YK=KR;2E8@7_y=w8ssdGhxl{_8x~8yJ89?BJb2X0kcfzAs!3Tsy77G2uv!9!9 zGyTz#)UDzM^zIG(z5syt=DSHri+rojh8z_^b8uj%d{#5P=682UmI1p4awfCm7B7>y zJO44{hHxZ|+7+LPbL6OLm(L?yCKu9_Z81L|({4jW@~Ye-;I)`%ciKuPOgRe!F7N^e zT=`7do@`NJU6ao>f4PtdB-yLmS+vqeL_L?BcpQpYI&7X%5%L9tHDpxl&%Da{r9pTd zhy&;kIvA5J%68bW;hah)M=zzpxA<8_`I}ae=29f|w4UPETB-;}W+T$lMKX;GN(Y@i zvSQ7Y1=aeRO@Cww!E%hs=y!Adm*NE36wX+&-?6H9-vV*zz2o4@3|)fss>-5_n&$Ob z{qO!lB-5s{s*^5w79#ef>^xhF#Lic==(ftIhMvMVvWh9f-(IC@3JC0RP8Qf*-XG^*_tQ<%r%OXanPv*e*^nWxz}D0 zphv}5G+cO+yQ5*P{>{|}JG5^h<7Q#m%4v9i0nw)qHEyOrYou8SizkcQTz0I_+I_uD z{I~t@=Q5(1RXQCp5naux(K$(k-FjSOy2x?WF02^^0G~IK+t@WsuMeuqt>!)pF`$(LW~hGi$b}pX7NgydS&&z`1)>6i$Pe067bI4ApP7?l z{BMSs6IGM-m77wddiQ#C;+25C{IsD}y`q}A{Z-wz3%&gDW*!9=Z%dw*CItGMMqbq1^H$W>ptqe8gt<2loF22B4v#pkY42eLMvo`hNlgpT44zvV1{ThWUpK z{1-65CaPo*knflo{2>(&9 zYK_O&d>76)7pQZnz40s1XzDgy5>uZBrG9XYd@sCix*u9Q>l=lW>60vrGzY`3si{$w zI;f2!)^9wCN@6srU~hER$>gY=)kQa*mx^U#>)P(4lT>~oV~e`B%CJ+pv<17DY%VDS zC|A5uZ*nl?jrSmrhPw-iN8twFf}UwZn;YUcKp>D@)(m|CBg@8EFnf%WbA%wok7Z!+ zQwz?F>F6^o335D_PAN+qcQx*B zyGW`%S&Hu`nIk1`c0eFCLE(Hb)Qv@jPi{0JR7Xc|^D2|G+54BW^y0QN3`_uX^Ubl# z*onc0Zj3zYVl8~FH6(dO>@0sjzAq-BR)!6#1T5Hb5h!~^W5>lysjm2?j+VAvi#a(H z3^*(7-6NVKQTr1OMS*Z%*8JE^?d_Y6Of$Y4bIgj#sAGlFl^a%qTK22>#6Ij% zgO98?>2*GyVA7^e+>swz^VMKbOpeZ+O`5*qxA#U8wtH&9#m$}G>i4KlxmkBlFUCu* zzH$R^!ld3Sk?&>tYi{dI{mUUsmCGM~IeH&o+r9ACs^BF_10qQS9R4eiZlFtc>N~}b zl){pT36_Y-7cQxdB-8kh-L{PCKD7>sQ=6^=?h}*+8eI@fm4bI0PB253e=KV#dgWv)|>JV5#$=O z*}l-~g=H3KOCA9eCp(zf#XpHAQlTO@14gb>{)*L_~=XHAE)6UUFcTe7u0Ws;5&dQgXb1LHR9PC?p}$oJb9=R8%27nfXa^1MRubZZmbOhAdCSJ_6pyBPLob&Q|IcuZR^Gt zR8{@V${n4_xXUE5ZhF*CsNqhi;r4H6^yoQun)OYH|bC&LplytcBuJK&n=*KG-!E`$%r$xOL{20={I05{C@5={(2GN9DdE{Z78?2oQ7KFizhzQLe6mg5Ko{tDTxT*Y=6V zNYndG6||&qf^-{lk^l|25T(8oi}gNt^|?SX3~a0XBq~QfvOXa$Txk?V*O>X-qcl-V zf`vm$nMLG9`&GxtRLZ@521=;&U5SSZ4XtqsX6~b6>&ym2Mj}-4Wx6ud82**2_(eQQ zwalO$1nLpXhW5{cXyrKDYP$RMCXM8?QSo?JGazpVbMUvwj)j@EifsUiK8RfMy0N)G|9$T}1dUX>(2@iQj_i!4yvz-pdP;-^s3c|-epDP65A04E-sZ`$kK{JN6PV*fJ?4t z82Oc8$a$+^n=^3M5Xr0Q3l=3N!fwt3|NCO>7i?B^WB(AYjBQgWf|8 zv70Djc7(rFUBN~zvA%=4U)7{vj(QJ;6QRvCPyX|ge4-S-jzk0*sv5}v8rOHuP_UY~ zJOe^VtTn%^KNrM+pVbSP8}X|2wxZs1UZgwzx?WC|qMibFhkox!^Ffyy$;Oa2Gc|!h zCAYG{a@Qd8^KtN5k!$4VBZror+a?NwBOHm+qULjW(orFA<&`nuL~=cE zK@~BBUVZ4#P5aZ*%9&`Xoib$rDOtg%5s{-}Lyyrx^_C?DwU;nGrD3Zdwni|i7P@MC zU(+{3^oC98+0Y6dqf0b+GC1%WtvB4{+ZX?Y7i_2p`qd~bF45#0SHS8}W%4P^Z$5L- zW!Oj1{H)Qfe+nHlrb?rlW_LQ$FdsYYnNp`8sd8QXoYJoK$wR86JX)58rpXgJGeR;U zRI;~#UGy3DYq`**0s#DDe3mzsFWa4FLH-?+%z;y-{${~wVP&)ogt4~#(s_LqJYD?|fRr{mGnVLSv=NPJ@r_U4J zmOlo&se4UrPS9(-q=q(baI4QoyM4$jCzw!M$fuq~q-lM4m!ci>$jCtTEyg{2@0J07 z!xZ~5ZrDzFPK!uBnZvXQx%@j8nGrvmsv3V6;di~8xSr~RLzyRQ?TvyuCb{1+9UHGp zHlAag=CMIM5|s9~ zztJoaMYi}A@-Y)=xv^w0l`c@f;+DTO4k72PsD2!}>`oC9jZpF-mrkyGvc{J5j5Arq zN=)=P{wK-797o(2x}1CTss;WvA`CUN!gwtG=F)(26&rHz(BVVb5@%c#{h4HaE3Q_1 z^H7J*{oSEGA^1_=dlcr)P0I55c5Wr;mHkOw*UZ*$l0O)$O5x5no_(YBwX-2w_b)S* z%u_h!(|ydwOAQ#MVm?nr`$rZx`wpeQwwde z=CH_J2kGAM<}lBkL%FEMn?D$P^Y%{NR*k^Bnzq&Be&Q5*sVIbrAznD@*K@n{r)26*8@;ALSf5$-mzq z=$v|qKxgzjRHdJ6H?NZUbq%Q>et3Q3l?v3*R@2DjJX`vMA)&4TDNQK?pxiL#3xD7) zrlOwlwcVio3|*CoO2A%+z2x%{&AFc$pDxU%2SqoYcb;in==fOLI^$0K!C(Y4bta7~ z7+rNH>_I7iW{sttH8kF{NkIX}uWDqO1$80=8@x+Si-oJXNBst2uo(MPztpDcj`Y&L zmxM6~SVbu~w|8VR^;EPv0_7<9-B74Eldgg=C=>HXf}nU5j5dW#9bZ_if3}PP+F3n7 zn|S$?){0@>nQ@QwHC*LxeV{iqll!4vd4QyW!lovd(4uS52KF$13OQ?8!u+D__zbgX zpOw3tyOfD#`fjqi8uzn=4f#g`TA!9#G`M4|D~wCVAiyY6(VnbYzx#PX)kQ)wJ+`4C zF+8?PxW(X%TzpJd#f#4KYbIX}j}MM1_ZK_GNqo&+`IDFAf;$BulyVfc)5>XW^6%|6 zuXDV&Jf01U2-o2+^NEVuuuV!_cC@-d4eiAPt!+TN?ZAJC`w$bYaQMd#;rT;)Zep~m zfk?yLC69!G7Zeze{gR7MQd-kBp{DEOC&Uq>x>nGaL`eRMjD_7&~2iQu}Ee|z_M zB=LZflPpROf&jn_|9jwH=|4Epj^6+C{wI$l4FCTlw;?uU!D_L~sdniI_IN!Eq2MW# zP2DoeFe$(OY_|8B6NyCTG){R~UmcR2WEp@CwVr-NNY6wzgC9JkiF>cMum*W>u{P(1rdD)fO?CYJZxl zsx7iVx=J^k1Hsbo?EE5qRHEmGXlCc9cBt-6>1p#V8@L99&sQrXVuKRMTN38<=%lMK zK3k^~Oc8!|Ca1-5jd|4taCR?POlHeZ?ue8>pi*zi{Gl({#6Ml?TXZ%s}LV*@#o~C z{CC_#dH1XQ0NWxX!Ez#HcAWCVb+MXmAr5oFnUgyT&cV*WNsgy1K*!Jf?P8V zTs&@i@RN=`;BzJVtO1&oX{)oSwBCABN@Xl~)l#L+U+kmw4I-bj&_vzWu*BjNcJ8ZXSNboZf&$ z)QX$)MYpWb>MwPx_xiAku0D;c35>2xFINse$asp2L1CnM_R6QqUM={?gpsr;UeD@E z_Is{Plgwl*yxAV2q8|(02*7Z{_u(*bM7Bxhm%U^@Ay4v}m|W)cKNxEbFDBmQ*uolj z^_23y4vcQ6U(Qhg?Q--Ua~-XuU#9SXT>DOa(UgwU4D+m4y)xk+$IB{k1Ky_Ky_4;` zD#jnN%4h-9U|0&ope;@um0rlv4spn(?!g@@*lMcbgcBUfl?0uEi*~$`T|7Rr_Ao_L z+kHC@~{Fb2}sE zL`2yf`0FkCk^Z+$AFnN}H%px=#IKWE)n|;240XKZM(0dSLgute-#@ux8pe$qr2ddp zeP246$;CZLHl!F;PrLKD`m=c`kuuEsZpb7XJ0AX)BsCQr_Bt%vp4h6ln-jIqsE_~5vTg#Q2`0*ol zPPi&_;DCJ#z0vD>#Ba3&^rGqioB-J_h|#)`SFB%+p$eu#Xu6@dp2>RGn+e%8`>bSd zoQ8r^M=1~fpQAd_dr9YjC}S4+&U!iQeR+f-UUFmg-0v*}IRCSaNi)6kD@gu^+2Xip z+#AxG;9yE}%B`t%DRI5D_WYi*UUVX$ZZ1ogSelfxBg<;d~(HI}AV+ttk;Zz$5vdg)7Jm>BZbWXL1aeU^HG| z)+KH?kouh@8zu~AC>5%2d|>z)13>}*EboD1YqnYrpTM*BVo3hEQ$ZcQLrZ770gd(Y z{DgVRU;6_3RX=oDg+S-i2;ERwq*K|8dULP_YyuL(L-|5iaA5oC!8YySQu&!T_kl6i zLX**f0e{Cq7FiBC<3E3D5D7Iz`A&Zq8=P}Y(IJ=JQoWas5|7@DA0KNYo%kUWHjOFN z-6L7b4oI7X{!_c-bMgY$vl!F(;R#`Hg|~_uzi8SSQvXX5c&5O!-CN;59@8`@3jY9v zoKDGJfMCzz+cuvJ{!yabC}J2MIc+UzR6r^P9T{h!NfbDahoa0EHD>L~a`$yVS2E}z zmtg-i^D3cz6FDzG%~exX7GsrOYMCv_C*PIIlb4P+@B5g|iLE&CL<6_-ZJcg?0xX-H zZDnDRU$ghuL(NoVyKU>O=73!w70I%$gxvr_!PT>jT^yiH-ym4WyN7ndfM5M{Z*_XG z8&6!fIS(!dm5KT)(>8DK>c>D3-67Aln?**4E4ZE~u{8C8g z)0%w_Lf-vd;-+Q503A#vM$wFLGN5&r{gdN_=WxriYe95L#tQBk)WA?19hSpS zi$Vxa$D(QwB(s^at!IPrU&?^U!VH4&RM%#4EG%9gWXA9ECUzX#BKmPfg z=nsbLmDF*1eJjU+VZyIPu~8s&gJb-QTbzy7Y-Q|(OsEB_ruJ#&kw1T32(m@7{SOA5 zh%-F3k3}f-l>U>85FlmMUs!G!UWxlZmP3(h8Atv zjz(IiyS}**?RBXVP49^*e||h*w~$Z_W3$h!?A=7`R0p#+^kc^QboI>UR6}oSZgoZI zDOjs79rXYfw-}&(tta-g;KpBd+;>639%oebqdppSgJW~P4LMsiDm1YvWWtCxvGOW> zR=Kb7BhGPcodmZ9kyI5?jWX58)lI!Gq$j+<+5@Bv%f#7}w!u#7TFIQOeL>O8ZxjC_ zOD?~Src;uJARrw?yL)Ouqr>hIfJQ1u1a*Ydo&5|=_66Vu{4;tEh`q_QJNMs zvroZ7T*L8PKmn+Nhxjo^TaS#E7mx!IRzSE{ENTP~9vv%ocGrEqc8UJ4+Q{v5X6Pgxm*T?2w<6BxEiy+i(5%2el?feG5B>crci|zbo(dnE> z*^keV(Ai4bKuvj~GHNIpkjI+&Yx(?i1q(xl@@+V_10D}VW*GF^YSb5WWT5ss9`mvwksxzB2PzmAk(wzi$tGUOO+ZCq>o zJ6y+St1kq&NxbaS-9L)GM||RNvu5R);{$Yix3C8X)=jXrui7cjfEIV;Ml(hQ<}(}( z&Z)MbeJe*N5gReO3NBX73GxOdhTN}WeS_N@Cj0DnPLn;?s(8s7Ec?MGwqi4~O z>I7EId{#Am^SSy@z|rdqqP!hBuApnhZ~-z;4GC%OQnmdceD8rS>MX+G7g zyR%SuHpmZ|y$&xQOO8K+1+LAEV|bJ-rbElZF{#_bFWmmsg{R%0tw$sY$_&0?XjKp& zSyNUw1)-Y11;~lqa5k{-&Fy=DFV1x|_9OSoUT5^DTNi((ugsQW2&d_ST3qORq$I2< zh92*KXydPlZm20I!SMN4<9Ps0tNbeQ*@!6(LQ)*{ikQH34FO?FH=CGtrCEJSiWRKX zey1{AG~pJ^XS54oDk=!61N?ZnR}%Oj$p}*NsAU4j8tAX{FWf*2Yw>7+%sk$;LwjTL zSNvLi-pEHugR=Hi@?|BF)H!5heJQw4tzKwNCjadfpYtP_w#aY!zqmLi=T+V!=Nskb zm|a)_RyW|d!IAORzbiw_O+Rkxj@eEyXl)N2!{j!I8!A1qy&j58T>(rfE{(n&>W_%y zd+qn22HY<{mlpTcj3Wc{P}#79pl`Q>9p&gDhVbNkQ08jXT@nNbtLIBXN@c;`ZzGV2 zl{!#Cp{6WdWlj<5oq>J*?KdBR7L?@e$Y7cyTFM_{F->j`FP=ol?vtha7@k~nFO=Pe z2-hz`+z-fn)nhgDYd??Y?#bqT_tgy+Fo;p+liHftIX^R_vq*Pv<@}IWm5v4)GNvHl#eQX#kl%R>)D3{A?h&lajx2!LbREH zsGlMl5oO#uPqsJp?f z2rcb*CuY>`mo{P<9!E5$*Jdpq<9bBvfyu&ucv;5w4w{S<{X;GBse$<(~(vVu}nc-Hzv1)R23Z6y#Ew*9zDR{1wkc_p!*43fVz zxJri>Va_1TzW&3vxAQ(UzKbiv-815iqa1hR?y10+6OGHWrP+nj7j22g8NM++FaD1a zH#j|Mt((oRMw(bs>nuh{Hy*fS`dIHJuZdZ4pHtc6#klJ7I((Niqb7J76+Th(j1b7w z<)`+J5`CiVjys73McRUbCZ9+##%8+5W+;4rtWg$0(u;t}Dt|@sozzD8UV{HU;u37T z+_>*F&KKP5VgQ)-Jic1zH2hAIF=UjZ@TvtW9dUUndJCcG7KY8m#J9q}rm%s*hta^R z_l_k`KBz_Rzj=XXp5gZ+`I`0HJBCmj)#`u;c^V7JUL28A;MCV?_yR>ax8`Uf9Z^Vj zE7OWxS8?!8MtkQ0x4fO>xo>$ZNKCmvY6$QK@%;llw~zhL^*vk;GYWw!zfagHxLted z5`8pvo0f;^$30@5!~2MpKj`6*#hf!(p(uI^xO*{5!%m?O z#Mq|jw!`fSuwi!xb`s1>36*bsQ?T}`h6p!u1aj=^TL_fI6j8_iuaig)Roe^?lqY6p zBc9IGSnu4DHM)#V*Bez3i(IoFT>5QD*5S-|FY8HXcIjd~xw zc;9xxk!g$>_O_L2sNCOhBsyGlWEyIg=BI{$LJzthoJ2zt&+Ci1AS z7ABzd!-#ZVj6z`dBjeY=h03Cs8C@dy0={?N-5M5RiM`wbVjxPg_cZj8aGx#S_^gdzP{c26v;+ zN2A;mxf?k(!n2A`qN&mYa-OIQ4Zfh5h?w;aoab*On*!w7kCQn>Q>PgTiUJ0lJC)EU zQVi7)*M88lV;Ps!TH)H8Z{alN!5{P>oWDyFXmUk$bhYto_0*om;TMoU37VG)1Ld#V z@yg8tlu-n@B`?zZh&OW{Nrh$%lo_W?&`vG6#@woZGUh5br6M%!8rnt}|)+%2L zGu0%_Z$2j6qZS@E$Hm1ZPbb=MF4myNiLn2@K3{xUQPlnf@XWLyHK;6z5=DeWhwC(u z&$T=0Xqg%jjtdEO+O(~TOvTRio?M!B42@qfzo#!-vQtA3laK_PN8FVo$wx`liq$73 zy26kQ-E$5Brg!pWSTt^;io*rAdm848QPza|MId;0s&-W{*Vgkn^vJVpN|_SR0+>Yx zH@ss{qv_Wl_YO0(SB-O!XWC`YB2QW=wk%J25?YbF*@0`5p2AK0ny>WJl>)&VCfpYs zd3z}uy6-qMo6`1$EQnHsh7G%O3)Y_hs0m0FRQq(L{2aY)_asdvTA$TGoD4^bwi4B^ zi~^HH_kQB)3w1puL8RPDnc>TjG1!pN2iAyd>QcRPWPZx%jLh5T^znC#)-Iw-I}li` zAZF?sp%kmIuSuOV^e7!U5inMLl{#xjff&A6K#N5_#Rzo^c|je;wmHz}>K2Un7I|4*Iz|I)JC-l>Z`?osgN-;?}g zQQP8i<{;oWcL%i^w<9zxv$O@a#16%*V_nHx87_qN!L3f}fv(zqC0la68ZUF=#xxLf zCnSJU`7bJ3?FTC2$k=>`4p-V0NtF|p1fz=?cZpX za){r?RhgX&ZGX4#uyM;anBKnt)aW`~7|HK7z*e_ZqY6;wvkUwJ7 zf~b%bh1Ker%`@PZsFk9I$MEBtJ+yMR5~_F2rzz@y@7d?dy_rErjZ61Rf*r*CX0(6i z%?5vy2hU=KCCH9|5bfja0L`RT^Fau1RjRrxtw?8epw zDU{CaVo2C(E6GB-iLiom(FbLD^ZrJv92;Om25og95LYLEDem@Eocf0BX%OQhHf{0A zY)jicc01FFpH#-94uq4dVVURJPl^ok;wo5mJ|trf(wvhRLZ{B`y!qMc)%q;btxkja zlZ)3oZ0~o`$M{?q-&WnTZ<-x+UR8bbZ$Yogby)rDMe6^2@h$R7tcdg0csQb}GjDNc zlZbNznUBJ!+tRGk`$o0oI79Ew_SHzky%7)g2ZLdwmei0Pqyx8B+B!7kK<|7_GyGif z4$a79ZR59JOs(Z+@}=mRP7yLG|0=fWrxscT1vXcL*bFt8dHgklLE-QwPKh}dOEX(gFW}smVMb+bzU&|JNZ0}^3HG@68lUXJ{B<{>d`QMS#Fci zanaSIA$>Z1U9z;GmgOd`3D1tM;Frf)Em(-25T55AMm0!X295vR(PEezKUiP7535hg zFExxQ^s4?PDF=`b>$A^sVr%)rRFo=Rj@*bOTpPM%gYpsEIwj)_N&z2!~vO7!k)ts%&7-zOHCSj*ia zlO8byP2+msw*O#Ic2>!>PRbV~#;GLarKAT!-JTg*JgrcyP#5T);nNgbqFa}m*-(2o z!#882URzn>lXhQ>#@xZh#YYM&)Fd)^j3?2}6?eMR4g&RQ{FNNFU&M>w2CrFm=zQ%- z%ScqVckztu>pCiU#r%Xfhd6szQVjU>YQ^VGuL?&hoO-w~S4Ymkcza<&;hUhg-%h`B zWZz@wqgp|;x>7*^OAVd!X7#?kg&X-)$~5m#5eDUjd0xTt{(4P$KYvw^>>NG=XjtA* zJ0$2tevgvF5!4_z$rpd2E1WjS17zS#2z#lf33figA=>mcG2F*XB%Cq*ktJ!XRAmP+ z*w(?LV7`t6(f7W=Gom z>wR=JPlHMd1okRfjX)5?m2X<}$V>@=d9DP@+zBz$nkChlUX;-J7=l5kL0??W!A%_T zI&nh5>1n-GNKaE3hMW|9X-Vk=6Mx?g?V2H2JIvNt9vK}v5~cO32QDvfQYKaTDZPp< zt49!H4wE`;)=h(df4Q*Sh90?LQBwuejj1}qW3HdkmEStt_Vgwcw$}2P^@Z8oCr?$u zIK;7j=#R^d<}aG!otLv$f6jst<7AYtG@71cd89^My>}?^3<O@*iMVKX={J&yVQaTMew~*iW{jozr>IHI`BV3caE! z)nTaq%xoU)`e>T>pU^aq9GNKL-*V~ zy!YD3yd$LHd_DK;r$)v%wjfB}L%d%m#2I0!4it!sc{3Y%Z?;Zjq?^KJqU}_$)_aE6 zE{ibB8l0aq%Is?mfm2kONY;X({iq%}8k}jUBi(ZY>Saljsj@JieIV1b_n(pNN@NZ1 zUp)muXT}pSra0Km?V|(F+ebmFGR>cUq6{psRzNVZU31#cR4U##IOjq)EC=ZIQWpWD zZI3q&Je<0Uuin4}ZR^O#ZPT+^driF;^D}7iOW_~-CrA7B!AV4XFaezAefM_nihgON zkwf4&4V5HSi!~t4fP!wiZNj%x`(+qGbKy5DxSq@WWn{G_m?TkpcDSJAGsuqj16lX% z7v+a4F<Jh}Rrx@iACbi?}njc?c4@2ida@V(eLkV?g?e4@`!kVFb~ zvIAb)seDt!gFH5>r|d!|(}*19)5EC=T1D;3;dEVjij8($<2ta;ij_2Y^nglzg1BgQ+>^tiTY0!n z%t+xQ3?y`^4KTMvU^yxP|Q8Q5a$ds*X+jAIAav72xo;fV!r zlr#7qkYX=eGJkT0hwrV|{^;7odFz_`LO^5oF<+sAIzG{!a`vv7692V^aD{p7lX*6* z3Y_@^{W*(=+;H)@FOXi9$ZP?E3@qa<59U3|{*~FSM-7*nnJ^8nir8%GEW~>?n9To zMrsq3J4jt2h``6Ce^dPGi{Vd|&)SHT+ByAxk?0~7gqLMi!G0mfP?EN73|o`fg*^7U zd%1D+T+?bekJY?R1k+ca69GM|5nDN_+SpNCyM8Tj1&_7DLr1FxZq0fU{;T`mxsUD% zf--U{u+9ldFMIn(Ny2tn;S_@w3HlecLjChVIjk6ZcsfAf@?2PQ!S9XT0B+j+hj7+P zian{o{dr#bCXvpQ8vhCISnhdRDkI~mCJ8p58kmuuJI!`wa=!wmQg+}S+GbWXe%oRs zU;&jLO=8*6_#EFm<#g6qa?ut}s4U%xSreNlhs;png*bCMiw(AFsDo4qFrlkaMsUme ztBf*1?8FpcJz?5vS_*f6DxtEC;HAlr5Gll->VeAPcHFYEA|`BFME3`F1#@Q&|hhHie05TRPs{*q5a)DPVr{e&;Lj!^x_EMgnR)-R7oT)cHFmK8j+(edDHB z(ZqmicRgedONGUEh^1%|6d>%{Q2U|>6 zGB>ZrO~2Gq@;=%>VKsttzEL6al=)nSI;2m~-gOB;kXnt96P3$5;hQWo4f8BgTov@I zGPSJ^(=6td1u}pW(|$e7=OSPoB#*ubz@&cwstoYdF}_1y((5AICS}qOTkRf%CKuVo zidcTN2}`hO&ta~KG~}|4<2(4kk=f91C>kgFVV-WpQb>ztFnR=Sf=CmeF+_{z9=Ql1 zaaYGeH>#JvWkzBid^?NXaXHgih?Vv1$A>)hR(9XJV0qaSJsaAn*T+9f=TPw=X-tXf z!DQMUOX)+k5vrU|j?)QPy4Hj+5?!M#gUX1Croq+-jr zk5{$8oA)ZKOeEi#ey~2Tm&?DD?5wp}uoI$R{2;+9qvBX=Zw>P#0A;5A$+~cQqO{_P zhuM7rbHIcMY>e&I7EZjnytgBQ4|NIvJj@*GXhvbgw5w`r?i_8YwiMgL3uwkZ(JYNb zzU!JI{b)Vfo({ozHpOl%=ZLU`(vhjsowp+EcFnNA6sIWv!8lKvnel4lA17Im=ypO$ zakz&>FWNe<;#4K%VsMg?o|Pn5Mz!wxMN-(1y?6gwvMP30+HZr$4BvX9=KSX!@sgq3W47*phcTt;=&`to?q`$36gU-x7abaaa_D;StKMN7c|6Wo$ z^_gcU8|UsAft(uzL)50o9U0{TtLo%uePOpj@+JLS%;>k+C?s0%VjVPS9sA?o-$G>y zgRWi=FQ$*wI}XT?uf6q+I{hr#G``GFV^6mK4ZTQ27A5ExE3};iGWN3a0n)vh^5L-w zor4XEB@B5F{Iu3AvDq*}5@UbYb|V(CIEY@Dc!`@2MqW7VJ{}lc5w384-5pxuYqqT8 zMc7zl$U|?Z;%<~*tQxJwO$2NCAixg5*yO1S(Xx!_+H)>b+^oJzaNznx0i0DS{$Bj4 zrbw6E`%#1RVxoU6-{$Ji_$9SgK1ljGLWUwzzeYp+3W^mt%w;>9%R@v>S0gr+$8bHy z&!8#ee%F2P06 zEDk-zK(7hVg)6TCEy{Ru$C?L63t5fhb90-(i7wB+{IJ%#*~teE%apL$rY#{$0yVv> zD6LjQ%u=V9W*8?x)JjxswmOt=@|eXAAI@Vu=t>5rCoHljd^$9+gB}Fc{o0XSwLry4 z!{7=G@8&VK27R6EEji?wx~eOt+pLK|Dm1)!EbF2A*#IYw6;DlILy-P*G7=@Vk3XBW zkg+`elY-ZiHgD>1Pb2O1$EoVk@ue$JyT1>5J&@M zRNt9Y+~e`gWv&^OTi|EyW671HrQEgmzlm46mS)Qvi7w@h;>YoBwP(Xb1{p<9+zC}) zWfinZ#{6ZfN<|F^2z3^| zMn*O63Le8v39xZ0M>Ez!_U4wV`@RO=rj!cC6byPE8=)4lx7~rj0!kaQ8EfSHywRur zZqlCm4(U(huZ7lPt$TYaExDuh>}$48hJ=Ncdn)v~n<4rSN-+&6V6;jsCW8VCDZaEh z#Hyjq(1BY+d0DR zoj0rAmgZNQ_M&HOe=y<*_&mLr2HT~Kd!(DFs{Eyvuxf}9a@mE%BW^Z>dN~SLqjw?% z(GOb2nZ*`3Nm&M-W?pI9iFJSDAKexEa-#m}V}C9A57Z6O4p?cSPxfFjO`=OVa$vY2 z(lffczM}Z$Pn=kVEBKu^?NYkI!LDSW71*vx>Huw*5Kw_3>(W?KB|rUr?9*ak_PI(T zCk^y#B6YTI(na~{_0vy|VPbebwbaf@o?=r*p%%wHmOLbRRCbjK{Snxn*woq+Lm2nZ zRZF}Svi2$-Sl0DhloFKNG^8_2PZ*;fOn58&BT_09wPR|ZS5~)rvFwDo=EHO&3BRWJ z6KhPS!|kGuT)bvgk33>7imE^6^JIo)vo2|0B*;s(1&zISc<-d6Yrt_jD2gV`c@yLf z0$-G}>uD)WV5B4}zM(OU;AA7>7#)NHfvpF0Ha{Y-6Yw^p@rYrQinT(&!*S8ph&1s# zwdoKk%$H;2@@Q zz>Y#yDNPF>-T8;E%@7*U1_H9j5>pxJ6ILViiQtum-QIRZSM?JHT!)*mSG3L|mIQc~ zy3;Guk0uimpB;U<#BOF;#2~PkH?EZw-0j~4e-4XMvMfl|KxG%>qkUV+Mfvc*?>ZX zdyE4z<-)Q~8!un(Ut-IdReybnuh_3t8BJsgvb|m}&3{LD!NfvNCG;nNEcF~r0@7^A zddBque&7zr^zkBaGHBss5xiw7^O3n-tXC10MNU{Z_A=!@N0_f11if>Dhe_>AwMd>n#|f`>SM!tw1wNIMUs1a{4?5=l^+~c{sT*Kr1S6_{;8gBX)v* ziXz?FUrJBkpEs$R0BbaW2sEVB!ncb^fk|mJ7FF3as8jT&-Yi>qzo>_lLwc$QM&6aU zhX|#8i-98zZB}Q3y)%qEdK~*-Ajy-`r9am#w9_qg5w_d-_F7wbg-kbJUH6#rRJzd$ zRqx+!hh8(gSvp7myWTCr3aA!MxaMT7whKK~tFq{fJPm-k>wKW3>}`rhK65A2p_|sT zVWNp$xh4v9RHb4!Ygrl2{YwWMn?+k!MXMlPBjqWbFZjD*20n%r<=i!gRHp25ogt!* z1CIFuEYp3&Uy=q)q`yjtW(I%UJc#||iPj1#Tt=2qvI8}RTBkGx@AFI3bO_CH!o>X3 zISovqw>sFQgt^7O)9uB4^I4kcm4SjwIQ>_CI3*8S=g9K4jqS{X|st?B_L?o37@f;m~37M`&Bv zi;}IVkk$6#e{=DRs!#cHO3jKRixP9h0|0p5> zZFFnWwDhjIWQ}P)nq79roDdQAjbyA;;}EFQsFgNo*gZq_Yb@LE@xWUm)q@wQeUP$D zGwvy5pt!N96T!%rIIMOB>R@}|^`aWw_=daPvLqucK9!HQAYZjBrS#~1(&vnRNJ$DPX;VM zpGM{OOvas`{=uR&{V21@tyZuseyJghWbvMs09#iLt2w{!3fpQPR*0`~3a2}aRJfb* zF;*4({ki%5{jRGWQGzLEK^5S2&1H6M@KH}Fhn&&fPnv)~7%fX^YF2-cr)>dsFkmm> zXZz}oZTD>ok+v(jSoDqA>6l2>M5(F}j7@YKNOy)34OV}71*e%*ZwgH+49Z@|zQJ=2 z@t71}`F$!kR;Z>9@pS&(!NN^NLP^KH4;ZH+c~|%Rx)oQFsGjf8@ZpTC1Bk#7y?nIQ zHoegDmsPNX;}3LRFmyNhy#~N;uv+5|w?)b4b$&^VlNbEKkTgxa!}L}=!$!x69!TXJ z1k2Xywo7sj80rx*9E9{rJl1*WM-*dm6p|6Pf*C zp1F0n$KNvBZ@hY&<=?RU^V>s;vR5DT<>8%OUcA<)e1)w7e=r{YSnJpLNB=Be6Vc%P&y8t1jm7CfuSMZX8*M2f41(QxZD`O%Vr5}sw$v@b(rk=iNv}|Aw)7!o zt3`{H({}ZS6}o@l{tft$QWwhu{~gg2j{&-CLX!>4Bma9YboSK$%=MpT|L*~RC=SBt zuL0x0e?At{xgEjgoBF)!sxA~z)d`D5I<{ZV>v(}BBDudmU`I10m#`^F;O!{%wGpMT z&};*xH;xji=By~JtLx7nB=5>zv*wbn;!dpOg4P@hDO^6(N@cNY&mD8}nEEVyoJ47T zF&DD!5r}q}2F~Al)jFcBywHawh$x^NfH=}(02^P;?tb5Zbgy~@=`(zM*eGII!x=>! z?3}gc!4hqfU2via-*_@h;n30y=y+<|FNpV>A$tGa>3Roktp{x#!`xP1LYK-0!E6Jb z@bnK$T{;O?3ay6+!e82RUNbK^twZd2A9HgdhtiFo+M(87TNK$_3*`eV3tbs==6h;M zl2M%{9&TtN*t+iV**1!@g5~LE7USq!a6R)M42-|&kedEW!td=5b3(jvCGgIk`;l%l z{$?M4f2Eg~cvL0BykZ)v*4>Ve_E~%O7MWJK^YRVWficcP z6YYU9;pH0~a4J^&aNyF$_e0k+$IG|!hptPuw8DJJ-+hM>tqbkc?ZXI(h4v?CF#K)* z;(=-O(#CeqfoaI!E|LH3(gC`;q5~=;(S7Z1ra@}mwV^V1)2Ckkm}Y{hM{oZ^>c8au zTRcha^6g(>mDc#L80~?;11c5~2ei?Dvy=WUXJB3^vG5LhN;Evyqv>?pkyzBkXU@5IWO9X0@_sBV z{J$A^TEd3LH=hB8RLvDg=6c7CSCAp|(S=mVknN>*lBn@^Oa?QGU3N|pT#o?GLK!n7 z#T|B@R-H*9{&qWsh6Y~AI&$uXa7{TJh$x&^S&a0-UmS>Lpkw$mz zM>Q8G(yWgVVSy|BeJ#~fR6Ef{la40a+9KbkX$fst>IrXPVuQtmjIT5Gh$7?|jq+VJ%d|`rPJ3=C2HQZUrVGfBd}+SY&$(=67JH2Kx>o zLl=R5C7x!6wwTevp2@G}4LC(uu>E-bR>g)<`ChdUc4c5$DYR*TINzZ#%kkarY8FKe zvZ2wl$uBZ~V&RuO<~6I)F9yDn3#JsIuoUE?H%^iwh1zRR0_8fxp#+&nbIp?&({zEj&6y!(Tpz4>53qrUaF=S@&uPb~+p z0N52!L<2XX)jtP zjj^cYM4(!RCQ7;cMVGEMnasN&6?=0e1PiznCH2OMt@3%DMx?KTpCS&P7us|#l5}Dr zk*{8uL%ZJpToo(`g|Xny5P=L|mmEAvh#r?0Vp&0S${ zAD(D7>>p=KeJLMR;$wZ+3r;;a17u&^nMk{U4Etm3Mn1;O{6ZCkb0hnAflBLzaa$i> zi@YZoLtD~Fb&S<%&%CFD_gb@0rsFPy*6j0y?<}#^QI6Tar$>B8==wnJTgM6usw=FN!ZxKno`&WB!kP*2xIL+o7itt0VjX&s!R4EWLWZL9{9(a$LhkB?jd zDU$f2-hnm`O8AsyS3fP_^?qT9k?5f9L;iY$@)ni|F{}e-Z%E1Uf*y4$`bsudZY5Fg z7nNxvjEvlJMXKTLo$YB?Oz#)+;nh{$)fx%iOY)mmLX}4rJV_HBgvby++>B5 z7!o#gYO>9Vv$|3W6$mM!^1tFLN*MSHLw3g9A^MkM1If>bSHpcfVsq@ma6$njV=!d; zZ8B@S9B{D}&La;qraIUsHod{BrDFEZzj-TMGJgI_Hb&NTU#;0&j~*P=O8THI`au~D zjYyZ4Z_hRTneOFZYeon;AZkg5!HIWJf39x-A$E=&%+z_D`4dA=>}w}SH+c^JF))}6 zeyu28rR=x*@-A5}M>fcYi|@%!vAqmDu?2z|-9b*gr~fUDMfe$$LP`|zC0_FsCtDoi zJ0PD&bJxp9GV4;DBY{tM9C|wb%UX~hMZby4U=)pSuP@e+%(hq+JYZJ5iLVvm%x9dd zOO)n+{e4$QH*g%3s{;&tjAF|~9wg!bs0%CrT zziEav2h|QIkj_{Ww|0o1e%A(eNzk?re)kROZVXiv?g&z)8||f8l#cP5H3>~}Jai`c z&1uH>DSGLiWtifDu#urhId2-f)r(XleIX@ZNyT^Zy+x0#8b0YLwu-Z zWf7zX)}AojOc-Z5{5o6a;Jz=3Aqm8QztCKlxs<9sbOi*f1F(q;+~mq%>hx6QKl#Pz zpQPNzG1lctUjv^mBmoNz)7BLP?HfaSqGv=okR(#!PvRb^0JRRZ-TOuvysmS+&25=B zC;3!FTeU6rajaB($FzytHPmdy%uRVm(!sh1jm-1tFBX9{+gbF%BW7c;w5jdgLHM$R z*e>80m_uN@_ir-q;WqdcH7D%9J{+Lwh|W|20fMrWu!^fKAk@)8ZNDR{o#^_4$c zexGohIe?=zS?Bmy9VXpjbe&COe*GimDu&}QSLZ(%4c>gYsu2~XQ=U-KS0uBz3y&s+ z$}W+EQo_{U)O8L-M=49O+Yd+6a+h>Cx@hfs%}GP>QH|WKBK};ThbqH-7tOJcE*< zs7XB`gYST=dUK&M1tpmJxZlC`$1ex~3(PmZ?;-UVLO(uCnwIhSn+l0QQh_M}rf>|AolQ{Ki}DB5U^|CQ^??&7xz;(roDpd3`i z3Rn-__)6XV8E5v=F9Ei%iHK!5%-VZN@@=Vi6l-@ZAso>Nai$Do0+}#xaLregP<57; zhl|kA5KOS@8Y*u1?hFjK-BshT9_85|*P%@N@ZzFd!rhuw>d9l-J3yv)+)!ynLqO&< z7zib_-VH2~x6ONA|2qPKHjAzo$_$W~K3al!m8BW_O^e*%xOy&Zd-OF5;br%^^(}6j zA2S_RZ-fWv0Vae&rXG-VCgJ>gCu`llEwAnnn5u6>7m9)u{wLk-2DC$FG;xr`ujy zq#<=-^pYW<;j6NX-&?#jDSIN4sZbjU_|@`4_tuZWS6`FLA0YuDsq_U(L~u|UuBoo2 z_pG9}gb~w)_V+N=BU5i)m?v>?n6^dN7hv>zrq1VcREjKQkzTpz{VOOzq zhuyI~gND|^>@Fa3m@5@i2S+&nq)vDkrQ`G$AaOdY^z;^^3xKj3Fpb;YI6CuYbh`RV zX+ePPxj}nzJ8t#(1~sSkq-YQ-EDS4IIE3KlvEh3t3jWgmQ@OurP<))W{G=p_w1Oe0 z`_mde-9sK;)v61*cUWyx$cgCMN)>XO7jHI9tsIyWrt2M53aQBG0~Qr@V4GwVw1dnb z3U;Yv|DbgF2ZnF?{~>nhvK|Zq6p38xYR{eFJ<Kc6Dz5XL-yP6!g0iAjP^}GPF!ffD~J-v7cE1ZS>FtC(}D-}y%QFjm?oge z&2+U3DIvctnbnlbAIngL_Mm)#1{DJ=ec;?khrx0c>&?{FVh&f9@%EZ*_x?3|&)#GH z8q_{#q4ehmgH(Wv?R;w;3;%bMmbrus;9O5;-yQ^m0qLy*jCd-F2EJ6EI?bZArl7>e zdhPajbA$lzRAFAJY#qZtSRt~P?hwwC|0?8V8ID~u9F`q=_dRRJjHvihZHCT_Q@YJ=vMr5W*@a4Ct zuR2wDm7@yL3(1v9{X^P_`Q=gDny^RX8pWN)i8)E3!4JGR%XTc*y~l4$%DYkWtg>WI zbmzYwgzn5Lc4R4KnDh*yifEqkSNaZdi0cs#686bAkIV-PJZ? z>z)Jjo~cDTY_DS%im-;J)72o*dKL~_(K!Yu;(5t~0)f~^3MW;)PG$IpUbTfMq1O#j zmDv7rwJZaIF9$CxZ{VAaDjJ~ZTsx)SL9E^N{gIgn0WyJhnF_D}i@CQ9j$~)ngw6Jt znQ_d_jALeIX681-m}!h-X2v!%Gcz-L%*-C!W1sDF?)&cEy}J=#?Czg0R8bXDt4eBB zRw|Xse3G4@z6^u+U9(B=OOAGYFtSZKJ*V~0Rw6XBT{&+kka7N&h-W69$W2v-nLyum zR|4%y<|RW|yK0rYlux1){dhHnM%VIPM;zf&_;j(_uvzk=npaOW1`}543BHGQ`s#9b zB~ni>6c=8qkJ0Zk&9l?Uwo0lh>&Lfm z)ViL-vg--v$*o+-(rX?fAIb7@OrAP)iA%_J(Rw)?u2I1a_L~kvfOFL-U4Wx$o}`nj zi|{(u8EmsgX;D3wCK~3}G*9PZ-IU{epM@!eNh8q&%)Yg}-*w5fp+-~!Yn25P@^SG! z+ll_YUEv2rY+}Tn=CXM#Lj@<1PX0LVNgMZVyid42f7cA5MVje_LhZ8&Nn4{77Qv7z zi=|>5+=v+r`LN;lBQsrXZ!6`J4Mp>O5MHemBNJGHG(P-c`Yj#CG!0*}YYrs7ABLg$ z&qcQ^wIdST zZSJ{LFPnhyYq;zRzQ2%gRHG7+b<8t$3E_@4>c@POzT`IwB&(bgUM>Mlz|B*pRD3a& z+M>f~yctI`*lWzhH;RldXGP3s>MnFiPfQ6iHR% z`A0LWs@Tx-p=Ba1?8VIg%rd2dT#}rgEhGo*{Pm@bm3s&!s-0m4%8PzJe92tj zphGcGW<0K&yUbxwN;X`1kV3!V#u#yK%}~L;^$;ae$e+nhU4^BWWD;l)f*XuA=>mWY zSgI9Opg1uNQ_SonNj_HtYIWBSveyqA_ERHe))i<#%-qdl$;*1IjBC`w{pm(*ydEWV zyGM#N8kyms(5cAt54kctsk4T0ow7z}rwk)sQXwiR(v12t*YpNJdHGPMN69s5oYJof z5E8@0jsFy?tH0-1l%am-xc^pMF7}f7%$IwJJE+p|W!HsA%k4=0jG!9LnQ6nlsMA&u z(a)!dT|$m6CHrh8j(S0i9L(m8-8ZRQM%@=;V{c22A6V;`JL#&Jt8iw}UD)ABK~`%A zZ_i4?O%io_zh#V^fDkCqk<``FI^9hW4A4I~C${YR33!pKuqh)AL@MxjNRpseUvOWC z`hI}o#EB^;tFIQ6%n~<|znEdOL~T#f;ggm}`KvOqO2<7^e9Cv@`;4>@wLwr35cC zk@py|c7D@VfT_e_$Kf)UY%yqMNOkYJ{_88(gWab3%DyQxqzQsJ<2X5ZMK(#1CLR( z@?j)vAqoeiHLSKlMOmT+JLxjf+q3&uQ8VHfDFwnEn^>$SE467T&=lqoK9ZE7aPMoa z&uz^iv6j9y%5#iMlhqjec%!+*xUk?;qj|9Ld!h%7!sd#Xyw1U{8rcq+2z$bKfkaty zQ4l^x9@s zy))HjL15vBlu+#thAphA*!s)-&i@EXXlOxc%DzU2Xx@^gT4h=Nl<6ty1$uuB>sm(Z zH{t;WK^E|zRPK)?UWI7^R`LV(_FTGrzvv zpJ*u4L0BIzzE%{td7_^~0P9p>Th_v65s*h(4jw#o%kS{AMM$OXR!XC-+$^TnpH@{1 zf5=quOyt;>a4&-hHWk)JFS9gdJ)Dy--rVuKcbqLz(#DGb>H#zwbzxK}2Xfb9-SFyz z*$+m<=YohNA$JWzO1}E7Ii@m}AjdG!O4+BOoF^_S&Z8{Jo!)+pczK*lXVV?tKM?Vi zrXz~B7Ks9SB|1!7+*viRHHG9rovIFzTTTMJ?1AF=rEa{4RISl2azm4-f&53*-^H%XZXD+Kamg4jPpbt%4Po7 zg~1yYj_R**o6M$j-meSLTfh)8AOuMB`DDSRN=Javea_3u*_NbBy}vWsw3t4QSPp0C zm|>?rBBi*8id&DzS&q4{!C9tF@9Xj>7cH_Sy!~fUnz0DxJ5OLa4UaR(sXh;Z#H_`l zuLv=C6ksz&)(k6X9sZToPt^+xmlT1wP~&UNm^T>}|IFm_7%4stMqx%P>p>{(?d)G* zrRTm_qW0Q9j}5X=)|JIaqL~1dD;n1h4(`x=N%o&(Zi4fN7p}C4qba|*o`C$Xv;}`0 z#tMiyWVLm%ZDdq^>xU@xngiXSg-k^5V>DBq$cv+)XIu4DqR@pTnE0P^JgP)~~enC0Sd0HkuF{uC!yQ zSnN$Rs*1uh0V;45j;(wg*1TId|%IP&t>YiqvR`fjork1>DDLG5+1r6{nR)DVby7T|A;B2D;iTx(9Qf~$D)~_usuQiJD=tEo% z6xZi!CC-)2dbl|@yc%+0*=b_r^b(CD1iz45K8x+0clW5Pm1B>Szc@&-L>n^^6dBs& zL`8hAN%TQ#k4>Y{3_{o>rC*L({=k{oBlRsuj!L(;GqIzXWD2;gj=&M|#$Jh@ow0yS z(v#3Hipr5qXz0H1(rwU_HY8%q7eH=8gk&vONlh>u9GsSj7)d-~zwpxFY-3p&o>cB5u3H>Jp-DU3OFew}Phr$l3=+R~csuWMk#Nkd6VkV&> zke}!JbGCKWkp8%&NJAGLSKmgOW70;TnHd%kMvmzcpPXZXqc=s9Y_M-x)J{^LyF2P? z>_pw59)IEdKY%ydY_T1Lj(iRDm4$w!luHVb)8^hC@~TD+SJWsZs+$|Q_?ma7RR&@Jak$P23b5xjOqCsr-_EBE2%_~De}1X!6uD?Rj?j%~ zxC%Iopc_K#*sy=->>@`k8fGtK3?{v^?&;M~Z(yxul1Usg`Pm+MiD-Ydji?b#ApSl9iwZbU@s=(d>}Bx>B2t+P;kG)@m8 z?W z8$vec(Tak1C*LY>_2D!CuGnT0d$#BAiDk_z^YLT9a2A4Q#Amxk8h{A94+!_UG@21f z$)()dfu>VEUf*M588J5TSg86|zINO7-bKxEckd?;53B7O?V zR&p#koa+?M){+)(k6Ycf_Gym1h<=Vz_2NX>53rvVVYhk4*(CN|tlcU55S$NqZYh+3 zJBUSUhy%zeBV%V6VMIKYj1|z9`d{gPEjvJqkqgl)oRczC20y5L(f4jUy3|t`Q9e`7 zT@RnNc#^c6Ze>~oHpnP=2aR*B!p5fmjXV(b{SxwmYLrOli{P@R_#DXM_qi+`&joLe zn$K9gOj$ITSH|T>6Llr?UX0@0Y532q{Yv;4JB0Hv`?m-0~h7Z>YKyy!i{1S?YBiKG`R4D^&# zDMNWQBaaq;>X4fH1`@W@nPyzU@lop!7U;NnFg%Jahu&za1aL}5M{xpvv~VpwLDMF= zZI+2ZrdEu|gZ2{#(@eKHQ##_B^v`!HoIz$FD4dN$TtPbeGf`8~3q0mURv&e_tI?Sz zywqTW^eC`~y@huB&TcnY4Cl|~TtDF;B_Cs#o~cG3yfXbZ?Gh_Db*=Y840P}|fsX4< z-V>SSCyXd@VHH33oj;9Th%_idSf4Z!(ZYOr^hXZAX|9TO_$(eG*?39XOF)LpThB!G zlvBH%WKJH^&u3yvbrv-GE7SO58=;2G=T=gq0zW`@N_rJ1>f#x&0`1l3L`YBHum_Ht zSBS~f71_iXnu_$d=XOnbylE(5GLR{@veI<3=7W*!wgRi%8n#{3*2 z-soy{H0uJU(gaD_B%;}0x2=}t*HR}N|A@GIGGyu6mhbH|wJeI$cBU;)0L%E!%j>nh zha1M|33IlRwWDsQ(CrdC2qSn$n3NHtkGi!Wbfb+nJ3CI^C z<&!v>we>8wnf>l9!G0lm0z-WF&P+~Q>^Rw`9f8vvLCAhg#!~lxB*sB836}~Qt0hdQ z^D-ejKCN1|XuYIt$?Ro5hLq7ZXi`oaNRJYHmL&NGtu9rQp~HPB$d}xkDiw60RlubB z?aNAORO(VRZ_)PL!Hmxz`8Qka5hL-3S{0o~T}9jcB{I;exROR+A)ii47un9)iAX;i zg$2l|c_LeBbU6;8Yd1+@^AxXIyQ-XaE3%7aUtOzmjD1%LUP+useoq}iAqc{jqJBK} z`|Y{5TaC9bINqb8JPo$6+(hgFnX+wgI`W|BMn64p64wk^$G!1sv|Rt{iE6hpgGZ5) zr^msHR;MH13J{E%WvTyZGOWwHjCX)Zu5S3fv4h||*mFV!n4Kz9u>{(@LXLx9hejFV z5CNZ06SoNdS-<>qw@l7oS!y{qc(jR!jSfHF7K#OFN9XI_J>ecq^c8WdWe$OtQ2oWF zDsC}ZEP{{K`(g-*#g>j{MVwzUW~sw6kKD)7y48=9y2WV9j&tNkJ;+*eUD7i`LVM8O zY(w$DWfqx0_F;c>^Rz>4X}J`_WK3wZ&08(?5IziICx$^Z0J6G$7S@aAI9&NxzOP$? zM|Bk&iU4;eiYlHu$5P#cwx3e}4WRD#k2#XcwN6nqlTF`Q%7}icZ|<;lq(whcpm_3J zjjR3U=tSpLbtJG~5smRw_z5_!A`no?YLKW6-Q-zKH?*(zPG8?qJ-+H)6LOTWe`wG( z=W8``J!tzXNQ1B`nkw*~5JPis=jVjN#O8Jmzv36|ApX z&_k=gs&9^q5g2kypagINoq%7q!Ry4&2)#MJuEL}JB%xXQ^IBN zE}hB`S{^oc6i&&G?$i3|Icv-ub1W@)#$r?N=#Ou@tKMk@fmf&KCR?YwV@>lcX2lzP0zu>)rcUcjK>JqR z6OM(7RQy(R+JoW(r0u0ls%cE*L~2A}j=c210eyGG5L--bfa@STl<9rKmyEgFcNO9x zJZuX>b~xYH&hkokV7Sa!4E&826M7ygXXNp!>taFocq2a*&wMvfjrjd5S?vWZC=POZ zuY}pfZ`qu8Nv>>c9`g-zU$tlP3H*&WW1%`MU`nDMy+@55-GyC;br$)ql+zY7Bye}x z6cTJhR|I;0sc0l1Zye;b&^^9%uCrQ36qjt?|Lt<>GwQdz5R?NYcC>+NOL`3b+Kl5^ zZAe|H(}5w=^??vve@N8AB`$c`SAg<1K~#D|A54>~*kR2^_7z`XS-=2l;)*ic9Wc9x zZJdc}o2Gmn)`_x9S?U0*iJ?ZEcMRQeb|(^&w5j|{!Td3jGJ25MTKTFv=AV~#O?Da8P|G*^!Nq}FZ_~4$h3i4qKiKz zx^>;q!p#_+R=k%<=+>XLv(5+H0~+Wu<96}{*zwVT+W_-|ok3@=PPp zC_J`Dc6x>ncN9Jd&5gb(xemR;W;Nlo=3pG=6iu+qjQ4ZzTjk<5&@N8EIR?5zjq)i- z=vh`AcP1A&Ry2Wh2i#YX9eIrwj0bwyZIP4bQo0eS$6KTR-{)+^7tUC)a^qv`z~B0E=ns+j#{h$Dv7;HSpDLn3c?AgiXoAdUmnMw@l&D<6^P+GRq4^2HsMcbyE zQTZS~s&OKlMo3OS7Jjr&DbCD!Q<9(|h2UY8PUX|l#;;}37=%hAQuG-Vsfg3=DkeJN z5p(U=4`x+rPuiug3|y@#nb*N}Ogvtd0c#%vrN)Dz+yWY;b}oR!9n$1Ae}OF@^H$Lg8k^)w$r)SZ+rlFyp&%~+8m$ot z7x7PT?<7iZ!A+l8MNhLoL`cl3NJ7HuxLD>G3k4^VPp_?suzVgJbi^lK=u)ANiSks` zBbPp0-D?$fio}4OFKW>}10?|0q2`)SfRX9o5Kk_XaNezaQWcdP{h)ysq}oOt3GqkL zU~J8$7;K3DJZqOxSL6n_v0~S0J&!>?cFU?&iF&#{)TW>O@vdVuLa0d-hLW*5wSnd@ zpk5AbVH-YQ6B$SHF{BcQ=5(17$eumro9xf-jbdb9vB+)3I4jP(_T{>|U@S{BLJqJp zdn1)DaK;lC_|r6%}=0o8MS}=F3F#&>sl8Q;6s7~-}y@bCh-=o zL)VtwUtot-1HH&+dit3l{9?`)R(1zjNO63d)BbTj-bTl%9F6|8yj@P6zNvRi#c(Yt zBwBJArt3mq6TgtlEjx*C#B=V=w$+Y75G#_pWz}LL&cxT@j}g$i$1z?}Q}z5&#kScj zO81uhY%3?PWq8|wt&8mI-XtQVYYDr#v744(vQHB_N;vYvr zVM$UAgLR!Tc5+hZJs3kRl2=yVNLVx3WF{=yHft;yd^Sw`*7IgL5h)2HeQO65CHOEs zZ{@ezeqxkYo4U+#$gd)0(WlBfoz}nZ?g*-DOS7^LLpgBv+-%cu!;QIoo2u@A!)i!E zBCL5Ae_oY~y68gjcS5UdEo;b~YG!4n0f1 zkjc^L0VTwk&eJh=WnHi%1hT8;1%yHO`owo?l0iEe3i(*gC5eVB@5}kyVmD$I9rG#h zm&X`t-EzGWnbYPKhB3>0HuGVFq8B=mOkHsk76)~Ifx(&7$GNq_FEaY1VqkC=C-Sv( zFSR}?K1r2Us$1J^e9ioJRJrZKx7UF&yGrnS4iFc}_A zZngjKiaPYRvKtUIfv3$c1&(KuZx9U*zTTCfg<6@UoY&??E8z58v^+QD1b4P8gMh>v zx$S3}nEo}f&Fm(Z!U8@%icn_VlJ4%V4d0TLAg)z;`O_D>*W4&Su zSn1RqxCB{8j6eVgTPBWa!LWBP#=h@HK)r2jTNc+|I@$`gh;T8-oBp;%jzYi9UHmVw zyXyA01<8tnVbPBqN3&lJk~UMrLhHEhj3tBZT=w+m@Z2O8Sc(zp3Vg41@srKOiAtQ4 zMJzaAhBixr5_UcA~5Qodvxy>E2Ovxuu#pf z+3;I~2)c58iH~`B7Cwjc$Q=NRCcO8@+hAA9XKLzmSJpX3mj-VP_R1XNz{memvYu!WF1r`KdOWyl&}JTdOS@p3I*m!xE6$ z2jp&?j!I+|zq$Sp#o)G!37^h4m#h3D3Cp!s#IVxV@o+c#au7%CMV`rXxiRV(!g*`v zY%%3Ev}(PwGSzGZH*v`tjnAUTCD_I6%olH(@h%)n*^fpblGFTs?m}@meDDvc90~o* zj0nAzlSfpdS<%K`UYoXgdmKsOpiGEE0w1fHPq0%3Xk#v}>2k(4HlytQye2mcsq5(( zD227%QfY{fqJpW($0gv7noR^vK)z*mPUghgzco#!xrR~kX7+j7J@uVd+rHS>uPKgY|a%%=qJ zw`R->bLF*>2E@7Ml78QUhm{5h^G1PP1V$XzwMF`Xyd5Td^xImS*$d_yTz{FWS@sC} zHimsGd!FrH{FWBt_oI{BHE*E@xQrYmXTIljrNywv~@Rw5_Jhn?ewcYU>4+EDkZw3$c_jR2Bi zQ#1K*eC&AppCiv>dd}qaob~m|DBdS4n(#ahc1}%46P0M1l73I?ao)7Kdvr$QFq|>g z)Cr==V+9FtZ7_LU?mAL9Am>@E{{M$_s9!;T{S3s;?gxVq0lJQ5>B{jr0|jeyY71+* zRu`CunYgF!)gbT@&G*LyZudlWIO_NFa`W#;S!P1RN#rkBmS1trovi6;bcMTa4qAMh zT9!f4br?`V(R3idA)uikAi=@G{|V-cM#lOL6jdkaXTdFM{rp}3KBco-%%Jm#boUv^Vl{C^gP+Ld{4_$V$ec;qHEJqD@M?=vr6F~607 zd^38_(>_%H0s|U7$$eIQ&$Ic@hto3STiLMMLB*Z=E6{IQ$Tp8tDYI-^?YQFMe=hjD z#&4jXh7jFYJF>z4oD6o}JF|5jQ84HLg~m-f}y zTdQ-brd65C-sRt2-=ixk^9_5#3w7Q}aD5SMy;J@I^MCjP4P>WOI@3tv8_zHEMDQ0h zF{;5GsJZM>K>cz-#Zcm_^{)EVbSI8%phMxLa)%FCM`~RN2Cfi8Q!7LGRT1; zC4Ho73rJRFOPO9;k3R}|WlcXNIMfNQz0sw@#8i`@bG!8&Srjq2x$3$`yAK|A;Dk`U zs0;xnOo98y)d|yjDsj*do}z`6AWrlj~AF+JqGmL04kB=O0O zzIp1_&`$$4l`JqxX@6~6e?Qjfuy9(@uD;;j76<3}Kr@|gS#mx%_|8C2ZC$qr+pOc# z5LxC^MEnIzkPg1#NoCqf>+SETJ>Q6jJ@Lvn4#SlNw?n1PcTaMCL ze@$UtJnkO#<_jl-2^h)$N|KjYwz=S-)r6jAVaw!aE_>mx@9G)L*Y(L$k_kxOu71Eo z>kI!a$ftC)+hBX3KI!;jDb5DWRWVP-w#OHYmoCyiAO7tlIl}CH{KstNXva$%R`iKe zM0K;M>?5Qn#7V|1I2P>&fvKLBIBi9U9`QvBxzw&$f+nfT^ZO#Qg<=XAa-YDq>z&s^mU4DsJQ{VolibQSQ=vigoZE3#%ssAHSf;6-HX z*kOadG+Vz!k9xBHw0_bC?(Z}Ctv(0?UX`{!)n0^?KW2~qw;9mN5u`9b5)OD(+67ho zFWG?PkKH3-t5>DSe_0q5)em7-m&zhqc7)M#9g8SJ*NRht&;(tSXX5Y zAfIHX)W5*~&~GX^eAetTPE9Oe^H_XSGhtl14eM5PtP0+b(X&n3M8BF5?nb812=4x_ z5i$d<9fhzIABx~Zue<|xzCD`Km={))^%6#Rv-%29{(d`0UtbK=9_@4EJiHyX_U zaU3;$L_38I+^ju-AO(W~wcE@YUvTyKoU|EG**Xch^a-ijNgcB6;LJPcRE+sK3Q4FA zv&wd1Wuvx3NSw-vL2YB`O-{2enzu3dnz_iB;tZ>+ZsuZ&meRpZ+aRdXJ)1VNf1@ns zV^OsnPMpxA1c-}9QYE#dC)7ueRj%vJC5=20)m zff>{bNxIeoVXc3Xlt6nv5UnO)OC3LR$ z7k?Y=+fay+R?G*$@&Lvj^V(;(PObqjyzki`Gv7~y0sk)GN&gk6R0XOB(#vUFQnv&) zP8@q#!^AaqWUkmXmIV}qR&)5|GwAm>)_&$wQ+{UowF*l0+r7qt6)q1j=`La#oKy#}4s>Ey8KKcq<(j8PE%L2TUPOI(QI4bk^L=>29BHg|&$*p2r-{-2V}`@fUYy(ns^N8;q; z=25%bs}2jwHx6}^ak135Np^TJ+F#m|nt}knSb;hwyh9l^$gZCh7l;s}rE8X=Y*VlU z-X;PiobbEynhs0y`<==+dlq@%OK!$ORykIacYIKZJj^#Rbq-T1!kkdLw&RQF@$Y4! z({Pu=H0JkuBYskvei3Ttbf@aZ_5C1+G#~JC!8|I@HjT-q1HEW&kjbea_z!Jp8fdIG zTdTo8#*JPU>leAV!deY?LXaTX6ph9KU#9|1K^NG7%xTFgp94jZkRSbKF1KCVp0uL9 zE@O;wgHis=b%1Cr`_!7|FEFBC&lAv2Md+=6f#v=M#@Kks_cI4trG-hc4rQvO12Wp0 zxr_Q5$1HU4Ia?xq6T))GNqxePcB3~snD2)Yu1iAJ9#5Qu9=PtP;=DAq6RXQwu#Mh% ziIZN?M#(*E(h4gi9DwLg42UoihM~4`pf@bi^;-;ehsFSei%*W1zDjxg55p+32cgu>^6FM}lU^?kwC#qjTEG5y^by!YAcFA7vh zm$o*3a7mQo;z;D8Ps{ z|C;bGBauVskmOFB@)mUteG<7tzTPK20qT~2=9e|*J@YraKS1>gl_+#SoWEcDnMD^R zl0pWc3-m7hEAhz43i98+a%RAOVe)!KuKkDa{LP$OKvn-BD#IOKqlDg9Nja;u8|B%; z+`rMd-^_TpII`-JaH!aSO8!kpM+6ySDhzxQq7w$H&8vj6y3j$iRC$k!mM59txZy=m zO0^*d0$Kcp#Qse>Padxh!KbejWyPiH8r)}4RR{twyuMY6r6m@r*#Op~U*U*6Rb6^pWHEd8?WYFb z@(GWOmVbfG{(BhSXcUk88Co`mzih8TCvKvYbhT_mTQ2Y;vjdk+4SCN6~H!E?dg?vib;uBtuoc`|JP~ zoMI|A8!40`@v%mN+Z6PADAkCnn__IM;U~PQGoYUHn_q<1XQfr>^R8?iZ(&&T9H9$S zpXCJRuIb(EBF&FBB&<`jD$u3{QEk?ktpGwXBG&Q5K3r9p;9{O5W-T#Dhg`Y~`ouaC zFMH8sLV(;&$}jQOp>s=NQG^VBf^Rd}uVn@^s5GZ&mv2tU5MMTE2>*GFXH#!{o`Lo? zW^NK`0b6{Mx{p`P@M7wdCXC(}1VJI;t=g_s0+iLt){Gklgi}Np$wQj@|K**Ae{x9+ ze82^>GGNeJc$7N>>`|tqO6%RdM=cx)%e?civl?2h@FFm*cf*S%X-oMy1V{RF-heW*rOT-fA!GE)^;4SHx69M`ZqM~+h2n0vr^i`Re1OO3zY zl9~o9RCes>%6;7p(GjTGaB(k5*pP?l#w}p(D&=CLBcBqC^n^5~hC~{klecJ!{ z@6Vzq{YCWQ5VEyJomGz1hR}%t~HT>v>8aHKsk-pQKSDp43A>59K4JV{t3{w-%E8@ z`ybKUG9=MpX%?G!b-^<9TKzb#R0f4dPCV{OJw?$Q%+uCP$|(Du=+Hgo-L2fQ)3iLb zr<+EjOp|XWQKdfBK!2bDlG`W2RX6N~GGSEbw0hdi`xiemH3dI4b{9!gkyPUS!Ngu_ zV7zRctWppyVZlf(vFhB+plWscF}&Ve&o9)ZeDHxr1V+3?z#(4(2$jM~Seb6%9Hx9* z@8%EsQdx<625$*v);E!Wu!{Ku<+I_T-~VDcJwNDHOuKCsw?J3pZtG3Z1`9I8+VPE% z=jWzh%c$wP`-n`ryd-u+BUi_%afJHn4a*b?g8Oj46pKV#5E{Jr)<%)7bE&}=ie$+E zY{{ZvW#Vx$c+KT+L+@u88Sngx2xh7J%P>*9PLW$j$tQ61 zlpY<4NkBsGqaM%PCv&427C)OnZ2rhY`}d?bS0~O`29|s62cr>L(z3_CJ{#=M6jw76 zJ{c`Br$O%GBE$64KjZ6*vl+EYbMPJWUtkKKdZs+U@iWGf*(UH!7UUZ0%4vM-?~O{B zm7S9^ZQ6mcPO(!y18ef`f6i4!J$sZX&`x-zs)n&bwm~x^D5^`x-b-M99821Vzi#_h z2h5Ra;ZESqivBTy9p$Tge1%-#-_zZHeDIGY-JT`ww8P;0JGv`x_aHQLsnOOskst%{ zTd6{}CO1+mk-g5&3R6z1nh{-J%0tdl=Jsq`3)Iw^Wk+TYkh-W8BzDxN(=EJH;(Uz9 zX!FR8@WaYKQG0DKWy^IGMHxY*#;+UM>k>SpR%%Wnu}xfP46TtAYap#-#(NtS{j@;? z7h(J!TvMKHV0PZ3jy%#6naSkWQ={dyBvOs>FNx^syA!rPj&3dw(S86pjqmJ`aB}@@ z2~mLA1NfXbNjkMq-Vw@q01c%dG2oaa-NrAM)i8j0g7$X+jnzUF_!*mC2@_uRw=f)T zw7h&__D9%b)#>d;X79J82VvFkR_97nUtE2@Tb(LReaO7#Re)+xA9K{giu@Vq39Ex@ zvuyq!-M|4M9R0&5Yo`xDA=&?4OZ3F9i3w7-WZR8nw*v)=?GytPi-J)JYhD;6^SGlKVXz0$x^KO}rzuEp66(%%ccsgz z%5fHHxnrohmiv)@MG~`i_!SB`Y^1FcJ6jLC`@j?utb(ZA6^|xe8`5iGO!qg?{){ ziKAsR6p8B(lWA)1zQgazU_JLS$5(>X&)oKa$JZ_$KlF|eM4Vk%0xq9VV{-W+31#$g zOR_pB`x?0=kqN%^hRhYRuhzwM87ASdH}Kg>410)Q_1K0%0vht73*Dfu^!29zG9u7( zs4<=Ig|d~#-USVxWmm9ZISiuH(>19E4#!~E=6gJtwiT1VS^cl%Yjpue^J+aDI+Kb8 zYw=)G(`$Mka#I9cef;Hsbrj^D5J~RGzlG1YUhe3>z{(nhPda+vuRtrMfXWB2Ud2o6 zAD&-*AA2&kXO2A^_W?(swEwSEf5T7w@V}l&juWNzN{1w~g}?Dpo9%cAp8Rtf`1?|3 z>+u=gowId);>q8>J#+2Z*!9M=bv5(Pp}2GfIwb7&>c{(I)(4}?rA5Cpn=d7Ha=I$6cCBSoj zh-sxr%~++3GVkrr4g+qL^#!E5+Z*@0KLS~n2FUQ>Lt0mVt5~DSaw#X@*NLX&3uZ`w zTpA2o<}|E}|Et6=8Tc+eTS-#8G zWL$s~y98F$n*+OcdaOKsslS@62lL@+6NjB-j*lG)=*Nv&3YtxEED? zF%z{ZEeR%?=?KVc&0yW`mT~RoqHT8)W4R)FVB;nZMJyr%;GN&fV<5}RP3l$2b9Gm< zcyuRE#m63NePn<<^=DNgt_F?Qnf{<0=8ivpIpEgwAx?p6omsIq@lCNFuL<5h8{Pf$ zMUO^BK>P=ng7v|90Y!zCX5|$_7hSgBc|V7ki8y_uE0cMLB)y*mgXQ(L`($D(8;P09|`n(;Kl3L?1$WS4^yU3MFq)@kD%{S(|4hA;d1L>Q1%wv&o3O4 zD&)OS9zvO)UT=a6(EQSC#|YTygnSLNFL$j;8vX^vrX%}K^hX=Ruwp#N?6d(=rw>o< zo=k{evmA?Z5Sii#F6ib`u8^yw0f;HmEG6>Zg#ev&(=OxVbIbhWBhy*o3Xt0SkHfzh zRlRyD6HnMM0;}!jhQJilSt-MWku*iDyb4)nPgh|%ohjMR{v8NaAo8N7l``Ja+#yn4 zx47y3v5CYvcZPXlr5Hx)1@{Tkpe4S>-oAc6La0Rxjtb1X{IKfg&UqO-)UzFqpRA!@ zt%lDXhF@(pC$@66kq-DEo5RR#c>FXoGV?* zl^dhd+cZ?)8wiRBO!=#~Rfg6O1<5wjbt4Uzu)w|jEsSPUE1uCzwwi`4ypEaMuN1GJ zXk&OJW!rH4QYiK>4DZYwj@IL?V!s)NV?lf-v4ER&#e4|;1@;$M?BBYlza=fcpYi3< ztoS`2M_y2t9D)&}~rJq~5;}Bi} zzVPhG90`GZ8x{zD=A}tBd%@U<5B28*y5(k5(}|O=eL++{B6sR5l?`t!&PjM{1$)_Y z{mr=7BM*#4(E11W)FI=Ex6ED*w>kiGxr9B+W%rD83UCc~noZGx^8VFi4N`VbtS+X> zCWIygZOH-Xx={_>xh7fNs@6@W>I3Km@UZ%cnwi_xY9bFavn);1Fk4!jp|9!cNS3M0 z(sJDCR`TfptW-j;kOk^=G!EUt9Y`>D<}^bB*-Kn)zJJb0lbnvT?2yn8&hR16b%LWs;rN3Ogl&4 zE`L}>=(RJN&3hpHmDujVP%&e-_&n8Dr8s?Xi!#zFN z9eN9wkI8!S@0vl{q%jSe$$Zidz9g98%@y*O`wB^H47#{$j}aK2!HwR;TqUruXutV9 zC0Y$lhvtO}!J>8XglwCDA5THeCrbS+qY}RB$zH+q0Js9;SQ&)67fmy>5x$JoA*&?> zjwb^iSy?=qi%4U*QRUs@9k52Qdb-XZM{(V1ka@aJcE2_vc;H7vwjw`?Xnx}kR=Ax27zF#!nCa*vKuZ$Q6#5C^53*p zHaxvb$SS5Q#NxAswfS4xKpUaW^eqV&mde()rzEHGu~DbZO{nD?6G_r`R7+bbMDsAb z$B4LX%rB=~yg>UPbQ?IgO++g96&ap=OB;`wM}^^pu6k85bde>NXw=sZpU&VYh~u|f zA#5ZTdRHevt(@~>s)8WRa|esCyD|u(;GCYn`v1#pZ1`8*do-<$MZAjZWNHI`r%(|H zJm%EYXIX<_v1q3Aod^<<8UH@UHp&A_3uR$p6i$2M@YPhq*m(t>Z?~{;45|V>qnq1& z>6PghTIq;$AV$C*N(BV!y^EoTFi@+a!<3%QPVB3#q0IK$hS7HpG(ob&jZvzJz1cm7 zCZV!}Ii|khW{U9}d6Pg$71QMOBK$&aR~4bEe9PD=SCYz(3`w0fUGW?xy4IlaV?VS; za&BZIja-63g(@HCYgDCs)52BkR8(+c>RDu1lb=>B7P`FLo^se(2q1<5#rP69dSeFR zi9d@G8AZO^kfL?Y`$&_+wEzX+ng|}1ft5(9>z1Qs&-p@+XqD(JH*8O1xOUO z=U^U{^bMQypF+YJJ4)LQfL$Ic1BB~HcD!>PW1a(qrn|M4m8<6ff;$DwC-0JCLtShH zjV1V^M3(E$^{^iFk_J^6KKFKMGwFiHPDgA5ReFp){SX~oDa=@>YDda?LfYbpxx~&) zte#^76IDorKW4p_yqfYT@u7Y6dcXT!7qkb7op6CgaX5k6Tqg>l&ILK9%vqTyWLupi z<%a5T&iVi2a(&f}98CXV(cGs;lF_34t*RsHg)^DOA%zfu$g#|V*_#cac-D9mZ%ivw zW6#D*E^59VXkJK#O;65xTSLE!%P?+0yx5oJsmxTKjlc9u6BaQ zpvhxw0J9z8i{d6sjvyPRx^Nsp;ex?mP9e4o1zp83wuLZxF=<#DGHZRQBNY=2&xShcL`+tj(*O% z8a{_9mmNxW+LqieX>H=xe2;gBqZ+HZbS+4I9>`DD-iD~4@_+`q)?&V4Fpn>R)~XGi z4|R8+qDob`1Z0rj>B}btnA?<4MMq$ufvV=w0qu&x-q7!niB7L8)Mf}LTFCnU&qDte zFeK?w-@x&XVYpsutyn_Pf*=$-KH*`v7d4-V>S6%+9l*aC8WEN<>E2LzsBxA4MZ2Og zQ4f8X{t&IKhV|eKy7-0kcr>4(pGmxsIl+&P>ifv%B330PsZNbY>*$Uk+*z-OP+i?o zGV1IpL6T#h*rCzrb4&^x!XGk_3EY%JS6aIm%t_g`BinRVhBva(_IM|lxJt-8gDHTU zbKMer`GX<--AuCaiqmvzgO|(`)ZU>Y_kMF@@J^o(M}0jm^f|6G0SR`n7n9k|azFoq zb_a&C=`!FoDi(-cJp{*qD+1}>A;v_z4Rc|eUSL%UvX=d?ci6DgkO`+rKM8uIb27^&FGe8$>$q@|2rL7u?+= zQQPJ{y<>NC%ki}OI5vNiXz|9B(!Dc|$|L+^Dv9;QMS0>F`lu1<3b@i3R*Q9R?^RyY z?^3RN#7B;;f?ezRZU(DW2Ib;Ut75NJ*H~#J;Jt6^&zPZuZ)BDupa15h5pU*h=gwLF z!qYumYwg^KIe2~_ej`{P*p@9KauIwAWIuEqax8S(Chk~Wy!pmuHvdD@9Ne3I;gq!Z zD+Deg16 zGZDf*{L9zTY{vvKx*Q#@lk3_f77wp)?+DgKriMR^3-RnEa#JZ2sLQ zOSrQ=Fih}w^Bv=~Tb$v+NDY;)`o&-UvU0#FVil!9b$>0lQM?3x?3=6u0IL_J!$H~! z*?dOzsQH-NX^iI3;-Y=Cc7D`$9x#5czVU;#5%p`7{C`}9&40NH*&fDE%g-#hhpbA{ zs;4FHN7Z_r3esoU+++|6%V2fnvU*IbJl$0NP-UvsbSpCdy{znzbh8O#(S)`He^4h40A?>^btR?r zZJfN*2GyOQ_6_t@i4E%ATO5>z47nG<*uBZrON@}ZtCA72AwRc_w57mTc(Ql&tn^FH zC>sqFgJ_rR?O|o~BoDSmGupcuUD9p`i@st4fh?hVue7zsY}1F2`;-9E#mq6&&&05X zPTarGyn6+77_V&FC?<~IJ+(4L5#H-2OaJ}SN(0|j&J@b|LnMYFo*RCKePI!;9^O2? z2W>K?Tdq|RR}pFg<{qpbcIvK5J4CBJ^$!TN+D-0}F9G~vzJ$96N4;)^(M?_Rfwj7D zxa%#_eNMto0rh7%)h%yASZ5R81 zPo5QI3%wO0ukuI zfV003e5~KK~C0{x98nJ&bhmG5+vjO{q;c-;S3|R=k4QZp1%miA^qX+|NPMh{2{e zmpf*5*gqv6h=3mK;9w8w5rMSn|0f-gpq`qaeo*NiM1Kba^ramx_8- z>UC>ooBwTc(NWyk&2Qz0|DM0)xBNzbDLe36`Xm0Se2=XMr_6~$=ay#hL=@rl5q_Xx zhEvm1T#7o9AE|L8?nK#vGFN=iy9{#Ne(DWIRK4z+CJ z1lJVg`i{95^`1r=Z$6xlZRsfts{9F}`px+4RRXU2O>6odl9{VX`84q_@B5MV2Zj1Z zxT5ZbhL}qud6_$eyKW_`Anrqc=NsRk z>=fwg{d~-E{3cM_YJg6g$3Egp)l84sQ=Tc(C%7hN-+H{UyP6E-f9nnhb&6A$_gDXG zdB1_!JCFL^CDlwFy+>@Qi|i*yC{eYozT;?ZQnO@Ib}4GB!i5iII2O;G^y|v#%`*A)XJ!{%5M`U^;4?T z4naDv^lw7&`9QBhhv$OuFf!y#{o5vb?Yd~b?M)*W#EHndcGjP9Y7)YvMk_~l_a-RF z2@a_HC60uhvoCuspyIIqa*>z0nRQ7|!gRt1ue(1t7P>X5qimOt_Nn@QZK50pi}no8 z9LYsf?rAifgg}D4ljVC3yk**SIE#qY5md;g;9KE834Zff8LFT3SZ(Qo}~o*~FlZG%Jr4 zXtn}&z#a|3BI04Umm0Tm38>~AB*qFFV(2Bj8fWwb?Fd;?BGl$=6b24mN~e>t%?acN zM`aE30h23TKs=dWg=RV`97`2X6Or#r#G(Ek2!6U3(LS%DYuJm)cTH*TP7B z6`yyZ=0*K?H_|7+ymNM^Tt1_#@ykKu9K9wvHl3cR_Gh-&*k%I|tQotm3+k}txxlE^ z5PV7ykeNKR+uE_KkgXSFEbk}10do`g*UZ_U!ueb&haR-oQXF80MMss@Eq3H`GdmAY zH@RA=;B&)odii%lQ%t-XrxnTsFp&qf2{fHiw`ietsh62i$Uv&0#m^Qo9=c0 zc1*eXaE_q(8&ND)dJTkxaEFJ#__W0nSI+$WD?!Wc^% zr0b~}QUlp)+$l@p{flLS49*DpZ$+N~{L0kjRH`*)yNIw|tr}iox%Ms(qEozHd;!9jmA581@n3 zJ;(=?A0(exK7mw3dGr6;bLWN3P)98I`*%6vMecdY?N7O)hZLjoG5I+B%swXnH55)04%Mm42XG_2tGqA$ z_tCAax41{4_m{$b9^Fdt08pLt6a~gxD5sYk;!`#%ACkMX{{NdyJ|^Shw^VA$O%H|R zIX{Vy$Xld;rT$s^2jHvX`^QB-_kSO$AIE+j`nBrk|JTL*=h_sH-ld8D3()IxJAmEV~Bb!*x7`1*w6L>Q>IDif2o? z%p!P~inU;K58I`RF4v_e%$!Xba}`CF-c2ciz|`QLAPg7*MJ$t}8C8V%sj4kqLbqA= zb;e{r2V**O{|8wjWs%)O{@-Wp6V_{NT($I)4W$iVIA{jPF|{0j%(SCET_tQlL?C}f zWdAaLGpv22P6=0+tXe5#N*?SI>oRJmPC!&vT?NJ5xiM1O94^wFy!C$-9;zz<)54p( zcl!H3)4x?~I;D7}SdqE4nSQ74BK{kgs2j3h?2A$}ukPjg?EahQxBA;Nzj1b(;x-Y# z_$0qaey!|EDWbO0SG4TQF8S@U zo5fel&*uOBL#ly${`n>si=XR{G}~R5eEdHk=-)jnhu^-541WJV^7;Pgr@Q|d`Tl#f zr$2l{pyK^krjdu}<8sCsdz3jLfStqnLN#&iW1OgIpgGU;^GMvU>HMDc%kwkl?}hTa z^2@p&gBE|sz_PgVYep2OUHU@&1{vOSGM(wUj@qTx5_dw$42@&W$;dmsP~w*2Q`AWz+pO;a;`aiqzf5yh>9zn%E!f_9%B9%#=J zDu4X)Aqq$~PE-+m33nrW+y&IC$hvRE2f6W1Cr{wnI$}MK=uJAugZJ#doBjcQW<;D+ zjHFZ(x~~WxE#)OG0JI?Q*F8lfMs@yM7ECrayMp2Z3PE5vVGD9U(Ll&-zD~~hiTGy{ zo8k_#1gcv!n$gc?M}q`0X)GhwtES=cg-Y7N%raY(OQLJj<^wLl4woI}aDf~u!$NLSzr-?c=lKW)X!#_% z=uIwOW3*2<0!{6zk>z2K66S6fY-y$R4>b0UtyhX^HKx_>0(h9hyf4(Xsx%7D6}F3^ z3+`bykun@F6%-(s^C134{&tdIPni1Q!AAm1>%d0Zpt7c{K2CurY9|;|;{RaetzWcyIL8W1z zph!uIJVH~SMD>1AzL-GKS7M`g$qMqf^s9Nd?}Xp5*%j8vY!{HY$E(#!^>i9dPLCzP z=V9o&jp%!>QB?qFYOsx+ROU5o_c0m1DzGw@TBYKe$W2Ygttts4%adVLd+Y96@m+o{h>Ha)}X}JUi0C1{1ZKhY7 zQ;*W)2l+v@R6?a%_yuP*0)T>NbaYKMu&Y z<51t)jgjlfj45!;8ep!Qq%7&8nlHx<3pd|R%O(@FoA97csy7%;whgf;kK%0~To z5>uAm7I?oL0hej$>O1{5DZ~!6tHTKfH$X~;KWrZ~U;ub&-;PXzR^=_{gigFg zZGPu{C;aRvIZ*x}Rz`eDOstIRPi5LXGyiC=E5z-+%TRVK=}CQ8-yvJUnj1Tx#Nub4kERGn;q+ zN{o=y*jOKSs-DwMmQ^L_>0=5pylldKL#CJ(rgd5IK&doSin=HTf&#Mdzf&%3k=SYuj^*WpW)X5yx90x zr4N{o5fZ*rDmFHYhTWgS!H6cSX-kc$X^+uc%*uD zVM@nXz&#P}n<5f=YIgxl!AGXy`<4pQmIk3;I$=CS%UmIqJaN8Fbo5^ zBKY$IhGhNk`StJR)%{i=|)|Xet#l z-hi25rLPujGw3;GAN8$V$A=XBo|y4k(`BI12yOa{7iu~=Ptsy8HES-Hx(Di&SU1{@ zP_y~(^O&nl!Uw(tHd1!#okTm5n2}khw)IzozVPHfc|FkOUH**im3if?2?9E)Y}Wo3 zS&6m$*&3dOc9c55L&{Np+SO@PotUID=;SfRsBvLDkzXe-hHvHGcCksk^fP2$?m(>! z!MQWYNKth~dxEl4t`lvlpVj;=%GT3vOscJkYZ$LZu+7k$!s_%I@G1$abE*wdVoDj> zP9J)d$P^kNuBY2eOD7hU`}D7qlqsWr&85KFJXBc!ZWNa$zShkjh!1{*waw6^eoALA ze6I1ICmeaiCVLUg-l>XBrVSKKuO*H}*VJSOQLWqHN(9c!*q&3%+!`lxws~rVwp-w) zbaHJh&Z}|t*(z`{A2%kwykW5Q(s2&bxJvEK!1W}#cmWy$BQL%S`*8P08d{DTL^FZE z-hto%l0n^5L{p71W3XjO(`L!wFl>*OgS}e`@Z7#+2?n`V>Mbk=S{bnhZ8`qI>Nlq! z^O=9ZTM*Jjv#K2sM@ZKrZ69^_6LFqW(HX^2y-n&4cq`^RKHmDA7X}mWlYiM(&ihM%W26L+JvOs!w5O0+agH8FjGhK>m)uoLoLU1k`cj7>Jak~%BO^B8m+MfywLo)P32Nh z?~o#%4LbMZqBAe_HAK|kamL>pL~C3l{<3mlefSwl9XCdf8C8HT_amwNIIelHs-@=_;*V?ZI6-QZfUVnHI7QmK&Dv!gM~7 zP`9fYZW8pAba_@EqBgW<*F%)bPI*_V{?o)bK^u3Mb7=y{f~zX!37@?3JyM}Zm_SeS zGJ?K#MFI3hx4-dG#Dm6^5mxyZ9F~j-v+y_kvS^s6D4QzIfbBt0S{e zkkn=;n|)9u)XBUdOEr8Yg!86Ib=W(^a@kRhbl+@G0px1WaJL)Ua>Xseg+mQzgLGVh zW5-m-94~=ui+^N(v*t}vJkpp*ujjU~pPXm(TPdaLq#r0#luhR|Ek!k`Gk+%4@0+Pi zcV%=ePzF%cV8^Vfxize4hPW@#y`-U|+I0OMYwiK9x^U_8`&nl&n80hQQ2Fa;LH=|i zTGRxfTJ!Q8a~oA#lMir91`0D@9oOSMwJsaY)5hdRfSbL2v{tJj(PCPMMyTh2EGM5M zytNv0(p?L7C`PrkTkUutb9D~J@}?6TB|Dz zQ;zsuwZihBK#3IocTUO`k&w7ol!6Pl^t<17-+?Mh72(qg1+Hs+7zLwHy>K|`)^nJC z?`C_e#3a;u;e2p&J(vmR&I zVbE_N{-Y-YRjSfMQTM{MSdF;J&Pel;S36{LQ*)^u$ac$Wef0 zhyKhM@1z3&;n8r;4vx515U@ZkI{YrZOfP{2EjVZSD*q#7Y3%9h_!=1~T{A`!1Ja%< zwBt;@<*c$85lCmaijEFsMfD2vu67gQu8HTWvjck_I@R|~lxc=v8i+zKd8e;lV=ZXe z=WOx{c)E3j^BOu=CXIQcntMz;Ajr^xv?vHc1dY+V*B-I&Mzn7C;}%*};~in|h+PGS zc>OvZ79O@n&+sa!0b)4oPQqC8<{`Lq6}%cI(*SBEES9BfiA^TwW&pwi#GsVFsjSNT z(-3v1U`U0~H;P@L!iNnjn=Z$(`PStf)v^NRURYTBWtza zH^p_#fg{Sx2`BGSz+;myxvHWO z2I}7utFUxA^p{F98M)i_43~Yv!8NmZ|Lvc_kg?4BBvvI%m){|gX@W9d4y^m#86{L< zsa`tbPA}R_{!)9IH6OH1|nh3r*u>7-4^j| z9B7*9l6*El&^0@nIH8=~l#GB_YFimTI&{uvryy$H?J8m%=P3I4vpUSosq_S+7}`S4 zPl%qqVP8RMp}z#Zm`hM{9U9qHZ=8;HhDC=Z!CxT3j6zJ9`t4W0;T-n z=3vavd=*(l3fJ^Uj8>XEm`TTDl-iUtoOGp>Dxg~9W|8dmgd#WQO={%(SS@GtVpGVD5Dj!Zuq#(b9%&lbL<47a~ zuqz$DsBK>BX4eTm-_bi10 z402vWoKFXK)@7P5NXl}*BNOy<=FM4RcDr?rcvUw|L8V5&7HmA)Gmx50>acJ18q1EX zBkd3HM}h+*>a4?U-G}`vQ|i`bV*UXk%R7E0Po}$Cvhb)sc=Bg4NM(_N1?7TWJ(l4W zQ^Y5E;*I!~Vms|2V{)JLduvkGF!blekW??y!zd>PW{C;>MkQ}i37l06Jyk~9(eB^` zZ9pd(ZXRNAiB7Myl;uegY~$&-j*h zy7A|XC&MYycS9FM+RBVSw2jC2?VTokd-kMCH!v)4smh|&D_O1#9^CqaQ5bP-?+eZF z0e?$NSw>zy(N;19RDRt)3Co{K#on#Hry33&&$9~8iI;@PB{NO zC>Sn-ZKWmI6S|Eo?!JqH<-^t=eI=fL0)AZ6Yo3~~5;0M6g#3_;w|>eVpGMtftR+=! zRuBG2hap}m@SvVbXTY=;g+0FI^hUx8sIXCKrNkR6k-5HfQOM6eF^uJFKu$Xph`ofmG!+T>E6qYBr#(Kl z9e}ls4-CivcevI}MUJe^b6`aOb)pc)b_R?m3fnmKO zS9rap1)|c@WIADg6PRl5}butC0PVwvr-ako)ESG4Q)Y^VZ%%ewZ2R!E@#zvu@E|I17bL@&jfp9y^jZvzO3c_*;&_ zIvnR@yqINJ&8Y1Xpkt*6CrHX{HcLdUHK3@`{e$|KUNdRz*y|FCeGZ?`@nzNMfg#k7 zUc69`EZPd4?C@wq&SZ7&a_fWxi$+k(%?0!!DV~OjwnubAsJVF$px!*}kNL}EF7^Eq zU$n4oXweM09Xsy&hQlUAYCXa}hF&LeBsa-z0wfI~3%U_rufr8uVokQWl#(&oH)1VH zTeokS&nAVbPnKuIc_n10$ASUEdd_XwtVMTJmQ+v3co2%Q4BOZ_D#SFnZ!H5lW&m3&~eu6mbs87-L911__Xa)pHUpeY7> zrONT+GucgysHdoHtL=B=;!O-a0t3r#;!8>kbzPP)wZPZ_kbBlZL=2IGyZLk6AAoDN z5IM_4L-Sa1;AqUYbSnPPGInf0b4(<_v+84B;MOt$i6<3AB_%#I>G0VbkQN!PkC5FKL~p1_K`(5md7;pe%-Mrw{#z zyD{FIWP}E}S{WbX$}t~hJQzvOr*>V@E{R`D5!ye%v zL9Jb5MWqiZ(cGl5tEt*_J?csjD6mnXv0MVj|E%s7&P?O<2Bt8sM>GU1CUQ$ zAXLOBKuu4fCu@$ysU?p`es+1Y%^p;`jQ|Uz3+39~0O4oV>ReNwo+P|#F?8VP?~*eO-Z|)$=jRfX>Vn+e*Q22K3(3bAVx#qA@U;|5X3a1)iZskhDG-Skoln%J zR-!57I1b*sr0R}SqPr%$_y&mj{%e!0j^;O78pB}c0pG8)z<8KY2yCuV`%|=D?4jTv zE8kQ!(jVWw@PaDeuWt(($ltPvdciW0I+i5vOxY1=_`bnC2fG8J84z?fBs$}{MOU>S zhUd|<7Ld~zM`rix?cc!z5kc@_j_Ra%1#e_}+%EBZ%Zyxz0_?L12X;tlMO+mY#RP1x z($TnhRqIpk-eZc6Ob)z)t%Uee;>I!<%rcOlp^E!rn0ZafmDqyjFBzUOxj}4g)@@a~ z)$U!aRz`_!DaEWL+) z)SVw=s(wnrEAc%T<#^*J9Or0Dll=sfg{uACJu!$f^*CPLJ!BzsHGY3#{z8N5L;H>a zjWZKc7*_|UV2iNkEAM}*h%D@^-Dl9|;~o+X&n|m3{&Z-bZ+oN;Zb1(0=+ics3axz3 zc|&uv>4jpMJhWb-HNN5`_NcR2#<8ix(GmkiTdPbdaCcR$S~-?LHyOLn&2K%_`L1)e zQOSoVMm&iho<437`n*8rLufEPe8jZ6LcB=B`YNAab4jml5ck4t37 zV3IDg_>1h?jV^`P4E ztObHBIf>K!j%*4Vz&dhGq5UCLi)fC*LVt&o5a;7}MBFlG4c^iEU%tcIKGeI)RbcHV z#a?Ya)|h(_-_0|4D@^%A8HOTliAg^X&)5E%5|`>RzesTV9eGW^RBwBRo>b_=H_$79jEo~z zeI~XAZVsb1GAgdJ0!{=*EMjXj~%ALSiydm!~0jFI*iS zHL&}mg{s>o$^)NLI!*N}nr^R+gXgcNd=I`jipoX=sHJOdaT3Om$3`He7H!JbwA@mg zm{R9yyoI2y!=)a0(u1`)^@z6inJGISfoV10$=FtF$m%7dX|#)fCkrhj8UzVQ2G%6igJ1nu+#sI3SE(#q^d{#VBS#XdoKxwk8&B%K2 zZb6X9-lgusJ+3;^OLRx<=auPg=~a{*2F`fvk8XJ;wXkiS#lC3 zGSOnH`&Vxt-eQa;8_w_-97&xtaI|;Sh>Z%m6HuW!({Fj*&8 z`v%QM>#Hk0#)@i4E!Y;FGnj7A(vMjq=eMlbeLlUb$C~C;#WUlTzxLh%<_Hh@c=(&y z9Ww_%77+Uv7RnRerv@Wv{O0{z@#Q`EXz;o8sk83|;Vnn@@TdP{`WeM-yCtqY=McFb zfGv(D_m-(qb15t~uTyAKiZ+P+sESIputSUalnSxLyv%9+SvY2UfaUAxD0|s6*Bsb0 zq_7@i5XYNrmsBjClILBg4a+{Ge?S~-KzxuiPOzS3t?;wkCkKZOBwM3W#$QydQnEo~ zAi4C5u(p+-2Q_R^WFy*&)}%R~0tbn3mh;(`a?$SOnPa9F_%yO$4F@EgFOk9w167`f z8?3uT>ZgC&@ReGGFcJR77h#lxv`N)DzsASuyoanUUTwDClI%RrMB2>0v>|E5&^U^e z)2c&YxH7FDv!}I|d5R>19)^=xY6E?6^?Eg~9BP+JrLhQB=SD!rjdj(4dwBkw>0Rqi z8B>kck+eG;g)WmTWX#LFg}>qrU4dIcO4|ftjVl$N@@YQ$YQCVuWdJIqRS_AHew?NI zmwuJ5g%J9Zs`NL_gK#EYQVCMj@eY6NF!lLEi}uaME0*O&r){&UZ>9%)sq{?DoXuULWuWu_dJ(@{IYFm3FM zCY|T->eqI(_IvmCN5OIq`AS&@N5;6v1)niBX33~Le#Xk%oPzuNmo%x^Q`bZtkEl;= z=N0`zFsY$I?oCJfKs%{Pw~ie9NCE}jw)S@`F1T^ZmM^$N{x9YK#P~pdPOpJ!lyZ%| zOtKP)O6Agf`jIzqCSf;+bPTeUM{1|s?@mATIhg{Ad-=_kxCu)PgBo}Y<3{QE^`g?F^F_ue$BiA5$#KP!MQBbat``PHPTx08p=mjIClLy)U&Y=Qs z8fcVdp^xEKjMX?$kVj0%j!iV&!}T6C(6b5*yckS@Q5oYOq(^=ReXk!h5uTgP!IQPilk0UZL?L^cyX=o!8;ZVelxT!mLR@- z2^mad^__fRuS!AhXanpZc3O-W!=hy~KOqWC(0Vi@3r{c0-)27DAnQ75y5i3k7A5P9 zI$z;5eA4vz2LugO_(s48Wx5teWLJLoybk+lxN}Ou-Q7q$sDLPfU_YcAD$e7UX`Zp! zHQ0EAvlqdb|4}HE`llwL(g@1|Q0g~ukp1!Ag_8lLyV-}`C&*+i8 z$v>c?-G?LMq*rm{jwGrzt;Nm{-G}{d6~N9M{XDtLRQLN#Jl3g%tc%tG9@;j~M+yzi zJ0&L!lX@G)GwX*@gSd?DjV1rAq*oQqotGmA3mU9qsxR0=Mbk)vlxqpY$M>n-H=h8S zOEH$hwRQrN1E5nNYEJ_)6d_~YlA<>a;Vrk7BK%0AP06oieLiIg(dEO|95MmZZsBtG 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%0ALum_G;a+POBAiP2mrAlKez>>?tI4#$^#c7c+< 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 this.STALE_THRESHOLD; + } + + private createEmptyResponse(id: string): CoverArtResponse { + return { images: [], release: `/release/${id}` }; + } + + private createCachedResponse(url: string, id: string): CoverArtResponse { + return { + images: [ + { + approved: true, + front: true, + id: 0, + thumbnails: { 250: url }, + }, + ], + release: `/release/${id}`, + }; + } + + public async getCoverArtFromCache( + id: string + ): Promise { + try { + const metadata = await getRepository(MetadataAlbum).findOne({ + where: { mbAlbumId: id }, + select: ['caaUrl'], + }); + return metadata?.caaUrl; + } catch (error) { + logger.error('Failed to fetch cover art from cache', { + label: 'CoverArtArchive', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } + } + public async getCoverArt(id: string): Promise { + try { + const metadata = await getRepository(MetadataAlbum).findOne({ + where: { mbAlbumId: id }, + select: ['caaUrl', 'updatedAt'], + }); + + if (metadata?.caaUrl) { + return this.createCachedResponse(metadata.caaUrl, id); + } + + if (metadata && !this.isMetadataStale(metadata)) { + return this.createEmptyResponse(id); + } + + return await this.fetchCoverArt(id); + } catch (error) { + logger.error('Failed to get cover art', { + label: 'CoverArtArchive', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return this.createEmptyResponse(id); + } + } + + private async fetchCoverArt(id: string): Promise { try { const data = await this.get( `/release-group/${id}`, undefined, - 43200 + this.CACHE_TTL ); + + const releaseMBID = data.release.split('/').pop(); + + data.images = data.images.map((image) => { + const fullUrl = `https://archive.org/download/mbid-${releaseMBID}/mbid-${releaseMBID}-${image.id}_thumb250.jpg`; + + if (image.front) { + getRepository(MetadataAlbum) + .upsert( + { mbAlbumId: id, caaUrl: fullUrl }, + { conflictPaths: ['mbAlbumId'] } + ) + .catch((e) => { + logger.error('Failed to save album metadata', { + label: 'CoverArtArchive', + error: e instanceof Error ? e.message : 'Unknown error', + }); + }); + } + + return { + approved: image.approved, + front: image.front, + id: image.id, + thumbnails: { 250: fullUrl }, + }; + }); + return data; - } catch (e) { - throw new Error( - `[CoverArtArchive] Failed to fetch cover art: ${e.message}` + } catch (error) { + await getRepository(MetadataAlbum).upsert( + { mbAlbumId: id, caaUrl: null }, + { conflictPaths: ['mbAlbumId'] } ); + return this.createEmptyResponse(id); } } + + public async batchGetCoverArt( + ids: string[] + ): Promise> { + if (!ids.length) return {}; + + const metadataRepository = getRepository(MetadataAlbum); + const existingMetadata = await metadataRepository.find({ + where: { mbAlbumId: In(ids) }, + select: ['mbAlbumId', 'caaUrl', 'updatedAt'], + }); + + const results: Record = {}; + const idsToFetch: string[] = []; + + ids.forEach((id) => { + const metadata = existingMetadata.find((m) => m.mbAlbumId === id); + + if (metadata?.caaUrl) { + results[id] = metadata.caaUrl; + } else if (metadata && !this.isMetadataStale(metadata)) { + results[id] = null; + } else { + idsToFetch.push(id); + } + }); + + if (idsToFetch.length > 0) { + const batchPromises = idsToFetch.map((id) => + this.fetchCoverArt(id) + .then((response) => { + const frontImage = response.images.find((img) => img.front); + results[id] = frontImage?.thumbnails?.[250] || null; + return true; + }) + .catch(() => { + results[id] = null; + return false; + }) + ); + + await Promise.all(batchPromises); + } + + return results; + } } export default CoverArtArchive; diff --git a/server/api/coverartarchive/interfaces.ts b/server/api/coverartarchive/interfaces.ts index 133a948b..28a11a22 100644 --- a/server/api/coverartarchive/interfaces.ts +++ b/server/api/coverartarchive/interfaces.ts @@ -1,21 +1,12 @@ interface CoverArtThumbnails { - 1200: string; 250: string; - 500: string; - large: string; - small: string; } interface CoverArtImage { approved: boolean; - back: boolean; - comment: string; - edit: number; front: boolean; id: number; - image: string; thumbnails: CoverArtThumbnails; - types: string[]; } export interface CoverArtResponse { diff --git a/server/api/listenbrainz/index.ts b/server/api/listenbrainz/index.ts index 34d475ab..af1a0383 100644 --- a/server/api/listenbrainz/index.ts +++ b/server/api/listenbrainz/index.ts @@ -1,8 +1,11 @@ import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import type { - LbSimilarArtistResponse, + LbAlbumDetails, + LbArtistDetails, + LbFreshReleasesResponse, LbTopAlbumsResponse, + LbTopArtistsResponse, } from './interfaces'; class ListenBrainzAPI extends ExternalAPI { @@ -13,48 +16,60 @@ class ListenBrainzAPI extends ExternalAPI { { nodeCache: cacheManager.getCache('listenbrainz').data, rateLimit: { - maxRPS: 50, + maxRPS: 25, id: 'listenbrainz', }, } ); } - public async getSimilarArtists( - artistMbid: string, - options: { - days?: number; - session?: number; - contribution?: number; - threshold?: number; - limit?: number; - skip?: number; - } = {} - ): Promise { - const { - days = 9000, - session = 300, - contribution = 5, - threshold = 15, - limit = 50, - skip = 30, - } = options; + public async getAlbum(mbid: string): Promise { + try { + return await this.getRolling( + `/album/${mbid}`, + {}, + 43200, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + 'https://listenbrainz.org' + ); + } catch (e) { + throw new Error( + `[ListenBrainz] Failed to fetch album details: ${e.message}` + ); + } + } - return this.getRolling( - '/similar-artists/json', - { - artist_mbids: artistMbid, - algorithm: `session_based_days_${days}_session_${session}_contribution_${contribution}_threshold_${threshold}_limit_${limit}_skip_${skip}`, - }, - 43200, - undefined, - 'https://labs.api.listenbrainz.org' - ); + public async getArtist(mbid: string): Promise { + try { + return await this.getRolling( + `/artist/${mbid}`, + {}, + 43200, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + 'https://listenbrainz.org' + ); + } catch (e) { + throw new Error( + `[ListenBrainz] Failed to fetch artist details: ${e.message}` + ); + } } public async getTopAlbums({ offset = 0, - range = 'week', + range = 'month', count = 20, }: { offset?: number; @@ -71,6 +86,49 @@ class ListenBrainzAPI extends ExternalAPI { 43200 ); } + + public async getTopArtists({ + offset = 0, + range = 'month', + count = 20, + }: { + offset?: number; + range?: string; + count?: number; + }): Promise { + return this.get( + '/stats/sitewide/artists', + { + offset: offset.toString(), + range, + count: count.toString(), + }, + 43200 + ); + } + + public async getFreshReleases({ + days = 7, + sort = 'release_date', + offset = 0, + count = 20, + }: { + days?: number; + sort?: string; + offset?: number; + count?: number; + } = {}): Promise { + return this.get( + '/explore/fresh-releases', + { + days: days.toString(), + sort, + offset: offset.toString(), + count: count.toString(), + }, + 43200 + ); + } } export default ListenBrainzAPI; diff --git a/server/api/listenbrainz/interfaces.ts b/server/api/listenbrainz/interfaces.ts index 607ddb41..9928ad9e 100644 --- a/server/api/listenbrainz/interfaces.ts +++ b/server/api/listenbrainz/interfaces.ts @@ -29,3 +29,215 @@ export interface LbTopAlbumsResponse { to_ts: number; }; } + +export interface LbArtist { + artist_credit_name: string; + artist_mbid: string; + join_phrase: string; +} + +export interface LbTrack { + artist_mbids: string[]; + artists: LbArtist[]; + length: number; + name: string; + position: number; + recording_mbid: string; + total_listen_count: number; + total_user_count: number; +} + +export interface LbMedium { + format: string; + name: string; + position: number; + tracks: LbTrack[]; +} + +export interface LbListener { + listen_count: number; + user_name: string; +} + +export interface LbListeningStats { + artist_mbids: string[]; + artist_name: string; + caa_id: number; + caa_release_mbid: string; + from_ts: number; + last_updated: number; + listeners: LbListener[]; + release_group_mbid: string; + release_group_name: string; + stats_range: string; + to_ts: number; + total_listen_count: number; + total_user_count: number; +} + +export interface LbAlbumDetails { + caa_id: number; + caa_release_mbid: string; + listening_stats: LbListeningStats; + mediums: LbMedium[]; + recordings_release_mbid: string; + release_group_mbid: string; + release_group_metadata: { + artist: { + artist_credit_id: number; + artists: { + area: string; + artist_mbid: string; + begin_year: number; + join_phrase: string; + name: string; + rels: { [key: string]: string }; + type: string; + }[]; + name: string; + }; + release: { + caa_id: number; + caa_release_mbid: string; + date: string; + name: string; + rels: any[]; + type: string; + }; + release_group: { + caa_id: number; + caa_release_mbid: string; + date: string; + name: string; + rels: any[]; + type: string; + }; + tag: { + artist: { + artist_mbid: string; + count: number; + tag: string; + }[]; + release_group: { + count: number; + genre_mbid: string; + tag: string; + }[]; + }; + }; + type: string; +} + +export interface LbArtistRels { + [key: string]: string; +} + +export interface LbArtistTag { + artist_mbid: string; + count: number; + tag: string; +} + +export interface LbArtistMetadata { + area: string; + artist_mbid: string; + begin_year: number; + mbid: string; + name: string; + rels: LbArtistRels; + tag: { + artist: LbArtistTag[]; + }; + type: string; +} + +export interface LbPopularRecording { + artist_mbids: string[]; + artist_name: string; + artists: LbArtist[]; + caa_id: number; + caa_release_mbid: string; + length: number; + recording_mbid: string; + recording_name: string; + release_color?: { + blue: number; + green: number; + red: number; + }; + release_mbid: string; + release_name: string; + total_listen_count: number; + total_user_count: number; +} + +export interface LbReleaseGroupExtended extends LbReleaseGroup { + artist_credit_name: string; + artists: LbArtist[]; + date: string; + mbid: string; + type: string; + name: string; + secondary_types?: string[]; + total_listen_count: number; +} + +export interface LbArtistDetails { + artist: LbArtistMetadata; + coverArt: string; + listeningStats: LbListeningStats; + popularRecordings: LbPopularRecording[]; + releaseGroups: LbReleaseGroupExtended[]; + similarArtists: { + artists: LbSimilarArtistResponse[]; + topRecordingColor: { + blue: number; + green: number; + red: number; + }; + topReleaseGroupColor: { + blue: number; + green: number; + red: number; + }; + }; +} + +export interface LbArtist { + artist_mbid: string; + artist_name: string; + listen_count: number; +} + +export interface LbTopArtistsResponse { + payload: { + count: number; + from_ts: number; + last_updated: number; + offset: number; + range: string; + artists: LbArtist[]; + to_ts: number; + }; +} + +export interface LbRelease { + artist_credit_name: string; + artist_mbids: string[]; + caa_id: number; + caa_release_mbid: string; + listen_count: number; + release_date: string; + release_group_mbid: string; + release_group_primary_type: string; + release_group_secondary_type: string; + release_mbid: string; + release_name: string; + release_tags: string[]; +} + +export interface LbFreshReleasesResponse { + payload: { + releases: LbRelease[]; + }; +} diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts index 75f1e5fd..6271383c 100644 --- a/server/api/musicbrainz/index.ts +++ b/server/api/musicbrainz/index.ts @@ -1,274 +1,164 @@ import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import DOMPurify from 'dompurify'; -import type { - MbAlbumDetails, - MbArtistDetails, - MbLink, - MbSearchMultiResponse, -} from './interfaces'; +import { JSDOM } from 'jsdom'; +import type { MbAlbumDetails, MbArtistDetails } from './interfaces'; + +const window = new JSDOM('').window; +const purify = DOMPurify(window); class MusicBrainz extends ExternalAPI { constructor() { super( - 'https://api.lidarr.audio/api/v0.4', + 'https://musicbrainz.org/ws/2', {}, { + headers: { + 'User-Agent': + 'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr)', + Accept: 'application/json', + }, nodeCache: cacheManager.getCache('musicbrainz').data, rateLimit: { - maxRPS: 50, + maxRPS: 25, id: 'musicbrainz', }, } ); } - public searchMulti = async ({ + public async searchAlbum({ query, + limit = 30, + offset = 0, }: { query: string; - }): Promise => { + limit?: number; + offset?: number; + }): Promise { try { - const data = await this.get('/search', { - type: 'all', - query, - }); - - return data.filter( - (result) => !result.artist || result.artist.type === 'Group' - ); - } catch (e) { - return []; - } - }; - - public async searchArtist({ - query, - }: { - query: string; - }): Promise { - try { - const data = await this.get( - '/search', + const data = await this.get<{ + created: string; + count: number; + offset: number; + 'release-groups': MbAlbumDetails[]; + }>( + '/release-group', { - type: 'artist', query, + fmt: 'json', + limit: limit.toString(), + offset: offset.toString(), }, 43200 ); - return data; + return data['release-groups']; + } catch (e) { + throw new Error(`[MusicBrainz] Failed to search albums: ${e.message}`); + } + } + + public async searchArtist({ + query, + limit = 50, + offset = 0, + }: { + query: string; + limit?: number; + offset?: number; + }): Promise { + try { + const data = await this.get<{ + created: string; + count: number; + offset: number; + artists: MbArtistDetails[]; + }>( + '/artist', + { + query, + fmt: 'json', + limit: limit.toString(), + offset: offset.toString(), + }, + 43200 + ); + + return data.artists; } catch (e) { throw new Error(`[MusicBrainz] Failed to search artists: ${e.message}`); } } - public async getAlbum({ - albumId, + public async getArtistWikipediaExtract({ + artistMbid, + language = 'en', }: { - albumId?: string; - }): Promise { + artistMbid: string; + language?: string; + }): Promise<{ title: string; url: string; content: string } | null> { try { - const data = await this.get( - `/album/${albumId}`, - {}, - 43200 + const response = await fetch( + `https://musicbrainz.org/artist/${artistMbid}/wikipedia-extract`, + { + headers: { + Accept: 'application/json', + 'Accept-Language': language, + }, + } ); - return data; - } catch (e) { - throw new Error( - `[MusicBrainz] Failed to fetch album details: ${e.message}` - ); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + if (!data.wikipediaExtract || !data.wikipediaExtract.content) { + throw new Error('No Wikipedia extract found'); + } + + const cleanContent = purify.sanitize(data.wikipediaExtract.content, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + }); + + return { + title: data.wikipediaExtract.title, + url: data.wikipediaExtract.url, + content: cleanContent.trim(), + }; + } catch (error) { + throw new Error(`Error fetching Wikipedia extract: ${error.message}`); } } - public async getArtist({ - artistId, - }: { - artistId: string; - }): Promise { - try { - const artistData = await this.get( - `/artist/${artistId}`, - {}, - 43200 - ); - - return artistData; - } catch (e) { - throw new Error( - `[MusicBrainz] Failed to fetch artist details: ${e.message}` - ); - } - } - - private static requestQueue: Promise = Promise.resolve(); - private lastRequestTime = 0; - private readonly RATE_LIMIT_DELAY = 1100; - public async getReleaseGroup({ releaseId, }: { releaseId: string; }): Promise { try { - await MusicBrainz.requestQueue; - - MusicBrainz.requestQueue = (async () => { - const now = Date.now(); - const timeSinceLastRequest = now - this.lastRequestTime; - if (timeSinceLastRequest < this.RATE_LIMIT_DELAY) { - await new Promise((resolve) => - setTimeout(resolve, this.RATE_LIMIT_DELAY - timeSinceLastRequest) - ); - } - this.lastRequestTime = Date.now(); - })(); - - await MusicBrainz.requestQueue; - - const data = await this.getRolling( + const data = await this.get<{ + 'release-group': { + id: string; + }; + }>( `/release/${releaseId}`, { inc: 'release-groups', fmt: 'json', }, - 43200, - { - headers: { - 'User-Agent': - 'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr; hello@jellyseerr.com)', - Accept: 'application/json', - }, - }, - 'https://musicbrainz.org/ws/2' + 43200 ); - return data['release-group']?.id || null; + return data['release-group']?.id ?? null; } catch (e) { - if (e.message.includes('503')) { - await new Promise((resolve) => - setTimeout(resolve, this.RATE_LIMIT_DELAY * 2) - ); - - await MusicBrainz.requestQueue; - MusicBrainz.requestQueue = Promise.resolve(); - this.lastRequestTime = Date.now(); - - try { - return await this.getReleaseGroup({ releaseId }); - } catch (retryError) { - throw new Error( - `[MusicBrainz] Failed to fetch release group after retry: ${retryError.message}` - ); - } - } throw new Error( `[MusicBrainz] Failed to fetch release group: ${e.message}` ); } } - - public async getWikipediaExtract( - id: string, - language = 'en', - type: 'artist' | 'album' = 'album' - ): Promise { - try { - const data = - type === 'album' - ? await this.get(`/album/${id}`, { language }, 43200) - : await this.get( - `/artist/${id}`, - { language }, - 43200 - ); - - let targetLinks: MbLink[] | undefined; - if (type === 'album') { - const albumData = data as MbAlbumDetails; - const artistId = albumData.artists?.[0]?.id; - if (!artistId) return null; - - const artistData = await this.get( - `/artist/${artistId}`, - { language }, - 43200 - ); - targetLinks = artistData.links; - } else { - const artistData = data as MbArtistDetails; - targetLinks = artistData.links; - } - - const wikiLink = targetLinks?.find( - (l: MbLink) => l.type.toLowerCase() === 'wikidata' - )?.target; - - if (!wikiLink) return null; - - const wikiId = wikiLink.split('/').pop(); - if (!wikiId) return null; - - interface WikidataResponse { - entities: { - [key: string]: { - sitelinks?: { - [key: string]: { - title: string; - }; - }; - }; - }; - } - - interface WikipediaResponse { - query: { - pages: { - [key: string]: { - extract: string; - }; - }; - }; - } - - const wikiResponse = await fetch( - `https://www.wikidata.org/w/api.php?action=wbgetentities&props=sitelinks&ids=${wikiId}&format=json` - ); - const wikiData = (await wikiResponse.json()) as WikidataResponse; - - const wikipediaTitle = - wikiData.entities[wikiId]?.sitelinks?.[`${language}wiki`]?.title; - if (!wikipediaTitle) return null; - - const extractResponse = await fetch( - `https://${language}.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&titles=${encodeURIComponent( - wikipediaTitle - )}&format=json&origin=*` - ); - const extractData = (await extractResponse.json()) as WikipediaResponse; - const extract = Object.values(extractData.query.pages)[0]?.extract; - - if (!extract) return null; - - const decoded = DOMPurify.sanitize(extract, { - ALLOWED_TAGS: [], // Strip all HTML tags - ALLOWED_ATTR: [], // Strip all attributes - }) - .trim() - .replace(/\s+/g, ' ') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - - return decoded; - } catch (e) { - return null; - } - } } export default MusicBrainz; diff --git a/server/api/musicbrainz/interfaces.ts b/server/api/musicbrainz/interfaces.ts index 18049c96..aab0af2a 100644 --- a/server/api/musicbrainz/interfaces.ts +++ b/server/api/musicbrainz/interfaces.ts @@ -1,126 +1,119 @@ -interface MbMediaResult { +interface MbResult { id: string; score: number; } -export interface MbArtistResult extends MbMediaResult { - media_type: 'artist'; - artistname: string; - overview: string; - disambiguation: string; - type: 'Group' | 'Person'; - status: string; - sortname: string; - genres: string[]; - images: MbImage[]; - links: MbLink[]; - rating?: { - Count: number; - Value: number | null; - }; -} - -export interface MbAlbumResult extends MbMediaResult { - media_type: 'album'; - title: string; - artistid: string; - artists: MbArtistResult[]; - type: string; - releasedate: string; - disambiguation: string; - genres: string[]; - images: MbImage[]; - secondarytypes: string[]; - overview?: string; - releases?: { - id: string; - track_count?: number; - title?: string; - releasedate?: string; - }[]; -} - -export interface MbImage { - CoverType: 'Fanart' | 'Logo' | 'Poster' | 'Cover'; - Url: string; -} - export interface MbLink { - target: string; type: string; + target: string; } -export interface MbSearchMultiResponse { - artist: MbArtistResult | null; - album: MbAlbumResult | null; - score: number; -} - -export interface MbArtistDetails extends MbArtistResult { - artistaliases: string[]; - oldids: string[]; - links: MbLink[]; - images: MbImage[]; - rating: { - Count: number; - Value: number | null; - }; - Albums?: Album[]; +export interface MbAlbumResult extends MbResult { + media_type: 'album'; + title: string; + 'primary-type': 'Album' | 'Single' | 'EP'; + 'first-release-date': string; + 'artist-credit': { + name: string; + artist: { + id: string; + name: string; + 'sort-name': string; + overview?: string; + }; + }[]; + posterPath: string | undefined; } export interface MbAlbumDetails extends MbAlbumResult { - aliases: string[]; - artists: MbArtistResult[]; - releases: MbRelease[]; - rating: { - Count: number; - Value: number | null; - }; - overview: string; - secondaryTypes: string[]; -} - -interface MbRelease { - id: string; - title: string; - status: string; - releasedate: string; - country: string[]; - label: string[]; - track_count: number; - media: MbMedium[]; - tracks: MbTrack[]; - disambiguation: string; -} - -interface MbMedium { - Format: string; - Name: string; - Position: number; -} - -interface MbTrack { - id: string; - artistid: string; - trackname: string; - tracknumber: string; - trackposition: number; - mediumnumber: number; - durationms: number; - recordingid: string; - oldids: string[]; - oldrecordingids: string[]; -} - -interface Album { - Id: string; - OldIds: string[]; - ReleaseStatuses: string[]; - SecondaryTypes: string[]; - Title: string; - Type: string; - images?: { - CoverType: string; - Url: string; + 'type-id': string; + 'primary-type-id': string; + count: number; + 'secondary-types'?: string[]; + 'secondary-type-ids'?: string[]; + releases: { + id: string; + title: string; + status: string; + 'status-id': string; }[]; + releasedate: string; + tags?: { + count: number; + name: string; + }[]; + artists?: { + id: string; + name: string; + overview?: string; + }[]; + links?: MbLink[]; + poster_path?: string; +} + +export interface MbArtistResult extends MbResult { + media_type: 'artist'; + name: string; + type: 'Group' | 'Person'; + 'sort-name': string; + country?: string; + disambiguation?: string; + artistThumb?: string | null; + artistBackdrop?: string | null; +} + +export interface MbArtistDetails extends MbArtistResult { + 'type-id': string; + area?: { + id: string; + name: string; + type: string; + 'type-id': string; + 'sort-name': string; + }; + 'begin-area'?: { + id: string; + name: string; + type: string; + 'sort-name': string; + }; + 'life-span'?: { + begin?: string; + ended: boolean; + }; + gender?: string; + 'gender-id'?: string; + isnis?: string[]; + aliases?: { + name: string; + 'sort-name': string; + type?: string; + 'type-id'?: string; + }[]; + tags?: { + count: number; + name: string; + }[]; + links?: MbLink[]; +} + +export interface MbSearchAlbumResponse { + created: string; + count: number; + offset: number; + 'release-groups': MbAlbumDetails[]; +} + +export interface MbSearchArtistResponse { + created: string; + count: number; + offset: number; + artists: MbArtistDetails[]; +} + +export interface MbSearchMultiResponse { + created: string; + count: number; + offset: number; + results: (MbArtistResult | MbAlbumResult)[]; } diff --git a/server/api/servarr/lidarr.ts b/server/api/servarr/lidarr.ts index 76f97849..a9fc2510 100644 --- a/server/api/servarr/lidarr.ts +++ b/server/api/servarr/lidarr.ts @@ -20,6 +20,11 @@ export interface LidarrArtistResult extends LidarrMediaResult { export interface LidarrAlbumResult extends LidarrMediaResult { album: { + disambiguation: string; + duration: number; + mediumCount: number; + ratings: LidarrRating | undefined; + links: never[]; media_type: 'music'; title: string; foreignAlbumId: string; @@ -29,6 +34,22 @@ export interface LidarrAlbumResult extends LidarrMediaResult { genres: string[]; images: LidarrImage[]; artist: { + id: number; + status: string; + ended: boolean; + foreignArtistId: string; + tadbId: number; + discogsId: number; + artistType: string; + disambiguation: string | undefined; + links: never[]; + images: never[]; + genres: never[]; + cleanName: string | undefined; + sortName: string | undefined; + tags: never[]; + added: string; + ratings: LidarrRating | undefined; artistName: string; overview: string; }; @@ -60,6 +81,8 @@ export interface LidarrArtistDetails { added: string; ratings: LidarrRating; remotePoster?: string; + cleanName?: string; + sortName?: string; } export interface LidarrAlbumDetails { @@ -165,24 +188,53 @@ export interface LidarrSearchResponse { export interface LidarrAlbumOptions { [key: string]: unknown; - profileId: number; - mbId: string; - qualityProfileId: number; - rootFolderPath: string; title: string; - monitored: boolean; - tags: string[]; - searchNow: boolean; + disambiguation?: string; + overview?: string; artistId: number; + foreignAlbumId: string; + monitored: boolean; + anyReleaseOk: boolean; + profileId: number; + duration?: number; + albumType: string; + secondaryTypes: string[]; + mediumCount?: number; + ratings?: LidarrRating; + releaseDate?: string; + releases: any[]; + genres: string[]; + media: any[]; artist: { - id: number; - foreignArtistId: string; + status: string; + ended: boolean; artistName: string; + foreignArtistId: string; + tadbId?: number; + discogsId?: number; + overview?: string; + artistType: string; + disambiguation?: string; + links: LidarrLink[]; + images: LidarrImage[]; + path: string; qualityProfileId: number; metadataProfileId: number; - rootFolderPath: string; monitored: boolean; monitorNewItems: string; + rootFolderPath: string; + genres: string[]; + cleanName?: string; + sortName?: string; + tags: number[]; + added?: string; + ratings?: LidarrRating; + id: number; + }; + images: LidarrImage[]; + links: LidarrLink[]; + addOptions: { + searchForNewAlbum: boolean; }; } @@ -221,6 +273,11 @@ export interface LidarrAlbum { }; } +export interface SearchCommand extends Record { + name: 'AlbumSearch'; + albumIds: number[]; +} + class LidarrAPI extends ServarrBase<{ albumId: number }> { protected apiKey: string; constructor({ url, apiKey }: { url: string; apiKey: string }) { @@ -237,15 +294,6 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> { } } - public async getArtist({ id }: { id: number }): Promise { - try { - const data = await this.get(`/artist/${id}`); - return data; - } catch (e) { - throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`); - } - } - public async getAlbum({ id }: { id: number }): Promise { try { const data = await this.get(`/album/${id}`); @@ -255,29 +303,6 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> { } } - public async getAlbumByMusicBrainzId( - mbId: string - ): Promise { - try { - const data = await this.get('/album/lookup', { - term: `lidarr:${mbId}`, - }); - - if (!data[0]) { - throw new Error('Album not found'); - } - - return data[0]; - } catch (e) { - logger.error('Error retrieving album by foreign ID', { - label: 'Lidarr API', - errorMessage: e.message, - mbId: mbId, - }); - throw new Error('Album not found'); - } - } - public async removeAlbum(albumId: number): Promise { try { await this.delete(`/album/${albumId}`, { @@ -290,215 +315,60 @@ class LidarrAPI extends ServarrBase<{ albumId: number }> { } } - public async getArtistByMusicBrainzId( - mbId: string - ): Promise { + public async searchAlbum(mbid: string): Promise { try { - const data = await this.get('/artist/lookup', { - term: `lidarr:${mbId}`, + const data = await this.get(`/search`, { + term: `lidarr:${mbid}`, }); - - if (!data[0]) { - throw new Error('Artist not found'); - } - - return data[0]; + return data; } catch (e) { - logger.error('Error retrieving artist by foreign ID', { - label: 'Lidarr API', - errorMessage: e.message, - mbId: mbId, - }); - throw new Error('Artist not found'); + throw new Error(`[Lidarr] Failed to search album: ${e.message}`); } } - public async addAlbum( - options: LidarrAlbumOptions - ): Promise { + public async addAlbum(options: LidarrAlbumOptions): Promise { try { - const data = await this.post('/album', options); - return data; - } catch (e) { - if (e.message.includes('This album has already been added')) { - logger.info('Album already exists in Lidarr, monitoring it in Lidarr', { - label: 'Lidarr', - albumTitle: options.title, - mbId: options.mbId, - }); - throw e; + const existingAlbums = await this.get('/album', { + foreignAlbumId: options.foreignAlbumId, + includeAllArtistAlbums: 'true', + }); + + if (existingAlbums.length > 0 && existingAlbums[0].monitored) { + logger.info( + 'Album is already monitored in Lidarr. Skipping add and returning success', + { + label: 'Lidarr', + } + ); + return existingAlbums[0]; } - logger.error('Failed to add album to Lidarr', { - label: 'Lidarr', - options, - errorMessage: e.message, + const data = await this.post('/album', { + ...options, + monitored: true, }); + return data; + } catch (e) { throw new Error(`[Lidarr] Failed to add album: ${e.message}`); } } - public async addArtist( - options: LidarrArtistOptions - ): Promise { + public async searchAlbumByMusicBrainzId( + mbid: string + ): Promise { try { - const data = await this.post('/artist', options); + const data = await this.get('/search', { + term: `lidarr:${mbid}`, + }); return data; } catch (e) { - logger.error('Failed to add artist to Lidarr', { - label: 'Lidarr', - options, - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to add artist: ${e.message}`); - } - } - - public async searchMulti(searchTerm: string): Promise { - try { - const data = await this.get< - { - foreignId: string; - artist?: { - artistName: string; - overview?: string; - remotePoster?: string; - artistType?: string; - genres: string[]; - foreignArtistId: string; - }; - album?: { - title: string; - foreignAlbumId: string; - overview?: string; - releaseDate?: string; - albumType: string; - genres: string[]; - images: LidarrImage[]; - artist: { - artistName: string; - overview?: string; - }; - remoteCover?: string; - }; - id: number; - }[] - >( - '/search', - { - term: searchTerm, - }, - undefined, - { - headers: { - 'X-Api-Key': this.apiKey, - }, - } + throw new Error( + `[Lidarr] Failed to search album by MusicBrainz ID: ${e.message}` ); - - if (!data) { - throw new Error('No data received from Lidarr'); - } - - const results = data.map((item) => { - if (item.album) { - return { - id: item.id, - mbId: item.album.foreignAlbumId, - media_type: 'music' as const, - album: { - media_type: 'music' as const, - title: item.album.title, - foreignAlbumId: item.album.foreignAlbumId, - overview: item.album.overview || '', - releaseDate: item.album.releaseDate || '', - albumType: item.album.albumType, - genres: item.album.genres, - images: item.album.remoteCover - ? [ - { - url: item.album.remoteCover, - coverType: 'cover', - }, - ] - : item.album.images, - artist: { - artistName: item.album.artist.artistName, - overview: item.album.artist.overview || '', - }, - }, - } satisfies LidarrAlbumResult; - } - - if (item.artist) { - return { - id: item.id, - mbId: item.artist.foreignArtistId, - media_type: 'artist' as const, - artist: { - media_type: 'artist' as const, - artistName: item.artist.artistName, - overview: item.artist.overview || '', - remotePoster: item.artist.remotePoster, - artistType: item.artist.artistType || '', - genres: item.artist.genres, - }, - } satisfies LidarrArtistResult; - } - - throw new Error('Invalid search result type'); - }); - - return { - page: 1, - total_pages: 1, - total_results: results.length, - results, - }; - } catch (e) { - logger.error('Failed to search Lidarr', { - label: 'Lidarr API', - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to search: ${e.message}`); } } - public async updateArtist( - artist: LidarrArtistDetails - ): Promise { - try { - const data = await this.put(`/artist/${artist.id}`, { - ...artist, - } as Record); - return data; - } catch (e) { - logger.error('Failed to update artist in Lidarr', { - label: 'Lidarr', - artistId: artist.id, - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to update artist: ${e.message}`); - } - } - - public async updateAlbum(album: LidarrAlbum): Promise { - try { - const data = await this.put(`/album/${album.id}`, { - ...album, - } as Record); - return data; - } catch (e) { - logger.error('Failed to update album in Lidarr', { - label: 'Lidarr', - albumId: album.id, - errorMessage: e.message, - }); - throw new Error(`[Lidarr] Failed to update album: ${e.message}`); - } - } - - public async searchAlbum(albumId: number): Promise { + public async searchOnAdd(albumId: number): Promise { logger.info('Executing album search command', { label: 'Lidarr API', albumId, diff --git a/server/api/theaudiodb/index.ts b/server/api/theaudiodb/index.ts new file mode 100644 index 00000000..6ae9f1d9 --- /dev/null +++ b/server/api/theaudiodb/index.ts @@ -0,0 +1,227 @@ +import ExternalAPI from '@server/api/externalapi'; +import { getRepository } from '@server/datasource'; +import MetadataArtist from '@server/entity/MetadataArtist'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; +import { In } from 'typeorm'; +import type { TadbArtistResponse } from './interfaces'; + +class TheAudioDb extends ExternalAPI { + private static instance: TheAudioDb; + private readonly apiKey = '195003'; + private readonly CACHE_TTL = 43200; + private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; + + constructor() { + super( + 'https://www.theaudiodb.com/api/v1/json', + {}, + { + nodeCache: cacheManager.getCache('tadb').data, + rateLimit: { + maxRPS: 25, + id: 'tadb', + }, + } + ); + } + + public static getInstance(): TheAudioDb { + if (!TheAudioDb.instance) { + TheAudioDb.instance = new TheAudioDb(); + } + return TheAudioDb.instance; + } + + private isMetadataStale(metadata: MetadataArtist | null): boolean { + if (!metadata || !metadata.tadbUpdatedAt) return true; + return Date.now() - metadata.tadbUpdatedAt.getTime() > this.STALE_THRESHOLD; + } + + private createEmptyResponse() { + return { artistThumb: null, artistBackground: null }; + } + + public async getArtistImagesFromCache(id: string): Promise< + | { + artistThumb: string | null; + artistBackground: string | null; + } + | null + | undefined + > { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: id }, + select: ['tadbThumb', 'tadbCover', 'tadbUpdatedAt'], + }); + + if (metadata) { + return { + artistThumb: metadata.tadbThumb, + artistBackground: metadata.tadbCover, + }; + } + return undefined; + } catch (error) { + logger.error('Failed to fetch artist images from cache', { + label: 'TheAudioDb', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } + } + + public async getArtistImages( + id: string + ): Promise<{ artistThumb: string | null; artistBackground: string | null }> { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: id }, + select: ['tadbThumb', 'tadbCover', 'tadbUpdatedAt'], + }); + + if (metadata?.tadbThumb || metadata?.tadbCover) { + return { + artistThumb: metadata.tadbThumb, + artistBackground: metadata.tadbCover, + }; + } + + if (metadata && !this.isMetadataStale(metadata)) { + return this.createEmptyResponse(); + } + + return await this.fetchArtistImages(id); + } catch (error) { + logger.error('Failed to get artist images', { + label: 'TheAudioDb', + id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return this.createEmptyResponse(); + } + } + + private async fetchArtistImages(id: string): Promise<{ + artistThumb: string | null; + artistBackground: string | null; + }> { + try { + const data = await this.get( + `/${this.apiKey}/artist-mb.php`, + { i: id }, + this.CACHE_TTL + ); + + const result = { + artistThumb: data.artists?.[0]?.strArtistThumb || null, + artistBackground: data.artists?.[0]?.strArtistFanart || null, + }; + + const metadataRepository = getRepository(MetadataArtist); + await metadataRepository + .upsert( + { + mbArtistId: id, + tadbThumb: result.artistThumb, + tadbCover: result.artistBackground, + tadbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ) + .catch((e) => { + logger.error('Failed to save artist metadata', { + label: 'TheAudioDb', + error: e instanceof Error ? e.message : 'Unknown error', + }); + }); + + return result; + } catch (error) { + await getRepository(MetadataArtist).upsert( + { + mbArtistId: id, + tadbThumb: null, + tadbCover: null, + tadbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ); + return this.createEmptyResponse(); + } + } + + public async batchGetArtistImages(ids: string[]): Promise< + Record< + string, + { + artistThumb: string | null; + artistBackground: string | null; + } + > + > { + if (!ids.length) return {}; + + const metadataRepository = getRepository(MetadataArtist); + const existingMetadata = await metadataRepository.find({ + where: { mbArtistId: In(ids) }, + select: ['mbArtistId', 'tadbThumb', 'tadbCover', 'tadbUpdatedAt'], + }); + + const results: Record< + string, + { + artistThumb: string | null; + artistBackground: string | null; + } + > = {}; + const idsToFetch: string[] = []; + + ids.forEach((id) => { + const metadata = existingMetadata.find((m) => m.mbArtistId === id); + + if (metadata?.tadbThumb || metadata?.tadbCover) { + results[id] = { + artistThumb: metadata.tadbThumb, + artistBackground: metadata.tadbCover, + }; + } else if (metadata && !this.isMetadataStale(metadata)) { + results[id] = { + artistThumb: null, + artistBackground: null, + }; + } else { + idsToFetch.push(id); + } + }); + + if (idsToFetch.length > 0) { + const batchPromises = idsToFetch.map((id) => + this.fetchArtistImages(id) + .then((response) => { + results[id] = response; + return true; + }) + .catch(() => { + results[id] = { + artistThumb: null, + artistBackground: null, + }; + return false; + }) + ); + + await Promise.all(batchPromises); + } + + return results; + } +} + +export default TheAudioDb; diff --git a/server/api/theaudiodb/interfaces.ts b/server/api/theaudiodb/interfaces.ts new file mode 100644 index 00000000..e32c1694 --- /dev/null +++ b/server/api/theaudiodb/interfaces.ts @@ -0,0 +1,8 @@ +interface TadbArtist { + strArtistThumb: string | null; + strArtistFanart: string | null; +} + +export interface TadbArtistResponse { + artists?: TadbArtist[]; +} diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 0509ea44..74ec5841 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -16,7 +16,6 @@ import type { TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetails, - TmdbPersonSearchResponse, TmdbProductionCompany, TmdbRegion, TmdbSearchMovieResponse, @@ -231,31 +230,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { } }; - public async searchPerson({ - query, - page = 1, - includeAdult = false, - language = 'en', - }: SearchOptions): Promise { - try { - const data = await this.get('/search/person', { - query, - page: page.toString(), - include_adult: includeAdult ? 'true' : 'false', - language, - }); - - return data; - } catch (e) { - return { - page: 1, - results: [], - total_pages: 1, - total_results: 0, - }; - } - } - public getPerson = async ({ personId, language = this.locale, diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index a39ed379..628adead 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -42,6 +42,7 @@ export interface TmdbCollectionResult { export interface TmdbPersonResult { id: number; + known_for_department: string; name: string; popularity: number; profile_path?: string; @@ -464,20 +465,12 @@ export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse { results: TmdbCompany[]; } +export interface TmdbSearchPersonResponse extends TmdbPaginatedResponse { + results: TmdbPersonResult[]; +} + export interface TmdbWatchProviderRegion { iso_3166_1: string; english_name: string; native_name: string; } - -export interface TmdbPersonSearchResponse extends TmdbPaginatedResponse { - results: TmdbPersonSearchResult[]; -} - -export interface TmdbPersonSearchResult - extends Omit { - gender: number; - known_for_department: string; - original_name: string; - known_for: (TmdbMovieResult | TmdbTvResult)[]; -} diff --git a/server/api/themoviedb/personMapper.ts b/server/api/themoviedb/personMapper.ts new file mode 100644 index 00000000..d0438e6c --- /dev/null +++ b/server/api/themoviedb/personMapper.ts @@ -0,0 +1,348 @@ +import ExternalAPI from '@server/api/externalapi'; +import TheMovieDb from '@server/api/themoviedb'; +import { getRepository } from '@server/datasource'; +import MetadataArtist from '@server/entity/MetadataArtist'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; +import { In } from 'typeorm'; +import type { TmdbSearchPersonResponse } from './interfaces'; + +interface SearchPersonOptions { + query: string; + page?: number; + includeAdult?: boolean; + language?: string; +} + +class TmdbPersonMapper extends ExternalAPI { + private static instance: TmdbPersonMapper; + private readonly CACHE_TTL = 43200; + private readonly STALE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; + private tmdb: TheMovieDb; + + private constructor() { + super( + 'https://api.themoviedb.org/3', + { + api_key: '431a8708161bcd1f1fbe7536137e61ed', + }, + { + nodeCache: cacheManager.getCache('tmdb').data, + rateLimit: { + maxRPS: 50, + id: 'tmdb', + }, + } + ); + this.tmdb = new TheMovieDb(); + } + + public static getInstance(): TmdbPersonMapper { + if (!TmdbPersonMapper.instance) { + TmdbPersonMapper.instance = new TmdbPersonMapper(); + } + return TmdbPersonMapper.instance; + } + + private isMetadataStale(metadata: MetadataArtist | null): boolean { + if (!metadata || !metadata.tmdbUpdatedAt) return true; + return Date.now() - metadata.tmdbUpdatedAt.getTime() > this.STALE_THRESHOLD; + } + + private createEmptyResponse() { + return { personId: null, profilePath: null }; + } + + public async getMappingFromCache( + artistId: string + ): Promise<{ personId: number | null; profilePath: string | null } | null> { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + if (!metadata) { + return null; + } + + if (this.isMetadataStale(metadata)) { + return null; + } + + return { + personId: metadata.tmdbPersonId ? Number(metadata.tmdbPersonId) : null, + profilePath: metadata.tmdbThumb, + }; + } catch (error) { + logger.error('Failed to get person mapping from cache', { + label: 'TmdbPersonMapper', + artistId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } + } + + public async getMapping( + artistId: string, + artistName: string + ): Promise<{ personId: number | null; profilePath: string | null }> { + try { + const metadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + if (metadata?.tmdbPersonId || metadata?.tmdbThumb) { + return { + personId: metadata.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + profilePath: metadata.tmdbThumb, + }; + } + + if (metadata && !this.isMetadataStale(metadata)) { + return this.createEmptyResponse(); + } + + return await this.fetchMapping(artistId, artistName); + } catch (error) { + logger.error('Failed to get person mapping', { + label: 'TmdbPersonMapper', + artistId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return this.createEmptyResponse(); + } + } + + private async fetchMapping( + artistId: string, + artistName: string + ): Promise<{ personId: number | null; profilePath: string | null }> { + try { + const existingMetadata = await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + select: ['tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + if (existingMetadata?.tmdbPersonId) { + return { + personId: Number(existingMetadata.tmdbPersonId), + profilePath: existingMetadata.tmdbThumb, + }; + } + + const cleanArtistName = artistName + .split(/(?:(?:feat|ft)\.?\s+|&\s*|,\s+)/i)[0] + .trim() + .replace(/['']/g, "'"); + + const searchResults = await this.get( + '/search/person', + { + query: cleanArtistName, + page: '1', + include_adult: 'false', + language: 'en', + }, + this.CACHE_TTL + ); + + const exactMatches = searchResults.results.filter((person) => { + const normalizedPersonName = person.name + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/['']/g, "'") + .replace(/[^a-z0-9\s]/g, '') + .trim(); + + const normalizedArtistName = cleanArtistName + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/['']/g, "'") + .replace(/[^a-z0-9\s]/g, '') + .trim(); + + return normalizedPersonName === normalizedArtistName; + }); + + if (exactMatches.length > 0) { + const tmdbPersonIds = exactMatches.map((match) => match.id.toString()); + const existingMappings = await getRepository(MetadataArtist).find({ + where: { tmdbPersonId: In(tmdbPersonIds) }, + select: ['mbArtistId', 'tmdbPersonId'], + }); + + const availableMatches = exactMatches.filter( + (match) => + !existingMappings.some( + (mapping) => + mapping.tmdbPersonId === match.id.toString() && + mapping.mbArtistId !== artistId + ) + ); + + const soundMatches = availableMatches.filter( + (person) => person.known_for_department === 'Sound' + ); + + const exactMatch = + soundMatches.length > 0 + ? soundMatches.reduce((prev, current) => + current.popularity > prev.popularity ? current : prev + ) + : availableMatches.length > 0 + ? availableMatches.reduce((prev, current) => + current.popularity > prev.popularity ? current : prev + ) + : null; + + const mapping = { + personId: exactMatch?.id ?? null, + profilePath: exactMatch?.profile_path + ? `https://image.tmdb.org/t/p/w500${exactMatch.profile_path}` + : null, + }; + + await getRepository(MetadataArtist) + .upsert( + { + mbArtistId: artistId, + tmdbPersonId: mapping.personId?.toString() ?? null, + tmdbThumb: mapping.profilePath, + tmdbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ) + .catch((e) => { + logger.error('Failed to save artist metadata', { + label: 'TmdbPersonMapper', + error: e instanceof Error ? e.message : 'Unknown error', + }); + }); + + return mapping; + } else { + await getRepository(MetadataArtist).upsert( + { + mbArtistId: artistId, + tmdbPersonId: null, + tmdbThumb: null, + tmdbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ); + return this.createEmptyResponse(); + } + } catch (error) { + await getRepository(MetadataArtist).upsert( + { + mbArtistId: artistId, + tmdbPersonId: null, + tmdbThumb: null, + tmdbUpdatedAt: new Date(), + }, + { + conflictPaths: ['mbArtistId'], + } + ); + return this.createEmptyResponse(); + } + } + + public async batchGetMappings( + artists: { artistId: string; artistName: string }[] + ): Promise< + Record + > { + if (!artists.length) return {}; + + const metadataRepository = getRepository(MetadataArtist); + const artistIds = artists.map((a) => a.artistId); + + const existingMetadata = await metadataRepository.find({ + where: { mbArtistId: In(artistIds) }, + select: ['mbArtistId', 'tmdbPersonId', 'tmdbThumb', 'tmdbUpdatedAt'], + }); + + const results: Record< + string, + { personId: number | null; profilePath: string | null } + > = {}; + const artistsToFetch: { artistId: string; artistName: string }[] = []; + + artists.forEach(({ artistId, artistName }) => { + const metadata = existingMetadata.find((m) => m.mbArtistId === artistId); + + if (metadata?.tmdbPersonId || metadata?.tmdbThumb) { + results[artistId] = { + personId: metadata.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + profilePath: metadata.tmdbThumb, + }; + } else if (metadata && !this.isMetadataStale(metadata)) { + results[artistId] = this.createEmptyResponse(); + } else { + artistsToFetch.push({ artistId, artistName }); + } + }); + + if (artistsToFetch.length > 0) { + const batchSize = 5; + for (let i = 0; i < artistsToFetch.length; i += batchSize) { + const batch = artistsToFetch.slice(i, i + batchSize); + const batchPromises = batch.map(({ artistId, artistName }) => + this.fetchMapping(artistId, artistName) + .then((mapping) => { + results[artistId] = mapping; + return true; + }) + .catch(() => { + results[artistId] = this.createEmptyResponse(); + return false; + }) + ); + + await Promise.all(batchPromises); + } + } + + return results; + } + + public async searchPerson( + options: SearchPersonOptions + ): Promise { + try { + return await this.get( + '/search/person', + { + query: options.query, + page: options.page?.toString() ?? '1', + include_adult: options.includeAdult ? 'true' : 'false', + language: options.language ?? 'en', + }, + this.CACHE_TTL + ); + } catch (e) { + return { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }; + } + } +} + +export default TmdbPersonMapper; diff --git a/server/constants/discover.ts b/server/constants/discover.ts index 15e8088e..69710d2a 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -4,7 +4,6 @@ export enum DiscoverSliderType { RECENTLY_ADDED = 1, RECENT_REQUESTS, PLEX_WATCHLIST, - POPULAR_ALBUMS, TRENDING, POPULAR_MOVIES, MOVIE_GENRES, @@ -23,6 +22,8 @@ export enum DiscoverSliderType { TMDB_NETWORK, TMDB_MOVIE_STREAMING_SERVICES, TMDB_TV_STREAMING_SERVICES, + POPULAR_ALBUMS, + POPULAR_ARTISTS, } export const defaultSliders: Partial[] = [ @@ -51,57 +52,63 @@ export const defaultSliders: Partial[] = [ order: 3, }, { - type: DiscoverSliderType.POPULAR_ALBUMS, + type: DiscoverSliderType.POPULAR_MOVIES, enabled: true, isBuiltIn: true, order: 4, }, { - type: DiscoverSliderType.POPULAR_MOVIES, + type: DiscoverSliderType.MOVIE_GENRES, enabled: true, isBuiltIn: true, order: 5, }, { - type: DiscoverSliderType.MOVIE_GENRES, + type: DiscoverSliderType.UPCOMING_MOVIES, enabled: true, isBuiltIn: true, order: 6, }, { - type: DiscoverSliderType.UPCOMING_MOVIES, + type: DiscoverSliderType.STUDIOS, enabled: true, isBuiltIn: true, order: 7, }, { - type: DiscoverSliderType.STUDIOS, + type: DiscoverSliderType.POPULAR_TV, enabled: true, isBuiltIn: true, order: 8, }, { - type: DiscoverSliderType.POPULAR_TV, + type: DiscoverSliderType.TV_GENRES, enabled: true, isBuiltIn: true, order: 9, }, { - type: DiscoverSliderType.TV_GENRES, + type: DiscoverSliderType.UPCOMING_TV, enabled: true, isBuiltIn: true, order: 10, }, { - type: DiscoverSliderType.UPCOMING_TV, + type: DiscoverSliderType.NETWORKS, enabled: true, isBuiltIn: true, order: 11, }, { - type: DiscoverSliderType.NETWORKS, + type: DiscoverSliderType.POPULAR_ALBUMS, enabled: true, isBuiltIn: true, order: 12, }, + { + type: DiscoverSliderType.POPULAR_ARTISTS, + enabled: true, + isBuiltIn: true, + order: 13, + }, ]; diff --git a/server/constants/issue.ts b/server/constants/issue.ts index 9e320866..90739951 100644 --- a/server/constants/issue.ts +++ b/server/constants/issue.ts @@ -2,8 +2,8 @@ export enum IssueType { VIDEO = 1, AUDIO = 2, SUBTITLES = 3, - LYRICS = 4, - OTHER = 5, + OTHER = 4, + LYRICS = 5, } export enum IssueStatus { @@ -15,6 +15,6 @@ export const IssueTypeName = { [IssueType.AUDIO]: 'Audio', [IssueType.VIDEO]: 'Video', [IssueType.SUBTITLES]: 'Subtitle', - [IssueType.LYRICS]: 'Lyrics', [IssueType.OTHER]: 'Other', + [IssueType.LYRICS]: 'Lyrics', }; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index e9bed8b5..ca8162c4 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -30,39 +30,38 @@ import Season from './Season'; class Media { public static async getRelatedMedia( user: User | undefined, - ids: (number | string)[] + ids: number | number[] | string | string[] ): Promise { const mediaRepository = getRepository(Media); try { - if (ids.length === 0) { + let finalIds: (number | string)[]; + if (!Array.isArray(ids)) { + finalIds = [ids]; + } else { + finalIds = ids; + } + + if (finalIds.length === 0) { return []; } - const tmdbIds = ids.filter((id): id is number => typeof id === 'number'); - const mbIds = ids.filter((id): id is string => typeof id === 'string'); - - const queryBuilder = mediaRepository + const media = await mediaRepository .createQueryBuilder('media') .leftJoinAndSelect( 'media.watchlists', 'watchlist', 'media.id = watchlist.media and watchlist.requestedBy = :userId', { userId: user?.id } - ); + ) + .where( + typeof finalIds[0] === 'string' + ? 'media.mbId IN (:...finalIds)' + : 'media.tmdbId IN (:...finalIds)', + { finalIds } + ) + .getMany(); - if (tmdbIds.length > 0 && mbIds.length > 0) { - queryBuilder.where( - '(media.tmdbId IN (:...tmdbIds) OR media.mbId IN (:...mbIds))', - { tmdbIds, mbIds } - ); - } else if (tmdbIds.length > 0) { - queryBuilder.where('media.tmdbId IN (:...tmdbIds)', { tmdbIds }); - } else if (mbIds.length > 0) { - queryBuilder.where('media.mbId IN (:...mbIds)', { mbIds }); - } - - const media = await queryBuilder.getMany(); return media; } catch (e) { logger.error(e.message); @@ -77,13 +76,11 @@ class Media { const mediaRepository = getRepository(Media); try { - const whereClause = - typeof id === 'string' - ? { mbId: id, mediaType } - : { tmdbId: id, mediaType }; - const media = await mediaRepository.findOne({ - where: whereClause, + where: + typeof id === 'string' + ? { mbId: id, mediaType } + : { tmdbId: id, mediaType }, relations: { requests: true, issues: true }, }); diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 7b22b623..4a63d634 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,15 +1,11 @@ -import type { LidarrAlbumDetails } from '@server/api/servarr/lidarr'; -import LidarrAPI from '@server/api/servarr/lidarr'; -import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; -import RadarrAPI from '@server/api/servarr/radarr'; -import type { - AddSeriesOptions, - SonarrSeries, -} from '@server/api/servarr/sonarr'; -import SonarrAPI from '@server/api/servarr/sonarr'; +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; +import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces'; +import MusicBrainz from '@server/api/musicbrainz'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { + TmdbKeyword, TmdbMovieDetails, TmdbTvDetails, } from '@server/api/themoviedb/interfaces'; @@ -58,12 +54,8 @@ export class MediaRequest { requestBody: MediaRequestBody, user: User, options: MediaRequestOptions = {} - ): Promise { + ): Promise { const tmdb = new TheMovieDb(); - const lidarr = new LidarrAPI({ - apiKey: getSettings().lidarr[0].apiKey, - url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'), - }); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); @@ -125,20 +117,6 @@ export class MediaRequest { ); } - if ( - requestBody.mediaType === MediaType.MUSIC && - !requestUser.hasPermission( - [Permission.REQUEST, Permission.REQUEST_MUSIC], - { - type: 'or', - } - ) - ) { - throw new RequestPermissionError( - 'You do not have permission to make music requests.' - ); - } - const quotas = await requestUser.getQuota(); if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { @@ -152,51 +130,55 @@ export class MediaRequest { throw new QuotaRestrictedError('Music Quota exceeded.'); } - const tmdbMedia = + const requestedMedia = requestBody.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: requestBody.mediaId }) : requestBody.mediaType === MediaType.TV ? await tmdb.getTvShow({ tvId: requestBody.mediaId }) - : await lidarr.getAlbumByMusicBrainzId(requestBody.mediaId.toString()); + : await new ListenBrainzAPI().getAlbum(requestBody.mediaId.toString()); let media = await mediaRepository.findOne({ - where: { - mbId: - requestBody.mediaType === MediaType.MUSIC - ? requestBody.mediaId.toString() - : undefined, - tmdbId: - requestBody.mediaType !== MediaType.MUSIC - ? requestBody.mediaId - : undefined, - mediaType: requestBody.mediaType, - }, + where: + requestBody.mediaType === MediaType.MUSIC + ? { + mbId: requestBody.mediaId.toString(), + mediaType: requestBody.mediaType, + } + : { tmdbId: requestBody.mediaId, mediaType: requestBody.mediaType }, relations: ['requests'], }); + const isTmdbMedia = ( + media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails + ): media is TmdbMovieDetails | TmdbTvDetails => { + return 'id' in media; + }; + + const isLbAlbum = ( + media: TmdbMovieDetails | TmdbTvDetails | LbAlbumDetails + ): media is LbAlbumDetails => { + return 'release_group_mbid' in media; + }; + if (!media) { media = new Media({ - mbId: - requestBody.mediaType === MediaType.MUSIC - ? requestBody.mediaId.toString() - : undefined, - tmdbId: - requestBody.mediaType !== MediaType.MUSIC - ? requestBody.mediaId - : undefined, + tmdbId: isTmdbMedia(requestedMedia) ? requestedMedia.id : undefined, + mbId: isLbAlbum(requestedMedia) + ? requestedMedia.release_group_mbid + : undefined, + tvdbId: isTmdbMedia(requestedMedia) + ? requestBody.tvdbId ?? requestedMedia.external_ids?.tvdb_id + : undefined, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: requestBody.mediaType, }); } else { if (media.status === MediaStatus.BLACKLISTED) { logger.warn('Request for media blocked due to being blacklisted', { - mbId: - requestBody.mediaType === MediaType.MUSIC - ? requestBody.mediaId - : undefined, - tmdbId: - requestBody.mediaType !== MediaType.MUSIC - ? tmdbMedia.id - : undefined, + id: isLbAlbum(requestedMedia) + ? requestedMedia.release_group_mbid + : requestedMedia.id, mediaType: requestBody.mediaType, label: 'Media Request', }); @@ -207,19 +189,31 @@ export class MediaRequest { if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { media.status = MediaStatus.PENDING; } + + if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { + media.status4k = MediaStatus.PENDING; + } } const existing = await requestRepository .createQueryBuilder('request') .leftJoin('request.media', 'media') .leftJoinAndSelect('request.requestedBy', 'user') - .where( - requestBody.mediaType === MediaType.MUSIC + .where('request.is4k = :is4k', { is4k: requestBody.is4k }) + .andWhere( + requestBody.mediaType === 'music' ? 'media.mbId = :mbId' : 'media.tmdbId = :tmdbId', - requestBody.mediaType === MediaType.MUSIC - ? { mbId: requestBody.mediaId } - : { tmdbId: tmdbMedia.id } + requestBody.mediaType === 'music' + ? { + mbId: (requestedMedia as { release_group_mbid: string }) + .release_group_mbid, + } + : { + tmdbId: isTmdbMedia(requestedMedia) + ? requestedMedia.id + : undefined, + } ) .andWhere('media.mediaType = :mediaType', { mediaType: requestBody.mediaType, @@ -229,12 +223,17 @@ export class MediaRequest { if (existing && existing.length > 0) { // If there is an existing movie request that isn't declined, don't allow a new one. if ( - requestBody.mediaType === MediaType.MUSIC && + (requestBody.mediaType === MediaType.MOVIE || + requestBody.mediaType === MediaType.MUSIC) && existing[0].status !== MediaRequestStatus.DECLINED ) { logger.warn('Duplicate request for media blocked', { - mbId: requestBody.mediaId, + id: + requestBody.mediaType === MediaType.MUSIC + ? media.mbId + : (requestedMedia as TmdbMovieDetails | TmdbTvDetails).id, mediaType: requestBody.mediaType, + is4k: requestBody.is4k, label: 'Media Request', }); @@ -256,116 +255,140 @@ export class MediaRequest { } } - const isTmdbMedia = ( - media: LidarrAlbumDetails | TmdbMovieDetails | TmdbTvDetails - ): media is TmdbMovieDetails | TmdbTvDetails => { - return 'original_language' in media && 'keywords' in media; - }; - - let prioritizedRule: OverrideRule | undefined; - + // Apply overrides if the user is not an admin or has the "advanced request" permission const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], { type: 'or', }); + let rootFolder = requestBody.rootFolder; + let profileId = requestBody.profileId; + let tags = requestBody.tags; + if (useOverrides) { - if (requestBody.mediaType !== MediaType.MUSIC) { - const defaultRadarrId = requestBody.is4k - ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) - : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); - const defaultSonarrId = requestBody.is4k - ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) - : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); + const defaultRadarrId = requestBody.is4k + ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) + : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); + const defaultSonarrId = requestBody.is4k + ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) + : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); + const defaultLidarrId = settings.lidarr.findIndex((l) => l.isDefault); - const overrideRuleRepository = getRepository(OverrideRule); - const overrideRules = await overrideRuleRepository.find({ - where: - requestBody.mediaType === MediaType.MOVIE - ? { radarrServiceId: defaultRadarrId } - : { sonarrServiceId: defaultSonarrId }, - }); + const overrideRuleRepository = getRepository(OverrideRule); + const overrideRules = await overrideRuleRepository.find({ + where: + requestBody.mediaType === MediaType.MOVIE + ? { radarrServiceId: defaultRadarrId } + : requestBody.mediaType === MediaType.TV + ? { sonarrServiceId: defaultSonarrId } + : { lidarrServiceId: defaultLidarrId }, + }); - const appliedOverrideRules = overrideRules.filter((rule) => { - if (isTmdbMedia(tmdbMedia)) { - if ( - rule.language && - !rule.language - .split('|') - .some( - (languageId) => languageId === tmdbMedia.original_language - ) - ) { - return false; - } - - if (rule.keywords) { - const keywordList = - 'results' in tmdbMedia.keywords - ? tmdbMedia.keywords.results - : 'keywords' in tmdbMedia.keywords - ? tmdbMedia.keywords.keywords - : []; - - if ( - !rule.keywords - .split(',') - .some((keywordId) => - keywordList.map((k) => k.id).includes(Number(keywordId)) - ) - ) { - return false; - } - } - - const hasAnimeKeyword = - 'results' in tmdbMedia.keywords && - tmdbMedia.keywords.results.some( - (keyword) => keyword.id === ANIME_KEYWORD_ID - ); - - if ( - requestBody.mediaType === MediaType.TV && - hasAnimeKeyword && - (!rule.keywords || - !rule.keywords - .split(',') - .map(Number) - .includes(ANIME_KEYWORD_ID)) - ) { - return false; - } - } + const appliedOverrideRules = overrideRules.filter((rule) => { + // Only apply keyword/genre rules for TMDB media + if (isTmdbMedia(requestedMedia)) { + const hasAnimeKeyword = + 'results' in requestedMedia.keywords && + requestedMedia.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ); if ( - rule.users && - !rule.users - .split(',') - .some((userId) => Number(userId) === requestUser.id) + requestBody.mediaType === MediaType.TV && + hasAnimeKeyword && + (!rule.keywords || + !rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID)) ) { return false; } - return true; - }); + if ( + rule.genre && + !rule.genre + .split(',') + .some((genreId) => + requestedMedia.genres + .map((genre) => genre.id) + .includes(Number(genreId)) + ) + ) { + return false; + } - prioritizedRule = appliedOverrideRules.sort((a, b) => { - const keys: (keyof OverrideRule)[] = [ - 'genre', - 'language', - 'keywords', - ]; - return ( - keys.filter((key) => b[key] !== null).length - - keys.filter((key) => a[key] !== null).length - ); - })[0]; + if ( + rule.language && + !rule.language + .split('|') + .some( + (languageId) => languageId === requestedMedia.original_language + ) + ) { + return false; + } - if (prioritizedRule) { - logger.debug('Override rule applied.', { - label: 'Media Request', - overrides: prioritizedRule, - }); + if ( + rule.keywords && + !rule.keywords.split(',').some((keywordId) => { + let keywordList: TmdbKeyword[] = []; + + if ('keywords' in requestedMedia.keywords) { + keywordList = requestedMedia.keywords.keywords; + } else if ('results' in requestedMedia.keywords) { + keywordList = requestedMedia.keywords.results; + } + + return keywordList + .map((keyword: TmdbKeyword) => keyword.id) + .includes(Number(keywordId)); + }) + ) { + return false; + } } + + if ( + rule.users && + !rule.users + .split(',') + .some((userId) => Number(userId) === requestUser.id) + ) { + return false; + } + + return true; + }); + + // hacky way to prioritize rules + // TODO: make this better + const prioritizedRule = appliedOverrideRules.sort((a, b) => { + const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; + + const aSpecificity = keys.filter((key) => a[key] !== null).length; + const bSpecificity = keys.filter((key) => b[key] !== null).length; + + // Take the rule with the most specific condition first + return bSpecificity - aSpecificity; + })[0]; + + if (prioritizedRule) { + if (prioritizedRule.rootFolder) { + rootFolder = prioritizedRule.rootFolder; + } + if (prioritizedRule.profileId) { + profileId = prioritizedRule.profileId; + } + if (prioritizedRule.tags) { + tags = [ + ...new Set([ + ...(tags || []), + ...prioritizedRule.tags.split(',').map((tag) => Number(tag)), + ]), + ]; + } + + logger.debug('Override rule applied.', { + label: 'Media Request', + overrides: prioritizedRule, + }); } } @@ -407,31 +430,65 @@ export class MediaRequest { : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, - profileId: prioritizedRule?.profileId ?? requestBody.profileId, - rootFolder: prioritizedRule?.rootFolder ?? requestBody.rootFolder, - tags: prioritizedRule?.tags - ? [ - ...new Set([ - ...(requestBody.tags || []), - ...prioritizedRule.tags.split(',').map(Number), - ]), - ] - : requestBody.tags, + profileId: profileId, + rootFolder: rootFolder, + tags: tags, isAutoRequest: options.isAutoRequest ?? false, }); await requestRepository.save(request); return request; - } else if (requestBody.mediaType === MediaType.TV) { - const tmdbMediaShow = tmdbMedia as Awaited< + } else if (requestBody.mediaType === MediaType.MUSIC) { + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.MUSIC, + media, + requestedBy: requestUser, + // If the user is an admin or has the music auto approve permission, automatically approve the request + status: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + serverId: requestBody.serverId, + profileId: profileId, + rootFolder: rootFolder, + tags: tags, + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } else { + const requestedMediaShow = requestedMedia as Awaited< ReturnType >; - const requestedSeasons = + let requestedSeasons = requestBody.seasons === 'all' ? tmdbMediaShow.seasons .filter((season) => season.season_number !== 0) .map((season) => season.season_number) : (requestBody.seasons as number[]); + if (!settings.main.enableSpecialEpisodes) { + requestedSeasons = requestedSeasons.filter((sn) => sn > 0); + } + let existingSeasons: number[] = []; // We need to check existing requests on this title to make sure we don't double up on seasons that were @@ -520,10 +577,10 @@ export class MediaRequest { : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, - profileId: requestBody.profileId, - rootFolder: requestBody.rootFolder, + profileId: profileId, + rootFolder: rootFolder, languageProfileId: requestBody.languageProfileId, - tags: requestBody.tags, + tags: tags, seasons: finalSeasons.map( (sn) => new SeasonRequest({ @@ -547,42 +604,6 @@ export class MediaRequest { isAutoRequest: options.isAutoRequest ?? false, }); - await requestRepository.save(request); - return request; - } else { - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.MUSIC, - media, - requestedBy: requestUser, - status: user.hasPermission( - [ - Permission.AUTO_APPROVE, - Permission.AUTO_APPROVE_MUSIC, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: user.hasPermission( - [ - Permission.AUTO_APPROVE, - Permission.AUTO_APPROVE_MUSIC, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? user - : undefined, - serverId: requestBody.serverId, - profileId: requestBody.profileId, - rootFolder: requestBody.rootFolder, - tags: requestBody.tags, - isAutoRequest: options.isAutoRequest ?? false, - }); - await requestRepository.save(request); return request; } @@ -794,13 +815,17 @@ export class MediaRequest { type: Notification ) { const tmdb = new TheMovieDb(); - const lidarr = new LidarrAPI({ - apiKey: getSettings().lidarr[0].apiKey, - url: LidarrAPI.buildUrl(getSettings().lidarr[0], '/api/v1'), - }); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); + const musicbrainz = new MusicBrainz(); try { - const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; + const mediaType = + this.type === MediaType.MOVIE + ? 'Movie' + : this.type === MediaType.TV + ? 'Series' + : 'Album'; let event: string | undefined; let notifyAdmin = true; let notifySystem = true; @@ -880,16 +905,18 @@ export class MediaRequest { }, ], }); - } else if (this.type === MediaType.MUSIC) { - if (!media.mbId) { - throw new Error('MusicBrainz ID not found for media'); - } - - const album = await lidarr.getAlbumByMusicBrainzId(media.mbId); - - const coverUrl = album.images?.find( - (img) => img.coverType === 'Cover' - )?.url; + } else if (this.type === MediaType.MUSIC && media.mbId) { + const album = await listenbrainz.getAlbum(media.mbId); + const coverArtResponse = await coverArt.getCoverArt(media.mbId); + const coverArtUrl = + coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; + const artistId = + album.release_group_metadata?.artist?.artists[0]?.artist_mbid; + const artistWiki = artistId + ? await musicbrainz.getArtistWikipediaExtract({ + artistMbid: artistId, + }) + : null; notificationManager.sendNotification(type, { media, @@ -898,15 +925,13 @@ export class MediaRequest { notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, - subject: `${album.title}${ - album.releaseDate ? ` (${album.releaseDate.slice(0, 4)})` : '' - }`, - message: truncate(album.overview || '', { + subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`, + message: truncate(artistWiki?.content ?? '', { length: 500, separator: /\s/, omission: '…', }), - image: coverUrl, + image: coverArtUrl, }); } } catch (e) { diff --git a/server/entity/MetadataAlbum.ts b/server/entity/MetadataAlbum.ts new file mode 100644 index 00000000..6365eea4 --- /dev/null +++ b/server/entity/MetadataAlbum.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +class MetadataAlbum { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ unique: true }) + public mbAlbumId: string; + + @Column({ nullable: true, type: 'varchar' }) + public caaUrl: string | null; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default MetadataAlbum; diff --git a/server/entity/MetadataArtist.ts b/server/entity/MetadataArtist.ts new file mode 100644 index 00000000..91373499 --- /dev/null +++ b/server/entity/MetadataArtist.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +class MetadataArtist { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ unique: true }) + public mbArtistId: string; + + @Column({ nullable: true, type: 'varchar' }) + public tmdbPersonId: string | null; + + @Column({ nullable: true, type: 'varchar' }) + public tmdbThumb: string | null; + + @Column({ nullable: true, type: 'datetime' }) + public tmdbUpdatedAt: Date | null; + + @Column({ nullable: true, type: 'varchar' }) + public tadbThumb: string | null; + + @Column({ nullable: true, type: 'varchar' }) + public tadbCover: string | null; + + @Column({ nullable: true, type: 'datetime' }) + public tadbUpdatedAt: Date | null; + + @CreateDateColumn() + public createdAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default MetadataArtist; diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts index aab72006..229ee8cb 100644 --- a/server/entity/OverrideRule.ts +++ b/server/entity/OverrideRule.ts @@ -12,6 +12,9 @@ class OverrideRule { @Column({ type: 'int', nullable: true }) public sonarrServiceId?: number; + @Column({ type: 'int', nullable: true }) + public lidarrServiceId?: number; + @Column({ nullable: true }) public users?: string; diff --git a/server/index.ts b/server/index.ts index 0b2c7770..152fa2d1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,8 +23,7 @@ import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; import avatarproxy from '@server/routes/avatarproxy'; import caaproxy from '@server/routes/caaproxy'; -import fanartproxy from '@server/routes/fanartproxy'; -import lidarrproxy from '@server/routes/lidarrproxy'; +import tadbproxy from '@server/routes/tadbproxy'; import tmdbproxy from '@server/routes/tmdbproxy'; import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; @@ -241,8 +240,7 @@ app server.use('/tmdbproxy', clearCookies, tmdbproxy); server.use('/avatarproxy', clearCookies, avatarproxy); server.use('/caaproxy', clearCookies, caaproxy); - server.use('/lidarrproxy', clearCookies, lidarrproxy); - server.use('/fanartproxy', clearCookies, fanartproxy); + server.use('/tadbproxy', clearCookies, tadbproxy); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index bca3597b..4beebeb9 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -64,7 +64,10 @@ export interface CacheItem { export interface CacheResponse { apiCaches: CacheItem[]; - imageCache: Record<'tmdb' | 'avatar' | 'caa' | 'lidarr' | 'fanart', { size: number; imageCount: number }>; + imageCache: Record< + 'tmdb' | 'avatar' | 'caa' | 'tadb', + { size: number; imageCount: number } + >; dnsCache: { stats: DnsStats | undefined; entries: DnsEntries | undefined; diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 3a9c6684..cfd5838f 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -5,6 +5,7 @@ export type AvailableCacheIds = | 'musicbrainz' | 'listenbrainz' | 'covertartarchive' + | 'tadb' | 'radarr' | 'sonarr' | 'lidarr' @@ -64,6 +65,10 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + tadb: new Cache('tadb', 'The Audio Database API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), lidarr: new Cache('lidarr', 'Lidarr API'), diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 0c5b4977..39b3e287 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -329,7 +329,7 @@ class ImageProxy { }); await promises.mkdir(dir, { recursive: true }); - await promises.writeFile(filename, new Uint8Array(buffer)); + await promises.writeFile(filename, buffer); } private getCacheKey(path: string) { @@ -340,9 +340,7 @@ class ImageProxy { const hash = createHash('sha256'); for (const item of items) { if (typeof item === 'number') hash.update(String(item)); - else if (Buffer.isBuffer(item)) { - hash.update(item.toString()); - } else { + else { hash.update(item); } } diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 7b7a679f..21a129f3 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -71,7 +71,9 @@ class EmailAgent const mediaType = payload.media ? payload.media.mediaType === MediaType.MOVIE ? 'movie' - : 'series' + : payload.media.mediaType === MediaType.TV + ? 'series' + : 'album' : undefined; const is4k = payload.request?.is4k; diff --git a/server/lib/search.ts b/server/lib/search.ts index 3be208aa..26f2a605 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -239,28 +239,17 @@ searchProviders.push({ const musicbrainz = new MusicBrainz(); try { - const response = await musicbrainz.searchMulti({ + const albumResults = await musicbrainz.searchAlbum({ query: query || '', + limit: 20, }); - const results: CombinedSearchResponse['results'] = response.map( - (result) => { - if (result.artist) { - return { - ...result.artist, - media_type: 'artist', - } as MbArtistResult; - } - - if (result.album) { - return { - ...result.album, - media_type: 'album', - } as MbAlbumResult; - } - - throw new Error('Invalid search result type'); - } + const results: CombinedSearchResponse['results'] = albumResults.map( + (album) => + ({ + ...album, + media_type: 'album', + } as MbAlbumResult) ); return { diff --git a/server/migration/postgres/1714310036946-AddMusicSupport.ts b/server/migration/postgres/1714310036946-AddMusicSupport.ts new file mode 100644 index 00000000..cc1e1343 --- /dev/null +++ b/server/migration/postgres/1714310036946-AddMusicSupport.ts @@ -0,0 +1,125 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMusicSupport1714310036946 implements MigrationInterface { + name = 'AddMusicSupport1714310036946'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaLimit" integer`); + await queryRunner.query(`ALTER TABLE "user" ADD "musicQuotaDays" integer`); + + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_7ff2d11f6a83cb52386eaebe74"` + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_41a289eb1fa489c1bc6f38d9c3"` + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_7157aad07c73f6a6ae3bbd5ef5"` + ); + + await queryRunner.query( + `ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" DROP NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD "mbId" character varying` + ); + await queryRunner.query(`DROP INDEX IF EXISTS "unique_user_db"`); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_watchlist_mbid" ON "watchlist" ("mbId")` + ); + + await queryRunner.query( + `ALTER TABLE "media" ALTER COLUMN "tmdbId" DROP NOT NULL` + ); + await queryRunner.query(`ALTER TABLE "media" ADD "mbId" character varying`); + await queryRunner.query( + `ALTER TABLE "media" ADD CONSTRAINT "CHK_media_type" CHECK ("mediaType" IN ('movie', 'tv', 'music'))` + ); + + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_media_mbid" ON "media" ("mbId")` + ); + + await queryRunner.query( + `CREATE TABLE "metadata_album" ( + "id" SERIAL NOT NULL, + "mbAlbumId" character varying NOT NULL, + "caaUrl" character varying NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_metadata_album" PRIMARY KEY ("id") + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_album_mbAlbumId" ON "metadata_album" ("mbAlbumId")` + ); + + await queryRunner.query( + `CREATE TABLE "metadata_artist" ( + "id" SERIAL NOT NULL, + "mbArtistId" character varying NOT NULL, + "tmdbPersonId" character varying, + "tmdbThumb" character varying, + "tmdbUpdatedAt" TIMESTAMP, + "tadbThumb" character varying, + "tadbCover" character varying, + "tadbUpdatedAt" TIMESTAMP, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_metadata_artist" PRIMARY KEY ("id") + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_artist_mbArtistId" ON "metadata_artist" ("mbArtistId")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_metadata_album_mbAlbumId"` + ); + await queryRunner.query(`DROP TABLE IF EXISTS "metadata_album"`); + + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_watchlist_mbid"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_media_mbid"`); + + await queryRunner.query( + `ALTER TABLE "watchlist" DROP CONSTRAINT IF EXISTS "UNIQUE_USER_FOREIGN"` + ); + await queryRunner.query( + `ALTER TABLE "media" DROP CONSTRAINT IF EXISTS "CHK_media_type"` + ); + + await queryRunner.query( + `DELETE FROM "watchlist" WHERE "mediaType" = 'music'` + ); + + await queryRunner.query(`ALTER TABLE "media" DROP COLUMN "mbId"`); + await queryRunner.query(`ALTER TABLE "watchlist" DROP COLUMN "mbId"`); + await queryRunner.query( + `ALTER TABLE "watchlist" ALTER COLUMN "tmdbId" SET NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "media" ALTER COLUMN "tmdbId" SET NOT NULL` + ); + + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaDays"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "musicQuotaLimit"`); + } +} diff --git a/server/migration/postgres/1734805738349-AddOverrideRules.ts b/server/migration/postgres/1734805738349-AddOverrideRules.ts index b9cc4721..62c65675 100644 --- a/server/migration/postgres/1734805738349-AddOverrideRules.ts +++ b/server/migration/postgres/1734805738349-AddOverrideRules.ts @@ -5,7 +5,7 @@ export class AddOverrideRules1734805738349 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))` + `CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "lidarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))` ); } diff --git a/server/migration/sqlite/1714310036946-AddMusicSupport.ts b/server/migration/sqlite/1714310036946-AddMusicSupport.ts index 811ff99a..d9681cc0 100644 --- a/server/migration/sqlite/1714310036946-AddMusicSupport.ts +++ b/server/migration/sqlite/1714310036946-AddMusicSupport.ts @@ -25,7 +25,9 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), - CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById") + CONSTRAINT "UNIQUE_USER_FOREIGN" UNIQUE ("mbId", "requestedById"), + CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION )` ); @@ -71,7 +73,8 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, - "jellyfinMediaId4k" varchar + "jellyfinMediaId4k" varchar, + CONSTRAINT "CHK_media_type" CHECK ("mediaType" IN ('movie', 'tv', 'music')) )` ); @@ -108,9 +111,47 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { await queryRunner.query( `CREATE INDEX "IDX_media_mbid" ON "media" ("mbId")` ); + + await queryRunner.query( + `CREATE TABLE "metadata_album" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "mbAlbumId" varchar NOT NULL, + "caaUrl" varchar NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_album_mbAlbumId" ON "metadata_album" ("mbAlbumId")` + ); + + await queryRunner.query( + `CREATE TABLE "metadata_artist" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "mbArtistId" varchar NOT NULL, + "tmdbPersonId" varchar, + "tmdbThumb" varchar, + "tmdbUpdatedAt" datetime, + "tadbThumb" varchar, + "tadbCover" varchar, + "tadbUpdatedAt" datetime, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')) + )` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_metadata_artist_mbArtistId" ON "metadata_artist" ("mbArtistId")` + ); } public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_metadata_album_mbAlbumId"`); + await queryRunner.query(`DROP TABLE "metadata_album"`); + + await queryRunner.query(`DROP INDEX "IDX_metadata_artist_mbArtistId"`); + await queryRunner.query(`DROP TABLE "metadata_artist"`); + await queryRunner.query(`DROP INDEX "IDX_watchlist_mbid"`); await queryRunner.query(`DROP INDEX "IDX_media_mbid"`); @@ -125,7 +166,9 @@ export class AddMusicSupport1714310036946 implements MigrationInterface { "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, - CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById") + CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), + CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION )` ); diff --git a/server/migration/sqlite/1734805733535-AddOverrideRules.ts b/server/migration/sqlite/1734805733535-AddOverrideRules.ts index 692dc875..ae132542 100644 --- a/server/migration/sqlite/1734805733535-AddOverrideRules.ts +++ b/server/migration/sqlite/1734805733535-AddOverrideRules.ts @@ -5,7 +5,7 @@ export class AddOverrideRules1734805733535 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` + `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "lidarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` ); } diff --git a/server/models/Artist.ts b/server/models/Artist.ts index 2abc0677..38b19231 100644 --- a/server/models/Artist.ts +++ b/server/models/Artist.ts @@ -1,53 +1,34 @@ -import type { MbArtistDetails } from '@server/api/musicbrainz/interfaces'; import type Media from '@server/entity/Media'; -export interface ArtistDetailsType { - id: string; +export interface ArtistDetails { name: string; - type: string; - overview: string; - disambiguation: string; - status: string; - genres: string[]; - images: { - CoverType: string; - Url: string; - }[]; - links: { - target: string; - type: string; - }[]; - Albums?: { + area?: string; + artist: { + name: string; + artist_mbid: string; + begin_year?: number; + end_year?: number; + area?: string; + }; + alsoKnownAs?: string[]; + biography?: string; + wikipedia?: { + content: string; + }; + artistThumb?: string | null; + artistBackdrop?: string | null; + profilePath?: string; + releaseGroups?: { id: string; title: string; - type: string; - releasedate: string; - images?: { - CoverType: string; - Url: string; + 'first-release-date': string; + 'artist-credit': { + name: string; }[]; + 'primary-type': string; + secondary_types?: string[]; + total_listen_count?: number; + posterPath?: string; mediaInfo?: Media; - onUserWatchlist?: boolean; }[]; } - -export const mapArtistDetails = ( - artist: MbArtistDetails -): ArtistDetailsType => ({ - id: artist.id, - name: artist.artistname, - type: artist.type, - overview: artist.overview, - disambiguation: artist.disambiguation, - status: artist.status, - genres: artist.genres, - images: artist.images, - links: artist.links, - Albums: artist.Albums?.map((album) => ({ - id: album.Id.toLowerCase(), - title: album.Title, - type: album.Type, - releasedate: '', - images: [], - })), -}); diff --git a/server/models/Music.ts b/server/models/Music.ts index 58e73e56..80625ad8 100644 --- a/server/models/Music.ts +++ b/server/models/Music.ts @@ -1,8 +1,4 @@ -import type { - MbAlbumDetails, - MbImage, - MbLink, -} from '@server/api/musicbrainz/interfaces'; +import type { LbAlbumDetails } from '@server/api/listenbrainz/interfaces'; import type Media from '@server/entity/Media'; export interface MusicDetails { @@ -10,121 +6,120 @@ export interface MusicDetails { mbId: string; title: string; titleSlug?: string; - overview: string; - artistId: string; + mediaType: 'album'; type: string; releaseDate: string; - disambiguation: string; - genres: string[]; - secondaryTypes: string[]; - releases: { - id: string; - title: string; - status: string; - releaseDate: string; - trackCount: number; - country: string[]; - label: string[]; - media: { - format: string; - name: string; - position: number; - }[]; - tracks: { - id: string; - artistId: string; - trackName: string; - trackNumber: string; - trackPosition: number; - mediumNumber: number; - durationMs: number; - recordingId: string; - }[]; - disambiguation: string; - }[]; artist: { id: string; - artistName: string; - sortName: string; - type: 'Group' | 'Person'; - disambiguation: string; - overview: string; - genres: string[]; - status: string; - images: MbImage[]; - links: MbLink[]; - rating?: { - count: number; - value: number | null; - }; + name: string; + area?: string; + beginYear?: number; + type?: string; + }; + tracks: { + name: string; + position: number; + length: number; + recordingMbid: string; + totalListenCount: number; + totalUserCount: number; + artists: { + name: string; + mbid: string; + tmdbMapping?: { + personId: number; + profilePath: string; + }; + }[]; + }[]; + tags?: { + artist: { + artistMbid: string; + count: number; + tag: string; + }[]; + releaseGroup: { + count: number; + genreMbid: string; + tag: string; + }[]; + }; + stats?: { + totalListenCount: number; + totalUserCount: number; + listeners: { + userName: string; + listenCount: number; + }[]; }; - images: MbImage[]; - links: MbLink[]; mediaInfo?: Media; onUserWatchlist?: boolean; + posterPath?: string; + artistWikipedia?: { + content: string; + title: string; + url: string; + }; + tmdbPersonId?: number; + artistBackdrop?: string; + artistThumb?: string; } export const mapMusicDetails = ( - album: MbAlbumDetails, + album: LbAlbumDetails, media?: Media, userWatchlist?: boolean ): MusicDetails => ({ - id: album.id, - mbId: album.id, - title: album.title, - titleSlug: album.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'), - overview: album.overview, - artistId: album.artistid, + id: album.release_group_mbid, + mbId: album.release_group_mbid, + title: album.release_group_metadata.release_group.name, + titleSlug: album.release_group_metadata.release_group.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-'), + mediaType: 'album', type: album.type, - releaseDate: album.releasedate, - disambiguation: album.disambiguation, - genres: album.genres, - secondaryTypes: album.secondaryTypes ?? [], - releases: album.releases.map((release) => ({ - id: release.id, - title: release.title, - status: release.status, - releaseDate: release.releasedate, - trackCount: release.track_count, - country: release.country, - label: release.label, - media: release.media.map((medium) => ({ - format: medium.Format, - name: medium.Name, - position: medium.Position, - })), - tracks: release.tracks.map((track) => ({ - id: track.id, - artistId: track.artistid, - trackName: track.trackname, - trackNumber: track.tracknumber, - trackPosition: track.trackposition, - mediumNumber: track.mediumnumber, - durationMs: track.durationms, - recordingId: track.recordingid, - })), - disambiguation: release.disambiguation, - })), + releaseDate: album.release_group_metadata.release_group.date, artist: { - id: album.artists[0].id, - artistName: album.artists[0].artistname, - sortName: album.artists[0].sortname, - type: album.artists[0].type, - disambiguation: album.artists[0].disambiguation, - overview: album.artists[0].overview, - genres: album.artists[0].genres, - status: album.artists[0].status, - images: album.artists[0].images, - links: album.artists[0].links, - rating: album.artists[0].rating - ? { - count: album.artists[0].rating.Count, - value: album.artists[0].rating.Value, - } - : undefined, + id: album.release_group_metadata.artist.artists[0].artist_mbid, + name: album.release_group_metadata.artist.name, + area: album.release_group_metadata.artist.artists[0].area, + beginYear: album.release_group_metadata.artist.artists[0].begin_year, + type: album.release_group_metadata.artist.artists[0].type, + }, + tracks: album.mediums.flatMap((medium) => + medium.tracks.map((track) => ({ + name: track.name, + position: track.position, + length: track.length, + recordingMbid: track.recording_mbid, + totalListenCount: track.total_listen_count, + totalUserCount: track.total_user_count, + artists: track.artists.map((artist) => ({ + name: artist.artist_credit_name, + mbid: artist.artist_mbid, + })), + })) + ), + tags: { + artist: album.release_group_metadata.tag.artist.map((tag) => ({ + artistMbid: tag.artist_mbid, + count: tag.count, + tag: tag.tag, + })), + releaseGroup: album.release_group_metadata.tag.release_group.map((tag) => ({ + count: tag.count, + genreMbid: tag.genre_mbid, + tag: tag.tag, + })), + }, + stats: { + totalListenCount: album.listening_stats.total_listen_count, + totalUserCount: album.listening_stats.total_user_count, + listeners: album.listening_stats.listeners.map((listener) => ({ + userName: listener.user_name, + listenCount: listener.listen_count, + })), }, - images: album.images, - links: album.artists[0].links, mediaInfo: media, onUserWatchlist: userWatchlist, }); diff --git a/server/models/Person.ts b/server/models/Person.ts index b2aeacf2..ca208033 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -20,7 +20,21 @@ export interface PersonDetails { adult: boolean; imdbId?: string; homepage?: string; - mbArtistId?: string; + artist?: { + artistBackdrop: string | null; + artistThumb?: string; + releaseGroups?: { + secondary_types?: string[]; + id: string; + title: string; + 'first-release-date': string; + 'artist-credit': { + name: string; + }[]; + 'primary-type': string; + posterPath?: string; + }[]; + }; } export interface PersonCredit { diff --git a/server/models/Search.ts b/server/models/Search.ts index b2cf171f..16eabd1d 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -1,9 +1,6 @@ import type { - MbAlbumDetails, MbAlbumResult, - MbArtistDetails, MbArtistResult, - MbImage, } from '@server/api/musicbrainz/interfaces'; import type { TmdbCollectionResult, @@ -87,35 +84,34 @@ export interface PersonResult { export interface ArtistResult extends MbSearchResult { mediaType: 'artist'; - artistname: string; - overview: string; - disambiguation: string; + tmdbPersonId?: number; + name: string; type: 'Group' | 'Person'; - status: string; - sortname: string; - genres: string[]; - images: MbImage[]; - artistimage?: string; - rating?: { - Count: number; - Value: number | null; - }; + 'sort-name': string; + country?: string; + disambiguation?: string; + artistThumb?: string | null; + artistBackdrop?: string | null; mediaInfo?: Media; } export interface AlbumResult extends MbSearchResult { mediaType: 'album'; title: string; - artistid: string; - artistname?: string; - type: string; - releasedate: string; - disambiguation: string; - genres: string[]; - images: MbImage[]; - secondarytypes: string[]; + 'primary-type': 'Album' | 'Single' | 'EP'; + 'first-release-date': string; + releaseDate?: string; + 'artist-credit': { + name: string; + artist: { + id: string; + name: string; + 'sort-name': string; + }; + }[]; + posterPath?: string; + needsCoverArt?: boolean; mediaInfo?: Media; - overview?: string; } export type Results = @@ -203,22 +199,18 @@ export const mapPersonResult = ( }); export const mapArtistResult = ( - artistResult: MbArtistResult, - media?: Media + artistResult: MbArtistResult ): ArtistResult => ({ id: artistResult.id, score: artistResult.score, mediaType: 'artist', - artistname: artistResult.artistname, - overview: artistResult.overview, - disambiguation: artistResult.disambiguation, + name: artistResult.name, type: artistResult.type, - status: artistResult.status, - sortname: artistResult.sortname, - genres: artistResult.genres, - images: artistResult.images, - rating: artistResult.rating, - mediaInfo: media, + 'sort-name': artistResult['sort-name'], + country: artistResult.country, + disambiguation: artistResult.disambiguation, + artistThumb: artistResult.artistThumb, + artistBackdrop: artistResult.artistBackdrop, }); export const mapAlbumResult = ( @@ -229,16 +221,12 @@ export const mapAlbumResult = ( score: albumResult.score, mediaType: 'album', title: albumResult.title, - artistid: albumResult.artistid, - artistname: albumResult.artists?.[0]?.artistname, - type: albumResult.type, - releasedate: albumResult.releasedate, - disambiguation: albumResult.disambiguation, - genres: albumResult.genres, - images: albumResult.images, - secondarytypes: albumResult.secondarytypes, + 'primary-type': albumResult['primary-type'], + 'first-release-date': albumResult['first-release-date'], + 'artist-credit': albumResult['artist-credit'], + posterPath: albumResult.posterPath, + needsCoverArt: !albumResult.posterPath, mediaInfo: media, - overview: albumResult.artists?.[0]?.overview, }); const isTmdbMovie = ( @@ -289,7 +277,7 @@ const isTmdbCollection = ( return result.media_type === 'collection'; }; -const isLidarrArtist = ( +const isMbArtist = ( result: | TmdbMovieResult | TmdbTvResult @@ -301,7 +289,7 @@ const isLidarrArtist = ( return result.media_type === 'artist'; }; -const isLidarrAlbum = ( +const isMbAlbum = ( result: | TmdbMovieResult | TmdbTvResult @@ -346,9 +334,9 @@ export const mapSearchResults = async ( return mapPersonResult(result); } else if (isTmdbCollection(result)) { return mapCollectionResult(result); - } else if (isLidarrArtist(result)) { + } else if (isMbArtist(result)) { return mapArtistResult(result); - } else if (isLidarrAlbum(result)) { + } else if (isMbAlbum(result)) { return mapAlbumResult( result, media?.find( @@ -405,6 +393,7 @@ export const mapPersonDetailsToResult = ( personDetails: TmdbPersonDetails ): TmdbPersonResult => ({ id: personDetails.id, + known_for_department: personDetails.known_for_department, media_type: 'person', name: personDetails.name, popularity: personDetails.popularity, @@ -412,39 +401,3 @@ export const mapPersonDetailsToResult = ( profile_path: personDetails.profile_path, known_for: [], }); - -export const mapArtistDetailsToResult = ( - artistDetails: MbArtistDetails -): MbArtistResult => ({ - id: artistDetails.id, - score: 100, // Default score since we're mapping details - media_type: 'artist', - artistname: artistDetails.artistname, - overview: artistDetails.overview, - disambiguation: artistDetails.disambiguation, - type: artistDetails.type, - status: artistDetails.status, - sortname: artistDetails.sortname, - genres: artistDetails.genres, - images: artistDetails.images, - links: artistDetails.links, - rating: artistDetails.rating, -}); - -export const mapAlbumDetailsToResult = ( - albumDetails: MbAlbumDetails -): MbAlbumResult => ({ - id: albumDetails.id, - score: 100, - media_type: 'album', - title: albumDetails.title, - artistid: albumDetails.artistid, - artists: albumDetails.artists, - type: albumDetails.type, - releasedate: albumDetails.releasedate, - disambiguation: albumDetails.disambiguation, - genres: albumDetails.genres, - images: albumDetails.images, - secondarytypes: albumDetails.secondarytypes, - overview: albumDetails.overview || albumDetails.artists?.[0]?.overview || '', -}); diff --git a/server/routes/artist.ts b/server/routes/artist.ts new file mode 100644 index 00000000..3af60a56 --- /dev/null +++ b/server/routes/artist.ts @@ -0,0 +1,169 @@ +import ListenBrainzAPI from '@server/api/listenbrainz'; +import type { LbReleaseGroupExtended } from '@server/api/listenbrainz/interfaces'; +import MusicBrainz from '@server/api/musicbrainz'; +import TheAudioDb from '@server/api/theaudiodb'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; +import logger from '@server/logger'; +import { Router } from 'express'; +import { In } from 'typeorm'; + +const artistRoutes = Router(); + +artistRoutes.get('/:id', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); + const musicbrainz = new MusicBrainz(); + const theAudioDb = TheAudioDb.getInstance(); + + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 20; + const initialItemsPerType = 20; + const albumType = req.query.albumType as string | undefined; + + try { + const [artistData, metadataArtist] = await Promise.all([ + listenbrainz.getArtist(req.params.id), + getRepository(MetadataArtist).findOne({ + where: { mbArtistId: req.params.id }, + select: ['mbArtistId', 'tadbThumb', 'tadbCover', 'tmdbThumb'], + }), + ]); + + if (!artistData) { + throw new Error('Artist not found'); + } + + const groupedReleaseGroups = artistData.releaseGroups.reduce((acc, rg) => { + const type = rg.secondary_types?.length + ? rg.secondary_types[0] + : rg.type || 'Other'; + + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(rg); + return acc; + }, {} as Record); + + Object.keys(groupedReleaseGroups).forEach((type) => { + groupedReleaseGroups[type].sort((a, b) => { + const dateA = a.date ? new Date(a.date).getTime() : 0; + const dateB = b.date ? new Date(b.date).getTime() : 0; + return dateB - dateA; + }); + }); + + let releaseGroupsToProcess: LbReleaseGroupExtended[]; + let totalCount; + let totalPages; + + if (albumType) { + const filteredReleaseGroups = groupedReleaseGroups[albumType] || []; + totalCount = filteredReleaseGroups.length; + totalPages = Math.ceil(totalCount / pageSize); + + releaseGroupsToProcess = filteredReleaseGroups.slice( + (page - 1) * pageSize, + page * pageSize + ); + } else { + releaseGroupsToProcess = []; + Object.entries(groupedReleaseGroups).forEach(([, releases]) => { + releaseGroupsToProcess.push(...releases.slice(0, initialItemsPerType)); + }); + + totalCount = Object.values(groupedReleaseGroups).reduce( + (sum, releases) => sum + releases.length, + 0 + ); + totalPages = 1; + } + + const mbIds = releaseGroupsToProcess.map((rg) => rg.mbid); + + const [artistWikipedia, artistImages, relatedMedia, albumMetadata] = + await Promise.all([ + musicbrainz + .getArtistWikipediaExtract({ + artistMbid: req.params.id, + language: req.locale, + }) + .catch(() => null), + !metadataArtist?.tadbThumb && !metadataArtist?.tadbCover + ? theAudioDb.getArtistImages(req.params.id) + : theAudioDb.getArtistImagesFromCache(req.params.id), + Media.getRelatedMedia(req.user, mbIds), + getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(mbIds) }, + cache: true, + }), + ]); + + const metadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) + ); + + const mediaMap = new Map(relatedMedia.map((media) => [media.mbId, media])); + + const mappedReleaseGroups = releaseGroupsToProcess.map((releaseGroup) => { + const metadata = metadataMap.get(releaseGroup.mbid); + const coverArtUrl = metadata?.caaUrl || null; + + return { + id: releaseGroup.mbid, + mediaType: 'album', + title: releaseGroup.name, + 'first-release-date': releaseGroup.date, + 'artist-credit': [{ name: releaseGroup.artist_credit_name }], + 'primary-type': releaseGroup.type || 'Other', + secondary_types: releaseGroup.secondary_types || [], + total_listen_count: releaseGroup.total_listen_count || 0, + posterPath: coverArtUrl, + needsCoverArt: !coverArtUrl, + mediaInfo: mediaMap.get(releaseGroup.mbid), + }; + }); + + const typeCounts = Object.fromEntries( + Object.entries(groupedReleaseGroups).map(([type, releases]) => [ + type, + releases.length, + ]) + ); + + return res.status(200).json({ + ...artistData, + wikipedia: artistWikipedia, + artistThumb: + metadataArtist?.tmdbThumb ?? + metadataArtist?.tadbThumb ?? + artistImages?.artistThumb ?? + null, + artistBackdrop: + metadataArtist?.tadbCover ?? artistImages?.artistBackground ?? null, + releaseGroups: mappedReleaseGroups, + pagination: { + page, + pageSize, + totalItems: totalCount, + totalPages, + albumType, + }, + typeCounts, + }); + } catch (e) { + logger.error('Something went wrong retrieving artist details', { + label: 'Artist API', + errorMessage: e.message, + artistId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve artist.', + }); + } +}); + +export default artistRoutes; diff --git a/server/routes/caaproxy.ts b/server/routes/caaproxy.ts index 4a1ecd9c..18306eca 100644 --- a/server/routes/caaproxy.ts +++ b/server/routes/caaproxy.ts @@ -3,14 +3,17 @@ import logger from '@server/logger'; import { Router } from 'express'; const router = Router(); -const caaImageProxy = new ImageProxy('caa', 'http://coverartarchive.org', { +const caaImageProxy = new ImageProxy('caa', 'https://archive.org/download', { rateLimitOptions: { maxRPS: 50, }, }); +/** + * Image Proxy + */ router.get('/*', async (req, res) => { - const imagePath = req.path; + const imagePath = req.path.replace('/download', ''); try { const imageData = await caaImageProxy.getImage(imagePath); @@ -28,7 +31,7 @@ router.get('/*', async (req, res) => { imagePath, errorMessage: e.message, }); - res.status(500).end(); + res.status(500).send(); } }); diff --git a/server/routes/coverart.ts b/server/routes/coverart.ts new file mode 100644 index 00000000..610a4406 --- /dev/null +++ b/server/routes/coverart.ts @@ -0,0 +1,28 @@ +import CoverArtArchive from '@server/api/coverartarchive'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const coverArtRoutes = Router(); + +coverArtRoutes.get('/batch/:ids', async (req, res) => { + const coverArtArchive = CoverArtArchive.getInstance(); + const ids = (req.params.ids || '').split(',').filter(Boolean); + + if (!ids.length) { + return res.status(200).json({}); + } + + try { + const coverResults = await coverArtArchive.batchGetCoverArt(ids); + return res.status(200).json(coverResults); + } catch (e) { + logger.error('Error fetching batch cover art', { + label: 'CoverArtArchive', + errorMessage: e instanceof Error ? e.message : 'Unknown error', + count: ids.length, + }); + return res.status(200).json({}); + } +}); + +export default coverArtRoutes; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e4e893a0..8ffe7b33 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,12 +1,15 @@ import ListenBrainzAPI from '@server/api/listenbrainz'; -import MusicBrainz from '@server/api/musicbrainz'; import PlexTvAPI from '@server/api/plextv'; +import TheAudioDb from '@server/api/theaudiodb'; import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import TmdbPersonMapper from '@server/api/themoviedb/personMapper'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { @@ -26,6 +29,7 @@ import { mapNetwork } from '@server/models/Tv'; import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; +import { In } from 'typeorm'; import { z } from 'zod'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { @@ -858,7 +862,246 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( discoverRoutes.get('/music', async (req, res, next) => { const listenbrainz = new ListenBrainzAPI(); - const musicbrainz = new MusicBrainz(); + + try { + const page = Number(req.query.page) || 1; + const pageSize = 20; + const sortBy = (req.query.sortBy as string) || 'release_date.desc'; + const days = Number(req.query.days) || 30; + const genreFilter = req.query.genre as string | undefined; + const showOnlyWithCovers = req.query.onlyWithCoverArt === 'true'; + const releaseDateGte = req.query.releaseDateGte as string | undefined; + const releaseDateLte = req.query.releaseDateLte as string | undefined; + + const [field, direction] = sortBy.split('.'); + let apiSortField = 'release_date'; + + if (field === 'title') { + apiSortField = 'release_name'; + } else if (field === 'artist') { + apiSortField = 'artist_credit_name'; + } + + const freshReleasesData = await listenbrainz.getFreshReleases({ + offset: 0, + count: 20, + days, + sort: apiSortField, + }); + + let filteredReleases = freshReleasesData.payload.releases; + + if (genreFilter) { + const genres = genreFilter.split(','); + filteredReleases = freshReleasesData.payload.releases.filter( + (release) => { + let releaseType; + + if (release.release_group_secondary_type) { + releaseType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + releaseType = release.release_tags[0]; + } else { + releaseType = release.release_group_primary_type || 'Album'; + } + + return genres.includes(releaseType); + } + ); + } + + if (releaseDateGte || releaseDateLte) { + filteredReleases = filteredReleases.filter((release) => { + if (!release.release_date) { + return false; + } + + const releaseDate = new Date(release.release_date); + + if (releaseDateGte) { + const gteDate = new Date(releaseDateGte); + if (releaseDate < gteDate) { + return false; + } + } + + if (releaseDateLte) { + const lteDate = new Date(releaseDateLte); + if (releaseDate > lteDate) { + return false; + } + } + + return true; + }); + } + + filteredReleases.sort((a, b) => { + const multiplier = direction === 'asc' ? 1 : -1; + + switch (field) { + case 'release_date': { + const dateA = a.release_date ? new Date(a.release_date).getTime() : 0; + const dateB = b.release_date ? new Date(b.release_date).getTime() : 0; + return (dateA - dateB) * multiplier; + } + case 'title': { + return ( + (a.release_name ?? '').localeCompare(b.release_name ?? '') * + multiplier + ); + } + case 'artist': { + return ( + (a.artist_credit_name ?? '').localeCompare( + b.artist_credit_name ?? '' + ) * multiplier + ); + } + default: + return 0; + } + }); + + const mbIds = filteredReleases + .map((release) => release.release_group_mbid) + .filter(Boolean); + + const existingMetadata = + mbIds.length > 0 + ? await getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(mbIds) }, + select: ['mbAlbumId', 'caaUrl'], + cache: true, + }) + : []; + + const metadataMap = new Map( + existingMetadata.map((meta) => [meta.mbAlbumId, meta]) + ); + + if (showOnlyWithCovers) { + filteredReleases = filteredReleases.filter((release) => { + if (!release.release_group_mbid) { + return false; + } + const metadata = metadataMap.get(release.release_group_mbid); + return !!metadata?.caaUrl; + }); + } + + const totalResults = filteredReleases.length; + const totalPages = Math.ceil(totalResults / pageSize); + + const offset = (page - 1) * pageSize; + const paginatedReleases = filteredReleases.slice(offset, offset + pageSize); + + const paginatedMbIds = paginatedReleases + .map((release) => release.release_group_mbid) + .filter(Boolean); + + if (paginatedMbIds.length === 0) { + const results = paginatedReleases.map((release) => { + let secondaryType; + if (release.release_group_secondary_type) { + secondaryType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + secondaryType = release.release_tags[0]; + } + + return { + id: null, + mediaType: 'album', + 'primary-type': release.release_group_primary_type || 'Album', + secondaryType, + title: release.release_name, + 'artist-credit': [{ name: release.artist_credit_name }], + releaseDate: release.release_date, + posterPath: undefined, + }; + }); + + return res.json({ + page, + totalPages, + totalResults, + results, + }); + } + + const media = await Media.getRelatedMedia(req.user, paginatedMbIds); + + const mediaMap = new Map( + media.map((mediaItem) => [mediaItem.mbId, mediaItem]) + ); + + const results = paginatedReleases.map((release) => { + if (!release.release_group_mbid) { + let secondaryType; + if (release.release_group_secondary_type) { + secondaryType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + secondaryType = release.release_tags[0]; + } + + return { + id: null, + mediaType: 'album', + 'primary-type': release.release_group_primary_type || 'Album', + secondaryType, + title: release.release_name, + 'artist-credit': [{ name: release.artist_credit_name }], + releaseDate: release.release_date, + posterPath: undefined, + }; + } + + const metadata = metadataMap.get(release.release_group_mbid); + const hasCoverArt = !!metadata?.caaUrl; + + let secondaryType; + if (release.release_group_secondary_type) { + secondaryType = release.release_group_secondary_type; + } else if (release.release_tags && release.release_tags.length > 0) { + secondaryType = release.release_tags[0]; + } + + return { + id: release.release_group_mbid, + mediaType: 'album', + 'primary-type': release.release_group_primary_type || 'Album', + secondaryType, + title: release.release_name, + 'artist-credit': [{ name: release.artist_credit_name }], + artistId: release.artist_mbids?.[0], + mediaInfo: mediaMap.get(release.release_group_mbid), + releaseDate: release.release_date, + posterPath: metadata?.caaUrl || null, + needsCoverArt: !hasCoverArt, + }; + }); + + return res.json({ + page, + totalPages, + totalResults, + results, + }); + } catch (e) { + logger.error('Failed to retrieve fresh music releases', { + label: 'API', + error: e instanceof Error ? e.message : 'Unknown error', + stack: e instanceof Error ? e.stack : undefined, + }); + return next({ + status: 500, + message: 'Unable to retrieve fresh music releases.', + }); + } +}); + +discoverRoutes.get('/music/albums', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); try { const page = Number(req.query.page) || 1; @@ -866,113 +1109,110 @@ discoverRoutes.get('/music', async (req, res, next) => { const offset = (page - 1) * pageSize; const sortBy = (req.query.sortBy as string) || 'listen_count.desc'; - const data = await listenbrainz.getTopAlbums({ + const topAlbumsData = await listenbrainz.getTopAlbums({ offset, count: pageSize, - range: 'week', + range: 'month', }); - const media = await Media.getRelatedMedia( - req.user, - data.payload.release_groups.map((album) => album.release_group_mbid) - ); + const mbIds = topAlbumsData.payload.release_groups + .map((album) => album.release_group_mbid) + .filter((id): id is string => !!id); - const albumDetailsPromises = data.payload.release_groups.map( - async (album) => { - try { - const details = await musicbrainz.getAlbum({ - albumId: album.release_group_mbid, - }); + if (mbIds.length === 0) { + const results = topAlbumsData.payload.release_groups.map((album) => ({ + id: null, + mediaType: 'album', + 'primary-type': 'Album', + title: album.release_group_name, + 'artist-credit': [{ name: album.artist_name }], + listenCount: album.listen_count, + posterPath: undefined, + })); - const images = - details.images?.length > 0 - ? details.images.filter((img) => img.CoverType === 'Cover') - : album.caa_id - ? [ - { - CoverType: 'Cover', - Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`, - }, - ] - : []; - - return { - id: album.release_group_mbid, - mediaType: 'album', - type: 'Album', - title: album.release_group_name, - artistname: album.artist_name, - artistId: album.artist_mbids[0], - releasedate: details.releasedate || '', - images, - mediaInfo: media?.find( - (med) => med.mbId === album.release_group_mbid - ), - listenCount: album.listen_count, - }; - } catch (e) { - return { - id: album.release_group_mbid, - mediaType: 'album', - type: 'Album', - title: album.release_group_name, - artistname: album.artist_name, - artistId: album.artist_mbids[0], - releasedate: '', - images: album.caa_id - ? [ - { - CoverType: 'Cover', - Url: `https://coverartarchive.org/release/${album.caa_release_mbid}/front`, - }, - ] - : [], - mediaInfo: media?.find( - (med) => med.mbId === album.release_group_mbid - ), - listenCount: album.listen_count, - }; - } - } - ); - - const results = await Promise.all(albumDetailsPromises); - - switch (sortBy) { - case 'listen_count.asc': - results.sort((a, b) => a.listenCount - b.listenCount); - break; - case 'listen_count.desc': - results.sort((a, b) => b.listenCount - a.listenCount); - break; - case 'title.asc': - results.sort((a, b) => a.title.localeCompare(b.title)); - break; - case 'title.desc': - results.sort((a, b) => b.title.localeCompare(a.title)); - break; - case 'release_date.asc': - results.sort((a, b) => - (a.releasedate || '').localeCompare(b.releasedate || '') - ); - break; - case 'release_date.desc': - results.sort((a, b) => - (b.releasedate || '').localeCompare(a.releasedate || '') - ); - break; + return res.json({ + page, + totalPages: Math.ceil(topAlbumsData.payload.count / pageSize), + totalResults: topAlbumsData.payload.count, + results, + }); } - return res.status(200).json({ + const [existingMetadata, media] = await Promise.all([ + getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(mbIds) }, + select: ['mbAlbumId', 'caaUrl'], + cache: true, + }), + Media.getRelatedMedia(req.user, mbIds), + ]); + + const metadataMap = new Map( + existingMetadata.map((meta) => [meta.mbAlbumId, meta]) + ); + + const mediaMap = new Map( + media.map((mediaItem) => [mediaItem.mbId, mediaItem]) + ); + + const results = topAlbumsData.payload.release_groups.map((album) => { + if (!album.release_group_mbid) { + return { + id: null, + mediaType: 'album', + 'primary-type': 'Album', + title: album.release_group_name, + 'artist-credit': [{ name: album.artist_name }], + listenCount: album.listen_count, + posterPath: undefined, + }; + } + + const metadata = metadataMap.get(album.release_group_mbid); + const hasCoverArt = !!metadata?.caaUrl; + + return { + id: album.release_group_mbid, + mediaType: 'album', + 'primary-type': 'Album', + title: album.release_group_name, + 'artist-credit': [{ name: album.artist_name }], + artistId: album.artist_mbids[0], + mediaInfo: mediaMap.get(album.release_group_mbid), + listenCount: album.listen_count, + posterPath: metadata?.caaUrl || null, + needsCoverArt: !hasCoverArt, + }; + }); + + if (sortBy) { + const [field, direction] = sortBy.split('.'); + const multiplier = direction === 'asc' ? 1 : -1; + + results.sort((a, b) => { + switch (field) { + case 'listen_count': { + return (a.listenCount - b.listenCount) * multiplier; + } + case 'title': { + return (a.title ?? '').localeCompare(b.title ?? '') * multiplier; + } + default: + return 0; + } + }); + } + + return res.json({ page, - totalPages: Math.ceil(data.payload.count / pageSize), - totalResults: data.payload.count, + totalPages: Math.ceil(topAlbumsData.payload.count / pageSize), + totalResults: topAlbumsData.payload.count, results, }); } catch (e) { - logger.debug('Something went wrong retrieving popular music', { + logger.error('Failed to retrieve popular music', { label: 'API', - errorMessage: e.message, + error: e instanceof Error ? e.message : 'Unknown error', }); return next({ status: 500, @@ -981,6 +1221,167 @@ discoverRoutes.get('/music', async (req, res, next) => { } }); +discoverRoutes.get('/music/artists', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); + const personMapper = TmdbPersonMapper.getInstance(); + const theAudioDb = TheAudioDb.getInstance(); + + try { + const page = Number(req.query.page) || 1; + const pageSize = 20; + const offset = (page - 1) * pageSize; + const sortBy = (req.query.sortBy as string) || 'listen_count.desc'; + + const topArtistsData = await listenbrainz.getTopArtists({ + offset, + count: pageSize, + range: 'month', + }); + + const mbIds = topArtistsData.payload.artists + .map((artist) => artist.artist_mbid) + .filter(Boolean); + + if (mbIds.length === 0) { + return res.status(200).json({ + page, + totalPages: Math.ceil(topArtistsData.payload.count / pageSize), + totalResults: topArtistsData.payload.count, + results: topArtistsData.payload.artists.map((artist) => ({ + id: null, + mediaType: 'artist', + name: artist.artist_name, + listenCount: artist.listen_count, + })), + }); + } + + const [media, artistMetadata] = await Promise.all([ + Media.getRelatedMedia(req.user, mbIds), + getRepository(MetadataArtist).find({ + where: { mbArtistId: In(mbIds) }, + }), + ]); + + const mediaMap = new Map( + media.map((mediaItem) => [mediaItem.mbId, mediaItem]) + ); + + const metadataMap = new Map( + artistMetadata.map((metadata) => [metadata.mbArtistId, metadata]) + ); + + const artistsNeedingImages = mbIds.filter((id) => { + const metadata = metadataMap.get(id); + return !metadata?.tadbThumb && !metadata?.tadbCover; + }); + + const artistsForPersonMapping = topArtistsData.payload.artists + .filter((artist) => artist.artist_mbid) + .filter((artist) => { + const metadata = metadataMap.get(artist.artist_mbid); + return !metadata?.tmdbPersonId; + }) + .map((artist) => ({ + artistId: artist.artist_mbid, + artistName: artist.artist_name, + })); + + interface ArtistImageResults { + [key: string]: { + artistThumb?: string; + artistBackground?: string; + }; + } + + const [artistImageResults] = await Promise.all([ + artistsNeedingImages.length > 0 + ? (theAudioDb.batchGetArtistImages( + artistsNeedingImages + ) as Promise) + : ({} as ArtistImageResults), + artistsForPersonMapping.length > 0 + ? personMapper.batchGetMappings(artistsForPersonMapping) + : {}, + ]); + + let updatedArtistMetadata = artistMetadata; + if (artistsForPersonMapping.length > 0 || artistsNeedingImages.length > 0) { + updatedArtistMetadata = await getRepository(MetadataArtist).find({ + where: { mbArtistId: In(mbIds) }, + }); + } + + const updatedMetadataMap = new Map( + updatedArtistMetadata.map((metadata) => [metadata.mbArtistId, metadata]) + ); + + const results = topArtistsData.payload.artists.map((artist) => { + if (!artist.artist_mbid) { + return { + id: null, + mediaType: 'artist', + name: artist.artist_name, + listenCount: artist.listen_count, + }; + } + + const metadata = updatedMetadataMap.get(artist.artist_mbid); + const imageResult = artistImageResults[artist.artist_mbid]; + + return { + id: artist.artist_mbid, + mediaType: 'artist', + name: artist.artist_name, + mediaInfo: mediaMap.get(artist.artist_mbid), + listenCount: artist.listen_count, + artistThumb: + metadata?.tmdbThumb ?? + metadata?.tadbThumb ?? + imageResult?.artistThumb ?? + null, + artistBackdrop: + metadata?.tadbCover ?? imageResult?.artistBackground ?? null, + tmdbPersonId: metadata?.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + }; + }); + + if (sortBy) { + const [field, direction] = sortBy.split('.'); + const multiplier = direction === 'asc' ? 1 : -1; + + results.sort((a, b) => { + switch (field) { + case 'listen_count': + return (a.listenCount - b.listenCount) * multiplier; + case 'name': + return (a.name ?? '').localeCompare(b.name ?? '') * multiplier; + default: + return 0; + } + }); + } + + return res.status(200).json({ + page, + totalPages: Math.ceil(topArtistsData.payload.count / pageSize), + totalResults: topArtistsData.payload.count, + results, + }); + } catch (e) { + logger.error('Failed to retrieve popular artists', { + label: 'API', + error: e instanceof Error ? e.message : 'Unknown error', + }); + return next({ + status: 500, + message: 'Unable to retrieve popular artists.', + }); + } +}); + discoverRoutes.get, WatchlistResponse>( '/watchlist', async (req, res) => { diff --git a/server/routes/fanartproxy.ts b/server/routes/fanartproxy.ts deleted file mode 100644 index 4b6dd809..00000000 --- a/server/routes/fanartproxy.ts +++ /dev/null @@ -1,35 +0,0 @@ -import ImageProxy from '@server/lib/imageproxy'; -import logger from '@server/logger'; -import { Router } from 'express'; - -const router = Router(); -const fanartImageProxy = new ImageProxy('fanart', 'http://assets.fanart.tv/', { - rateLimitOptions: { - maxRPS: 50, - }, -}); - -router.get('/*', async (req, res) => { - const imagePath = req.path; - try { - const imageData = await fanartImageProxy.getImage(imagePath); - - res.writeHead(200, { - 'Content-Type': `image/${imageData.meta.extension}`, - 'Content-Length': imageData.imageBuffer.length, - 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, - 'OS-Cache-Key': imageData.meta.cacheKey, - 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', - }); - - res.end(imageData.imageBuffer); - } catch (e) { - logger.error('Failed to proxy image', { - imagePath, - errorMessage: e.message, - }); - res.status(500).end(); - } -}); - -export default router; diff --git a/server/routes/group.ts b/server/routes/group.ts deleted file mode 100644 index a5f2c1f5..00000000 --- a/server/routes/group.ts +++ /dev/null @@ -1,165 +0,0 @@ -import CoverArtArchive from '@server/api/coverartarchive'; -import MusicBrainz from '@server/api/musicbrainz'; -import Media from '@server/entity/Media'; -import logger from '@server/logger'; -import { mapArtistDetails } from '@server/models/Artist'; -import { Router } from 'express'; - -const groupRoutes = Router(); - -groupRoutes.get('/:id', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const locale = req.locale || 'en'; - - try { - const [artistDetails, wikipediaExtract] = await Promise.all([ - musicbrainz.getArtist({ - artistId: req.params.id, - }), - musicbrainz.getWikipediaExtract(req.params.id, locale, 'artist'), - ]); - - const mappedDetails = await mapArtistDetails(artistDetails); - - if (wikipediaExtract) { - mappedDetails.overview = wikipediaExtract; - } - - return res.status(200).json(mappedDetails); - } catch (e) { - logger.error('Something went wrong retrieving artist details', { - label: 'Group API', - errorMessage: e.message, - artistId: req.params.id, - }); - return next({ - status: 500, - message: 'Unable to retrieve artist details.', - }); - } -}); - -groupRoutes.get('/:id/discography', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const coverArtArchive = new CoverArtArchive(); - - try { - const type = req.query.type as string; - const page = Number(req.query.page) || 1; - const pageSize = 20; - - const artistDetails = await musicbrainz.getArtist({ - artistId: req.params.id, - }); - - const mappedDetails = await mapArtistDetails(artistDetails); - - if (!mappedDetails.Albums?.length) { - return res.status(200).json({ - page: 1, - pageInfo: { - total: 0, - totalPages: 0, - }, - results: [], - }); - } - - let filteredAlbums = mappedDetails.Albums; - if (type) { - if (type === 'Other') { - filteredAlbums = mappedDetails.Albums.filter( - (album) => !['Album', 'Single', 'EP'].includes(album.type) - ); - } else { - filteredAlbums = mappedDetails.Albums.filter( - (album) => album.type === type - ); - } - } - - const albumsWithDetails = await Promise.all( - filteredAlbums.map(async (album) => { - try { - const albumDetails = await musicbrainz.getAlbum({ - albumId: album.id, - }); - - let images = albumDetails.images; - - if (!images?.length) { - try { - const coverArtData = await coverArtArchive.getCoverArt(album.id); - if (coverArtData.images?.length) { - images = coverArtData.images.map((img) => ({ - CoverType: img.front ? 'Cover' : 'Poster', - Url: img.image, - })); - } - } catch (e) { - // Handle cover art errors silently - } - } - - return { - ...album, - images: images || [], - releasedate: albumDetails.releasedate || '', - }; - } catch (e) { - return { - ...album, - images: [], - releasedate: '', - }; - } - }) - ); - - const sortedAlbums = albumsWithDetails.sort((a, b) => { - if (!a.releasedate && !b.releasedate) return 0; - if (!a.releasedate) return 1; - if (!b.releasedate) return -1; - return ( - new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime() - ); - }); - - const totalResults = sortedAlbums.length; - const totalPages = Math.ceil(totalResults / pageSize); - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedAlbums = sortedAlbums.slice(start, end); - - const media = await Media.getRelatedMedia( - req.user, - paginatedAlbums.map((album) => album.id) - ); - - const results = paginatedAlbums.map((album) => ({ - ...album, - mediaInfo: media?.find((med) => med.mbId === album.id), - })); - - return res.status(200).json({ - page, - pageInfo: { - total: totalResults, - totalPages, - }, - results, - }); - } catch (e) { - logger.error('Something went wrong retrieving artist discography', { - label: 'Group API', - errorMessage: e.message, - artistId: req.params.id, - }); - return next({ - status: 500, - message: 'Unable to retrieve artist discography.', - }); - } -}); - -export default groupRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index 93d62177..fb71f527 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -27,11 +27,12 @@ import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; +import artistRoutes from './artist'; import authRoutes from './auth'; import blacklistRoutes from './blacklist'; import collectionRoutes from './collection'; +import coverArtRoutes from './coverart'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; -import groupRoutes from './group'; import issueRoutes from './issue'; import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; @@ -159,7 +160,7 @@ router.use('/tv', isAuthenticated(), tvRoutes); router.use('/music', isAuthenticated(), musicRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); -router.use('/group', isAuthenticated(), groupRoutes); +router.use('/artist', isAuthenticated(), artistRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); @@ -170,7 +171,7 @@ router.use( isAuthenticated(Permission.ADMIN), overrideRuleRoutes ); - +router.use('/coverart', isAuthenticated(), coverArtRoutes); router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/issue.ts b/server/routes/issue.ts index cc543342..153e4e3f 100644 --- a/server/routes/issue.ts +++ b/server/routes/issue.ts @@ -173,18 +173,18 @@ issueRoutes.get('/count', async (req, res, next) => { }) .getCount(); - const lyricsCount = await query - .where('issue.issueType = :issueType', { - issueType: IssueType.LYRICS, - }) - .getCount(); - const othersCount = await query .where('issue.issueType = :issueType', { issueType: IssueType.OTHER, }) .getCount(); + const lyricsCount = await query + .where('issue.issueType = :issueType', { + issueType: IssueType.LYRICS, + }) + .getCount(); + const openCount = await query .where('issue.status = :issueStatus', { issueStatus: IssueStatus.OPEN, @@ -202,8 +202,8 @@ issueRoutes.get('/count', async (req, res, next) => { video: videoCount, audio: audioCount, subtitles: subtitlesCount, - lyrics: lyricsCount, others: othersCount, + lyrics: lyricsCount, open: openCount, closed: closedCount, }); diff --git a/server/routes/lidarrproxy.ts b/server/routes/lidarrproxy.ts deleted file mode 100644 index f44966d0..00000000 --- a/server/routes/lidarrproxy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import ImageProxy from '@server/lib/imageproxy'; -import logger from '@server/logger'; -import { Router } from 'express'; - -const router = Router(); -const lidarrImageProxy = new ImageProxy( - 'lidarr', - 'https://imagecache.lidarr.audio', - { - rateLimitOptions: { - maxRPS: 50, - }, - } -); - -router.get('/*', async (req, res) => { - const imagePath = req.path; - try { - const imageData = await lidarrImageProxy.getImage(imagePath); - - res.writeHead(200, { - 'Content-Type': `image/${imageData.meta.extension}`, - 'Content-Length': imageData.imageBuffer.length, - 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, - 'OS-Cache-Key': imageData.meta.cacheKey, - 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', - }); - - res.end(imageData.imageBuffer); - } catch (e) { - logger.error('Failed to proxy image', { - imagePath, - errorMessage: e.message, - }); - res.status(500).end(); - } -}); - -export default router; diff --git a/server/routes/media.ts b/server/routes/media.ts index 0f1716cc..266453f1 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -6,6 +6,7 @@ import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { @@ -24,6 +25,7 @@ const mediaRoutes = Router(); mediaRoutes.get('/', async (req, res, next) => { const mediaRepository = getRepository(Media); + const metadataAlbumRepository = getRepository(MetadataAlbum); const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; @@ -78,6 +80,37 @@ mediaRoutes.get('/', async (req, res, next) => { take: pageSize, skip, }); + + const musicMediaItems = media.filter( + (item) => item.mediaType === 'music' && item.mbId + ); + + const mbIds = musicMediaItems.map((item) => item.mbId as string); + + const albumMetadata = + mbIds.length > 0 + ? await metadataAlbumRepository.find({ + where: { mbAlbumId: In(mbIds) }, + select: ['mbAlbumId', 'caaUrl'], + }) + : []; + + const albumMetadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) + ); + + const mediaWithCoverArt = media.map((item) => { + if (item.mediaType === 'music' && item.mbId) { + const metadata = albumMetadataMap.get(item.mbId); + return { + ...item, + posterPath: metadata?.caaUrl || null, + needsCoverArt: !metadata?.caaUrl, + }; + } + return item; + }); + return res.status(200).json({ pageInfo: { pages: Math.ceil(mediaCount / pageSize), @@ -85,10 +118,14 @@ mediaRoutes.get('/', async (req, res, next) => { results: mediaCount, page: Math.ceil(skip / pageSize) + 1, }, - results: media, + results: mediaWithCoverArt, } as MediaResultsResponse); } catch (e) { - next({ status: 500, message: e.message }); + logger.error('Something went wrong retrieving media', { + label: 'Media', + error: e instanceof Error ? e.message : 'Unknown error', + }); + next({ status: 500, message: 'Unable to retrieve media' }); } }); @@ -206,38 +243,36 @@ mediaRoutes.delete( serviceSettings = settings.radarr.find( (radarr) => radarr.isDefault && radarr.is4k === is4k ); - } else if(media.mediaType === MediaType.TV) { + } else if (media.mediaType === MediaType.TV) { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.isDefault && sonarr.is4k === is4k ); } else { - serviceSettings = settings.lidarr.find( - (lidarr) => lidarr.isDefault); + serviceSettings = settings.lidarr.find((lidarr) => lidarr.isDefault); } - - const specificServiceId = is4k ? media.serviceId4k : media.serviceId; - if ( - specificServiceId && - specificServiceId >= 0 && - serviceSettings?.id !== specificServiceId - ) { - if (media.mediaType === MediaType.MOVIE) { - serviceSettings = settings.radarr.find( - (radarr) => radarr.id === specificServiceId - ); - } else if (media.mediaType === MediaType.TV) { - serviceSettings = settings.sonarr.find( - (sonarr) => sonarr.id === specificServiceId - ); - } else { - serviceSettings = settings.lidarr.find( - (lidarr) => lidarr.id === media.serviceId - ) - } + const specificServiceId = is4k ? media.serviceId4k : media.serviceId; + if ( + specificServiceId && + specificServiceId >= 0 && + serviceSettings?.id !== specificServiceId + ) { + if (media.mediaType === MediaType.MOVIE) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.id === specificServiceId + ); + } else if (media.mediaType === MediaType.TV) { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.id === specificServiceId + ); + } else { + serviceSettings = settings.lidarr.find( + (lidarr) => lidarr.id === media.serviceId + ); } - - if (!serviceSettings) { + } + + if (!serviceSettings) { logger.warn( `There is no default ${ media.mediaType === MediaType.MOVIE @@ -260,7 +295,7 @@ mediaRoutes.delete( apiKey: serviceSettings.apiKey, url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), }); - + await (service as RadarrAPI).removeMovie(media.tmdbId); } else if (media.mediaType === MediaType.TV) { service = new SonarrAPI({ @@ -276,8 +311,7 @@ mediaRoutes.delete( throw new Error('TVDB ID not found'); } await (service as SonarrAPI).removeSeries(tvdbId); - } else if (media.mediaType == MediaType.MUSIC) - { + } else if (media.mediaType == MediaType.MUSIC) { service = new LidarrAPI({ apiKey: serviceSettings.apiKey, url: LidarrAPI.buildUrl(serviceSettings, '/api/v1'), @@ -291,7 +325,6 @@ mediaRoutes.delete( } return res.status(204).send(); - } catch (e) { logger.error('Something went wrong fetching media in delete request', { label: 'Media', diff --git a/server/routes/music.ts b/server/routes/music.ts index 12778f2c..f8e5db55 100644 --- a/server/routes/music.ts +++ b/server/routes/music.ts @@ -1,30 +1,29 @@ -import CoverArtArchive from '@server/api/coverartarchive'; import ListenBrainzAPI from '@server/api/listenbrainz'; import MusicBrainz from '@server/api/musicbrainz'; -import TheMovieDb from '@server/api/themoviedb'; +import TheAudioDb from '@server/api/theaudiodb'; +import TmdbPersonMapper from '@server/api/themoviedb/personMapper'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapMusicDetails } from '@server/models/Music'; import { Router } from 'express'; +import { In } from 'typeorm'; const musicRoutes = Router(); musicRoutes.get('/:id', async (req, res, next) => { + const listenbrainz = new ListenBrainzAPI(); const musicbrainz = new MusicBrainz(); - const locale = req.locale || 'en'; + const personMapper = TmdbPersonMapper.getInstance(); + const theAudioDb = TheAudioDb.getInstance(); try { - const [albumDetails, wikipediaExtract] = await Promise.all([ - musicbrainz.getAlbum({ - albumId: req.params.id, - }), - musicbrainz.getWikipediaExtract(req.params.id, locale), - ]); - - const [media, onUserWatchlist] = await Promise.all([ + const [albumDetails, media, onUserWatchlist] = await Promise.all([ + listenbrainz.getAlbum(req.params.id), getRepository(Media) .createQueryBuilder('media') .leftJoinAndSelect('media.requests', 'requests') @@ -36,31 +35,143 @@ musicRoutes.get('/:id', async (req, res, next) => { }) .getOne() .then((media) => media ?? undefined), - getRepository(Watchlist).exist({ where: { mbId: req.params.id, - requestedBy: { - id: req.user?.id, - }, + requestedBy: { id: req.user?.id }, }, }), ]); + const artistId = + albumDetails.release_group_metadata?.artist?.artists[0]?.artist_mbid; + const isPerson = + albumDetails.release_group_metadata?.artist?.artists[0]?.type === + 'Person'; + const trackArtistIds = albumDetails.mediums + .flatMap((medium) => medium.tracks) + .flatMap((track) => track.artists) + .filter((artist) => artist.artist_mbid) + .map((artist) => artist.artist_mbid); + + const [ + metadataAlbum, + metadataArtist, + trackArtistMetadata, + artistWikipedia, + ] = await Promise.all([ + getRepository(MetadataAlbum).findOne({ + where: { mbAlbumId: req.params.id }, + }), + artistId + ? getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + }) + : Promise.resolve(undefined), + getRepository(MetadataArtist).find({ + where: { mbArtistId: In(trackArtistIds) }, + }), + artistId && isPerson + ? musicbrainz + .getArtistWikipediaExtract({ + artistMbid: artistId, + language: req.locale, + }) + .catch(() => null) + : Promise.resolve(null), + ]); + + const trackArtistsToMap = albumDetails.mediums + .flatMap((medium) => medium.tracks) + .flatMap((track) => + track.artists + .filter((artist) => artist.artist_mbid) + .filter( + (artist) => + !trackArtistMetadata.some( + (m) => m.mbArtistId === artist.artist_mbid && m.tmdbPersonId + ) + ) + .map((artist) => ({ + artistId: artist.artist_mbid, + artistName: artist.artist_credit_name, + })) + ); + + const [artistImages, personMappingResult, updatedArtistMetadata] = + await Promise.all([ + artistId && !metadataArtist?.tadbThumb && !metadataArtist?.tadbCover + ? theAudioDb.getArtistImages(artistId) + : Promise.resolve(null), + artistId && isPerson && !metadataArtist?.tmdbPersonId + ? personMapper + .getMapping( + artistId, + albumDetails.release_group_metadata.artist.artists[0].name + ) + .catch(() => null) + : Promise.resolve(null), + trackArtistsToMap.length > 0 + ? personMapper.batchGetMappings(trackArtistsToMap).then(() => + getRepository(MetadataArtist).find({ + where: { mbArtistId: In(trackArtistIds) }, + }) + ) + : Promise.resolve(trackArtistMetadata), + ]); + + const updatedMetadataArtist = + personMappingResult && artistId + ? await getRepository(MetadataArtist).findOne({ + where: { mbArtistId: artistId }, + }) + : metadataArtist; + const mappedDetails = mapMusicDetails(albumDetails, media, onUserWatchlist); + const finalTrackArtistMetadata = + updatedArtistMetadata || trackArtistMetadata; - if (wikipediaExtract) { - mappedDetails.artist.overview = wikipediaExtract; - } - - return res.status(200).json(mappedDetails); + return res.status(200).json({ + ...mappedDetails, + posterPath: metadataAlbum?.caaUrl ?? null, + needsCoverArt: !metadataAlbum?.caaUrl, + artistWikipedia, + artistThumb: + updatedMetadataArtist?.tmdbThumb ?? + updatedMetadataArtist?.tadbThumb ?? + artistImages?.artistThumb ?? + null, + artistBackdrop: + updatedMetadataArtist?.tadbCover ?? + artistImages?.artistBackground ?? + null, + tmdbPersonId: updatedMetadataArtist?.tmdbPersonId + ? Number(updatedMetadataArtist.tmdbPersonId) + : null, + tracks: mappedDetails.tracks.map((track) => ({ + ...track, + artists: track.artists.map((artist) => { + const metadata = finalTrackArtistMetadata.find( + (m) => m.mbArtistId === artist.mbid + ); + return { + ...artist, + tmdbMapping: metadata?.tmdbPersonId + ? { + personId: Number(metadata.tmdbPersonId), + profilePath: metadata.tmdbThumb, + } + : null, + }; + }), + })), + }); } catch (e) { logger.error('Something went wrong retrieving album details', { label: 'Music API', errorMessage: e.message, mbId: req.params.id, }); - return next({ status: 500, message: 'Unable to retrieve album details.', @@ -68,186 +179,203 @@ musicRoutes.get('/:id', async (req, res, next) => { } }); -musicRoutes.get('/:id/discography', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const coverArtArchive = new CoverArtArchive(); - +musicRoutes.get('/:id/artist', async (req, res, next) => { try { - const albumDetails = await musicbrainz.getAlbum({ - albumId: req.params.id, - }); - - if (!albumDetails.artists?.[0]?.id) { - throw new Error('No artist found for album'); - } + const listenbrainzApi = new ListenBrainzAPI(); + const personMapper = TmdbPersonMapper.getInstance(); + const theAudioDb = TheAudioDb.getInstance(); + const metadataAlbumRepository = getRepository(MetadataAlbum); + const metadataArtistRepository = getRepository(MetadataArtist); const page = Number(req.query.page) || 1; - const pageSize = 20; + const pageSize = Number(req.query.pageSize) || 20; + const isSlider = req.query.slider === 'true'; - const artistData = await musicbrainz.getArtist({ - artistId: albumDetails.artists[0].id, - }); + const albumData = await listenbrainzApi.getAlbum(req.params.id); + const artistData = albumData?.release_group_metadata?.artist?.artists?.[0]; + const artistType = artistData?.type; - const albums = - artistData.Albums?.map((album) => ({ - id: album.Id.toLowerCase(), - title: album.Title, - type: album.Type, - mediaType: 'album', - })) ?? []; - - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedAlbums = albums.slice(start, end); - - const albumDetailsPromises = paginatedAlbums.map(async (album) => { - try { - const details = await musicbrainz.getAlbum({ - albumId: album.id, - }); - - let images = details.images; - - // Try to get cover art if no images found - if (!images || images.length === 0) { - try { - const coverArtData = await coverArtArchive.getCoverArt(album.id); - if (coverArtData.images?.length > 0) { - images = coverArtData.images.map((img) => ({ - CoverType: img.front ? 'Cover' : 'Poster', - Url: img.image, - })); - } - } catch (coverArtError) { - // Fallback silently - } - } - - return { - ...album, - images, - releasedate: details.releasedate, - }; - } catch (e) { - return album; - } - }); - - const albumsWithDetails = await Promise.all(albumDetailsPromises); - - const media = await Media.getRelatedMedia( - req.user, - albumsWithDetails.map((album) => album.id) - ); - - const resultsWithMedia = albumsWithDetails.map((album) => ({ - ...album, - mediaInfo: media?.find((med) => med.mbId === album.id), - })); - - return res.status(200).json({ - page, - totalPages: Math.ceil(albums.length / pageSize), - totalResults: albums.length, - results: resultsWithMedia, - }); - } catch (e) { - logger.error('Something went wrong retrieving artist discography', { - label: 'Music API', - errorMessage: e.message, - albumId: req.params.id, - }); - - return next({ - status: 500, - message: 'Unable to retrieve artist discography.', - }); - } -}); - -musicRoutes.get('/:id/similar', async (req, res, next) => { - const musicbrainz = new MusicBrainz(); - const listenbrainz = new ListenBrainzAPI(); - const tmdb = new TheMovieDb(); - - try { - const albumDetails = await musicbrainz.getAlbum({ - albumId: req.params.id, - }); - - if (!albumDetails.artists?.[0]?.id) { - throw new Error('No artist found for album'); + if (!artistData?.artist_mbid || artistType === 'Other') { + return res.status(404).json({ + status: 404, + message: 'Artist details not available for this type', + }); } - const page = Number(req.query.page) || 1; - const pageSize = 20; - - const similarArtists = await listenbrainz.getSimilarArtists( - albumDetails.artists[0].id + const [artistDetails, cachedTheAudioDb, metadataArtist] = await Promise.all( + [ + listenbrainzApi.getArtist(artistData.artist_mbid), + theAudioDb.getArtistImagesFromCache(artistData.artist_mbid), + metadataArtistRepository.findOne({ + where: { mbArtistId: artistData.artist_mbid }, + }), + ] ); - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedArtists = similarArtists.slice(start, end); + if (!artistDetails) { + return res.status(404).json({ status: 404, message: 'Artist not found' }); + } - const artistDetailsPromises = paginatedArtists.map(async (artist) => { - try { - let tmdbId = null; - if (artist.type === 'Person') { - const searchResults = await tmdb.searchPerson({ - query: artist.name, - page: 1, - }); - - const match = searchResults.results.find( - (result) => result.name.toLowerCase() === artist.name.toLowerCase() + const totalReleaseGroups = artistDetails.releaseGroups.length; + const paginatedReleaseGroups = + isSlider || page === 1 + ? artistDetails.releaseGroups.slice(0, pageSize) + : artistDetails.releaseGroups.slice( + (page - 1) * pageSize, + page * pageSize ); - if (match) { - tmdbId = match.id; - } - } - const details = await musicbrainz.getArtist({ - artistId: artist.artist_mbid, - }); + const releaseGroupIds = paginatedReleaseGroups.map((rg) => rg.mbid); + const similarArtistIds = + artistDetails.similarArtists?.artists?.map((a) => a.artist_mbid) ?? []; - return { - id: tmdbId || artist.artist_mbid, - mediaType: 'artist' as const, - artistname: artist.name, - type: artist.type || 'Person', - overview: artist.comment, - score: artist.score, - images: details.images || [], - artistimage: details.images?.find((img) => img.CoverType === 'Poster') - ?.Url, - }; - } catch (e) { - return null; - } - }); + const [relatedMedia, albumMetadata, similarArtistMetadata] = + await Promise.all([ + Media.getRelatedMedia(req.user, releaseGroupIds), + metadataAlbumRepository.find({ + where: { mbAlbumId: In(releaseGroupIds) }, + }), + similarArtistIds.length > 0 + ? metadataArtistRepository.find({ + where: { mbArtistId: In(similarArtistIds) }, + }) + : Promise.resolve([]), + ]); - const artistDetails = (await Promise.all(artistDetailsPromises)).filter( - (artist): artist is NonNullable => artist !== null + const albumMetadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) ); - return res.status(200).json({ - page, - totalPages: Math.ceil(similarArtists.length / pageSize), - totalResults: similarArtists.length, - results: artistDetails, - }); - } catch (e) { - logger.error('Something went wrong retrieving similar artists', { - label: 'Music API', - errorMessage: e.message, - albumId: req.params.id, + const similarArtistMetadataMap = new Map( + similarArtistMetadata.map((metadata) => [metadata.mbArtistId, metadata]) + ); + + const artistsNeedingImages = similarArtistIds.filter((id) => { + const metadata = similarArtistMetadataMap.get(id); + return !metadata?.tadbThumb && !metadata?.tadbCover; }); - return next({ - status: 500, - message: 'Unable to retrieve similar artists.', + const personArtists = + artistDetails.similarArtists?.artists + ?.filter((artist) => artist.type === 'Person') + .filter((artist) => { + const metadata = similarArtistMetadataMap.get(artist.artist_mbid); + return !metadata?.tmdbPersonId; + }) + .map((artist) => ({ + artistId: artist.artist_mbid, + artistName: artist.name, + })) ?? []; + + type ArtistImageResults = Record< + string, + { artistThumb: string | null; artistBackground: string | null } + >; + + const [artistImageResults, updatedArtistMetadata, artistImagesResult] = + await Promise.all([ + artistsNeedingImages.length > 0 + ? theAudioDb.batchGetArtistImages(artistsNeedingImages) + : ({} as ArtistImageResults), + personArtists.length > 0 + ? personMapper.batchGetMappings(personArtists).then(() => + metadataArtistRepository.find({ + where: { mbArtistId: In(similarArtistIds) }, + }) + ) + : Promise.resolve(similarArtistMetadata), + !cachedTheAudioDb && + !metadataArtist?.tadbThumb && + !metadataArtist?.tadbCover + ? theAudioDb.getArtistImages(artistData.artist_mbid) + : Promise.resolve(null), + ]); + + const relatedMediaMap = new Map( + relatedMedia.map((media) => [media.mbId, media]) + ); + + const finalArtistMetadataMap = new Map( + (updatedArtistMetadata || similarArtistMetadata).map((metadata) => [ + metadata.mbArtistId, + metadata, + ]) + ); + + const transformedReleaseGroups = paginatedReleaseGroups.map( + (releaseGroup) => { + const metadata = albumMetadataMap.get(releaseGroup.mbid); + return { + id: releaseGroup.mbid, + mediaType: 'album', + title: releaseGroup.name, + 'first-release-date': releaseGroup.date, + 'artist-credit': [{ name: releaseGroup.artist_credit_name }], + 'primary-type': releaseGroup.type || 'Other', + posterPath: metadata?.caaUrl ?? null, + needsCoverArt: !metadata?.caaUrl, + mediaInfo: relatedMediaMap.get(releaseGroup.mbid), + }; + } + ); + + const transformedSimilarArtists = + artistDetails.similarArtists?.artists?.map((artist) => { + const metadata = finalArtistMetadataMap.get(artist.artist_mbid); + const artistImageResult = + artistImageResults[ + artist.artist_mbid as keyof typeof artistImageResults + ]; + + const artistThumb = + metadata?.tadbThumb || (artistImageResult?.artistThumb ?? null); + + const artistBackground = + metadata?.tadbCover || (artistImageResult?.artistBackground ?? null); + + return { + ...artist, + artistThumb: metadata?.tmdbThumb ?? artistThumb, + artistBackground: artistBackground, + tmdbPersonId: metadata?.tmdbPersonId + ? Number(metadata.tmdbPersonId) + : null, + }; + }) ?? []; + + return res.status(200).json({ + artist: { + ...artistDetails, + artistThumb: + cachedTheAudioDb?.artistThumb ?? + metadataArtist?.tadbThumb ?? + artistImagesResult?.artistThumb ?? + null, + artistBackdrop: + cachedTheAudioDb?.artistBackground ?? + metadataArtist?.tadbCover ?? + artistImagesResult?.artistBackground ?? + null, + similarArtists: { + ...artistDetails.similarArtists, + artists: transformedSimilarArtists, + }, + releaseGroups: transformedReleaseGroups, + pagination: { + page, + pageSize, + totalItems: totalReleaseGroups, + totalPages: Math.ceil(totalReleaseGroups / pageSize), + }, + }, }); + } catch (error) { + logger.error('Something went wrong retrieving artist details', { + label: 'Music API', + errorMessage: error.message, + artistId: req.params.id, + }); + return next({ status: 500, message: 'Unable to retrieve artist details.' }); } }); diff --git a/server/routes/person.ts b/server/routes/person.ts index 6594481f..ebf4eca1 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,7 +1,10 @@ -import CoverArtArchive from '@server/api/coverartarchive'; -import MusicBrainz from '@server/api/musicbrainz'; +import ListenBrainzAPI from '@server/api/listenbrainz'; +import TheAudioDb from '@server/api/theaudiodb'; import TheMovieDb from '@server/api/themoviedb'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import logger from '@server/logger'; import { mapCastCredits, @@ -9,53 +12,176 @@ import { mapPersonDetails, } from '@server/models/Person'; import { Router } from 'express'; +import { In } from 'typeorm'; const personRoutes = Router(); personRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); - const musicBrainz = new MusicBrainz(); + const listenbrainz = new ListenBrainzAPI(); + const theAudioDb = TheAudioDb.getInstance(); + + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 20; + const initialItemsPerType = 20; + const albumType = req.query.albumType as string | undefined; try { - const person = await tmdb.getPerson({ - personId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, - }); + const [person, existingMetadata] = await Promise.all([ + tmdb.getPerson({ + personId: Number(req.params.id), + language: (req.query.language as string) ?? req.locale, + }), + getRepository(MetadataArtist).findOne({ + where: { tmdbPersonId: req.params.id }, + select: ['mbArtistId', 'tmdbThumb', 'tadbThumb', 'tadbCover'], + }), + ]); - let mbArtistId = null; - try { - const artists = await musicBrainz.searchArtist({ - query: person.name, - }); + let artistData = null; - const matchedArtist = artists.find((artist) => { - if (artist.type !== 'Person') { - return false; + if (existingMetadata?.mbArtistId) { + artistData = await listenbrainz.getArtist(existingMetadata.mbArtistId); + + if (artistData?.releaseGroups) { + const groupedReleaseGroups = artistData.releaseGroups.reduce( + (acc, rg) => { + const type = rg.secondary_types?.length + ? rg.secondary_types[0] + : rg.type || 'Other'; + + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(rg); + return acc; + }, + {} as Record + ); + + Object.keys(groupedReleaseGroups).forEach((type) => { + groupedReleaseGroups[type].sort((a, b) => { + const dateA = a.date ? new Date(a.date).getTime() : 0; + const dateB = b.date ? new Date(b.date).getTime() : 0; + return dateB - dateA; + }); + }); + + let releaseGroupsToProcess: typeof artistData.releaseGroups = []; + let totalCount: number; + let totalPages: number; + + if (albumType) { + const filteredReleaseGroups = groupedReleaseGroups[albumType] || []; + totalCount = filteredReleaseGroups.length; + totalPages = Math.ceil(totalCount / pageSize); + + releaseGroupsToProcess = filteredReleaseGroups.slice( + (page - 1) * pageSize, + page * pageSize + ); + } else { + Object.entries(groupedReleaseGroups).forEach(([, releases]) => { + releaseGroupsToProcess.push( + ...releases.slice(0, initialItemsPerType) + ); + }); + + totalCount = Object.values(groupedReleaseGroups).reduce( + (sum, releases) => sum + releases.length, + 0 + ); + totalPages = 1; } - const nameMatches = - artist.artistname.toLowerCase() === person.name.toLowerCase(); - const aliasMatches = artist.artistaliases?.some( - (alias) => alias.toLowerCase() === person.name.toLowerCase() - ); - return nameMatches || aliasMatches; - }); + const allReleaseGroupIds = releaseGroupsToProcess.map((rg) => rg.mbid); - if (matchedArtist) { - mbArtistId = matchedArtist.id; + const [artistImagesPromise, relatedMedia, albumMetadata] = + await Promise.all([ + !existingMetadata.tadbThumb && !existingMetadata.tadbCover + ? theAudioDb.getArtistImages(existingMetadata.mbArtistId) + : Promise.resolve(null), + Media.getRelatedMedia(req.user, allReleaseGroupIds), + getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(allReleaseGroupIds) }, + select: ['mbAlbumId', 'caaUrl'], + cache: true, + }), + ]); + + if (artistImagesPromise) { + existingMetadata.tadbThumb = artistImagesPromise.artistThumb; + existingMetadata.tadbCover = artistImagesPromise.artistBackground; + } + + const mediaMap = new Map( + relatedMedia.map((media) => [media.mbId, media]) + ); + + const metadataMap = new Map( + albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) + ); + + const transformedReleaseGroups = releaseGroupsToProcess.map( + (releaseGroup) => { + const metadata = metadataMap.get(releaseGroup.mbid); + const coverArtUrl = metadata?.caaUrl || null; + + return { + id: releaseGroup.mbid, + mediaType: 'album', + title: releaseGroup.name, + 'first-release-date': releaseGroup.date, + 'artist-credit': [{ name: releaseGroup.artist_credit_name }], + 'primary-type': releaseGroup.type || 'Other', + secondary_types: releaseGroup.secondary_types || [], + total_listen_count: releaseGroup.total_listen_count || 0, + posterPath: coverArtUrl, + needsCoverArt: !coverArtUrl, + mediaInfo: mediaMap.get(releaseGroup.mbid), + }; + } + ); + + const typeCounts = Object.fromEntries( + Object.entries(groupedReleaseGroups).map(([type, releases]) => [ + type, + releases.length, + ]) + ); + + artistData = { + ...artistData, + releaseGroups: transformedReleaseGroups, + typeCounts, + pagination: { + page, + pageSize, + totalItems: totalCount, + totalPages, + albumType, + }, + }; } - } catch (e) { - logger.debug('Failed to fetch music artist data', { - label: 'API', - errorMessage: e.message, - personName: person.name, - }); } - return res.status(200).json({ + const mappedDetails = { ...mapPersonDetails(person), - mbArtistId, - }); + artist: + artistData && existingMetadata?.mbArtistId + ? { + mbid: existingMetadata.mbArtistId, + profilePath: person.profile_path + ? `https://image.tmdb.org/t/p/w500${person.profile_path}` + : existingMetadata.tadbThumb ?? null, + artistThumb: existingMetadata.tadbThumb ?? null, + artistBackdrop: existingMetadata.tadbCover ?? null, + ...artistData, + } + : null, + }; + + return res.status(200).json(mappedDetails); } catch (e) { logger.debug('Something went wrong retrieving person', { label: 'API', @@ -69,148 +195,6 @@ personRoutes.get('/:id', async (req, res, next) => { } }); -personRoutes.get('/:id/discography', async (req, res, next) => { - const musicBrainz = new MusicBrainz(); - const tmdb = new TheMovieDb(); - const coverArtArchive = new CoverArtArchive(); - const artistId = req.query.artistId as string; - const type = req.query.type as string; - const page = Number(req.query.page) || 1; - const pageSize = 20; - - if (!artistId) { - return next({ - status: 400, - message: 'Artist ID is required', - }); - } - - const person = await tmdb.getPerson({ - personId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, - }); - - if (!person.birthday) { - return res.status(200).json({ - page: 1, - pageInfo: { total: 0, totalPages: 0 }, - results: [], - }); - } - - try { - const artistDetails = await musicBrainz.getArtist({ - artistId: artistId, - }); - - const { mapArtistDetails } = await import('@server/models/Artist'); - const mappedDetails = await mapArtistDetails(artistDetails); - - if (!mappedDetails.Albums?.length) { - return res.status(200).json({ - page: 1, - pageInfo: { - total: 0, - totalPages: 0, - }, - results: [], - }); - } - - let filteredAlbums = mappedDetails.Albums; - if (type) { - if (type === 'Other') { - filteredAlbums = mappedDetails.Albums.filter( - (album) => !['Album', 'Single', 'EP'].includes(album.type) - ); - } else { - filteredAlbums = mappedDetails.Albums.filter( - (album) => album.type === type - ); - } - } - - const albumPromises = filteredAlbums.map(async (album) => { - try { - const albumDetails = await musicBrainz.getAlbum({ - albumId: album.id, - }); - - let images = albumDetails.images; - - if (!images || images.length === 0) { - try { - const coverArtData = await coverArtArchive.getCoverArt(album.id); - if (coverArtData.images?.length > 0) { - images = coverArtData.images.map((img) => ({ - CoverType: img.front ? 'Cover' : 'Poster', - Url: img.image, - })); - } - } catch (coverArtError) { - // Silently handle cover art fetch errors - } - } - - return { - ...album, - images: images || [], - releasedate: albumDetails.releasedate || '', - }; - } catch (e) { - return album; - } - }); - - const albumsWithDetails = await Promise.all(albumPromises); - - const sortedAlbums = albumsWithDetails.sort((a, b) => { - if (!a.releasedate && !b.releasedate) return 0; - if (!a.releasedate) return 1; - if (!b.releasedate) return -1; - return ( - new Date(b.releasedate).getTime() - new Date(a.releasedate).getTime() - ); - }); - - const totalResults = sortedAlbums.length; - const totalPages = Math.ceil(totalResults / pageSize); - const start = (page - 1) * pageSize; - const end = start + pageSize; - const paginatedAlbums = sortedAlbums.slice(start, end); - - const media = await Media.getRelatedMedia( - req.user, - paginatedAlbums.map((album) => album.id) - ); - - const results = paginatedAlbums.map((album) => ({ - ...album, - mediaInfo: media?.find((med) => med.mbId === album.id), - })); - - return res.status(200).json({ - page, - pageInfo: { - total: totalResults, - totalPages, - }, - results, - }); - } catch (e) { - logger.error('Something went wrong retrieving discography', { - label: 'Person API', - errorMessage: e.message, - personId: req.params.id, - artistId, - }); - return next({ - status: 500, - message: 'Unable to retrieve discography.', - }); - } -}); - personRoutes.get('/:id/combined_credits', async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/search.ts b/server/routes/search.ts index 9e530406..638cb774 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,10 +1,11 @@ import MusicBrainz from '@server/api/musicbrainz'; -import type { - MbAlbumResult, - MbArtistResult, -} from '@server/api/musicbrainz/interfaces'; +import TheAudioDb from '@server/api/theaudiodb'; import TheMovieDb from '@server/api/themoviedb'; +import TmdbPersonMapper from '@server/api/themoviedb/personMapper'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MetadataAlbum from '@server/entity/MetadataAlbum'; +import MetadataArtist from '@server/entity/MetadataArtist'; import { findSearchProvider, type CombinedSearchResponse, @@ -12,69 +13,272 @@ import { import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; +import { In } from 'typeorm'; const searchRoutes = Router(); +const ITEMS_PER_PAGE = 20; searchRoutes.get('/', async (req, res, next) => { const queryString = req.query.query as string; - const searchProvider = findSearchProvider(queryString.toLowerCase()); - let results: CombinedSearchResponse; - let combinedResults: CombinedSearchResponse['results'] = []; + const page = Number(req.query.page) || 1; + const language = (req.query.language as string) ?? req.locale; try { + const searchProvider = findSearchProvider(queryString.toLowerCase()); + let results: CombinedSearchResponse; + if (searchProvider) { const [id] = queryString .toLowerCase() .match(searchProvider.pattern) as RegExpMatchArray; results = await searchProvider.search({ id, - language: (req.query.language as string) ?? req.locale, + language, query: queryString, }); } else { const tmdb = new TheMovieDb(); - const tmdbResults = await tmdb.searchMulti({ - query: queryString, - page: Number(req.query.page), + const musicbrainz = new MusicBrainz(); + const theAudioDb = TheAudioDb.getInstance(); + const personMapper = TmdbPersonMapper.getInstance(); + + const [tmdbResults, albumResults, artistResults] = await Promise.all([ + tmdb.searchMulti({ + query: queryString, + page, + language, + }), + musicbrainz.searchAlbum({ + query: queryString, + limit: ITEMS_PER_PAGE, + }), + musicbrainz.searchArtist({ + query: queryString, + limit: ITEMS_PER_PAGE, + }), + ]); + + const personIds = tmdbResults.results + .filter( + (result) => result.media_type === 'person' && !result.profile_path + ) + .map((p) => p.id.toString()); + + const albumIds = albumResults.map((album) => album.id); + const artistIds = artistResults.map((artist) => artist.id); + const tmdbPersonIds = tmdbResults.results + .filter((result) => result.media_type === 'person') + .map((person) => person.id.toString()); + + const [artistMetadata, albumMetadata, artistsMetadata, existingMappings] = + await Promise.all([ + personIds.length > 0 + ? getRepository(MetadataArtist).find({ + where: { tmdbPersonId: In(personIds) }, + cache: true, + select: ['tmdbPersonId', 'tadbThumb', 'tadbCover'], + }) + : [], + albumIds.length > 0 + ? getRepository(MetadataAlbum).find({ + where: { mbAlbumId: In(albumIds) }, + cache: true, + select: ['mbAlbumId', 'caaUrl'], + }) + : [], + artistIds.length > 0 + ? getRepository(MetadataArtist).find({ + where: { mbArtistId: In(artistIds) }, + cache: true, + select: [ + 'mbArtistId', + 'tmdbPersonId', + 'tadbThumb', + 'tadbCover', + ], + }) + : [], + tmdbPersonIds.length > 0 + ? getRepository(MetadataArtist).find({ + where: { tmdbPersonId: In(tmdbPersonIds) }, + cache: true, + select: ['mbArtistId', 'tmdbPersonId'], + }) + : [], + ]); + + const artistMetadataMap = new Map( + artistMetadata.map((m) => [m.tmdbPersonId, m]) + ); + + const albumMetadataMap = new Map( + albumMetadata.map((m) => [m.mbAlbumId, m]) + ); + + const artistsMetadataMap = new Map( + artistsMetadata.map((m) => [m.mbArtistId, m]) + ); + + const existingMappingsMap = new Map( + existingMappings.map((m) => [m.mbArtistId, m.tmdbPersonId]) + ); + + const personsWithoutImages = tmdbResults.results.filter( + (result) => result.media_type === 'person' && !result.profile_path + ); + + personsWithoutImages.forEach((person) => { + const metadata = artistMetadataMap.get(person.id.toString()); + if (metadata?.tadbThumb) { + Object.assign(person, { + profile_path: metadata.tadbThumb, + artist_backdrop: metadata.tadbCover, + }); + } }); - combinedResults = [...tmdbResults.results]; + const artistsNeedingMapping = artistResults + .filter( + (artist) => + artist.type === 'Person' && + !artistsMetadataMap.get(artist.id)?.tmdbPersonId + ) + .map((artist) => ({ + artistId: artist.id, + artistName: artist.name, + })); - const musicbrainz = new MusicBrainz(); - const mbResults = await musicbrainz.searchMulti({ query: queryString }); + const artistsNeedingImages = artistIds.filter((id) => { + const metadata = artistsMetadataMap.get(id); + return !metadata?.tadbThumb && !metadata?.tadbCover; + }); - if (mbResults.length > 0) { - const mbMappedResults = mbResults.map((result) => { - if (result.artist) { - return { - ...result.artist, - media_type: 'artist', - } as MbArtistResult; + type PersonMappingResult = Record< + string, + { personId: number | null; profilePath: string | null } + >; + type ArtistImageResult = Record< + string, + { artistThumb: string | null; artistBackground: string | null } + >; + + const [personMappingResults, artistImageResults] = await Promise.all([ + artistsNeedingMapping.length > 0 + ? personMapper.batchGetMappings(artistsNeedingMapping) + : ({} as PersonMappingResult), + artistsNeedingImages.length > 0 + ? theAudioDb.batchGetArtistImages(artistsNeedingImages) + : ({} as ArtistImageResult), + ]); + + let updatedArtistsMetadataMap = artistsMetadataMap; + if ( + (artistsNeedingMapping.length > 0 || artistsNeedingImages.length > 0) && + artistIds.length > 0 + ) { + const updatedArtistsMetadata = await getRepository(MetadataArtist).find( + { + where: { mbArtistId: In(artistIds) }, + cache: true, + select: ['mbArtistId', 'tmdbPersonId', 'tadbThumb', 'tadbCover'], } - if (result.album) { - return { - ...result.album, - media_type: 'album', - } as MbAlbumResult; - } - throw new Error('Invalid search result type'); - }); + ); - combinedResults = [...combinedResults, ...mbMappedResults]; + updatedArtistsMetadataMap = new Map( + updatedArtistsMetadata.map((m) => [m.mbArtistId, m]) + ); } + const albumsWithArt = albumResults.map((album) => { + const metadata = albumMetadataMap.get(album.id); + + return { + ...album, + media_type: 'album' as const, + posterPath: metadata?.caaUrl ?? undefined, + needsCoverArt: !metadata?.caaUrl, + score: album.score || 0, + }; + }); + + const artistsWithArt = artistResults + .map((artist) => { + const metadata = updatedArtistsMetadataMap.get(artist.id); + const personMapping = personMappingResults[artist.id]; + const hasTmdbPersonId = + metadata?.tmdbPersonId || personMapping?.personId !== null; + + if (artist.type === 'Person' && hasTmdbPersonId) { + return null; + } + + const artistThumb = + metadata?.tadbThumb || + (artistImageResults[artist.id]?.artistThumb ?? null); + + const artistBackdrop = + metadata?.tadbCover || + (artistImageResults[artist.id]?.artistBackground ?? null); + + return { + ...artist, + media_type: 'artist' as const, + artistThumb, + artistBackdrop, + score: artist.score || 0, + }; + }) + .filter( + (artist): artist is NonNullable => artist !== null + ); + + const filteredArtists = artistsWithArt.filter((artist) => { + const tmdbPersonId = existingMappingsMap.get(artist.id); + return !tmdbPersonId || !tmdbPersonIds.includes(tmdbPersonId); + }); + + const musicResults = [...albumsWithArt, ...filteredArtists].sort( + (a, b) => (b.score || 0) - (a.score || 0) + ); + + const totalItems = tmdbResults.total_results + musicResults.length; + const totalPages = Math.max( + tmdbResults.total_pages, + Math.ceil(totalItems / ITEMS_PER_PAGE) + ); + + const combinedResults = + page === 1 + ? [...tmdbResults.results, ...musicResults] + : tmdbResults.results; + results = { page: tmdbResults.page, - total_pages: tmdbResults.total_pages, - total_results: tmdbResults.total_results + mbResults.length, + total_pages: totalPages, + total_results: totalItems, results: combinedResults, }; } - const media = await Media.getRelatedMedia( - req.user, - results.results.map((result) => ('id' in result ? result.id : 0)) - ); + const movieTvIds = results.results + .filter( + (result) => result.media_type === 'movie' || result.media_type === 'tv' + ) + .map((result) => Number(result.id)); + + const musicIds = results.results + .filter( + (result) => + result.media_type === 'album' || result.media_type === 'artist' + ) + .map((result) => result.id.toString()); + + const [movieTvMedia, musicMedia] = await Promise.all([ + movieTvIds.length > 0 ? Media.getRelatedMedia(req.user, movieTvIds) : [], + musicIds.length > 0 ? Media.getRelatedMedia(req.user, musicIds) : [], + ]); + + const media = [...movieTvMedia, ...musicMedia]; const mappedResults = await mapSearchResults(results.results, media); @@ -87,8 +291,8 @@ searchRoutes.get('/', async (req, res, next) => { } catch (e) { logger.debug('Something went wrong retrieving search results', { label: 'API', - errorMessage: e.message, - query: req.query.query, + errorMessage: e instanceof Error ? e.message : 'Unknown error', + query: queryString, }); return next({ status: 500, diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 64b905f2..32af9b59 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -761,8 +761,7 @@ settingsRoutes.get('/cache', async (_req, res) => { const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const avatarImageCache = await ImageProxy.getImageStats('avatar'); const caaImageCache = await ImageProxy.getImageStats('caa'); - const lidarrImageCache = await ImageProxy.getImageStats('lidarr'); - const fanartImageCache = await ImageProxy.getImageStats('fanart'); + const tadbImageCache = await ImageProxy.getImageStats('tadb'); const stats: DnsStats | undefined = dnsCache?.getStats(); const entries: DnsEntries | undefined = dnsCache?.getCacheEntries(); @@ -773,8 +772,7 @@ settingsRoutes.get('/cache', async (_req, res) => { tmdb: tmdbImageCache, avatar: avatarImageCache, caa: caaImageCache, - lidarr: lidarrImageCache, - fanart: fanartImageCache, + tadb: tadbImageCache, }, dnsCache: { stats, diff --git a/server/routes/imageproxy.ts b/server/routes/tadbproxy.ts similarity index 88% rename from server/routes/imageproxy.ts rename to server/routes/tadbproxy.ts index ac2fbe08..af99941a 100644 --- a/server/routes/imageproxy.ts +++ b/server/routes/tadbproxy.ts @@ -30,6 +30,13 @@ function initTvdbImageProxy() { return _tvdbImageProxy; } +const tadbImageProxy = new ImageProxy('tadb', 'https://r2.theaudiodb.com', { + rateLimitOptions: { + maxRequests: 20, + maxRPS: 50, + }, +}); + router.get('/:type/*', async (req, res) => { const imagePath = req.path.replace(/^\/\w+/, ''); try { @@ -38,6 +45,8 @@ router.get('/:type/*', async (req, res) => { imageData = await initTmdbImageProxy().getImage(imagePath); } else if (req.params.type === 'tvdb') { imageData = await initTvdbImageProxy().getImage(imagePath); + } else if (req.params.type === 'tabd') { + imageData = await tadbImageProxy.getImage(imagePath); } else { logger.error('Unsupported image type', { imagePath, diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 9a73275a..f875c8af 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -1,4 +1,5 @@ -import LidarrAPI from '@server/api/servarr/lidarr'; +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; import TheMovieDb from '@server/api/themoviedb'; import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; @@ -8,7 +9,6 @@ import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; -import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; @@ -26,6 +26,8 @@ export class IssueCommentSubscriber let title = ''; let image = ''; const tmdb = new TheMovieDb(); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); try { const issue = ( @@ -57,26 +59,12 @@ export class IssueCommentSubscriber tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; - } else if (media.mediaType === MediaType.MUSIC) { - const settings = getSettings(); - if (!settings.lidarr[0]) { - throw new Error('No Lidarr server configured'); - } + } else if (media.mediaType === MediaType.MUSIC && media.mbId) { + const album = await listenbrainz.getAlbum(media.mbId); + const coverArtResponse = await coverArt.getCoverArt(media.mbId); - const lidarr = new LidarrAPI({ - apiKey: settings.lidarr[0].apiKey, - url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'), - }); - - if (!media.mbId) { - throw new Error('MusicBrainz ID is undefined'); - } - - const album = await lidarr.getAlbumByMusicBrainzId(media.mbId); - const artist = await lidarr.getArtist({ id: album.artistId }); - - title = `${artist.artistName} - ${album.title}`; - image = album.images?.[0]?.url ?? ''; + title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`; + image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; } const [firstComment] = sortBy(issue.comments, 'id'); diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index b117b4db..8f7a756d 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -1,11 +1,11 @@ -import LidarrAPI from '@server/api/servarr/lidarr'; +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; import TheMovieDb from '@server/api/themoviedb'; import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import Issue from '@server/entity/Issue'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; -import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { @@ -25,6 +25,8 @@ export class IssueSubscriber implements EntitySubscriberInterface { let title = ''; let image = ''; const tmdb = new TheMovieDb(); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); try { if (entity.media.mediaType === MediaType.MOVIE) { @@ -41,26 +43,15 @@ export class IssueSubscriber implements EntitySubscriberInterface { tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; - } else if (entity.media.mediaType === MediaType.MUSIC) { - const settings = getSettings(); - if (!settings.lidarr[0]) { - throw new Error('No Lidarr server configured'); - } + } else if ( + entity.media.mediaType === MediaType.MUSIC && + entity.media.mbId + ) { + const album = await listenbrainz.getAlbum(entity.media.mbId); + const coverArtResponse = await coverArt.getCoverArt(entity.media.mbId); - const lidarr = new LidarrAPI({ - apiKey: settings.lidarr[0].apiKey, - url: LidarrAPI.buildUrl(settings.lidarr[0], '/api/v1'), - }); - - if (!entity.media.mbId) { - throw new Error('MusicBrainz ID is undefined'); - } - const album = await lidarr.getAlbumByMusicBrainzId(entity.media.mbId); - - const artist = await lidarr.getArtist({ id: album.artistId }); - - title = `${artist.artistName} - ${album.title}`; - image = album.images?.[0]?.url ?? ''; + title = `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`; + image = coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; } const [firstComment] = sortBy(entity.comments, 'id'); diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 2c6985c8..aa260a78 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -1,11 +1,13 @@ +import CoverArtArchive from '@server/api/coverartarchive'; +import ListenBrainzAPI from '@server/api/listenbrainz'; +import MusicBrainz from '@server/api/musicbrainz'; +import LidarrAPI from '@server/api/servarr/lidarr'; import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr'; import type { AddSeriesOptions, SonarrSeries, } from '@server/api/servarr/sonarr'; -import LidarrAPI from '@server/api/servarr/lidarr'; -import MusicBrainz from '@server/api/musicbrainz' import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; @@ -28,7 +30,6 @@ import type { InsertEvent, RemoveEvent, UpdateEvent, - Not } from 'typeorm'; import { EventSubscriber } from 'typeorm'; @@ -178,7 +179,6 @@ export class MediaRequestSubscriber entity: MediaRequest, event?: UpdateEvent ) { - // Get fresh media state using event manager let latestMedia: Media | null = null; if (event?.manager) { @@ -192,44 +192,55 @@ export class MediaRequestSubscriber where: { id: entity.media.id }, }); - if(!latestMedia - || latestMedia.mediaType !== MediaType.MUSIC - || latestMedia['status'] != MediaStatus.AVAILABLE) - { - return - } + if ( + !latestMedia || + latestMedia.mediaType !== MediaType.MUSIC || + latestMedia['status'] != MediaStatus.AVAILABLE + ) { + return; + } - try { - const musicbrainz = new MusicBrainz(); - const albumDetails = await musicbrainz.getAlbum({ - albumId: latestMedia.mbId, - }); + const listenbrainz = new ListenBrainzAPI(); + const coverArt = CoverArtArchive.getInstance(); + const musicbrainz = new MusicBrainz(); - const coverImage = albumDetails.images?.find( - (img) => img.CoverType.toLowerCase() === 'cover' - )?.Url; + try { + const album = await listenbrainz.getAlbum(latestMedia.mbId ?? ''); + const coverArtResponse = await coverArt.getCoverArt( + latestMedia.mbId ?? '' + ); + const coverArtUrl = + coverArtResponse.images[0]?.thumbnails?.['250'] ?? ''; + const artistId = + album.release_group_metadata?.artist?.artists[0]?.artist_mbid; + const artistWiki = artistId + ? await musicbrainz.getArtistWikipediaExtract({ + artistMbid: artistId, + }) + : null; - notificationManager.sendNotification( - Notification.MEDIA_AVAILABLE, - { - event: `Album Request Now Available`, - notifyAdmin: false, - notifySystem: true, - notifyUser: entity.requestedBy, - subject: albumDetails.title ?? latestMedia.mbId ?? 'Unknown Album', - message: albumDetails.overview || 'Album is now available.', - media: latestMedia, - request: entity, - image: coverImage, - } - ); - } catch (e) { - logger.error('Something went wrong sending media notification(s)', { - label: 'Notifications', - errorMessage: e.message, - mediaId: entity.id, - }); - } + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + event: 'Album Request Now Available', + notifyAdmin: false, + notifySystem: true, + notifyUser: entity.requestedBy, + subject: `${album.release_group_metadata.release_group.name} by ${album.release_group_metadata.artist.name}`, + message: truncate(artistWiki?.content ?? '', { + length: 500, + separator: /\s/, + omission: '…', + }), + media: latestMedia, + image: coverArtUrl, + request: entity, + }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); + } } } @@ -802,224 +813,216 @@ export class MediaRequestSubscriber public async sendToLidarr(entity: MediaRequest): Promise { if ( - entity.status !== MediaRequestStatus.APPROVED || - entity.type !== MediaType.MUSIC + entity.status === MediaRequestStatus.APPROVED && + entity.type === MediaType.MUSIC ) { - return; - } + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); - try { - const mediaRepository = getRepository(Media); - const settings = getSettings(); - const media = await mediaRepository.findOne({ - where: { id: entity.media.id }, - relations: { requests: true }, - }); + if (settings.lidarr.length === 0 && !settings.lidarr[0]) { + logger.info( + 'No Lidarr server configured, skipping request processing', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } - if (!media?.mbId) { - throw new Error('Media data or MusicBrainz ID not found'); - } + let lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault); - const lidarrSettings = - entity.serverId !== null && entity.serverId >= 0 - ? settings.lidarr.find((l) => l.id === entity.serverId) - : settings.lidarr.find((l) => l.isDefault); + if ( + entity.serverId !== null && + entity.serverId >= 0 && + lidarrSettings?.id !== entity.serverId + ) { + lidarrSettings = settings.lidarr.find( + (lidarr) => lidarr.id === entity.serverId + ); + logger.info( + `Request has an override server: ${lidarrSettings?.name}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } - if (!lidarrSettings) { - logger.warn('No valid Lidarr server configured', { + if (!lidarrSettings) { + logger.warn('There is no default Lidarr server configured.', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + return; + } + + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + if (media.status === MediaStatus.AVAILABLE) { + logger.warn('Media already exists, marking request as APPROVED', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.APPROVED; + await requestRepository.save(entity); + return; + } + + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), + }); + + if (!media.mbId) { + throw new Error('media.mbId is required but is undefined'); + } + const searchResults = await lidarr.searchAlbumByMusicBrainzId( + media.mbId + ); + + if (!searchResults?.length) { + throw new Error('Album not found in Lidarr search'); + } + + const albumInfo = searchResults[0].album; + + let rootFolder = lidarrSettings.activeDirectory; + + if ( + entity.rootFolder && + entity.rootFolder !== '' && + entity.rootFolder !== rootFolder + ) { + rootFolder = entity.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } + + const artistPath = `${rootFolder}/${albumInfo.artist.artistName}`; + + const addAlbumPayload: LidarrAlbumOptions = { + title: albumInfo.title, + disambiguation: albumInfo.disambiguation || '', + overview: albumInfo.overview, + artistId: albumInfo.artist.id, + foreignAlbumId: albumInfo.foreignAlbumId, + monitored: true, + anyReleaseOk: true, + profileId: 1, + duration: albumInfo.duration || 0, + albumType: albumInfo.albumType, + secondaryTypes: [], + mediumCount: albumInfo.mediumCount || 0, + ratings: albumInfo.ratings, + releaseDate: albumInfo.releaseDate, + releases: [], + genres: albumInfo.genres, + media: [], + artist: { + status: albumInfo.artist.status, + ended: albumInfo.artist.ended, + artistName: albumInfo.artist.artistName, + foreignArtistId: albumInfo.artist.foreignArtistId, + tadbId: albumInfo.artist.tadbId || 0, + discogsId: albumInfo.artist.discogsId || 0, + overview: albumInfo.artist.overview, + artistType: albumInfo.artist.artistType, + disambiguation: albumInfo.artist.disambiguation, + links: albumInfo.artist.links || [], + images: albumInfo.artist.images || [], + path: artistPath, + qualityProfileId: 1, + metadataProfileId: 2, + monitored: true, + monitorNewItems: 'none', + rootFolderPath: rootFolder, + genres: albumInfo.artist.genres || [], + cleanName: albumInfo.artist.cleanName, + sortName: albumInfo.artist.sortName, + tags: albumInfo.artist.tags || [], + added: albumInfo.artist.added || new Date().toISOString(), + ratings: albumInfo.artist.ratings, + id: albumInfo.artist.id, + }, + images: albumInfo.images || [], + links: albumInfo.links || [], + addOptions: { + searchForNewAlbum: true, + }, + }; + + lidarr + .addAlbum(addAlbumPayload) + .then(async (result) => { + const updateFields = { + externalServiceId: result.id, + externalServiceSlug: result.titleSlug, + serviceId: lidarrSettings?.id, + }; + + await mediaRepository.update({ id: entity.media.id }, updateFields); + + if (addAlbumPayload.addOptions.searchForNewAlbum) { + await lidarr.searchOnAdd(result.id); + } + }) + .catch(async (error) => { + const requestRepository = getRepository(MediaRequest); + + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + + logger.warn( + 'Something went wrong sending album request to Lidarr, marking status as FAILED', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + error: error.message, + } + ); + + MediaRequest.sendNotification( + entity, + media, + Notification.MEDIA_FAILED + ); + }); + + logger.info('Sent request to Lidarr', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); - return; - } - - const rootFolder = entity.rootFolder || lidarrSettings.activeDirectory; - const qualityProfile = entity.profileId || lidarrSettings.activeProfileId; - const tags = lidarrSettings.tags?.map((t) => t.toString()) || []; - - const lidarr = new LidarrAPI({ - apiKey: lidarrSettings.apiKey, - url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), - }); - - const lidarrAlbum = await lidarr.getAlbumByMusicBrainzId(media.mbId); - - let artistId: number; - try { - const existingArtist = await lidarr.getArtistByMusicBrainzId( - lidarrAlbum.artist.foreignArtistId - ); - artistId = existingArtist.id; - } catch { - const addedArtist = await lidarr.addArtist({ - artistName: lidarrAlbum.artist.artistName, - foreignArtistId: lidarrAlbum.artist.foreignArtistId, - qualityProfileId: qualityProfile, - profileId: qualityProfile, - metadataProfileId: qualityProfile, - rootFolderPath: rootFolder, - monitored: false, - tags: tags.map((t) => Number(t)), - searchNow: !lidarrSettings.preventSearch, - monitorNewItems: 'none', - monitor: 'none', - searchForMissingAlbums: false, - addOptions: { - monitor: 'none', - monitored: false, - searchForMissingAlbums: false, - }, + } catch (e) { + logger.error('Something went wrong sending request to Lidarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: entity.id, + mediaId: entity.media.id, }); - - await new Promise((resolve) => setTimeout(resolve, 60000)); - artistId = addedArtist.id; + throw new Error(e.message); } - - try { - const album = await lidarr.addAlbum({ - mbId: media.mbId, - foreignAlbumId: media.mbId, - title: lidarrAlbum.title, - qualityProfileId: qualityProfile, - profileId: qualityProfile, - metadataProfileId: qualityProfile, - rootFolderPath: rootFolder, - monitored: false, - tags, - searchNow: false, - artistId, - images: lidarrAlbum.images?.length - ? lidarrAlbum.images - : [ - { - url: '', - coverType: 'cover', - }, - ], - addOptions: { - monitor: 'none', - monitored: false, - searchForMissingAlbums: false, - }, - artist: { - id: artistId, - foreignArtistId: lidarrAlbum.artist.foreignArtistId, - artistName: lidarrAlbum.artist.artistName, - qualityProfileId: qualityProfile, - metadataProfileId: qualityProfile, - rootFolderPath: rootFolder, - monitored: false, - monitorNewItems: 'none', - }, - }); - - media.externalServiceId = album.id; - (media.externalServiceSlug = media.mbId), - (media.serviceId = lidarrSettings.id); - media.status = MediaStatus.PROCESSING; - await mediaRepository.save(media); - setTimeout(async () => { - try { - const albumDetails = await lidarr.getAlbum({ id: album.id }); - albumDetails.monitored = true; - await lidarr.updateAlbum(albumDetails); - - if (!lidarrSettings.preventSearch) { - await lidarr.searchAlbum(album.id); - } - - setTimeout(async () => { - try { - const finalAlbumDetails = await lidarr.getAlbum({ - id: album.id, - }); - if (!finalAlbumDetails.monitored) { - finalAlbumDetails.monitored = true; - await lidarr.updateAlbum(finalAlbumDetails); - await lidarr.searchAlbum(album.id); - } - } catch (err) { - logger.error('Failed final album monitoring check', { - label: 'Media Request', - error: err.message, - requestId: entity.id, - mediaId: entity.media.id, - albumId: entity.id, - }); - } - }, 20000); - - logger.info('Completed album monitoring setup', { - label: 'Media Request', - requestId: entity.id, - mediaId: entity.media.id, - albumId: entity.id, - }); - } catch (err) { - logger.error('Failed to process album monitoring', { - label: 'Media Request', - error: err.message, - requestId: entity.id, - mediaId: entity.media.id, - albumId: entity.id, - }); - } - }, 60000); - } catch (error) { - if (error.message.includes('This album has already been added')) { - const existingAlbums = await lidarr.getAlbums(); - const existingAlbum = existingAlbums.find( - (a) => a.foreignAlbumId === media.mbId - ); - - if (existingAlbum) { - media.externalServiceId = existingAlbum.id; - media.externalServiceSlug = media.mbId; - media.serviceId = lidarrSettings.id; - media.status = MediaStatus.PROCESSING; - await mediaRepository.save(media); - - setTimeout(async () => { - try { - await new Promise((resolve) => setTimeout(resolve, 20000)); - const albumDetails = await lidarr.getAlbum({ - id: existingAlbum.id, - }); - albumDetails.monitored = true; - await lidarr.updateAlbum(albumDetails); - - if (!lidarrSettings.preventSearch) { - await lidarr.searchAlbum(existingAlbum.id); - } - } catch (err) { - logger.error('Failed to process existing album', { - label: 'Media Request', - error: err.message, - requestId: entity.id, - mediaId: entity.media.id, - albumId: existingAlbum.id, - }); - } - }, 0); - } - } else { - const requestRepository = getRepository(MediaRequest); - entity.status = MediaRequestStatus.FAILED; - await requestRepository.save(this); - MediaRequest.sendNotification(entity, media, Notification.MEDIA_FAILED); - throw error; - } - } - } catch (e) { - logger.error('Failed to process Lidarr request', { - label: 'Media Request', - error: e.message, - requestId: entity.id, - mediaId: entity.media.id, - }); } } + public async updateParentStatus(entity: MediaRequest): Promise { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 1f860940..2b7def18 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -109,9 +109,11 @@ export class MediaSubscriber implements EntitySubscriberInterface { const allSeasonsReady = allSeasonResults.every((result) => result); shouldComplete = allSeasonsReady; - } else if (event.mediaType === MediaType.MUSIC) - { - if(event['status'] == MediaStatus.AVAILABLE || event['status'] === MediaStatus.DELETED) { + } else if (event.mediaType === MediaType.MUSIC) { + if ( + event['status'] == MediaStatus.AVAILABLE || + event['status'] === MediaStatus.DELETED + ) { shouldComplete = true; } } diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index f3789339..548378ff 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -1,9 +1,3 @@ -import type { - LidarrAlbumDetails, - LidarrAlbumResult, - LidarrArtistDetails, - LidarrArtistResult, -} from '@server/api/servarr/lidarr'; import type { TmdbCollectionResult, TmdbMovieDetails, @@ -44,18 +38,6 @@ export const isCollection = ( return (collection as TmdbCollectionResult).media_type === 'collection'; }; -export const isAlbum = ( - media: LidarrAlbumResult | LidarrArtistResult -): media is LidarrAlbumResult => { - return (media as LidarrAlbumResult).album?.albumType !== undefined; -}; - -export const isArtist = ( - media: LidarrAlbumResult | LidarrArtistResult -): media is LidarrArtistResult => { - return (media as LidarrArtistResult).artist?.artistType !== undefined; -}; - export const isMovieDetails = ( movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails ): movie is TmdbMovieDetails => { @@ -67,15 +49,3 @@ export const isTvDetails = ( ): tv is TmdbTvDetails => { return (tv as TmdbTvDetails).number_of_seasons !== undefined; }; - -export const isAlbumDetails = ( - details: LidarrAlbumDetails | LidarrArtistDetails -): details is LidarrAlbumDetails => { - return (details as LidarrAlbumDetails).albumType !== undefined; -}; - -export const isArtistDetails = ( - details: LidarrAlbumDetails | LidarrArtistDetails -): details is LidarrArtistDetails => { - return (details as LidarrArtistDetails).artistType !== undefined; -}; diff --git a/src/components/AddedCard/index.tsx b/src/components/AddedCard/index.tsx index a083dbe5..b35d0166 100644 --- a/src/components/AddedCard/index.tsx +++ b/src/components/AddedCard/index.tsx @@ -1,4 +1,5 @@ import TitleCard from '@app/components/TitleCard'; +import { useProgressiveCovers } from '@app/hooks/useProgressiveCovers'; import { Permission, useUser } from '@app/hooks/useUser'; import type { MovieDetails } from '@server/models/Movie'; import type { MusicDetails } from '@server/models/Music'; @@ -15,6 +16,8 @@ export interface AddedCardProps { canExpand?: boolean; isAddedToWatchlist?: boolean; mutateParent?: () => void; + posterPath?: string | null; + needsCoverArt?: boolean; } const isMovie = ( @@ -26,7 +29,7 @@ const isMovie = ( const isMusic = ( media: MovieDetails | TvDetails | MusicDetails ): media is MusicDetails => { - return (media as MusicDetails).artistId !== undefined; + return (media as MusicDetails).artist !== undefined; }; const AddedCard = ({ @@ -38,6 +41,8 @@ const AddedCard = ({ canExpand, isAddedToWatchlist = false, mutateParent, + posterPath: initialPosterPath, + needsCoverArt: initialNeedsCoverArt, }: AddedCardProps) => { const { hasPermission } = useUser(); @@ -52,10 +57,31 @@ const AddedCard = ({ ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`; - const { data: title, error } = useSWR< + const { data: titleData, error } = useSWR< MovieDetails | TvDetails | MusicDetails >(inView ? url : null); + const title = + useProgressiveCovers( + type === 'music' && + titleData && + isMusic(titleData) && + (initialPosterPath || initialNeedsCoverArt) + ? [ + { + ...titleData, + posterPath: initialPosterPath || titleData.posterPath, + needsCoverArt: + initialNeedsCoverArt ?? + (titleData as MusicDetails & { needsCoverArt?: boolean }) + .needsCoverArt, + } as MusicDetails, + ] + : titleData + ? [titleData] + : [] + )[0] ?? titleData; + if (!title && !error) { return (
@@ -84,10 +110,10 @@ const AddedCard = ({ isAddedToWatchlist={ title.mediaInfo?.watchlists?.length || isAddedToWatchlist } - image={title.images?.find((image) => image.CoverType === 'Cover')?.Url} + image={title.posterPath} status={title.mediaInfo?.status} title={title.title} - artist={title.artist.artistName} + artist={title.artist.name} type={title.type} year={title.releaseDate} mediaType={'album'} diff --git a/src/components/GroupCard/index.tsx b/src/components/ArtistCard/index.tsx similarity index 84% rename from src/components/GroupCard/index.tsx rename to src/components/ArtistCard/index.tsx index 5c4541d3..4371cbfd 100644 --- a/src/components/GroupCard/index.tsx +++ b/src/components/ArtistCard/index.tsx @@ -3,26 +3,30 @@ import { UserCircleIcon } from '@heroicons/react/24/solid'; import Link from 'next/link'; import { useState } from 'react'; -interface GroupCardProps { - groupId: string; +interface ArtistCardProps { + artistId: string; name: string; subName?: string; - image?: string; + profilePath?: string | null; + artistThumb?: string | null; + type?: string; canExpand?: boolean; } -const GroupCard = ({ - groupId, +const ArtistCard = ({ + artistId, name, subName, - image, + profilePath, + artistThumb, + type, canExpand = false, -}: GroupCardProps) => { +}: ArtistCardProps) => { const [isHovered, setHovered] = useState(false); return ( { setHovered(true); @@ -48,11 +52,11 @@ const GroupCard = ({
- {image ? ( + {artistThumb || profilePath ? (
{name}
- {subName && ( + {(subName || type) && (
- {subName} + {subName || type}
)}
; +} + +interface AlbumTypeState { + albums: Album[]; + isExpanded: boolean; + isLoading: boolean; + isHovered: boolean; + isCollapsing: boolean; +} + +const albumTypeMessages: Record = { + Album: 'album', + EP: 'ep', + Single: 'single', + Live: 'live', + Compilation: 'compilation', + Remix: 'remix', + Soundtrack: 'soundtrack', + Broadcast: 'broadcast', + Demo: 'demo', + Other: 'other', +}; + +const Biography = ({ + content, + showBio, + onClick, +}: { + content: string; + showBio: boolean; + onClick: () => void; +}) => { + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + role="button" + tabIndex={0} + > + + } + > +

{content}

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

{artistName}

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

{data.name}

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

{data.overview}

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

{content}

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

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

{intl.formatMessage(messages.biography)}

-

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

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

{intl.formatMessage(messages.trackstitle)}

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

{content}

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

{data.name}

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

{data.biography}

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