diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c806066..50a22c6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Pnpm Setup uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 @@ -96,6 +97,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Pnpm Setup uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 @@ -139,6 +141,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Pnpm Setup uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 @@ -187,6 +190,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Commit timestamp id: ts @@ -235,6 +239,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Commit timestamp id: ts @@ -374,6 +379,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Log in to GitHub Container Registry on seerr.home env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 08f2bdc5..e1083bea 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,6 +40,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Initialize CodeQL uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index c4d24575..4c1c3a21 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -40,6 +40,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + clean: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/renovate-helm-custom-hooks.yml b/.github/workflows/renovate-helm-custom-hooks.yml index 3f62cc55..e2f4f70f 100644 --- a/.github/workflows/renovate-helm-custom-hooks.yml +++ b/.github/workflows/renovate-helm-custom-hooks.yml @@ -30,7 +30,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: app-id: 2138788 diff --git a/.prettierignore b/.prettierignore index 91fd263c..ba4d900e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,8 @@ dist/ config/ cache/config.json +.bookshelf-migration-lab +.bookshelf-migration-lab/** pnpm-lock.yaml cypress/config/settings.cypress.json .github diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index f4009f1b..e70c5594 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -297,9 +297,7 @@ describe('Discover', () => { cy.contains('.slider-header', 'Recent Requests') .next('[data-testid=media-slider]') .contains('[data-testid=request-card]', 'Failed Card Album') - .find( - 'a[href="/music/56565656-5656-5656-5656-565656565656?manage=1"]' - ) + .find('a[href="/music/56565656-5656-5656-5656-565656565656?manage=1"]') .should('contain', 'Failed'); }); diff --git a/deploy/bookshelf-hardcover-migration.mjs b/deploy/bookshelf-hardcover-migration.mjs index c554dfe0..5567a6f2 100755 --- a/deploy/bookshelf-hardcover-migration.mjs +++ b/deploy/bookshelf-hardcover-migration.mjs @@ -507,6 +507,23 @@ const normalizePathComparable = (value) => { return normalized.replace(/\/+$/, ''); }; +const normalizeReadarrRootFolderPath = (value) => { + const normalized = normalizeText(value).replace(/\\/g, '/'); + const pathPattern = /^\/[A-Za-z0-9._~:/@()+, -]+$/; + + if (!pathPattern.test(normalized)) { + throw new Error(`Invalid Readarr root folder path: ${value}`); + } + + const normalizedPath = path.posix.normalize(normalized); + + if (normalizedPath === '.' || normalizedPath.includes('/../')) { + throw new Error(`Invalid Readarr root folder path: ${value}`); + } + + return normalizedPath; +}; + const deriveRootFolderPath = ({ book, author, rootFolders }) => { const direct = normalizeText( firstValue(book, [ @@ -2503,6 +2520,9 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { }; const resolveTargetIds = async (item, job, targetConfig) => { + const rootFolderPath = normalizeReadarrRootFolderPath( + item.addBook.rootFolderPath + ); const qualityProfile = item.source.qualityProfileName ? targetConfig.qualityProfiles.find( (profile) => @@ -2527,7 +2547,7 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { let rootFolder = targetConfig.rootFolders.find( (folder) => normalizePathComparable(folder.path ?? folder.Path) === - normalizePathComparable(item.addBook.rootFolderPath) + normalizePathComparable(rootFolderPath) ); const missing = []; @@ -2549,10 +2569,8 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { apiKey: job.apiKey, endpoint: '/rootfolder', body: { - path: item.addBook.rootFolderPath, - name: - item.addBook.rootFolderPath.split('/').filter(Boolean).pop() ?? - 'Books', + path: rootFolderPath, + name: rootFolderPath.split('/').filter(Boolean).pop() ?? 'Books', defaultQualityProfileId: Number( qualityProfile.id ?? qualityProfile.Id ), @@ -2567,7 +2585,6 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { }, }); targetConfig.rootFolders.push(createdRootFolder); - rootFolder = createdRootFolder; } if (missing.length) { @@ -2577,6 +2594,7 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { return { qualityProfileId: Number(qualityProfile.id ?? qualityProfile.Id), metadataProfileId: Number(metadataProfile.id ?? metadataProfile.Id), + rootFolderPath, }; }; @@ -2667,7 +2685,7 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { tags, author: { ...local.author, - rootFolderPath: item.addBook.rootFolderPath, + rootFolderPath: targetIds.rootFolderPath, qualityProfileId: targetIds.qualityProfileId, metadataProfileId: targetIds.metadataProfileId, monitored: false, @@ -2744,7 +2762,7 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { ...lookupAuthor, qualityProfileId: targetIds.qualityProfileId, metadataProfileId: targetIds.metadataProfileId, - rootFolderPath: item.addBook.rootFolderPath, + rootFolderPath: targetIds.rootFolderPath, monitored: false, monitorNewItems: 'none', tags: [], @@ -2828,7 +2846,7 @@ const applyRebuildPayload = async ({ migrationDir, jobs }) => { tags, author: { ...decision.result.author, - rootFolderPath: item.addBook.rootFolderPath, + rootFolderPath: targetIds.rootFolderPath, qualityProfileId: targetIds.qualityProfileId, metadataProfileId: targetIds.metadataProfileId, monitored: false, diff --git a/deploy/compose.main.yml b/deploy/compose.main.yml index e941e4d6..ca8f2924 100644 --- a/deploy/compose.main.yml +++ b/deploy/compose.main.yml @@ -14,8 +14,8 @@ services: healthcheck: test: [ - "CMD-SHELL", - "node -e \"fetch('http://127.0.0.1:5055/api/v1/status').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"", + 'CMD-SHELL', + 'node -e "fetch(''http://127.0.0.1:5055/api/v1/status'').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"', ] interval: 10s timeout: 5s diff --git a/deploy/compose.readonly-local.yml b/deploy/compose.readonly-local.yml index 4b8b846d..170e8139 100644 --- a/deploy/compose.readonly-local.yml +++ b/deploy/compose.readonly-local.yml @@ -10,10 +10,10 @@ services: TZ: ${TZ:-America/Regina} PORT: 5055 CONFIG_DIRECTORY: /app/config - SEERR_EXTERNAL_READ_ONLY: "true" - SEERR_ALLOW_PRODUCTION_EXTERNAL_READ_ONLY: "true" + SEERR_EXTERNAL_READ_ONLY: 'true' + SEERR_ALLOW_PRODUCTION_EXTERNAL_READ_ONLY: 'true' LOG_LEVEL: ${LOG_LEVEL:-info} ports: - - "${SEERRNG_PORT:-5055}:5055" + - '${SEERRNG_PORT:-5055}:5055' volumes: - ${SEERRNG_CONFIG_DIR:-../config}:/app/config diff --git a/deploy/compose.readonly-swap.yml b/deploy/compose.readonly-swap.yml index a599c73f..52f7d60b 100644 --- a/deploy/compose.readonly-swap.yml +++ b/deploy/compose.readonly-swap.yml @@ -7,10 +7,10 @@ services: TZ: ${TZ:-America/Regina} PORT: 5055 CONFIG_DIRECTORY: /app/config - SEERR_EXTERNAL_READ_ONLY: "true" - SEERR_ALLOW_PRODUCTION_EXTERNAL_READ_ONLY: "true" + SEERR_EXTERNAL_READ_ONLY: 'true' + SEERR_ALLOW_PRODUCTION_EXTERNAL_READ_ONLY: 'true' LOG_LEVEL: ${LOG_LEVEL:-info} ports: - - "${SEERRNG_PORT:-5055}:5055" + - '${SEERRNG_PORT:-5055}:5055' volumes: - ${SEERRNG_CONFIG_DIR:-./config}:/app/config diff --git a/package.json b/package.json index f0820062..ae6ae2ce 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", "migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts", "migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts", - "format": "prettier --log-level warn --write --cache .", - "format:check": "prettier --check --cache .", + "format": "git ls-files -z | xargs -0 prettier --log-level warn --write --cache --ignore-unknown", + "format:check": "git ls-files -z | xargs -0 prettier --check --cache --ignore-unknown", "typecheck": "pnpm typecheck:server && pnpm typecheck:client", "typecheck:server": "tsc --project server/tsconfig.json --noEmit", "typecheck:client": "next typegen && tsc --noEmit", @@ -85,6 +85,7 @@ "openpgp": "6.3.0", "pg": "8.20.0", "pug": "3.0.4", + "qs": "6.15.2", "react": "19.2.6", "react-ace": "14.0.1", "react-animate-height": "3.2.3", @@ -236,7 +237,7 @@ "nodemailer": "8.0.5", "picomatch": "2.3.2", "postcss": "8.5.10", - "qs": "6.14.2", + "qs": "6.15.2", "systeminformation": "5.31.6", "tar": "7.5.15", "tmp": "0.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17480e32..1e99317d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ overrides: nodemailer: 8.0.5 picomatch: 2.3.2 postcss: 8.5.10 - qs: 6.14.2 + qs: 6.15.2 systeminformation: 5.31.6 tar: 7.5.15 tmp: 0.2.4 @@ -181,6 +181,9 @@ importers: pug: specifier: 3.0.4 version: 3.0.4 + qs: + specifier: 6.15.2 + version: 6.15.2 react: specifier: 19.2.6 version: 19.2.6 @@ -6735,8 +6738,8 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} queue-lit@1.5.2: @@ -9194,7 +9197,7 @@ snapshots: json-stringify-safe: 5.0.1 mime-types: 2.1.35 performance-now: 2.1.0 - qs: 6.14.2 + qs: 6.15.2 safe-buffer: 5.2.1 tough-cookie: 5.1.2 tunnel-agent: 0.6.0 @@ -9852,7 +9855,7 @@ snapshots: lodash: 4.18.1 multimatch: 5.0.0 punycode: 2.3.1 - qs: 6.14.2 + qs: 6.15.2 titleize: 2.1.0 tlds: 1.261.0 transitivePeerDependencies: @@ -11438,7 +11441,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.14.2 + qs: 6.15.2 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -12906,7 +12909,7 @@ snapshots: multer: 2.1.1 ono: 7.1.3 path-to-regexp: 8.4.2 - qs: 6.14.2 + qs: 6.15.2 transitivePeerDependencies: - '@types/json-schema' @@ -12950,7 +12953,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.2 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -15721,7 +15724,7 @@ snapshots: pure-rand@7.0.1: optional: true - qs@6.14.2: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -16623,7 +16626,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.14.2 + qs: 6.15.2 transitivePeerDependencies: - supports-color diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 2281791d..62a17cf2 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -2,8 +2,8 @@ import { requestInterceptorFunction } from '@server/utils/customProxyAgent'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; import rateLimit from 'axios-rate-limit'; -import { createHash } from 'node:crypto'; import type NodeCache from 'node-cache'; +import { createHash } from 'node:crypto'; // 5 minute default TTL (in seconds) const DEFAULT_TTL = 300; diff --git a/server/api/github.ts b/server/api/github.ts index 67c93cc4..60839c08 100644 --- a/server/api/github.ts +++ b/server/api/github.ts @@ -84,14 +84,11 @@ class GithubAPI extends ExternalAPI { take?: number; } = {}): Promise { try { - const data = await this.get( - `${SEERR_REPO}/releases`, - { - params: { - per_page: take, - }, - } - ); + const data = await this.get(`${SEERR_REPO}/releases`, { + params: { + per_page: take, + }, + }); return data; } catch (e) { @@ -111,15 +108,12 @@ class GithubAPI extends ExternalAPI { branch?: string; } = {}): Promise { try { - const data = await this.get( - `${SEERR_REPO}/commits`, - { - params: { - per_page: take, - branch, - }, - } - ); + const data = await this.get(`${SEERR_REPO}/commits`, { + params: { + per_page: take, + branch, + }, + }); return data; } catch (e) { diff --git a/server/api/openlibrary/index.ts b/server/api/openlibrary/index.ts index c903cc94..f30e6ad8 100644 --- a/server/api/openlibrary/index.ts +++ b/server/api/openlibrary/index.ts @@ -122,7 +122,7 @@ class OpenLibraryAPI extends ExternalAPI { } public async getWork(workId: string): Promise { - const normalizedWorkId = workId.startsWith('/works/') + const normalizedWorkId = /^\/works\//i.test(workId) ? workId : `/works/${workId}`; @@ -134,7 +134,7 @@ class OpenLibraryAPI extends ExternalAPI { } public async getEdition(editionId: string): Promise { - const normalizedEditionId = editionId.startsWith('/books/') + const normalizedEditionId = /^\/books\//i.test(editionId) ? editionId : `/books/${editionId}`; @@ -187,7 +187,7 @@ class OpenLibraryAPI extends ExternalAPI { workId: string, limit = 100 ): Promise { - const normalizedWorkId = workId.startsWith('/works/') + const normalizedWorkId = /^\/works\//i.test(workId) ? workId : `/works/${workId}`; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 208e01e7..838f45a4 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -133,18 +133,14 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { discoverRegion, originalLanguage, }: { discoverRegion?: string; originalLanguage?: string } = {}) { - super( - 'https://api.themoviedb.org/3', - getTmdbAuthParams(), - { - headers: getTmdbAuthHeaders(), - nodeCache: cacheManager.getCache('tmdb').data, - rateLimit: { - maxRequests: 20, - maxRPS: 50, - }, - } - ); + super('https://api.themoviedb.org/3', getTmdbAuthParams(), { + headers: getTmdbAuthHeaders(), + nodeCache: cacheManager.getCache('tmdb').data, + rateLimit: { + maxRequests: 20, + maxRPS: 50, + }, + }); this.locale = getSettings().main?.locale || 'en'; this.discoverRegion = discoverRegion; this.originalLanguage = originalLanguage; diff --git a/server/api/themoviedb/personMapper.ts b/server/api/themoviedb/personMapper.ts index 678b2442..0639d00b 100644 --- a/server/api/themoviedb/personMapper.ts +++ b/server/api/themoviedb/personMapper.ts @@ -21,18 +21,14 @@ class TmdbPersonMapper extends ExternalAPI { private tmdb: TheMovieDb; constructor() { - super( - 'https://api.themoviedb.org/3', - getTmdbAuthParams(), - { - headers: getTmdbAuthHeaders(), - nodeCache: cacheManager.getCache('tmdb').data, - rateLimit: { - maxRequests: 20, - maxRPS: 50, - }, - } - ); + super('https://api.themoviedb.org/3', getTmdbAuthParams(), { + headers: getTmdbAuthHeaders(), + nodeCache: cacheManager.getCache('tmdb').data, + rateLimit: { + maxRequests: 20, + maxRPS: 50, + }, + }); this.tmdb = new TheMovieDb(); } @@ -188,10 +184,10 @@ class TmdbPersonMapper extends ExternalAPI { current.popularity > prev.popularity ? current : prev ) : availableMatches.length > 0 - ? availableMatches.reduce((prev, current) => - current.popularity > prev.popularity ? current : prev - ) - : null; + ? availableMatches.reduce((prev, current) => + current.popularity > prev.popularity ? current : prev + ) + : null; const mapping = { personId: exactMatch?.id ?? null, diff --git a/server/api/tvdb.test.ts b/server/api/tvdb.test.ts index acefd1ae..9a532a9e 100644 --- a/server/api/tvdb.test.ts +++ b/server/api/tvdb.test.ts @@ -21,7 +21,10 @@ describe('tvdbTokenNeedsRefresh', () => { assert.equal(tvdbTokenNeedsRefresh('not-a-jwt', now), true); assert.equal(tvdbTokenNeedsRefresh('header.not-json.sig', now), true); assert.equal(tvdbTokenNeedsRefresh(encodePayload({}), now), true); - assert.equal(tvdbTokenNeedsRefresh(encodePayload({ exp: now + 60 }), now), true); + assert.equal( + tvdbTokenNeedsRefresh(encodePayload({ exp: now + 60 }), now), + true + ); }); it('refreshes oversized tokens before decoding payloads', () => { diff --git a/server/entity/Blocklist.ts b/server/entity/Blocklist.ts index 44272333..6a64cf76 100644 --- a/server/entity/Blocklist.ts +++ b/server/entity/Blocklist.ts @@ -6,6 +6,7 @@ import MediaIdentifier, { } from '@server/entity/MediaIdentifier'; import { User } from '@server/entity/User'; import type { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces'; +import { normalizeExternalMediaId } from '@server/lib/externalIds'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import type { EntityManager } from 'typeorm'; import { @@ -82,6 +83,16 @@ export class Blocklist implements BlocklistItem { ): Promise { const em = entityManager ?? dataSource; const tmdbId = blocklistRequest.tmdbId ?? 0; + blocklistRequest = { + ...blocklistRequest, + externalId: blocklistRequest.externalId + ? normalizeExternalMediaId( + blocklistRequest.externalId, + blocklistRequest.mediaType, + blocklistRequest.externalProvider + ) + : undefined, + }; const blocklist = new this({ ...blocklistRequest, tmdbId, diff --git a/server/entity/Media.test.ts b/server/entity/Media.test.ts new file mode 100644 index 00000000..2e995818 --- /dev/null +++ b/server/entity/Media.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { setupTestDb } from '@server/test/db'; + +setupTestDb(); + +describe('Media.getRelatedMedia', () => { + it('normalizes MusicBrainz IDs before matching related media', async () => { + const media = await getRepository(Media).save( + new Media({ + tmdbId: 0, + mbId: 'release-group-id', + mediaType: MediaType.MUSIC, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }) + ); + + const relatedMedia = await Media.getRelatedMedia(undefined, [ + ' RELEASE-GROUP-ID ', + ]); + + assert.equal(relatedMedia.length, 1); + assert.equal(relatedMedia[0].id, media.id); + }); +}); diff --git a/server/entity/Media.ts b/server/entity/Media.ts index f7c9841b..3a156bca 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -1,5 +1,5 @@ -import RadarrAPI from '@server/api/servarr/radarr'; import LidarrAPI from '@server/api/servarr/lidarr'; +import RadarrAPI from '@server/api/servarr/radarr'; import ReadarrAPI from '@server/api/servarr/readarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus, MediaType } from '@server/constants/media'; @@ -10,6 +10,7 @@ import type { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; +import { normalizeMusicBrainzId } from '@server/lib/externalIds'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper'; @@ -50,7 +51,12 @@ class Media { (i) => i.tmdbId ) : (items as (number | string)[]); - const finalIds = [...new Set(ids)]; + const isMusicIdLookup = typeof ids[0] === 'string'; + const finalIds = [ + ...new Set( + isMusicIdLookup ? (ids as string[]).map(normalizeMusicBrainzId) : ids + ), + ]; const media = await mediaRepository .createQueryBuilder('media') @@ -61,7 +67,7 @@ class Media { { userId: user?.id } ) //, .where( - typeof finalIds[0] === 'string' + isMusicIdLookup ? 'media.mbId in (:...finalIds)' : 'media.tmdbId in (:...finalIds)', { finalIds } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ac6d96b4..9ac65f24 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -16,6 +16,11 @@ import MediaIdentifier, { } from '@server/entity/MediaIdentifier'; import OverrideRule from '@server/entity/OverrideRule'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; +import { + normalizeMusicBrainzId, + normalizeOpenLibraryEditionId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; import { normalizeValidIsbn } from '@server/lib/isbn'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; @@ -72,7 +77,7 @@ const resolveMusicReleaseGroupId = async ( let listenBrainzError: unknown; try { - const album = await listenbrainz.getAlbum(mediaId); + const album = await listenbrainz.getAlbum(normalizeMusicBrainzId(mediaId)); if (album.release_group_mbid) { return album.release_group_mbid; @@ -89,10 +94,10 @@ const resolveMusicReleaseGroupId = async ( try { const album = await musicbrainz.getReleaseGroupDetails({ - releaseGroupId: mediaId, + releaseGroupId: normalizeMusicBrainzId(mediaId), }); - return album.id; + return normalizeMusicBrainzId(album.id); } catch (releaseGroupError) { logger.warn( 'MusicBrainz release group lookup failed during music request', @@ -113,7 +118,7 @@ const resolveMusicReleaseGroupId = async ( const resolvedReleaseGroupId = await musicbrainz .getReleaseGroup({ - releaseId: mediaId, + releaseId: normalizeMusicBrainzId(mediaId), }) .catch((error) => { if (listenBrainzError) { @@ -127,7 +132,7 @@ const resolveMusicReleaseGroupId = async ( throw new Error('MusicBrainz ID did not resolve to a release group.'); } - return resolvedReleaseGroupId; + return normalizeMusicBrainzId(resolvedReleaseGroupId); }; @Entity() @@ -246,10 +251,12 @@ export class MediaRequest { } if (requestBody.mediaType === MediaType.MUSIC) { - const musicMbId = await resolveMusicReleaseGroupId( - requestBody.mediaId.toString(), - listenbrainz, - musicbrainz + const musicMbId = normalizeMusicBrainzId( + await resolveMusicReleaseGroupId( + requestBody.mediaId.toString(), + listenbrainz, + musicbrainz + ) ); const blocklistedAlbum = await getRepository(Blocklist).findOne({ where: { @@ -411,9 +418,9 @@ export class MediaRequest { } if (requestBody.mediaType === MediaType.BOOK) { - const openLibraryId = requestBody.mediaId - .toString() - .replace(/^\/?works\//, ''); + const openLibraryId = normalizeOpenLibraryWorkId( + requestBody.mediaId.toString() + ); const [, editions] = await Promise.all([ openLibrary.getWork(openLibraryId), openLibrary.getWorkEditions(openLibraryId).catch(() => ({ @@ -432,8 +439,8 @@ export class MediaRequest { .map((isbn) => normalizeValidIsbn(isbn)) .find((isbn): isbn is string => !!isbn); const openLibraryEditionId = requestBody.editionId - ?.toString() - .replace(/^\/?books\//, ''); + ? normalizeOpenLibraryEditionId(requestBody.editionId.toString()) + : undefined; const identifierCandidates = [ { provider: MediaIdentifierProvider.OPENLIBRARY, diff --git a/server/entity/Watchlist.ts b/server/entity/Watchlist.ts index 57358e39..e5377d78 100644 --- a/server/entity/Watchlist.ts +++ b/server/entity/Watchlist.ts @@ -16,6 +16,10 @@ import { RequestPermissionError, } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; +import { + normalizeMusicBrainzId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -112,6 +116,15 @@ export class Watchlist { const watchlistRepository = getRepository(this); const mediaRepository = getRepository(Media); const tmdb = new TheMovieDb(); + watchlistRequest = { + ...watchlistRequest, + mbId: watchlistRequest.mbId + ? normalizeMusicBrainzId(watchlistRequest.mbId) + : undefined, + externalId: watchlistRequest.externalId + ? normalizeOpenLibraryWorkId(watchlistRequest.externalId) + : undefined, + }; if (watchlistRequest.mediaType === MediaType.MUSIC) { if (!watchlistRequest.mbId) { diff --git a/server/lib/associations/index.ts b/server/lib/associations/index.ts index 0edf376c..d7bbd93a 100644 --- a/server/lib/associations/index.ts +++ b/server/lib/associations/index.ts @@ -14,6 +14,7 @@ import MediaIdentifier, { import MetadataArtist from '@server/entity/MetadataArtist'; import type { User } from '@server/entity/User'; import cacheManager from '@server/lib/cache'; +import { normalizeOpenLibraryWorkId } from '@server/lib/externalIds'; import { getSettings } from '@server/lib/settings'; import { scoreTmdbResult } from '@server/lib/tmdbRank'; import logger from '@server/logger'; @@ -482,9 +483,11 @@ const buildForBook = async ( let work = await openLibrary.getWork(openLibraryId); let workId = openLibraryId; - if (!work.key?.startsWith('/works/')) { + if (!work.key || !/^\/works\//i.test(work.key)) { const edition = await openLibrary.getEdition(openLibraryId); - const editionWorkId = edition.works?.[0]?.key?.replace('/works/', ''); + const editionWorkId = edition.works?.[0]?.key + ? normalizeOpenLibraryWorkId(edition.works[0].key) + : undefined; if (editionWorkId) { work = await openLibrary.getWork(editionWorkId); workId = editionWorkId; @@ -512,14 +515,14 @@ const buildForBook = async ( work.key, getPreferredOpenLibraryLanguage() ).slice(0, ASSOCIATION_LIMITS.MAX_SAME_MEDIUM); - const bookIds = books.map((book) => book.key.replace('/works/', '')); + const bookIds = books.map((book) => normalizeOpenLibraryWorkId(book.key)); const mediaByOpenLibraryId = await findBookMediaByOpenLibraryIds( bookIds, user?.id ); const edges = books.map((authorWork) => { - const bookId = authorWork.key.replace('/works/', ''); + const bookId = normalizeOpenLibraryWorkId(authorWork.key); const node = mapOpenLibraryAuthorWork( authorWork, mediaByOpenLibraryId.get(bookId), diff --git a/server/lib/bookIdentifierResolver.ts b/server/lib/bookIdentifierResolver.ts index d2ca9550..0694de59 100644 --- a/server/lib/bookIdentifierResolver.ts +++ b/server/lib/bookIdentifierResolver.ts @@ -1,6 +1,10 @@ import OpenLibraryAPI from '@server/api/openlibrary'; import type { ReadarrBook } from '@server/api/servarr/readarr'; import { MediaIdentifierProvider } from '@server/entity/MediaIdentifier'; +import { + normalizeOpenLibraryEditionId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; import logger from '@server/logger'; type ResolvedIdentifier = { @@ -8,13 +12,13 @@ type ResolvedIdentifier = { value: string; }; -const normalizeOpenLibraryWorkId = (value?: string): string | undefined => { - const id = value?.replace('/works/', ''); +const getOpenLibraryWorkId = (value?: string): string | undefined => { + const id = value ? normalizeOpenLibraryWorkId(value) : undefined; return id && /^OL\d+W$/i.test(id) ? id : undefined; }; -const normalizeOpenLibraryEditionId = (value?: string): string | undefined => { - const id = value?.replace('/books/', ''); +const getOpenLibraryEditionId = (value?: string): string | undefined => { + const id = value ? normalizeOpenLibraryEditionId(value) : undefined; return id && /^OL\d+M$/i.test(id) ? id : undefined; }; @@ -46,7 +50,7 @@ export const resolveOpenLibraryIdentifiersForReadarrBook = async ( openLibrary = new OpenLibraryAPI() ): Promise => { const identifiers: (ResolvedIdentifier | undefined)[] = []; - const workId = normalizeOpenLibraryWorkId(book.foreignBookId); + const workId = getOpenLibraryWorkId(book.foreignBookId); if (workId) { identifiers.push({ @@ -58,9 +62,7 @@ export const resolveOpenLibraryIdentifiersForReadarrBook = async ( const editionIds = [ ...new Set( (book.editions ?? []) - .map((edition) => - normalizeOpenLibraryEditionId(edition.foreignEditionId) - ) + .map((edition) => getOpenLibraryEditionId(edition.foreignEditionId)) .filter((editionId): editionId is string => !!editionId) ), ]; @@ -75,7 +77,7 @@ export const resolveOpenLibraryIdentifiersForReadarrBook = async ( for (const editionId of editionIds) { try { const edition = await openLibrary.getEdition(editionId); - const editionWorkId = normalizeOpenLibraryWorkId(edition.works?.[0]?.key); + const editionWorkId = getOpenLibraryWorkId(edition.works?.[0]?.key); if (editionWorkId) { identifiers.push({ diff --git a/server/lib/bookMediaMatcher.ts b/server/lib/bookMediaMatcher.ts index ad8ff0b3..d6a42d18 100644 --- a/server/lib/bookMediaMatcher.ts +++ b/server/lib/bookMediaMatcher.ts @@ -8,6 +8,10 @@ import type Media from '@server/entity/Media'; import MediaIdentifier, { MediaIdentifierProvider, } from '@server/entity/MediaIdentifier'; +import { + normalizeOpenLibraryEditionId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; import { normalizeValidIsbn } from '@server/lib/isbn'; import { In } from 'typeorm'; @@ -48,12 +52,6 @@ const uniqIdentifierLookups = ( return unique; }; -const normalizeOpenLibraryWorkId = (key?: string): string | undefined => - key?.replace('/works/', ''); - -const normalizeOpenLibraryEditionId = (key?: string): string | undefined => - key?.replace('/books/', ''); - const getSearchDocIsbns = (doc: OpenLibrarySearchDoc): string[] => [ ...new Set( (doc.isbn ?? []) @@ -138,7 +136,7 @@ export const findBookMediaForSearchDocs = async ( userId?: number ): Promise> => { const lookups = docs.flatMap((doc) => { - const workId = normalizeOpenLibraryWorkId(doc.key); + const workId = doc.key ? normalizeOpenLibraryWorkId(doc.key) : undefined; const isbnLookups = getSearchDocIsbns(doc).map((isbn) => ({ provider: MediaIdentifierProvider.ISBN, value: isbn, @@ -155,7 +153,7 @@ export const findBookMediaForSearchDocs = async ( const mediaByWorkId = new Map(); docs.forEach((doc) => { - const workId = normalizeOpenLibraryWorkId(doc.key); + const workId = doc.key ? normalizeOpenLibraryWorkId(doc.key) : undefined; const media = (workId ? mediaByIdentifier.get( @@ -221,7 +219,9 @@ export const findBookMediaForWork = async ( userId?: number ): Promise => { const editionLookups = editions - .map((edition) => normalizeOpenLibraryEditionId(edition.key)) + .map((edition) => + edition.key ? normalizeOpenLibraryEditionId(edition.key) : undefined + ) .filter((editionId): editionId is string => !!editionId) .map((editionId) => ({ provider: MediaIdentifierProvider.OPENLIBRARY_EDITION, diff --git a/server/lib/browserImageCache.test.ts b/server/lib/browserImageCache.test.ts index 65a46905..49873862 100644 --- a/server/lib/browserImageCache.test.ts +++ b/server/lib/browserImageCache.test.ts @@ -154,8 +154,14 @@ describe('doesBrowserImageEtagMatch', () => { }); it('ignores oversized or malformed validator headers', () => { - assert.equal(doesBrowserImageEtagMatch(`${'"x",'.repeat(400)}"abc"`, '"abc"'), false); - assert.equal(doesBrowserImageEtagMatch('"abc"\r\nX-Test: yes', '"abc"'), false); + assert.equal( + doesBrowserImageEtagMatch(`${'"x",'.repeat(400)}"abc"`, '"abc"'), + false + ); + assert.equal( + doesBrowserImageEtagMatch('"abc"\r\nX-Test: yes', '"abc"'), + false + ); }); }); diff --git a/server/lib/imageproxy.test.ts b/server/lib/imageproxy.test.ts index 713f9670..327b631c 100644 --- a/server/lib/imageproxy.test.ts +++ b/server/lib/imageproxy.test.ts @@ -2,11 +2,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + MAX_IMAGE_CACHE_MAX_AGE, getImageCacheLastModified, getImageResponseContentType, - MAX_IMAGE_CACHE_MAX_AGE, - parseImageCacheFileMetadata, parseCacheControlMaxAge, + parseImageCacheFileMetadata, } from './imageproxy'; describe('parseCacheControlMaxAge', () => { diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 9b80b499..7819d3ad 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -66,19 +66,16 @@ export const parseCacheControlMaxAge = ( export const parseImageCacheFileMetadata = ( filename: string, now = Date.now() -): - | { - maxAge: number; - expireAt: number; - etag: string; - extension: string; - lastModified: number; - revalidateAfter: number; - isStale: boolean; - } - | null => { - const [maxAgeSt, expireAtSt, etag, extension, ...extra] = - filename.split('.'); +): { + maxAge: number; + expireAt: number; + etag: string; + extension: string; + lastModified: number; + revalidateAfter: number; + isStale: boolean; +} | null => { + const [maxAgeSt, expireAtSt, etag, extension, ...extra] = filename.split('.'); if (extra.length || !etag || !extension) { return null; diff --git a/server/lib/isbn.ts b/server/lib/isbn.ts index fe733dac..bc15cf89 100644 --- a/server/lib/isbn.ts +++ b/server/lib/isbn.ts @@ -26,7 +26,8 @@ export const isValidIsbn13 = (isbn: string): boolean => { .slice(0, 12) .split('') .reduce( - (total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 1 : 3), + (total, digit, index) => + total + Number(digit) * (index % 2 === 0 ? 1 : 3), 0 ); const checkDigit = (10 - (sum % 10)) % 10; @@ -43,7 +44,8 @@ export const convertIsbn10To13 = (isbn: string): string | undefined => { const sum = body .split('') .reduce( - (total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 1 : 3), + (total, digit, index) => + total + Number(digit) * (index % 2 === 0 ? 1 : 3), 0 ); const checkDigit = (10 - (sum % 10)) % 10; diff --git a/server/lib/notifications/agents/agent.test.ts b/server/lib/notifications/agents/agent.test.ts index 1691c5ca..cf2d62a9 100644 --- a/server/lib/notifications/agents/agent.test.ts +++ b/server/lib/notifications/agents/agent.test.ts @@ -63,9 +63,9 @@ describe('WebhookAgent', () => { types: Notification.TEST_NOTIFICATION, options: { webhookUrl: 'http://127.0.0.1/webhook', - jsonPayload: Buffer.from(JSON.stringify(JSON.stringify(nested))).toString( - 'base64' - ), + jsonPayload: Buffer.from( + JSON.stringify(JSON.stringify(nested)) + ).toString('base64'), customHeaders: [], supportVariables: false, }, diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index aa79db8b..bb94ef71 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -164,7 +164,11 @@ class GotifyAgent const endpoint = `${settings.options.url}/message?token=${settings.options.token}`; const notificationPayload = this.getNotificationPayload(type, payload); - await axios.post(endpoint, notificationPayload, NOTIFICATION_HTTP_OPTIONS); + await axios.post( + endpoint, + notificationPayload, + NOTIFICATION_HTTP_OPTIONS + ); return true; } catch (e) { diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index a348f240..fe3ec4d2 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -288,25 +288,25 @@ class WebhookAgent settings.options.customHeaders .slice(0, MAX_WEBHOOK_CUSTOM_HEADERS) .forEach((header) => { - const key = header.key?.trim(); - const value = header.value?.trim(); - - if ( - key && - value && - WEBHOOK_HEADER_NAME.test(key) && - !/[\r\n]/.test(value) && - value.length <= MAX_WEBHOOK_HEADER_VALUE_LENGTH - ) { - // Don't override Authorization header if it's already set via authHeader + const key = header.key?.trim(); + const value = header.value?.trim(); + if ( - key.toLowerCase() !== 'authorization' || - !settings.options.authHeader + key && + value && + WEBHOOK_HEADER_NAME.test(key) && + !/[\r\n]/.test(value) && + value.length <= MAX_WEBHOOK_HEADER_VALUE_LENGTH ) { - headers[key] = value; + // Don't override Authorization header if it's already set via authHeader + if ( + key.toLowerCase() !== 'authorization' || + !settings.options.authHeader + ) { + headers[key] = value; + } } - } - }); + }); } await axios.post( diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index 68d4d1d8..dc8e96d4 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -5,6 +5,7 @@ import Media from '@server/entity/Media'; import type { MediaIdentifierProvider } from '@server/entity/MediaIdentifier'; import MediaIdentifier from '@server/entity/MediaIdentifier'; import Season from '@server/entity/Season'; +import { normalizeMusicBrainzId } from '@server/lib/externalIds'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import AsyncLock from '@server/utils/asyncLock'; @@ -14,6 +15,26 @@ import { randomUUID } from 'crypto'; const BUNDLE_SIZE = 20; const UPDATE_RATE = 4 * 1000; +const dedupeIdentifierCandidates = ( + identifiers: { + provider: MediaIdentifierProvider; + value: string; + }[] +) => { + const seen = new Set(); + + return identifiers.filter((identifier) => { + const key = `${identifier.provider}:${identifier.value}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +}; + export type StatusBase = { running: boolean; progress: number; @@ -277,10 +298,11 @@ class BaseScanner { }: ProcessOptions = {} ): Promise { const mediaRepository = getRepository(Media); + const normalizedMbId = normalizeMusicBrainzId(mbId); - await this.asyncLock.dispatch(mbId, async () => { + await this.asyncLock.dispatch(normalizedMbId, async () => { const existing = await mediaRepository.findOne({ - where: { mbId, mediaType: MediaType.MUSIC }, + where: { mbId: normalizedMbId, mediaType: MediaType.MUSIC }, }); if (existing) { @@ -341,7 +363,7 @@ class BaseScanner { await mediaRepository.save( new Media({ tmdbId: 0, - mbId, + mbId: normalizedMbId, mediaType: MediaType.MUSIC, mediaAddedAt, serviceId, @@ -381,17 +403,33 @@ class BaseScanner { const lockKey = `${provider}:${value}`; await this.asyncLock.dispatch(lockKey, async () => { - const identifierCandidates = [ + const identifierCandidates = dedupeIdentifierCandidates([ { provider, value }, ...secondaryIdentifiers, - ]; - const existingIdentifier = await identifierRepository.findOne({ + ]); + const candidateKeys = new Set( + identifierCandidates.map( + (identifier) => `${identifier.provider}:${identifier.value}` + ) + ); + const existingIdentifiers = await identifierRepository.find({ where: identifierCandidates.map((identifier) => ({ provider: identifier.provider, value: identifier.value, })), relations: { media: true }, + order: { id: 'ASC' }, }); + const existingIdentifier = + existingIdentifiers.find( + (identifier) => + identifier.provider === provider && + identifier.value === value && + identifier.media.mediaType === MediaType.BOOK + ) ?? + existingIdentifiers.find( + (identifier) => identifier.media.mediaType === MediaType.BOOK + ); const existing = existingIdentifier?.media.mediaType === MediaType.BOOK ? existingIdentifier.media @@ -491,9 +529,22 @@ class BaseScanner { const existingKeys = new Set( ( await identifierRepository.find({ - where: { media: { id: existing.id } }, + where: [ + { media: { id: existing.id } }, + ...identifierCandidates.map((identifier) => ({ + provider: identifier.provider, + value: identifier.value, + })), + ], + relations: { media: true }, }) - ).map((identifier) => `${identifier.provider}:${identifier.value}`) + ) + .filter( + (identifier) => + identifier.media.id !== existing.id || + candidateKeys.has(`${identifier.provider}:${identifier.value}`) + ) + .map((identifier) => `${identifier.provider}:${identifier.value}`) ); const missingIdentifiers = identifierCandidates.filter( (identifier) => diff --git a/server/lib/scanners/lidarr/index.ts b/server/lib/scanners/lidarr/index.ts index bcaf1031..1ac041fa 100644 --- a/server/lib/scanners/lidarr/index.ts +++ b/server/lib/scanners/lidarr/index.ts @@ -3,6 +3,7 @@ import LidarrAPI from '@server/api/servarr/lidarr'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import { normalizeMusicBrainzId } from '@server/lib/externalIds'; import type { RunnableScanner, StatusBase, @@ -92,7 +93,9 @@ class LidarrScanner private async processLidarrAlbum(lidarrAlbum: LidarrAlbum): Promise { try { - const mbId = lidarrAlbum.foreignAlbumId; + const mbId = lidarrAlbum.foreignAlbumId + ? normalizeMusicBrainzId(lidarrAlbum.foreignAlbumId) + : undefined; if (!mbId) { this.log( 'No MusicBrainz ID found for this title. Skipping item.', diff --git a/server/lib/scanners/lidarr/lidarr.test.ts b/server/lib/scanners/lidarr/lidarr.test.ts index c7748cb2..aae257ee 100644 --- a/server/lib/scanners/lidarr/lidarr.test.ts +++ b/server/lib/scanners/lidarr/lidarr.test.ts @@ -182,4 +182,23 @@ describe('Lidarr Scanner', () => { }); assert.strictEqual(updated.status, MediaStatus.UNKNOWN); }); + + it('normalizes MusicBrainz release group IDs before saving scanned albums', async () => { + const mediaRepository = getRepository(Media); + + configureLidarr([{ syncEnabled: true }]); + getAlbumsImpl = async () => [ + fakeLidarrAlbum({ + foreignAlbumId: ' RELEASE-GROUP-ID ', + }), + ]; + + await lidarrScanner.run(); + + const media = await mediaRepository.find({ + where: { mediaType: MediaType.MUSIC }, + }); + assert.strictEqual(media.length, 1); + assert.strictEqual(media[0].mbId, 'release-group-id'); + }); }); diff --git a/server/lib/scanners/readarr/readarr.test.ts b/server/lib/scanners/readarr/readarr.test.ts index 3c33c5a4..1e165f38 100644 --- a/server/lib/scanners/readarr/readarr.test.ts +++ b/server/lib/scanners/readarr/readarr.test.ts @@ -365,4 +365,55 @@ describe('Readarr Scanner', () => { assert.strictEqual(identifiers.length, 1); assert.strictEqual(identifiers[0].media.mediaType, MediaType.BOOK); }); + + it('does not attach a secondary Bookshelf identifier already linked to another book', async () => { + const isbnMedia = await seedBook('9780000000009'); + const readarrMedia = await getRepository(Media).save( + new Media({ + tmdbId: 0, + mediaType: MediaType.BOOK, + status: MediaStatus.PROCESSING, + status4k: MediaStatus.UNKNOWN, + identifiers: [ + new MediaIdentifier({ + provider: MediaIdentifierProvider.READARR, + value: 'readarr-duplicate', + canonical: true, + }), + ], + }) + ); + + configureReadarr([{ syncEnabled: true }]); + getBooksImpl = async () => [ + fakeReadarrBook({ + foreignBookId: 'readarr-duplicate', + editions: [ + { + foreignEditionId: 'edition-id', + title: 'Test Book', + isbn13: '9780000000009', + monitored: true, + }, + ], + }), + ]; + + await readarrScanner.run(); + + const readarrIdentifiers = await getRepository(MediaIdentifier).find({ + where: { + provider: MediaIdentifierProvider.READARR, + value: 'readarr-duplicate', + }, + relations: { media: true }, + }); + const updatedIsbnMedia = await getRepository(Media).findOneOrFail({ + where: { id: isbnMedia.id }, + }); + + assert.strictEqual(readarrIdentifiers.length, 1); + assert.strictEqual(readarrIdentifiers[0].media.id, readarrMedia.id); + assert.strictEqual(updatedIsbnMedia.externalServiceSlug, 'test-book'); + }); }); diff --git a/server/lib/watchlist.ts b/server/lib/watchlist.ts index b7e53bbc..a7166574 100644 --- a/server/lib/watchlist.ts +++ b/server/lib/watchlist.ts @@ -6,6 +6,10 @@ import type { WatchlistItem, WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; +import { + normalizeMusicBrainzId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; const mapLocalWatchlistItem = (item: Watchlist): WatchlistItem => ({ id: item.id, @@ -33,13 +37,13 @@ const getWatchlistDedupeKey = (item: WatchlistItem) => { } if (item.mediaType === MediaType.MUSIC && item.mbId) { - return `${item.mediaType}:mb:${item.mbId.toLocaleLowerCase()}`; + return `${item.mediaType}:mb:${normalizeMusicBrainzId(item.mbId)}`; } if (item.mediaType === MediaType.BOOK && item.externalId) { - return `${item.mediaType}:openlibrary:${item.externalId - .replace(/^\/?works\//, '') - .toLocaleLowerCase()}`; + return `${item.mediaType}:openlibrary:${normalizeOpenLibraryWorkId( + item.externalId + ).toLocaleLowerCase()}`; } return `${item.mediaType}:rating:${item.ratingKey}`; diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 3e4dd75c..728d7161 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -14,6 +14,28 @@ import { User } from '@server/entity/User'; import logger from '@server/logger'; import { Permission } from './permissions'; +type PlexWatchlistItem = Awaited< + ReturnType +>['items'][number]; + +const dedupePlexWatchlistItems = ( + items: PlexWatchlistItem[] +): PlexWatchlistItem[] => { + const seen = new Set(); + + return items.filter((item) => { + const mediaType = item.type === 'show' ? MediaType.TV : MediaType.MOVIE; + const key = `${mediaType}:${item.tmdbId}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +}; + class WatchlistSync { public async syncWatchlist() { const userRepository = getRepository(User); @@ -68,16 +90,17 @@ class WatchlistSync { const plexTvApi = new PlexTvAPI(user.plexToken); const response = await plexTvApi.getWatchlist({ size: 20 }); + const watchlistItems = dedupePlexWatchlistItems(response.items); const mediaItems = await Media.getRelatedMedia( user, - response.items.map((i) => ({ + watchlistItems.map((i) => ({ tmdbId: i.tmdbId, mediaType: i.type === 'show' ? MediaType.TV : MediaType.MOVIE, })) ); - const watchlistTmdbIds = response.items.map((i) => i.tmdbId); + const watchlistTmdbIds = watchlistItems.map((i) => i.tmdbId); const requestRepository = getRepository(MediaRequest); const existingAutoRequests: MediaRequest[] = @@ -99,7 +122,7 @@ class WatchlistSync { .map((r) => `${r.media.mediaType}:${r.media.tmdbId}`) ); - const unavailableItems = response.items.filter((i) => { + const unavailableItems = watchlistItems.filter((i) => { const itemMediaType = i.type === 'show' ? MediaType.TV : MediaType.MOVIE; return ( diff --git a/server/migration/postgres/1780100000000-AddWatchlistSyncMusic.ts b/server/migration/postgres/1780100000000-AddWatchlistSyncMusic.ts index e4e2592e..9de47e9d 100644 --- a/server/migration/postgres/1780100000000-AddWatchlistSyncMusic.ts +++ b/server/migration/postgres/1780100000000-AddWatchlistSyncMusic.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddWatchlistSyncMusic1780100000000 - implements MigrationInterface -{ +export class AddWatchlistSyncMusic1780100000000 implements MigrationInterface { name = 'AddWatchlistSyncMusic1780100000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/postgres/1780200000000-AddMediaIdentifiers.ts b/server/migration/postgres/1780200000000-AddMediaIdentifiers.ts index 7e015891..82836e3d 100644 --- a/server/migration/postgres/1780200000000-AddMediaIdentifiers.ts +++ b/server/migration/postgres/1780200000000-AddMediaIdentifiers.ts @@ -34,7 +34,9 @@ export class AddMediaIdentifiers1780200000000 implements MigrationInterface { await queryRunner.query( `ALTER TABLE "media_identifier" DROP CONSTRAINT "FK_media_identifier_media"` ); - await queryRunner.query(`DROP INDEX "public"."IDX_media_identifier_mediaId"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_media_identifier_mediaId"` + ); await queryRunner.query( `DROP INDEX "public"."IDX_media_identifier_provider_value"` ); diff --git a/server/migration/postgres/1780400000000-AddBookWatchlistSyncSetting.ts b/server/migration/postgres/1780400000000-AddBookWatchlistSyncSetting.ts index 8aca17c6..7335a06f 100644 --- a/server/migration/postgres/1780400000000-AddBookWatchlistSyncSetting.ts +++ b/server/migration/postgres/1780400000000-AddBookWatchlistSyncSetting.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddBookWatchlistSyncSetting1780400000000 - implements MigrationInterface -{ +export class AddBookWatchlistSyncSetting1780400000000 implements MigrationInterface { name = 'AddBookWatchlistSyncSetting1780400000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/postgres/1780600000000-AddWatchlistExternalId.ts b/server/migration/postgres/1780600000000-AddWatchlistExternalId.ts index dd23e9a5..f7521bd4 100644 --- a/server/migration/postgres/1780600000000-AddWatchlistExternalId.ts +++ b/server/migration/postgres/1780600000000-AddWatchlistExternalId.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddWatchlistExternalId1780600000000 - implements MigrationInterface -{ +export class AddWatchlistExternalId1780600000000 implements MigrationInterface { name = 'AddWatchlistExternalId1780600000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/postgres/1780700000000-AddRequestMetadataProfile.ts b/server/migration/postgres/1780700000000-AddRequestMetadataProfile.ts index 62b63929..9de06286 100644 --- a/server/migration/postgres/1780700000000-AddRequestMetadataProfile.ts +++ b/server/migration/postgres/1780700000000-AddRequestMetadataProfile.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddRequestMetadataProfile1780700000000 - implements MigrationInterface -{ +export class AddRequestMetadataProfile1780700000000 implements MigrationInterface { name = 'AddRequestMetadataProfile1780700000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/postgres/1780800000000-AddBlocklistExternalIds.ts b/server/migration/postgres/1780800000000-AddBlocklistExternalIds.ts index ad43b5d3..fa6c92bf 100644 --- a/server/migration/postgres/1780800000000-AddBlocklistExternalIds.ts +++ b/server/migration/postgres/1780800000000-AddBlocklistExternalIds.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddBlocklistExternalIds1780800000000 - implements MigrationInterface -{ +export class AddBlocklistExternalIds1780800000000 implements MigrationInterface { name = 'AddBlocklistExternalIds1780800000000'; public async up(queryRunner: QueryRunner): Promise { @@ -18,8 +16,12 @@ export class AddBlocklistExternalIds1780800000000 } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_blocklist_external_media_type"`); - await queryRunner.query(`ALTER TABLE "blocklist" DROP COLUMN "externalProvider"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_blocklist_external_media_type"` + ); + await queryRunner.query( + `ALTER TABLE "blocklist" DROP COLUMN "externalProvider"` + ); await queryRunner.query(`ALTER TABLE "blocklist" DROP COLUMN "externalId"`); } } diff --git a/server/migration/postgres/1780900000000-AddBookAudiobookServiceFields.ts b/server/migration/postgres/1780900000000-AddBookAudiobookServiceFields.ts index 8d75b592..132f1b26 100644 --- a/server/migration/postgres/1780900000000-AddBookAudiobookServiceFields.ts +++ b/server/migration/postgres/1780900000000-AddBookAudiobookServiceFields.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddBookAudiobookServiceFields1780900000000 - implements MigrationInterface -{ +export class AddBookAudiobookServiceFields1780900000000 implements MigrationInterface { name = 'AddBookAudiobookServiceFields1780900000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/postgres/1781400000000-NormalizeExternalMediaIds.ts b/server/migration/postgres/1781400000000-NormalizeExternalMediaIds.ts new file mode 100644 index 00000000..84368b0c --- /dev/null +++ b/server/migration/postgres/1781400000000-NormalizeExternalMediaIds.ts @@ -0,0 +1,130 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NormalizeExternalMediaIds1781400000000 implements MigrationInterface { + name = 'NormalizeExternalMediaIds1781400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "watchlist" w USING "watchlist" kept + WHERE w."id" > kept."id" + AND w."requestedById" = kept."requestedById" + AND w."mediaType" = kept."mediaType" + AND ( + (w."mediaType" = 'music' + AND w."mbId" IS NOT NULL + AND kept."mbId" IS NOT NULL + AND lower(trim(w."mbId")) = lower(trim(kept."mbId"))) + OR + (w."mediaType" = 'book' + AND w."externalId" IS NOT NULL + AND kept."externalId" IS NOT NULL + AND upper(regexp_replace(trim(w."externalId"), '^/?works/', '', 'i')) = upper(regexp_replace(trim(kept."externalId"), '^/?works/', '', 'i'))) + )` + ); + await queryRunner.query( + `DELETE FROM "blocklist" b USING "blocklist" kept + WHERE b."id" > kept."id" + AND b."mediaType" = kept."mediaType" + AND b."externalId" IS NOT NULL + AND kept."externalId" IS NOT NULL + AND ( + (b."mediaType" = 'music' + AND lower(trim(b."externalId")) = lower(trim(kept."externalId"))) + OR + (b."mediaType" = 'book' + AND COALESCE(b."externalProvider", 'openlibrary') = COALESCE(kept."externalProvider", 'openlibrary') + AND CASE + WHEN COALESCE(b."externalProvider", 'openlibrary') = 'isbn' THEN upper(regexp_replace(trim(b."externalId"), '[^0-9Xx]', '', 'g')) + WHEN COALESCE(b."externalProvider", 'openlibrary') = 'openlibrary_edition' THEN upper(regexp_replace(trim(b."externalId"), '^/?books/', '', 'i')) + ELSE upper(regexp_replace(trim(b."externalId"), '^/?works/', '', 'i')) + END = CASE + WHEN COALESCE(kept."externalProvider", 'openlibrary') = 'isbn' THEN upper(regexp_replace(trim(kept."externalId"), '[^0-9Xx]', '', 'g')) + WHEN COALESCE(kept."externalProvider", 'openlibrary') = 'openlibrary_edition' THEN upper(regexp_replace(trim(kept."externalId"), '^/?books/', '', 'i')) + ELSE upper(regexp_replace(trim(kept."externalId"), '^/?works/', '', 'i')) + END) + )` + ); + await queryRunner.query( + `DELETE FROM "media_identifier" mi USING "media_identifier" kept + WHERE mi."id" > kept."id" + AND mi."mediaId" = kept."mediaId" + AND mi."provider" = kept."provider" + AND CASE + WHEN mi."provider" = 'musicbrainz' THEN lower(trim(mi."value")) + WHEN mi."provider" = 'isbn' THEN upper(regexp_replace(trim(mi."value"), '[^0-9Xx]', '', 'g')) + WHEN mi."provider" = 'openlibrary_edition' THEN upper(regexp_replace(trim(mi."value"), '^/?books/', '', 'i')) + WHEN mi."provider" = 'openlibrary' THEN upper(regexp_replace(trim(mi."value"), '^/?works/', '', 'i')) + ELSE trim(mi."value") + END = CASE + WHEN kept."provider" = 'musicbrainz' THEN lower(trim(kept."value")) + WHEN kept."provider" = 'isbn' THEN upper(regexp_replace(trim(kept."value"), '[^0-9Xx]', '', 'g')) + WHEN kept."provider" = 'openlibrary_edition' THEN upper(regexp_replace(trim(kept."value"), '^/?books/', '', 'i')) + WHEN kept."provider" = 'openlibrary' THEN upper(regexp_replace(trim(kept."value"), '^/?works/', '', 'i')) + ELSE trim(kept."value") + END` + ); + await queryRunner.query( + `WITH ranked AS ( + SELECT + mi."id", + row_number() OVER ( + PARTITION BY + mi."provider", + CASE + WHEN mi."provider" = 'isbn' THEN upper(regexp_replace(trim(mi."value"), '[^0-9Xx]', '', 'g')) + WHEN mi."provider" = 'openlibrary_edition' THEN upper(regexp_replace(trim(mi."value"), '^/?books/', '', 'i')) + WHEN mi."provider" = 'openlibrary' THEN upper(regexp_replace(trim(mi."value"), '^/?works/', '', 'i')) + ELSE trim(mi."value") + END + ORDER BY + CASE WHEN EXISTS (SELECT 1 FROM "media_request" WHERE "mediaId" = mi."mediaId") THEN 4 ELSE 0 END + + CASE media."status" WHEN 5 THEN 3 WHEN 3 THEN 2 WHEN 1 THEN 1 ELSE 0 END DESC, + mi."id" ASC + ) AS rn + FROM "media_identifier" mi + INNER JOIN "media" media ON media."id" = mi."mediaId" + WHERE media."mediaType" = 'book' + AND mi."provider" IN ('isbn', 'openlibrary', 'openlibrary_edition', 'readarr', 'bookshelf', 'audiobookshelf', 'hardcover') + ) + DELETE FROM "media_identifier" + WHERE "id" IN (SELECT "id" FROM ranked WHERE rn > 1)` + ); + await queryRunner.query( + `UPDATE "media" SET "mbId" = lower(trim("mbId")) WHERE "mediaType" = 'music' AND "mbId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "watchlist" SET "mbId" = lower(trim("mbId")) WHERE "mediaType" = 'music' AND "mbId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "watchlist" SET "externalId" = upper(regexp_replace(trim("externalId"), '^/?works/', '', 'i')) WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = lower(trim("externalId")) WHERE "mediaType" = 'music' AND "externalId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = upper(regexp_replace(trim("externalId"), '[^0-9Xx]', '', 'g')) WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL AND COALESCE("externalProvider", 'openlibrary') = 'isbn'` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = upper(regexp_replace(trim("externalId"), '^/?books/', '', 'i')) WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL AND COALESCE("externalProvider", 'openlibrary') = 'openlibrary_edition'` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = upper(regexp_replace(trim("externalId"), '^/?works/', '', 'i')) WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL AND COALESCE("externalProvider", 'openlibrary') = 'openlibrary'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = lower(trim("value")) WHERE "provider" = 'musicbrainz'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = upper(regexp_replace(trim("value"), '[^0-9Xx]', '', 'g')) WHERE "provider" = 'isbn'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = upper(regexp_replace(trim("value"), '^/?books/', '', 'i')) WHERE "provider" = 'openlibrary_edition'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = upper(regexp_replace(trim("value"), '^/?works/', '', 'i')) WHERE "provider" = 'openlibrary'` + ); + } + + public async down(): Promise { + // Normalized IDs cannot be losslessly restored. + } +} diff --git a/server/migration/sqlite/1780100000000-AddWatchlistSyncMusic.ts b/server/migration/sqlite/1780100000000-AddWatchlistSyncMusic.ts index e4e2592e..9de47e9d 100644 --- a/server/migration/sqlite/1780100000000-AddWatchlistSyncMusic.ts +++ b/server/migration/sqlite/1780100000000-AddWatchlistSyncMusic.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddWatchlistSyncMusic1780100000000 - implements MigrationInterface -{ +export class AddWatchlistSyncMusic1780100000000 implements MigrationInterface { name = 'AddWatchlistSyncMusic1780100000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1780400000000-AddBookWatchlistSyncSetting.ts b/server/migration/sqlite/1780400000000-AddBookWatchlistSyncSetting.ts index 8aca17c6..7335a06f 100644 --- a/server/migration/sqlite/1780400000000-AddBookWatchlistSyncSetting.ts +++ b/server/migration/sqlite/1780400000000-AddBookWatchlistSyncSetting.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddBookWatchlistSyncSetting1780400000000 - implements MigrationInterface -{ +export class AddBookWatchlistSyncSetting1780400000000 implements MigrationInterface { name = 'AddBookWatchlistSyncSetting1780400000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1780600000000-AddWatchlistExternalId.ts b/server/migration/sqlite/1780600000000-AddWatchlistExternalId.ts index fb227736..8b9d5fff 100644 --- a/server/migration/sqlite/1780600000000-AddWatchlistExternalId.ts +++ b/server/migration/sqlite/1780600000000-AddWatchlistExternalId.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddWatchlistExternalId1780600000000 - implements MigrationInterface -{ +export class AddWatchlistExternalId1780600000000 implements MigrationInterface { name = 'AddWatchlistExternalId1780600000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1780700000000-AddRequestMetadataProfile.ts b/server/migration/sqlite/1780700000000-AddRequestMetadataProfile.ts index 62b63929..9de06286 100644 --- a/server/migration/sqlite/1780700000000-AddRequestMetadataProfile.ts +++ b/server/migration/sqlite/1780700000000-AddRequestMetadataProfile.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddRequestMetadataProfile1780700000000 - implements MigrationInterface -{ +export class AddRequestMetadataProfile1780700000000 implements MigrationInterface { name = 'AddRequestMetadataProfile1780700000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1780800000000-AddBlocklistExternalIds.ts b/server/migration/sqlite/1780800000000-AddBlocklistExternalIds.ts index 953da3f7..1527a53e 100644 --- a/server/migration/sqlite/1780800000000-AddBlocklistExternalIds.ts +++ b/server/migration/sqlite/1780800000000-AddBlocklistExternalIds.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddBlocklistExternalIds1780800000000 - implements MigrationInterface -{ +export class AddBlocklistExternalIds1780800000000 implements MigrationInterface { name = 'AddBlocklistExternalIds1780800000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1780900000000-AddBookAudiobookServiceFields.ts b/server/migration/sqlite/1780900000000-AddBookAudiobookServiceFields.ts index 36ac3d62..a64b565f 100644 --- a/server/migration/sqlite/1780900000000-AddBookAudiobookServiceFields.ts +++ b/server/migration/sqlite/1780900000000-AddBookAudiobookServiceFields.ts @@ -1,8 +1,6 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddBookAudiobookServiceFields1780900000000 - implements MigrationInterface -{ +export class AddBookAudiobookServiceFields1780900000000 implements MigrationInterface { name = 'AddBookAudiobookServiceFields1780900000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/sqlite/1781400000000-NormalizeExternalMediaIds.test.ts b/server/migration/sqlite/1781400000000-NormalizeExternalMediaIds.test.ts new file mode 100644 index 00000000..d4550f97 --- /dev/null +++ b/server/migration/sqlite/1781400000000-NormalizeExternalMediaIds.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DataSource } from 'typeorm'; +import { NormalizeExternalMediaIds1781400000000 } from './1781400000000-NormalizeExternalMediaIds'; + +const createDataSource = () => + new DataSource({ + type: 'sqlite', + database: ':memory:', + }); + +test('SQLite external ID normalization migration canonicalizes dirty historical rows', async () => { + const dataSource = await createDataSource().initialize(); + const queryRunner = dataSource.createQueryRunner(); + + try { + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY, "mediaType" varchar, "mbId" varchar, "status" integer)` + ); + await queryRunner.query( + `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY, "mediaType" varchar, "tmdbId" integer, "mbId" varchar, "externalId" varchar, "requestedById" integer)` + ); + await queryRunner.query( + `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY, "mediaType" varchar, "tmdbId" integer, "externalId" varchar, "externalProvider" varchar)` + ); + await queryRunner.query( + `CREATE TABLE "media_identifier" ("id" integer PRIMARY KEY, "mediaId" integer, "provider" varchar, "value" varchar)` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY, "mediaId" integer)` + ); + + await queryRunner.query( + `INSERT INTO "media" VALUES + (1, 'music', ' ABC ', 5), + (2, 'book', NULL, 5), + (3, 'book', NULL, 1), + (4, 'book', NULL, 1)` + ); + await queryRunner.query(`INSERT INTO "media_request" VALUES (1, 4)`); + await queryRunner.query( + `INSERT INTO "watchlist" VALUES + (1, 'music', NULL, ' ABC ', NULL, 7), + (2, 'music', NULL, 'abc', NULL, 7), + (3, 'book', NULL, NULL, '/WORKS/ol123w', 7), + (4, 'book', NULL, NULL, 'OL123W', 7)` + ); + await queryRunner.query( + `INSERT INTO "blocklist" VALUES + (1, 'music', 0, ' ABC ', NULL), + (2, 'music', 0, 'abc', NULL), + (3, 'book', 0, '978-0-441-47812-5', 'isbn'), + (4, 'book', 0, '9780441478125', 'isbn'), + (5, 'book', 0, '/WORKS/ol123w', 'openlibrary'), + (6, 'book', 0, 'OL123W', 'openlibrary'), + (7, 'book', 0, '/BOOKS/ol5m', 'openlibrary_edition'), + (8, 'book', 0, 'OL5M', 'openlibrary_edition')` + ); + await queryRunner.query( + `INSERT INTO "media_identifier" VALUES + (1, 1, 'musicbrainz', ' ABC '), + (2, 1, 'musicbrainz', 'abc'), + (3, 2, 'openlibrary', '/WORKS/ol123w'), + (4, 2, 'openlibrary', 'OL123W'), + (5, 2, 'openlibrary_edition', '/BOOKS/ol5m'), + (6, 2, 'openlibrary_edition', 'OL5M'), + (7, 2, 'isbn', '978-0-441-47812-5'), + (8, 2, 'isbn', '9780441478125'), + (9, 3, 'isbn', '978-1-111-11111-1'), + (10, 4, 'isbn', '9781111111111')` + ); + + await new NormalizeExternalMediaIds1781400000000().up(queryRunner); + + assert.deepEqual(await queryRunner.query(`SELECT "mbId" FROM "media"`), [ + { mbId: 'abc' }, + { mbId: null }, + { mbId: null }, + { mbId: null }, + ]); + assert.deepEqual( + await queryRunner.query( + `SELECT COALESCE("mbId", "externalId") AS "id" FROM "watchlist" ORDER BY "id"` + ), + [{ id: 'OL123W' }, { id: 'abc' }] + ); + assert.deepEqual( + await queryRunner.query( + `SELECT "externalId" FROM "blocklist" ORDER BY "externalId"` + ), + [ + { externalId: '9780441478125' }, + { externalId: 'OL123W' }, + { externalId: 'OL5M' }, + { externalId: 'abc' }, + ] + ); + assert.deepEqual( + await queryRunner.query( + `SELECT "value" FROM "media_identifier" ORDER BY "value"` + ), + [ + { value: '9780441478125' }, + { value: '9781111111111' }, + { value: 'OL123W' }, + { value: 'OL5M' }, + { value: 'abc' }, + ] + ); + assert.deepEqual( + await queryRunner.query( + `SELECT "mediaId" FROM "media_identifier" WHERE "value" = '9781111111111'` + ), + [{ mediaId: 4 }] + ); + } finally { + await queryRunner.release(); + await dataSource.destroy(); + } +}); diff --git a/server/migration/sqlite/1781400000000-NormalizeExternalMediaIds.ts b/server/migration/sqlite/1781400000000-NormalizeExternalMediaIds.ts new file mode 100644 index 00000000..0f1e965e --- /dev/null +++ b/server/migration/sqlite/1781400000000-NormalizeExternalMediaIds.ts @@ -0,0 +1,138 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NormalizeExternalMediaIds1781400000000 implements MigrationInterface { + name = 'NormalizeExternalMediaIds1781400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "watchlist" + WHERE EXISTS ( + SELECT 1 FROM "watchlist" kept + WHERE "watchlist"."id" > kept."id" + AND "watchlist"."requestedById" = kept."requestedById" + AND "watchlist"."mediaType" = kept."mediaType" + AND ( + ("watchlist"."mediaType" = 'music' + AND "watchlist"."mbId" IS NOT NULL + AND kept."mbId" IS NOT NULL + AND lower(trim("watchlist"."mbId")) = lower(trim(kept."mbId"))) + OR + ("watchlist"."mediaType" = 'book' + AND "watchlist"."externalId" IS NOT NULL + AND kept."externalId" IS NOT NULL + AND replace(replace(upper(trim("watchlist"."externalId")), '/WORKS/', ''), 'WORKS/', '') = replace(replace(upper(trim(kept."externalId")), '/WORKS/', ''), 'WORKS/', '')) + ) + )` + ); + await queryRunner.query( + `DELETE FROM "blocklist" + WHERE EXISTS ( + SELECT 1 FROM "blocklist" kept + WHERE "blocklist"."id" > kept."id" + AND "blocklist"."mediaType" = kept."mediaType" + AND "blocklist"."externalId" IS NOT NULL + AND kept."externalId" IS NOT NULL + AND ( + ("blocklist"."mediaType" = 'music' + AND lower(trim("blocklist"."externalId")) = lower(trim(kept."externalId"))) + OR + ("blocklist"."mediaType" = 'book' + AND COALESCE("blocklist"."externalProvider", 'openlibrary') = COALESCE(kept."externalProvider", 'openlibrary') + AND CASE + WHEN COALESCE("blocklist"."externalProvider", 'openlibrary') = 'isbn' THEN upper(replace(replace(trim("blocklist"."externalId"), '-', ''), ' ', '')) + WHEN COALESCE("blocklist"."externalProvider", 'openlibrary') = 'openlibrary_edition' THEN replace(replace(upper(trim("blocklist"."externalId")), '/BOOKS/', ''), 'BOOKS/', '') + ELSE replace(replace(upper(trim("blocklist"."externalId")), '/WORKS/', ''), 'WORKS/', '') + END = CASE + WHEN COALESCE(kept."externalProvider", 'openlibrary') = 'isbn' THEN upper(replace(replace(trim(kept."externalId"), '-', ''), ' ', '')) + WHEN COALESCE(kept."externalProvider", 'openlibrary') = 'openlibrary_edition' THEN replace(replace(upper(trim(kept."externalId")), '/BOOKS/', ''), 'BOOKS/', '') + ELSE replace(replace(upper(trim(kept."externalId")), '/WORKS/', ''), 'WORKS/', '') + END) + ) + )` + ); + await queryRunner.query( + `DELETE FROM "media_identifier" WHERE "id" NOT IN ( + SELECT MIN("id") FROM "media_identifier" + GROUP BY + "mediaId", + "provider", + CASE + WHEN "provider" = 'musicbrainz' THEN lower(trim("value")) + WHEN "provider" = 'isbn' THEN upper(replace(replace(trim("value"), '-', ''), ' ', '')) + WHEN "provider" = 'openlibrary_edition' THEN replace(replace(upper(trim("value")), '/BOOKS/', ''), 'BOOKS/', '') + WHEN "provider" = 'openlibrary' THEN replace(replace(upper(trim("value")), '/WORKS/', ''), 'WORKS/', '') + ELSE trim("value") + END + )` + ); + await queryRunner.query( + `CREATE TEMP TABLE "tmp_ranked_book_identifier" AS + SELECT + "media_identifier"."id" AS "id", + "media_identifier"."provider" AS "provider", + CASE + WHEN "media_identifier"."provider" = 'isbn' THEN upper(replace(replace(trim("media_identifier"."value"), '-', ''), ' ', '')) + WHEN "media_identifier"."provider" = 'openlibrary_edition' THEN replace(replace(upper(trim("media_identifier"."value")), '/BOOKS/', ''), 'BOOKS/', '') + WHEN "media_identifier"."provider" = 'openlibrary' THEN replace(replace(upper(trim("media_identifier"."value")), '/WORKS/', ''), 'WORKS/', '') + ELSE trim("media_identifier"."value") + END AS "normalizedValue", + ( + CASE WHEN EXISTS (SELECT 1 FROM "media_request" WHERE "mediaId" = "media_identifier"."mediaId") THEN 4 ELSE 0 END + + CASE "media"."status" WHEN 5 THEN 3 WHEN 3 THEN 2 WHEN 1 THEN 1 ELSE 0 END + ) * 1000000000 - "media_identifier"."id" AS "score" + FROM "media_identifier" + INNER JOIN "media" ON "media"."id" = "media_identifier"."mediaId" + WHERE "media"."mediaType" = 'book' + AND "media_identifier"."provider" IN ('isbn', 'openlibrary', 'openlibrary_edition', 'readarr', 'bookshelf', 'audiobookshelf', 'hardcover')` + ); + await queryRunner.query( + `DELETE FROM "media_identifier" + WHERE "id" IN ( + SELECT loser."id" + FROM "tmp_ranked_book_identifier" loser + INNER JOIN "tmp_ranked_book_identifier" kept + ON kept."provider" = loser."provider" + AND kept."normalizedValue" = loser."normalizedValue" + AND kept."score" > loser."score" + )` + ); + await queryRunner.query(`DROP TABLE "tmp_ranked_book_identifier"`); + await queryRunner.query( + `UPDATE "media" SET "mbId" = lower(trim("mbId")) WHERE "mediaType" = 'music' AND "mbId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "watchlist" SET "mbId" = lower(trim("mbId")) WHERE "mediaType" = 'music' AND "mbId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "watchlist" SET "externalId" = replace(replace(upper(trim("externalId")), '/WORKS/', ''), 'WORKS/', '') WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = lower(trim("externalId")) WHERE "mediaType" = 'music' AND "externalId" IS NOT NULL` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = upper(replace(replace(trim("externalId"), '-', ''), ' ', '')) WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL AND COALESCE("externalProvider", 'openlibrary') = 'isbn'` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = replace(replace(upper(trim("externalId")), '/BOOKS/', ''), 'BOOKS/', '') WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL AND COALESCE("externalProvider", 'openlibrary') = 'openlibrary_edition'` + ); + await queryRunner.query( + `UPDATE "blocklist" SET "externalId" = replace(replace(upper(trim("externalId")), '/WORKS/', ''), 'WORKS/', '') WHERE "mediaType" = 'book' AND "externalId" IS NOT NULL AND COALESCE("externalProvider", 'openlibrary') = 'openlibrary'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = lower(trim("value")) WHERE "provider" = 'musicbrainz'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = upper(replace(replace(trim("value"), '-', ''), ' ', '')) WHERE "provider" = 'isbn'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = replace(replace(upper(trim("value")), '/BOOKS/', ''), 'BOOKS/', '') WHERE "provider" = 'openlibrary_edition'` + ); + await queryRunner.query( + `UPDATE "media_identifier" SET "value" = replace(replace(upper(trim("value")), '/WORKS/', ''), 'WORKS/', '') WHERE "provider" = 'openlibrary'` + ); + } + + public async down(): Promise { + // Normalized IDs cannot be losslessly restored. + } +} diff --git a/server/models/Book.test.ts b/server/models/Book.test.ts index 36773079..b757662d 100644 --- a/server/models/Book.test.ts +++ b/server/models/Book.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { mapOpenLibraryWork } from '@server/models/Book'; +import { + mapOpenLibrarySearchDoc, + mapOpenLibraryWork, +} from '@server/models/Book'; describe('mapOpenLibraryWork', () => { it('extracts and ranks unique ISBN candidates from editions', () => { @@ -44,4 +47,35 @@ describe('mapOpenLibraryWork', () => { ['9780441478125', '9780679783268'] ); }); + + it('normalizes uppercase Open Library work and edition prefixes', () => { + const result = mapOpenLibraryWork( + { + key: '/WORKS/ol123w', + title: 'Test Book', + }, + undefined, + [ + { + key: '/BOOKS/ol1m', + title: 'Paperback', + isbn_10: ['0-441-47812-3'], + }, + ] + ); + + assert.strictEqual(result.id, 'OL123W'); + assert.strictEqual(result.editionId, 'OL1M'); + }); +}); + +describe('mapOpenLibrarySearchDoc', () => { + it('normalizes uppercase Open Library search document prefixes', () => { + const result = mapOpenLibrarySearchDoc({ + key: '/WORKS/ol456w', + title: 'Search Book', + }); + + assert.strictEqual(result.id, 'OL456W'); + }); }); diff --git a/server/models/Book.ts b/server/models/Book.ts index b437f6bf..216699ea 100644 --- a/server/models/Book.ts +++ b/server/models/Book.ts @@ -5,6 +5,10 @@ import type { OpenLibraryWork, } from '@server/api/openlibrary'; import type Media from '@server/entity/Media'; +import { + normalizeOpenLibraryEditionId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; import { normalizeValidIsbn } from '@server/lib/isbn'; export interface BookResult { @@ -55,7 +59,7 @@ export interface BookIsbnCandidate { } const getEditionId = (key?: string): string | undefined => - key?.replace('/books/', ''); + key ? normalizeOpenLibraryEditionId(key) : undefined; const mapEditionIsbnCandidates = ( editions: OpenLibraryEdition[] @@ -97,7 +101,7 @@ export const mapOpenLibrarySearchDoc = ( const isbn13 = doc.isbn ?.map((isbn) => normalizeValidIsbn(isbn)) .find((isbn): isbn is string => !!isbn); - const workId = doc.key.replace('/works/', ''); + const workId = normalizeOpenLibraryWorkId(doc.key); return { id: workId, @@ -135,7 +139,7 @@ export const mapOpenLibraryWork = ( const selectedCandidate = isbnCandidates[0]; return { - id: work.key.replace('/works/', ''), + id: normalizeOpenLibraryWorkId(work.key), mediaType: 'book', title: work.title, author: authorName, @@ -165,7 +169,7 @@ export const mapOpenLibraryAuthorWork = ( const coverId = work.covers?.[0]; return { - id: work.key.replace('/works/', ''), + id: normalizeOpenLibraryWorkId(work.key), mediaType: 'book', title: work.title, author: authorName, diff --git a/server/models/Music.ts b/server/models/Music.ts index dd3f0a15..fdd6e86d 100644 --- a/server/models/Music.ts +++ b/server/models/Music.ts @@ -93,17 +93,17 @@ export const mapMusicDetails = ( }, 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, - })), - })) + 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) => ({ @@ -111,13 +111,13 @@ export const mapMusicDetails = ( 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, - }) - ), + 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 ?? 0, diff --git a/server/models/Search.ts b/server/models/Search.ts index 5cab1ec9..8989a25e 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -2,8 +2,6 @@ import type { MbAlbumResult, MbArtistResult, } from '@server/api/musicbrainz/interfaces'; -import type { BookResult } from '@server/models/Book'; -export type { BookResult } from '@server/models/Book'; import type { TmdbCollectionResult, TmdbMovieDetails, @@ -15,6 +13,8 @@ import type { } from '@server/api/themoviedb/interfaces'; import { MediaType as MainMediaType } from '@server/constants/media'; import type Media from '@server/entity/Media'; +import type { BookResult } from '@server/models/Book'; +export type { BookResult } from '@server/models/Book'; export type MediaType = | 'tv' | 'movie' diff --git a/server/routes/artist.ts b/server/routes/artist.ts index 7f873678..69b9b9a7 100644 --- a/server/routes/artist.ts +++ b/server/routes/artist.ts @@ -7,6 +7,7 @@ import Media from '@server/entity/Media'; import MetadataAlbum from '@server/entity/MetadataAlbum'; import MetadataArtist from '@server/entity/MetadataArtist'; import { getAssociations } from '@server/lib/associations'; +import { normalizeMusicBrainzId } from '@server/lib/externalIds'; import logger from '@server/logger'; import { parsePositiveInt } from '@server/utils/pagination'; import { @@ -30,6 +31,11 @@ const parseMusicBrainzId = (value: unknown, fieldName = 'Artist ID') => maxLength: MAX_MUSICBRAINZ_ID_LENGTH, }); +const normalizeParsedMusicBrainzId = ( + parsed: ReturnType +) => + 'error' in parsed ? parsed : { value: normalizeMusicBrainzId(parsed.value) }; + const normalizeReleaseGroupTitle = (title: string) => title .toLocaleLowerCase() @@ -43,7 +49,7 @@ const dedupeReleaseGroups = (releaseGroups: LbReleaseGroupExtended[]) => { const seenTitles = new Set(); return releaseGroups.filter((releaseGroup) => { - const idKey = releaseGroup.mbid; + const idKey = normalizeMusicBrainzId(releaseGroup.mbid); const type = releaseGroup.secondary_types?.length ? releaseGroup.secondary_types[0] : releaseGroup.type || 'Other'; @@ -65,7 +71,9 @@ const dedupeReleaseGroups = (releaseGroups: LbReleaseGroupExtended[]) => { }; artistRoutes.get('/:id/similar', async (req, res, next) => { - const parsedArtistId = parseMusicBrainzId(req.params.id); + const parsedArtistId = normalizeParsedMusicBrainzId( + parseMusicBrainzId(req.params.id) + ); if ('error' in parsedArtistId) { return res.status(404).json({ status: 404, message: 'Artist not found' }); } @@ -114,7 +122,9 @@ artistRoutes.get('/:id/similar', async (req, res, next) => { }); artistRoutes.get('/:id', async (req, res, next) => { - const parsedArtistId = parseMusicBrainzId(req.params.id); + const parsedArtistId = normalizeParsedMusicBrainzId( + parseMusicBrainzId(req.params.id) + ); if ('error' in parsedArtistId) { return res.status(404).json({ status: 404, message: 'Artist not found' }); } @@ -221,7 +231,11 @@ artistRoutes.get('/:id', async (req, res, next) => { totalPages = 1; } - const mbIds = releaseGroupsToProcess.map((rg) => rg.mbid); + const mbIds = [ + ...new Set( + releaseGroupsToProcess.map((rg) => normalizeMusicBrainzId(rg.mbid)) + ), + ]; const responses = await Promise.allSettled([ musicbrainz @@ -253,7 +267,11 @@ artistRoutes.get('/:id', async (req, res, next) => { albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) ); - const mediaMap = new Map(relatedMedia.map((media) => [media.mbId, media])); + const mediaMap = new Map( + relatedMedia + .filter((media) => media.mbId) + .map((media) => [normalizeMusicBrainzId(media.mbId as string), media]) + ); const mappedReleaseGroups = releaseGroupsToProcess.map((releaseGroup) => { const metadata = metadataMap.get(releaseGroup.mbid); @@ -270,7 +288,7 @@ artistRoutes.get('/:id', async (req, res, next) => { total_listen_count: releaseGroup.total_listen_count || 0, posterPath: coverArtUrl, needsCoverArt: !coverArtUrl, - mediaInfo: mediaMap.get(releaseGroup.mbid), + mediaInfo: mediaMap.get(normalizeMusicBrainzId(releaseGroup.mbid)), }; }); diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index e2986aad..a52c0bf7 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -130,13 +130,11 @@ describe('POST /auth/jellyfin', () => { }); it('rejects malformed Jellyfin setup port values before external auth', async () => { - const res = await request(app) - .post('/auth/jellyfin') - .send({ - username: 'admin', - hostname: 'jellyfin.example.com', - port: 70000, - }); + const res = await request(app).post('/auth/jellyfin').send({ + username: 'admin', + hostname: 'jellyfin.example.com', + port: 70000, + }); assert.strictEqual(res.status, 400); assert.strictEqual( @@ -146,39 +144,33 @@ describe('POST /auth/jellyfin', () => { }); it('rejects malformed Jellyfin setup TLS flags before external auth', async () => { - const res = await request(app) - .post('/auth/jellyfin') - .send({ - username: 'admin', - hostname: 'jellyfin.example.com', - useSsl: 'yes', - }); + const res = await request(app).post('/auth/jellyfin').send({ + username: 'admin', + hostname: 'jellyfin.example.com', + useSsl: 'yes', + }); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error, 'useSsl must be a boolean.'); }); it('rejects absolute Jellyfin setup URL bases before external auth', async () => { - const res = await request(app) - .post('/auth/jellyfin') - .send({ - username: 'admin', - hostname: 'jellyfin.example.com', - urlBase: 'https://evil.example.com/jellyfin', - }); + const res = await request(app).post('/auth/jellyfin').send({ + username: 'admin', + hostname: 'jellyfin.example.com', + urlBase: 'https://evil.example.com/jellyfin', + }); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error, 'urlBase must be a relative path.'); }); it('rejects unsupported Jellyfin setup server types before external auth', async () => { - const res = await request(app) - .post('/auth/jellyfin') - .send({ - username: 'admin', - hostname: 'jellyfin.example.com', - serverType: 999, - }); + const res = await request(app).post('/auth/jellyfin').send({ + username: 'admin', + hostname: 'jellyfin.example.com', + serverType: 999, + }); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error, 'serverType must be Jellyfin or Emby.'); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d0caaea0..b22c687f 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -134,9 +134,11 @@ const parseOptionalPort = ( const parseOptionalMediaServerType = ( value: unknown -): { value: MediaServerType.JELLYFIN | MediaServerType.EMBY | undefined } | { - error: string; -} => { +): + | { value: MediaServerType.JELLYFIN | MediaServerType.EMBY | undefined } + | { + error: string; + } => { if (value === undefined || value === null || value === '') { return { value: undefined }; } diff --git a/server/routes/author.ts b/server/routes/author.ts index 714f42d1..8ee7a0a2 100644 --- a/server/routes/author.ts +++ b/server/routes/author.ts @@ -7,6 +7,7 @@ import type Media from '@server/entity/Media'; import MediaIdentifier, { MediaIdentifierProvider, } from '@server/entity/MediaIdentifier'; +import { normalizeOpenLibraryWorkId } from '@server/lib/externalIds'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { @@ -166,14 +167,14 @@ const getAuthorWorksPayload = async ( const works = await openLibrary.getAuthorWorks(authorId, { limit, offset }); const preferredLanguage = getPreferredOpenLibraryLanguage(); const filteredWorks = filterAuthorWorks(works.entries, preferredLanguage); - const ids = filteredWorks.map((work) => work.key.replace('/works/', '')); + const ids = filteredWorks.map((work) => normalizeOpenLibraryWorkId(work.key)); const mediaByOpenLibraryId = await findBookMediaByOpenLibraryIds(ids, userId); return { works: filteredWorks.map((work) => mapOpenLibraryAuthorWork( work, - mediaByOpenLibraryId.get(work.key.replace('/works/', '')), + mediaByOpenLibraryId.get(normalizeOpenLibraryWorkId(work.key)), undefined, authorId.replace(/^\/?authors\//, '') ) @@ -187,57 +188,57 @@ const getAuthorWorksPayload = async ( }; }; -authorRoutes.get<{ id: string }, AuthorDetails | { status: number; message: string }>( - '/:id', - async (req, res, next) => { - const parsedAuthorId = parseOpenLibraryAuthorId(req.params.id); - if ('error' in parsedAuthorId) { - return res.status(404).json({ status: 404, message: 'Author not found' }); - } +authorRoutes.get< + { id: string }, + AuthorDetails | { status: number; message: string } +>('/:id', async (req, res, next) => { + const parsedAuthorId = parseOpenLibraryAuthorId(req.params.id); + if ('error' in parsedAuthorId) { + return res.status(404).json({ status: 404, message: 'Author not found' }); + } - const authorId = parsedAuthorId.value; - const limit = parsePositiveInt(req.query.limit, 20, 100); - const offset = parseNonNegativeInt(req.query.offset); - const openLibrary = new OpenLibraryAPI(); + const authorId = parsedAuthorId.value; + const limit = parsePositiveInt(req.query.limit, 20, 100); + const offset = parseNonNegativeInt(req.query.offset); + const openLibrary = new OpenLibraryAPI(); - try { - const [author, worksPayload] = await Promise.all([ - openLibrary.getAuthor(authorId), - getAuthorWorksPayload(authorId, limit, offset, req.user?.id), - ]); - const biography = - typeof author.bio === 'string' ? author.bio : author.bio?.value; - const normalizedAuthorId = author.key.replace('/authors/', ''); + try { + const [author, worksPayload] = await Promise.all([ + openLibrary.getAuthor(authorId), + getAuthorWorksPayload(authorId, limit, offset, req.user?.id), + ]); + const biography = + typeof author.bio === 'string' ? author.bio : author.bio?.value; + const normalizedAuthorId = author.key.replace('/authors/', ''); - return res.status(200).json({ - id: normalizedAuthorId, - name: author.name, - biography, - birthDate: author.birth_date, - deathDate: author.death_date, - posterPath: author.photos?.[0] - ? `https://covers.openlibrary.org/a/id/${author.photos[0]}-L.jpg` - : undefined, - works: worksPayload.works.map((work) => ({ - ...work, - author: author.name, - authorId: normalizedAuthorId, - })), - pagination: worksPayload.pagination, - }); - } catch (e) { - logger.error('Failed to retrieve author details', { - label: 'Author', - errorMessage: e instanceof Error ? e.message : 'Unknown error', - authorId, - }); - return next({ - status: 500, - message: 'Unable to retrieve author details.', - }); - } + return res.status(200).json({ + id: normalizedAuthorId, + name: author.name, + biography, + birthDate: author.birth_date, + deathDate: author.death_date, + posterPath: author.photos?.[0] + ? `https://covers.openlibrary.org/a/id/${author.photos[0]}-L.jpg` + : undefined, + works: worksPayload.works.map((work) => ({ + ...work, + author: author.name, + authorId: normalizedAuthorId, + })), + pagination: worksPayload.pagination, + }); + } catch (e) { + logger.error('Failed to retrieve author details', { + label: 'Author', + errorMessage: e instanceof Error ? e.message : 'Unknown error', + authorId, + }); + return next({ + status: 500, + message: 'Unable to retrieve author details.', + }); } -); +}); authorRoutes.get<{ id: string }>('/:id/works', async (req, res, next) => { const parsedAuthorId = parseOpenLibraryAuthorId(req.params.id); diff --git a/server/routes/avatarproxy.test.ts b/server/routes/avatarproxy.test.ts index def72110..8a128130 100644 --- a/server/routes/avatarproxy.test.ts +++ b/server/routes/avatarproxy.test.ts @@ -118,12 +118,14 @@ describe('GET /avatarproxy/remote', () => { it('rejects duplicate remote avatar URL query params', async () => { mockAvatarDependencies(); - const res = await request(createApp()).get('/avatarproxy/remote').query({ - url: [ - 'https://secure.gravatar.com/avatar/abc?d=mm', - 'https://secure.gravatar.com/avatar/def?d=mm', - ], - }); + const res = await request(createApp()) + .get('/avatarproxy/remote') + .query({ + url: [ + 'https://secure.gravatar.com/avatar/abc?d=mm', + 'https://secure.gravatar.com/avatar/def?d=mm', + ], + }); assert.equal(res.status, 400); assert.deepEqual(res.body, { error: 'Avatar URL must be a string' }); diff --git a/server/routes/blocklist.test.ts b/server/routes/blocklist.test.ts index 1f8d355a..15ce5dc6 100644 --- a/server/routes/blocklist.test.ts +++ b/server/routes/blocklist.test.ts @@ -113,7 +113,7 @@ describe('POST /blocklist', () => { const agent = await loginAs('admin@seerr.dev', 'test1234'); const res = await agent.post('/blocklist').send({ mediaType: MediaType.MUSIC, - externalId: 'musicbrainz-release-group-id', + externalId: 'MUSICBRAINZ-RELEASE-GROUP-ID', externalProvider: MediaIdentifierProvider.MUSICBRAINZ, title: 'Test Album', }); @@ -141,7 +141,7 @@ describe('POST /blocklist', () => { const agent = await loginAs('admin@seerr.dev', 'test1234'); const res = await agent.post('/blocklist').send({ mediaType: MediaType.BOOK, - externalId: 'OL123W', + externalId: '/works/ol123w', externalProvider: MediaIdentifierProvider.OPENLIBRARY, title: 'Test Book', }); @@ -223,6 +223,39 @@ describe('POST /blocklist', () => { ); }); + it('blocks duplicate external blocklist ids after normalization', async () => { + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const music = await agent.post('/blocklist').send({ + mediaType: MediaType.MUSIC, + externalId: 'DUPLICATE-MUSIC-ID', + externalProvider: MediaIdentifierProvider.MUSICBRAINZ, + title: 'Duplicate Album', + }); + const duplicateMusic = await agent.post('/blocklist').send({ + mediaType: MediaType.MUSIC, + externalId: 'duplicate-music-id', + externalProvider: MediaIdentifierProvider.MUSICBRAINZ, + title: 'Duplicate Album', + }); + const book = await agent.post('/blocklist').send({ + mediaType: MediaType.BOOK, + externalId: '/works/ol333w', + externalProvider: MediaIdentifierProvider.OPENLIBRARY, + title: 'Duplicate Book', + }); + const duplicateBook = await agent.post('/blocklist').send({ + mediaType: MediaType.BOOK, + externalId: 'OL333W', + externalProvider: MediaIdentifierProvider.OPENLIBRARY, + title: 'Duplicate Book', + }); + + assert.strictEqual(music.status, 201); + assert.strictEqual(duplicateMusic.status, 412); + assert.strictEqual(book.status, 201); + assert.strictEqual(duplicateBook.status, 412); + }); + it('links an existing book media row through its identifier', async () => { const media = await getRepository(Media).save( new Media({ @@ -288,7 +321,7 @@ describe('GET and DELETE /blocklist/:id', () => { const agent = await loginAs('admin@seerr.dev', 'test1234'); await agent.post('/blocklist').send({ mediaType: MediaType.MUSIC, - externalId: 'musicbrainz-delete-id', + externalId: 'MUSICBRAINZ-DELETE-ID', externalProvider: MediaIdentifierProvider.MUSICBRAINZ, title: 'Delete Album', }); diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index f4c95a07..efeeebdb 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -7,6 +7,7 @@ import MediaIdentifier, { MediaIdentifierProvider, } from '@server/entity/MediaIdentifier'; import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces'; +import { normalizeExternalMediaId } from '@server/lib/externalIds'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -79,7 +80,7 @@ const getBlocklistLookup = (id: string, mediaType: MediaType) => { } return { - externalId, + externalId: normalizeExternalMediaId(externalId, mediaType), mediaType, }; }; @@ -212,7 +213,16 @@ blocklistRoutes.post( if (!parsedBody.success) { return next({ status: 400, message: 'Invalid blocklist payload.' }); } - const values = parsedBody.data; + const values = { + ...parsedBody.data, + externalId: parsedBody.data.externalId + ? normalizeExternalMediaId( + parsedBody.data.externalId, + parsedBody.data.mediaType, + parsedBody.data.externalProvider + ) + : undefined, + }; logPayload = { externalId: values.externalId, mediaType: values.mediaType, diff --git a/server/routes/book.ts b/server/routes/book.ts index bb73491e..6d7d893e 100644 --- a/server/routes/book.ts +++ b/server/routes/book.ts @@ -6,6 +6,7 @@ import { findBookMediaForSearchDocs, findBookMediaForWork, } from '@server/lib/bookMediaMatcher'; +import { normalizeOpenLibraryWorkId } from '@server/lib/externalIds'; import logger from '@server/logger'; import { mapOpenLibrarySearchDoc, @@ -61,7 +62,7 @@ bookRoutes.get('/search', async (req, res, next) => { results: response.docs.map((doc) => mapOpenLibrarySearchDoc( doc, - mediaByOpenLibraryId.get(doc.key.replace('/works/', '')) + mediaByOpenLibraryId.get(normalizeOpenLibraryWorkId(doc.key)) ) ), }); @@ -81,7 +82,7 @@ bookRoutes.get('/:id', async (req, res, next) => { return res.status(404).json({ status: 404, message: 'Book not found' }); } - const bookId = parsedBookId.value; + const bookId = normalizeOpenLibraryWorkId(parsedBookId.value); try { const openLibrary = new OpenLibraryAPI(); diff --git a/server/routes/collection.ts b/server/routes/collection.ts index 294e89ee..c50e5d90 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -3,10 +3,8 @@ import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; import logger from '@server/logger'; import { mapCollection } from '@server/models/Collection'; -import { - parseOptionalLanguage, -} from '@server/utils/validation'; import { parsePositiveRouteId } from '@server/utils/routeId'; +import { parseOptionalLanguage } from '@server/utils/validation'; import { Router } from 'express'; const collectionRoutes = Router(); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 2a01c168..0de76e83 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -28,6 +28,10 @@ import type { GenreSliderItem, WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; +import { + normalizeMusicBrainzId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; import { extractImageCacheUrls } from '@server/lib/imageCacheUrls'; import { enqueueImageCacheWarm } from '@server/lib/imageCacheWarmer'; import { getSettings } from '@server/lib/settings'; @@ -186,7 +190,7 @@ const dedupeMusicAlbums = (albums: T[]): T[] => { const seenTitles = new Set(); return albums.filter((album) => { - const idKey = album.id; + const idKey = normalizeMusicBrainzId(album.id); const titleKey = [ normalizeDiscoverTitle(album.title), normalizeDiscoverTitle(album['artist-credit']?.[0]?.name), @@ -209,7 +213,7 @@ const dedupeFreshReleases = (releases: LbRelease[]): LbRelease[] => { const seenTitles = new Set(); return releases.filter((release) => { - const idKey = release.release_group_mbid; + const idKey = normalizeMusicBrainzId(release.release_group_mbid); const titleKey = [ normalizeDiscoverTitle(release.release_name), normalizeDiscoverTitle(release.artist_credit_name), @@ -234,7 +238,7 @@ const dedupeBookDocs = ( const seenTitles = new Set(); return docs.filter((doc) => { - const key = doc.key.replace('/works/', '').toLocaleLowerCase(); + const key = normalizeOpenLibraryWorkId(doc.key).toLocaleLowerCase(); const titleKey = [ normalizeDiscoverTitle(doc.title), normalizeDiscoverTitle(doc.author_name?.[0]), @@ -275,6 +279,37 @@ const getProviderWindow = ( }; }; +const getRelatedMusicMediaMap = async ( + ids: string[], + userId?: number +): Promise> => { + const normalizedIds = [...new Set(ids.map(normalizeMusicBrainzId))].filter( + Boolean + ); + + if (!normalizedIds.length) { + return new Map(); + } + + const relatedMedia = await getRepository(Media).find({ + where: { mbId: In(normalizedIds), mediaType: MediaType.MUSIC }, + relations: { requests: true, watchlists: true }, + }); + + relatedMedia.forEach((media) => { + media.watchlists = + media.watchlists?.filter( + (watchlist) => watchlist.requestedBy.id === userId + ) ?? []; + }); + + return new Map( + relatedMedia + .filter((media) => media.mbId) + .map((media) => [normalizeMusicBrainzId(media.mbId as string), media]) + ); +}; + const scoreMusicRelease = (release: LbRelease): number => { const listenScore = Math.log10((release.listen_count ?? 0) + 1) * 40; const recencyScore = getRecencyScore(release.release_date); @@ -1714,19 +1749,10 @@ discoverRoutes.get('/music', async (req, res) => { const albums = dedupeMusicAlbums( albumWindow.slice(providerWindow.sliceStart, providerWindow.sliceEnd) ); - const mbIds = albums.map((album) => album.id); - const relatedMedia = mbIds.length - ? await getRepository(Media).find({ - where: { mbId: In(mbIds), mediaType: MediaType.MUSIC }, - relations: { requests: true, watchlists: true }, - }) - : []; - relatedMedia.forEach((media) => { - media.watchlists = - media.watchlists?.filter( - (watchlist) => watchlist.requestedBy.id === req.user?.id - ) ?? []; - }); + const relatedMediaMap = await getRelatedMusicMediaMap( + albums.map((album) => album.id), + req.user?.id + ); return res.status(200).json({ page, @@ -1735,7 +1761,7 @@ discoverRoutes.get('/music', async (req, res) => { results: albums.map((album) => mapAlbumResult( album, - relatedMedia.find((media) => media.mbId === album.id) + relatedMediaMap.get(normalizeMusicBrainzId(album.id)) ) ), }); @@ -1782,19 +1808,10 @@ discoverRoutes.get('/music', async (req, res) => { providerWindow.sliceStart, providerWindow.sliceEnd ); - const mbIds = albums.map((album) => album.id); - const relatedMedia = mbIds.length - ? await getRepository(Media).find({ - where: { mbId: In(mbIds), mediaType: MediaType.MUSIC }, - relations: { requests: true, watchlists: true }, - }) - : []; - relatedMedia.forEach((media) => { - media.watchlists = - media.watchlists?.filter( - (watchlist) => watchlist.requestedBy.id === req.user?.id - ) ?? []; - }); + const relatedMediaMap = await getRelatedMusicMediaMap( + albums.map((album) => album.id), + req.user?.id + ); return res.status(200).json({ page, @@ -1803,7 +1820,7 @@ discoverRoutes.get('/music', async (req, res) => { results: albums.map((album) => mapAlbumResult( album, - relatedMedia.find((media) => media.mbId === album.id) + relatedMediaMap.get(normalizeMusicBrainzId(album.id)) ) ), }); @@ -1830,19 +1847,10 @@ discoverRoutes.get('/music', async (req, res) => { ), providerWindow.sliceEnd ).slice(providerWindow.sliceStart, providerWindow.sliceEnd); - const mbIds = albums.map((album) => album.id); - const relatedMedia = mbIds.length - ? await getRepository(Media).find({ - where: { mbId: In(mbIds), mediaType: MediaType.MUSIC }, - relations: { requests: true, watchlists: true }, - }) - : []; - relatedMedia.forEach((media) => { - media.watchlists = - media.watchlists?.filter( - (watchlist) => watchlist.requestedBy.id === req.user?.id - ) ?? []; - }); + const relatedMediaMap = await getRelatedMusicMediaMap( + albums.map((album) => album.id), + req.user?.id + ); return res.status(200).json({ page, @@ -1854,7 +1862,7 @@ discoverRoutes.get('/music', async (req, res) => { results: albums.map((album) => mapAlbumResult( album, - relatedMedia.find((media) => media.mbId === album.id) + relatedMediaMap.get(normalizeMusicBrainzId(album.id)) ) ), }); @@ -1944,19 +1952,10 @@ discoverRoutes.get('/music', async (req, res) => { return res.status(200).json(emptyDiscoverResponse(page)); } - const fallbackMbIds = fallbackAlbums.map((album) => album.id); - const fallbackRelatedMedia = fallbackMbIds.length - ? await getRepository(Media).find({ - where: { mbId: In(fallbackMbIds), mediaType: MediaType.MUSIC }, - relations: { requests: true, watchlists: true }, - }) - : []; - fallbackRelatedMedia.forEach((media) => { - media.watchlists = - media.watchlists?.filter( - (watchlist) => watchlist.requestedBy.id === req.user?.id - ) ?? []; - }); + const fallbackRelatedMediaMap = await getRelatedMusicMediaMap( + fallbackAlbums.map((album) => album.id), + req.user?.id + ); return res.status(200).json({ page, @@ -1969,7 +1968,7 @@ discoverRoutes.get('/music', async (req, res) => { results: fallbackAlbums.map((album) => mapAlbumResult( album, - fallbackRelatedMedia.find((media) => media.mbId === album.id) + fallbackRelatedMediaMap.get(normalizeMusicBrainzId(album.id)) ) ), }); @@ -2021,19 +2020,10 @@ discoverRoutes.get('/music', async (req, res) => { ), providerWindow.sliceEnd ).slice(providerWindow.sliceStart, providerWindow.sliceEnd); - const mbIds = albums.map((album) => album.id); - const relatedMedia = mbIds.length - ? await getRepository(Media).find({ - where: { mbId: In(mbIds), mediaType: MediaType.MUSIC }, - relations: { requests: true, watchlists: true }, - }) - : []; - relatedMedia.forEach((media) => { - media.watchlists = - media.watchlists?.filter( - (watchlist) => watchlist.requestedBy.id === req.user?.id - ) ?? []; - }); + const relatedMediaMap = await getRelatedMusicMediaMap( + albums.map((album) => album.id), + req.user?.id + ); return res.status(200).json({ page, @@ -2042,7 +2032,7 @@ discoverRoutes.get('/music', async (req, res) => { results: albums.map((album) => mapAlbumResult( album, - relatedMedia.find((media) => media.mbId === album.id) + relatedMediaMap.get(normalizeMusicBrainzId(album.id)) ) ), }); @@ -2124,19 +2114,10 @@ discoverRoutes.get('/music', async (req, res) => { providerWindow.sliceStart, providerWindow.sliceEnd ); - const mbIds = releases.map((release) => release.release_group_mbid); - const relatedMedia = mbIds.length - ? await getRepository(Media).find({ - where: { mbId: In(mbIds), mediaType: MediaType.MUSIC }, - relations: { requests: true, watchlists: true }, - }) - : []; - relatedMedia.forEach((media) => { - media.watchlists = - media.watchlists?.filter( - (watchlist) => watchlist.requestedBy.id === req.user?.id - ) ?? []; - }); + const relatedMediaMap = await getRelatedMusicMediaMap( + releases.map((release) => release.release_group_mbid), + req.user?.id + ); const results = releases.map((release) => mapAlbumResult( @@ -2147,7 +2128,7 @@ discoverRoutes.get('/music', async (req, res) => { ? scoreMusicRelease(release) : (release.listen_count ?? 0), }, - relatedMedia.find((media) => media.mbId === release.release_group_mbid) + relatedMediaMap.get(normalizeMusicBrainzId(release.release_group_mbid)) ) ); @@ -2311,7 +2292,7 @@ discoverRoutes.get('/books', async (req, res) => { shuffleSeed ) : dedupedDocs; - const ids = sortedDocs.map((doc) => doc.key.replace('/works/', '')); + const ids = sortedDocs.map((doc) => normalizeOpenLibraryWorkId(doc.key)); const identifiers = ids.length ? await getRepository(MediaIdentifier).find({ where: { @@ -2341,7 +2322,7 @@ discoverRoutes.get('/books', async (req, res) => { results: sortedDocs.map((doc) => ({ ...mapOpenLibrarySearchDoc( doc, - mediaByOpenLibraryId.get(doc.key.replace('/works/', '')) + mediaByOpenLibraryId.get(normalizeOpenLibraryWorkId(doc.key)) ), score: scoreBookDoc(doc), })), diff --git a/server/routes/imageproxy.ts b/server/routes/imageproxy.ts index 69649ab3..863d0290 100644 --- a/server/routes/imageproxy.ts +++ b/server/routes/imageproxy.ts @@ -217,11 +217,9 @@ router.post('/warm', warmRateLimit, (req, res) => { } if (url.length > maxWarmUrlLength) { - return res - .status(400) - .json({ - error: `urls must be ${maxWarmUrlLength} characters or fewer.`, - }); + return res.status(400).json({ + error: `urls must be ${maxWarmUrlLength} characters or fewer.`, + }); } urls.push(url); diff --git a/server/routes/index.test.ts b/server/routes/index.test.ts index fdd0f441..2b7f80b3 100644 --- a/server/routes/index.test.ts +++ b/server/routes/index.test.ts @@ -75,7 +75,9 @@ describe('Top-level API route validation', () => { it('rejects missing Pushover sound tokens before provider lookup', async () => { const agent = await login(); - const res = await agent.get('/api/v1/settings/notifications/pushover/sounds'); + const res = await agent.get( + '/api/v1/settings/notifications/pushover/sounds' + ); assert.strictEqual(res.status, 400); assert.match(res.body.message, /Pushover application token/); diff --git a/server/routes/index.ts b/server/routes/index.ts index ced15185..5e172006 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -26,14 +26,14 @@ import { appDataStatus, } from '@server/utils/appDataVolume'; import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; +import restartFlag from '@server/utils/restartFlag'; import { parsePositiveRouteId } from '@server/utils/routeId'; +import { isPerson } from '@server/utils/typeHelpers'; import { parseBoundedString, parseOptionalBoundedString, parseOptionalLanguage, } from '@server/utils/validation'; -import restartFlag from '@server/utils/restartFlag'; -import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import artistRoutes from './artist'; diff --git a/server/routes/issue.test.ts b/server/routes/issue.test.ts index 2bde1fbc..f7f80d84 100644 --- a/server/routes/issue.test.ts +++ b/server/routes/issue.test.ts @@ -16,8 +16,8 @@ import express from 'express'; import session from 'express-session'; import request from 'supertest'; import authRoutes from './auth'; -import issueCommentRoutes from './issueComment'; import issueRoutes from './issue'; +import issueCommentRoutes from './issueComment'; let app: Express; @@ -109,7 +109,9 @@ async function createIssue() { describe('Issue route validation', () => { it('rejects malformed issue list query filters', async () => { const agent = await login(); - const res = await agent.get('/issue').query({ filter: ['open', 'resolved'] }); + const res = await agent + .get('/issue') + .query({ filter: ['open', 'resolved'] }); assert.strictEqual(res.status, 400); assert.match(res.body.message, /Filter must be a string/); diff --git a/server/routes/issueComment.ts b/server/routes/issueComment.ts index 0cdf31ab..70b242a4 100644 --- a/server/routes/issueComment.ts +++ b/server/routes/issueComment.ts @@ -1,14 +1,12 @@ +import { MAX_ISSUE_MESSAGE_LENGTH } from '@server/constants/issue'; import { getRepository } from '@server/datasource'; import IssueComment from '@server/entity/IssueComment'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; -import { MAX_ISSUE_MESSAGE_LENGTH } from '@server/constants/issue'; import { filterEntityResponse } from '@server/utils/entityResponse'; import { parsePositiveRouteId } from '@server/utils/routeId'; -import { - parseBoundedString, -} from '@server/utils/validation'; +import { parseBoundedString } from '@server/utils/validation'; import { Router } from 'express'; const issueCommentRoutes = Router(); diff --git a/server/routes/music.ts b/server/routes/music.ts index 677c3723..8729217e 100644 --- a/server/routes/music.ts +++ b/server/routes/music.ts @@ -10,6 +10,7 @@ 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 { normalizeMusicBrainzId } from '@server/lib/externalIds'; import logger from '@server/logger'; import { mapMusicDetails } from '@server/models/Music'; import { filterEntityResponse } from '@server/utils/entityResponse'; @@ -37,6 +38,11 @@ const parseMusicBrainzId = (value: unknown) => maxLength: MAX_MUSICBRAINZ_ID_LENGTH, }); +const normalizeParsedMusicBrainzId = ( + parsed: ReturnType +) => + 'error' in parsed ? parsed : { value: normalizeMusicBrainzId(parsed.value) }; + const mapMusicBrainzReleaseGroupToListenBrainzAlbum = ( album: MbAlbumDetails ): LbAlbumDetails => { @@ -148,7 +154,9 @@ const getAlbumDetails = async ( }; musicRoutes.get('/:id', async (req, res, next) => { - const parsedMbId = parseMusicBrainzId(req.params.id); + const parsedMbId = normalizeParsedMusicBrainzId( + parseMusicBrainzId(req.params.id) + ); if ('error' in parsedMbId) { return res.status(404).json({ status: 404, message: 'Album not found' }); } @@ -355,7 +363,9 @@ musicRoutes.get('/:id', async (req, res, next) => { }); musicRoutes.get('/:id/artist', async (req, res, next) => { - const parsedMbId = parseMusicBrainzId(req.params.id); + const parsedMbId = normalizeParsedMusicBrainzId( + parseMusicBrainzId(req.params.id) + ); if ('error' in parsedMbId) { return res.status(404).json({ status: 404, message: 'Album not found' }); } @@ -436,7 +446,9 @@ musicRoutes.get('/:id/artist', async (req, res, next) => { }); musicRoutes.get('/:id/artist-discography', async (req, res, next) => { - const parsedMbId = parseMusicBrainzId(req.params.id); + const parsedMbId = normalizeParsedMusicBrainzId( + parseMusicBrainzId(req.params.id) + ); if ('error' in parsedMbId) { return res.status(404).json({ status: 404, message: 'Album not found' }); } @@ -483,7 +495,11 @@ musicRoutes.get('/:id/artist-discography', async (req, res, next) => { page * pageSize ); - const releaseGroupIds = paginatedReleaseGroups.map((rg) => rg.mbid); + const releaseGroupIds = [ + ...new Set( + paginatedReleaseGroups.map((rg) => normalizeMusicBrainzId(rg.mbid)) + ), + ]; const mediaResponses = await Promise.allSettled([ Media.getRelatedMedia(req.user, releaseGroupIds), @@ -498,16 +514,22 @@ musicRoutes.get('/:id/artist-discography', async (req, res, next) => { mediaResponses[1].status === 'fulfilled' ? mediaResponses[1].value : []; const albumMetadataMap = new Map( - albumMetadata.map((metadata) => [metadata.mbAlbumId, metadata]) + albumMetadata.map((metadata) => [ + normalizeMusicBrainzId(metadata.mbAlbumId), + metadata, + ]) ); const relatedMediaMap = new Map( - relatedMedia.map((media) => [media.mbId, media]) + relatedMedia + .filter((media) => media.mbId) + .map((media) => [normalizeMusicBrainzId(media.mbId as string), media]) ); const transformedReleaseGroups = paginatedReleaseGroups.map( (releaseGroup) => { - const metadata = albumMetadataMap.get(releaseGroup.mbid); + const releaseGroupId = normalizeMusicBrainzId(releaseGroup.mbid); + const metadata = albumMetadataMap.get(releaseGroupId); return { id: releaseGroup.mbid, mediaType: 'album', @@ -517,7 +539,7 @@ musicRoutes.get('/:id/artist-discography', async (req, res, next) => { 'primary-type': releaseGroup.type || 'Other', posterPath: metadata?.caaUrl ?? null, needsCoverArt: !metadata?.caaUrl, - mediaInfo: relatedMediaMap.get(releaseGroup.mbid), + mediaInfo: relatedMediaMap.get(releaseGroupId), }; } ); @@ -542,7 +564,9 @@ musicRoutes.get('/:id/artist-discography', async (req, res, next) => { }); musicRoutes.get('/:id/artist-similar', async (req, res, next) => { - const parsedMbId = parseMusicBrainzId(req.params.id); + const parsedMbId = normalizeParsedMusicBrainzId( + parseMusicBrainzId(req.params.id) + ); if ('error' in parsedMbId) { return res.status(404).json({ status: 404, message: 'Album not found' }); } diff --git a/server/routes/person.test.ts b/server/routes/person.test.ts index e79d43aa..8132db89 100644 --- a/server/routes/person.test.ts +++ b/server/routes/person.test.ts @@ -113,13 +113,9 @@ const castCredit = ( describe('GET /person/:id/combined_credits', () => { it('rejects malformed person IDs before provider calls', async () => { - const getMock = mockPrivate( - ExternalAPI.prototype, - 'get', - async () => { - throw new Error('Provider should not be called'); - } - ) as ReturnType; + const getMock = mockPrivate(ExternalAPI.prototype, 'get', async () => { + throw new Error('Provider should not be called'); + }) as ReturnType; const agent = await login(); const res = await agent.get('/person/not-a-number/combined_credits'); diff --git a/server/routes/person.ts b/server/routes/person.ts index 727e01ae..ac9d2fc7 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -7,10 +7,8 @@ import { mapCrewCredits, mapPersonDetails, } from '@server/models/Person'; -import { - parseOptionalLanguage, -} from '@server/utils/validation'; import { parsePositiveRouteId } from '@server/utils/routeId'; +import { parseOptionalLanguage } from '@server/utils/validation'; import { Router } from 'express'; const personRoutes = Router(); diff --git a/server/routes/search.ts b/server/routes/search.ts index 83310fce..3f6b8394 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -11,6 +11,7 @@ import { findBookMediaForBookResults, findBookMediaForSearchDocs, } from '@server/lib/bookMediaMatcher'; +import { normalizeOpenLibraryWorkId } from '@server/lib/externalIds'; import { findSearchProvider, type CombinedSearchResponse, @@ -80,7 +81,7 @@ const dedupeBookSearchDocs = < const seenTitles = new Set(); return docs.filter((doc) => { - const key = doc.key.replace('/works/', '').toLocaleLowerCase(); + const key = normalizeOpenLibraryWorkId(doc.key).toLocaleLowerCase(); const titleKey = [ normalizeSearchText(doc.title), normalizeSearchText(doc.author_name?.[0]), @@ -391,7 +392,7 @@ searchRoutes.get('/', async (req, res, next) => { const mappedBookResults = dedupedBookDocs.map((doc) => mapOpenLibrarySearchDoc( doc, - bookMediaMap.get(doc.key.replace('/works/', '')) + bookMediaMap.get(normalizeOpenLibraryWorkId(doc.key)) ) ); diff --git a/server/routes/settings/metadata.ts b/server/routes/settings/metadata.ts index 30c0da39..5161c886 100644 --- a/server/routes/settings/metadata.ts +++ b/server/routes/settings/metadata.ts @@ -16,7 +16,9 @@ function getTestResultString(testValue: number): string { const metadataRoutes = Router(); -const isMetadataProviderType = (value: unknown): value is MetadataProviderType => +const isMetadataProviderType = ( + value: unknown +): value is MetadataProviderType => value === MetadataProviderType.TMDB || value === MetadataProviderType.TVDB; const parseMetadataSettings = ( diff --git a/server/routes/settingsIndex.test.ts b/server/routes/settingsIndex.test.ts index c74a3ea4..19266d4e 100644 --- a/server/routes/settingsIndex.test.ts +++ b/server/routes/settingsIndex.test.ts @@ -168,9 +168,11 @@ describe('Settings route input validation', () => { const jellyfinRes = await request(app).post('/settings/jellyfin').send({ externalHostname: 'javascript:alert(1)', }); - const jellyfinResetRes = await request(app).post('/settings/jellyfin').send({ - jellyfinForgotPasswordUrl: 'javascript:alert(1)', - }); + const jellyfinResetRes = await request(app) + .post('/settings/jellyfin') + .send({ + jellyfinForgotPasswordUrl: 'javascript:alert(1)', + }); assert.strictEqual(plexRes.status, 400); assert.match(plexRes.body.message, /webAppUrl must be a valid HTTP URL/); @@ -204,9 +206,15 @@ describe('Settings route input validation', () => { assert.strictEqual(proxyShapeRes.status, 400); assert.match(proxyShapeRes.body.message, /proxy must be an object/); assert.strictEqual(proxyPortRes.status, 400); - assert.match(proxyPortRes.body.message, /proxy.port must be a valid number/); + assert.match( + proxyPortRes.body.message, + /proxy.port must be a valid number/ + ); assert.strictEqual(proxyEnabledRes.status, 400); - assert.match(proxyEnabledRes.body.message, /proxy.enabled must be a boolean/); + assert.match( + proxyEnabledRes.body.message, + /proxy.enabled must be a boolean/ + ); assert.strictEqual(saveMock.mock.callCount(), 0); }); @@ -498,7 +506,10 @@ describe('Settings route input validation', () => { /Gotify priority must be an integer/ ); assert.strictEqual(ntfyAuthRes.status, 400); - assert.match(ntfyAuthRes.body.message, /ntfy authMethodToken must be a boolean/); + assert.match( + ntfyAuthRes.body.message, + /ntfy authMethodToken must be a boolean/ + ); }); it('persists normalized Gotify and ntfy notification bodies', async () => { diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 8a9ce480..8549aa09 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -194,7 +194,7 @@ const parseGeneralSettingsBody = ( 'bookQuotaDays', ] as const) { value[fieldName] = parseOptionalNonNegativeInteger( - bodyObject[fieldName], + bodyObject[fieldName], USER_SETTINGS_LIMITS.quota ); } diff --git a/server/routes/watchlist.test.ts b/server/routes/watchlist.test.ts index 57c2da46..1ec728c1 100644 --- a/server/routes/watchlist.test.ts +++ b/server/routes/watchlist.test.ts @@ -262,18 +262,18 @@ describe('POST /watchlist', () => { const agent = await loginAs('admin@seerr.dev', 'test1234'); const body = { mediaType: MediaType.BOOK, - externalId: '/works/OLduplicateW', + externalId: '/works/OL123W', title: 'Duplicate Book', }; const firstRes = await agent.post('/watchlist').send(body); const duplicateRes = await agent.post('/watchlist').send({ ...body, - externalId: 'olduplicatew', + externalId: 'ol123w', }); assert.strictEqual(firstRes.status, 201); - assert.strictEqual(firstRes.body.externalId, 'OLduplicateW'); + assert.strictEqual(firstRes.body.externalId, 'OL123W'); assert.strictEqual(duplicateRes.status, 409); }); diff --git a/server/routes/watchlist.ts b/server/routes/watchlist.ts index 59ced303..e5ea2b62 100644 --- a/server/routes/watchlist.ts +++ b/server/routes/watchlist.ts @@ -11,6 +11,10 @@ import { QueryFailedError } from 'typeorm'; import { MediaType } from '@server/constants/media'; import { watchlistCreate } from '@server/interfaces/api/watchlistCreate'; +import { + normalizeMusicBrainzId, + normalizeOpenLibraryWorkId, +} from '@server/lib/externalIds'; const watchlistRoutes = Router(); const maxWatchlistId = 1_000_000_000; @@ -31,11 +35,6 @@ const parseWatchlistExternalId = (id: unknown): string | undefined => { : undefined; }; -const normalizeWatchlistMusicId = (id: string): string => id.toLowerCase(); - -const normalizeWatchlistBookId = (id: string): string => - id.replace(/^\/?works\//i, '').replace(/^ol(\d+)w$/i, 'OL$1W'); - watchlistRoutes.post( '/', async (req, res, next) => { @@ -55,10 +54,10 @@ watchlistRoutes.post( const values = { ...parsedBody.data, mbId: parsedBody.data.mbId - ? normalizeWatchlistMusicId(parsedBody.data.mbId) + ? normalizeMusicBrainzId(parsedBody.data.mbId) : undefined, externalId: parsedBody.data.externalId - ? normalizeWatchlistBookId(parsedBody.data.externalId) + ? normalizeOpenLibraryWorkId(parsedBody.data.externalId) : undefined, }; logPayload = { @@ -125,9 +124,9 @@ watchlistRoutes.delete('/:mediaId', async (req, res, next) => { const mediaId = mediaType === MediaType.MUSIC - ? normalizeWatchlistMusicId(parsedMediaId as string) + ? normalizeMusicBrainzId(parsedMediaId as string) : mediaType === MediaType.BOOK - ? normalizeWatchlistBookId(parsedMediaId as string) + ? normalizeOpenLibraryWorkId(parsedMediaId as string) : parsedMediaId; await Watchlist.deleteWatchlist(mediaId, mediaType, req.user); diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts index 083bc569..1f9c5cc7 100644 --- a/server/scripts/prepareTestDb.ts +++ b/server/scripts/prepareTestDb.ts @@ -7,7 +7,10 @@ const sourceSettingsPath = path.join( repoRoot, 'cypress/config/settings.cypress.json' ); -const defaultTestConfigDirectory = path.join(repoRoot, 'cypress/runtime-config'); +const defaultTestConfigDirectory = path.join( + repoRoot, + 'cypress/runtime-config' +); const configDirectory = process.env.CONFIG_DIRECTORY || defaultTestConfigDirectory; const targetSettingsPath = path.join(configDirectory, 'settings.json'); diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 1845a000..b8737108 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -1830,7 +1830,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface edition.isbn13 )?.isbn13; const normalizedResultIsbn = normalizeValidIsbn(resultIsbn); - const identifiersToSave = [ + const identifierCandidates = [ (result.foreignBookId ?? bookInfo.foreignBookId) ? { provider: MediaIdentifierProvider.READARR, @@ -1849,13 +1849,29 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface - !!identifier && - (identifier.provider === MediaIdentifierProvider.READARR || - !existingIdentifierKeys.has( - `${identifier.provider}:${identifier.value}` - )) + } => !!identifier ); + const existingCandidateIdentifiers = identifierCandidates.length + ? await identifierRepository.find({ + where: identifierCandidates.map((identifier) => ({ + provider: identifier.provider, + value: identifier.value, + })), + relations: { media: true }, + }) + : []; + const existingCandidateKeys = new Set( + existingCandidateIdentifiers.map( + (identifier) => `${identifier.provider}:${identifier.value}` + ) + ); + const identifiersToSave = identifierCandidates.filter((identifier) => { + const key = `${identifier.provider}:${identifier.value}`; + + return ( + !existingIdentifierKeys.has(key) && !existingCandidateKeys.has(key) + ); + }); if (identifiersToSave.length) { await identifierRepository.insert( diff --git a/server/test/MediaRequestNotification.test.ts b/server/test/MediaRequestNotification.test.ts index 306a1504..b92d3614 100644 --- a/server/test/MediaRequestNotification.test.ts +++ b/server/test/MediaRequestNotification.test.ts @@ -250,7 +250,10 @@ describe('notification media URLs', () => { getNotificationMediaUrl({ mediaUrl: 'https://evil.example/movie/1' }), undefined ); - assert.equal(getNotificationMediaUrl({ mediaUrl: '//evil.example' }), undefined); + assert.equal( + getNotificationMediaUrl({ mediaUrl: '//evil.example' }), + undefined + ); assert.equal( getNotificationMediaUrl({ mediaUrl: '/movie/1\r\nX-Test: yes' }), undefined diff --git a/server/test/MediaRequestSubscriber.test.ts b/server/test/MediaRequestSubscriber.test.ts index 63abe4fb..a0f73ece 100644 --- a/server/test/MediaRequestSubscriber.test.ts +++ b/server/test/MediaRequestSubscriber.test.ts @@ -830,6 +830,113 @@ describe('MediaRequestSubscriber service dispatch', () => { assert.equal(savedRequest.status, MediaRequestStatus.COMPLETED); }); + it('does not save Bookshelf identifiers already linked to another book media row', async () => { + const settings = getSettings(); + settings.readarr = [ + { + id: 20, + name: 'Bookshelf', + hostname: 'bookshelf.local', + port: 8787, + apiKey: 'test-key', + useSsl: false, + activeProfileId: 11, + activeProfileName: 'Books', + activeMetadataProfileId: 12, + activeMetadataProfileName: 'Standard', + activeDirectory: '/books', + tags: [4], + is4k: false, + isDefault: true, + syncEnabled: true, + preventSearch: false, + tagRequests: false, + overrideRule: [], + serviceType: 'ebook', + }, + ]; + + const requestedBy = await getRequester(); + const media = await getRepository(Media).save( + new Media({ + mediaType: MediaType.BOOK, + tmdbId: 0, + status: MediaStatus.PENDING, + status4k: MediaStatus.UNKNOWN, + identifiers: [ + new MediaIdentifier({ + provider: MediaIdentifierProvider.ISBN, + value: '9780441478125', + canonical: true, + }), + ], + }) + ); + const otherMedia = await getRepository(Media).save( + new Media({ + mediaType: MediaType.BOOK, + tmdbId: 0, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + identifiers: [ + new MediaIdentifier({ + provider: MediaIdentifierProvider.READARR, + value: 'readarr-work-id', + canonical: true, + }), + ], + }) + ); + const request = await createApprovedRequest(media, requestedBy); + request.serverId = 20; + + mock.method(ReadarrAPI.prototype, 'lookupBook', async () => { + return [ + { + title: 'The Left Hand of Darkness', + foreignBookId: 'readarr-work-id', + titleSlug: 'left-hand-darkness', + author: { + foreignAuthorId: 'le-guin-author-id', + authorName: 'Ursula K. Le Guin', + }, + editions: [ + { + foreignEditionId: 'edition-id', + title: 'The Left Hand of Darkness', + isbn13: '9780441478125', + monitored: true, + }, + ], + }, + ] as ReadarrBookLookupResult[]; + }); + mock.method( + ReadarrAPI.prototype, + 'addBook', + async (payload: ReadarrBookOptions) => + ({ + ...payload, + id: 55, + titleSlug: 'left-hand-darkness', + }) as Awaited> + ); + mock.method(notificationManager, 'sendNotification', () => undefined); + + await new MediaRequestSubscriber().sendToReadarr(request); + + const readarrIdentifiers = await getRepository(MediaIdentifier).find({ + where: { + provider: MediaIdentifierProvider.READARR, + value: 'readarr-work-id', + }, + relations: { media: true }, + }); + + assert.equal(readarrIdentifiers.length, 1); + assert.equal(readarrIdentifiers[0].media.id, otherMedia.id); + }); + it('fails book requests without posting incomplete Bookshelf metadata', async () => { const settings = getSettings(); settings.readarr = [ diff --git a/server/utils/discoverQuery.test.ts b/server/utils/discoverQuery.test.ts index aa694fa4..0605c25a 100644 --- a/server/utils/discoverQuery.test.ts +++ b/server/utils/discoverQuery.test.ts @@ -1,9 +1,9 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; import { appendDiscoverQueryString, buildDiscoverQueryString, } from '@server/utils/discoverQuery'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; describe('buildDiscoverQueryString', () => { it('omits nullish and empty query values', () => { diff --git a/server/utils/security.test.ts b/server/utils/security.test.ts index f4b9bf98..2093f64e 100644 --- a/server/utils/security.test.ts +++ b/server/utils/security.test.ts @@ -1,6 +1,6 @@ +import type { Request } from 'express'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import type { Request } from 'express'; import { getRateLimitKey, isSafeHttpUrl, diff --git a/server/utils/security.ts b/server/utils/security.ts index 854981ee..495c418b 100644 --- a/server/utils/security.ts +++ b/server/utils/security.ts @@ -1,7 +1,7 @@ import type { Request } from 'express'; import { ipKeyGenerator } from 'express-rate-limit'; -import { timingSafeEqual } from 'node:crypto'; import net from 'net'; +import { timingSafeEqual } from 'node:crypto'; import dns from 'node:dns/promises'; const SECRET_KEY_PATTERN = diff --git a/server/utils/validation.ts b/server/utils/validation.ts index b1cec9e4..777e8bf0 100644 --- a/server/utils/validation.ts +++ b/server/utils/validation.ts @@ -84,7 +84,9 @@ export const parseOptionalQueryBoolean = ( return parsed; } - return { value: parsed.value === undefined ? undefined : parsed.value === 'true' }; + return { + value: parsed.value === undefined ? undefined : parsed.value === 'true', + }; }; export const parseOptionalBodyBoolean = ( diff --git a/src/components/Discover/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx index 80c46d04..1d2b5f0f 100644 --- a/src/components/Discover/CreateSlider/index.tsx +++ b/src/components/Discover/CreateSlider/index.tsx @@ -45,8 +45,7 @@ const messages = defineMessages('components.Discover.CreateSlider', { needresults: 'You need to have at least 1 result.', validationDatarequired: 'You must provide a data value.', validationTitlerequired: 'You must provide a title.', - validationDataLength: - 'Data must be {maxLength, number} characters or fewer.', + validationDataLength: 'Data must be {maxLength, number} characters or fewer.', validationTitleLength: 'Title must be {maxLength, number} characters or fewer.', addcustomslider: 'Create Custom Slider', diff --git a/src/components/Discover/DiscoverWatchlist/index.tsx b/src/components/Discover/DiscoverWatchlist/index.tsx index 15a8ac94..3ca9209c 100644 --- a/src/components/Discover/DiscoverWatchlist/index.tsx +++ b/src/components/Discover/DiscoverWatchlist/index.tsx @@ -54,9 +54,7 @@ const DiscoverWatchlist = () => { return ( <> - +
{ const recentlyAddedCards = useMemo( () => (media?.results ?? []) - .filter( - (item) => item.mediaType === 'movie' || item.mediaType === 'tv' - ) + .filter((item) => item.mediaType === 'movie' || item.mediaType === 'tv') .map((item) => ( { titles, fetchMore, error, - } = useDiscover( - `/api/v1/movie/${movieId}/similar`, - undefined, - { enabled: !!movieId, randomizeOrder: true } - ); + } = useDiscover(`/api/v1/movie/${movieId}/similar`, undefined, { + enabled: !!movieId, + randomizeOrder: true, + }); if (error) { return ; diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx index 7b61d0a6..ad9a88b3 100644 --- a/src/components/QuotaSelector/index.tsx +++ b/src/components/QuotaSelector/index.tsx @@ -65,7 +65,7 @@ const QuotaSelector = ({ ? messages.musicRequests : mediaType === 'book' ? messages.bookRequests - : messages.tvRequests, + : messages.tvRequests, { quotaLimit: (