├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── loadtest.yaml ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-4.9.2.cjs ├── .yarnrc.yml ├── README.md ├── app.json ├── eslint.config.mjs ├── jest.config.cjs ├── nodemon.json ├── openapi.yaml ├── package.json ├── renovate.json ├── scripts └── purge.ts ├── sequence.puml ├── src ├── app.js.map ├── app.ts ├── controllers │ ├── configuration.ts │ ├── deprecated │ │ └── media.ts │ └── media.ts ├── helpers │ ├── customErrors.ts │ └── subversioning.ts ├── interfaces.d.ts ├── models │ ├── CollectionMetadata.ts │ ├── EpisodeProcessing.ts │ ├── FailedLookups.ts │ ├── LocalizeMetadata.ts │ ├── MediaMetadata.ts │ ├── SeasonMetadata.ts │ ├── SeriesMetadata.ts │ ├── TMDBConfiguration.ts │ └── connection.ts ├── routes │ ├── deprecated │ │ └── media.ts │ ├── index.js.map │ ├── index.ts │ └── media.ts ├── services │ ├── deprecated │ │ └── external-api-helper.ts │ ├── external-api-helper.ts │ └── tmdb-api.ts └── utils │ └── data-mapper.ts ├── test ├── e2e │ ├── index.spec.ts │ ├── media-collection.spec.ts │ ├── media-localize.spec.ts │ ├── media-season.spec.ts │ ├── media-series.spec.ts │ └── media-video.spec.ts ├── load │ ├── movies.csv │ └── ums-api-http.yaml ├── models │ ├── FailedLookups.spec.ts │ └── MediaMetadata.spec.ts └── unit │ ├── data-mapper.spec.ts │ └── episodeParser.spec.ts ├── tsconfig.json └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 0 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: UMS API tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22' 18 | - name: Get yarn cache directory path 19 | id: yarn-cache-dir-path 20 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 21 | - uses: actions/cache@v4 22 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 23 | with: 24 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 25 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-yarn- 28 | - name: Install Dependencies 29 | if: steps.cache.outputs.cache-hit != 'true' 30 | run: yarn 31 | - run: yarn eslint . 32 | - run: yarn run tsc:ci 33 | - run: yarn test 34 | 35 | deploy: 36 | runs-on: ubuntu-latest 37 | needs: build 38 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 39 | steps: 40 | - name: Cloning repo 41 | uses: actions/checkout@v4 42 | with: 43 | fetch-depth: 0 44 | 45 | - name: Push to dokku 46 | uses: dokku/github-action@master 47 | with: 48 | git_push_flags: "--force" 49 | ci_branch_name: main 50 | git_remote_url: ${{ secrets.DOKKU_GIT_REMOTE }} 51 | ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} 52 | -------------------------------------------------------------------------------- /.github/workflows/loadtest.yaml: -------------------------------------------------------------------------------- 1 | 2 | name: Production load tests 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - 'loadtest/**' 8 | jobs: 9 | artillery: 10 | runs-on: ubuntu-latest 11 | container: artilleryio/artillery:latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Make reports directory 18 | run: mkdir reports 19 | 20 | - name: Execute load tests 21 | run: /home/node/artillery/bin/artillery run --output reports/report.json test/load/ums-api-http.yaml 22 | 23 | - name: Generate HTML report 24 | run: /home/node/artillery/bin/artillery report --output reports/report reports/report.json 25 | 26 | - name: Archive test report 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: artillery-test-report 30 | path: reports/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # netbean environment variables file 61 | nbproject 62 | 63 | # next.js build output 64 | .next 65 | 66 | !jest-mongodb-config.js 67 | !jest.config.js 68 | !ecosystem.config.js 69 | 70 | # yarn 3 71 | 72 | .pnp.* 73 | .yarn/* 74 | !.yarn/patches 75 | !.yarn/plugins 76 | !.yarn/releases 77 | !.yarn/sdks 78 | !.yarn/versions -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.packageManager": "yarn", 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | { "language": "typescript", "autoFix": true }, 7 | { "language": "typescriptreact", "autoFix": true } 8 | ] 9 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.9.2.cjs 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UMS API ![Build Status](https://github.com/UniversalMediaServer/api/workflows/UMS%20API%20tests/badge.svg) 2 | 3 | ## Development 4 | 5 | The required Node.js and Yarn versions are listed in the `package.json` file. 6 | 7 | You can run `nvm use` and `yvm use` from the project root if you have [Node version manager](https://github.com/nvm-sh/nvm) and [Yarn version manager](https://yvm.js.org)s installed to select the correct version. 8 | 9 | ### Environment variables 10 | 11 | - `MONGO_URL` URL to a running Mongo instance which includes user and password 12 | - `BYPASS_MONGO` if set to `"true"`, will drop the database on each request, to get fresh data from external APIS. Use with caution. 13 | - `UMS_API_PRIVATE_KEY_LOCATION` and `UMS_API_PUBLIC_KEY_LOCATION` optional absolute locations of SSL keys for HTTPS 14 | 15 | ### Commands 16 | 17 | #### `yarn watch` 18 | Watches for changes to TypeScript files and compiles them to JavaScript (use `yarn run build` to do it manually) 19 | 20 | #### `yarn dev` 21 | Runs the development server and restarts it when any file is changed 22 | 23 | #### `yarn dev:cron` 24 | Runs the cron job feature using ts-node 25 | 26 | #### `yarn start` 27 | Runs the server 28 | 29 | #### `yarn test` 30 | Runs the test suite 31 | 32 | #### `yarn run start:prod` 33 | Starts the API and cron job in production mode. TypeScript files are compiled in memory on application start. 34 | 35 | ## Logs 36 | 37 | To view the production logs: 38 | 1. Run `sudo sh -c "du -ch /var/lib/docker/containers/*/*-json.log"` to print the list of logs. Look for the biggest one and copy the container ID. 39 | 2. Run `docker logs -f --tail 100 CONTAINER_ID` 40 | 41 | ## Troubleshooting 42 | 43 | If the production server is unstable, it could be because the Docker cache is filling the hard drive. To fix it, run `docker system prune -a` to clear unused stuff. 44 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dokku": { 4 | "predeploy": "yarn build" 5 | } 6 | }, 7 | "healthchecks": { 8 | "web": [ 9 | { 10 | "type": "startup", 11 | "name": "Web check", 12 | "description": "Checking if the app responds to the / endpoint", 13 | "path": "/", 14 | "attempts": 5 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | { 10 | "ignores": ["jest.config.cjs"], 11 | } 12 | ); -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testTimeout: 15000, 6 | verbose: true, 7 | restoreMocks: true, 8 | }; -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "**/*.test.ts", 4 | "**/*.spec.ts", 5 | ".git", 6 | "node_modules" 7 | ], 8 | "watch": [ 9 | "src/**/*.ts" 10 | ], 11 | "exec": "npm start", 12 | "ext": "ts" 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "universalmediaserver-api", 3 | "version": "0.0.0", 4 | "private": true, 5 | "engines": { 6 | "node": "22", 7 | "yarn": ">=1.22.10" 8 | }, 9 | "scripts": { 10 | "dev": "DEBUG=\"*universalmediaserver-api:server\" nodemon", 11 | "purge": "TS_NODE_FILES=true ts-node --files scripts/purge", 12 | "start": "node build/app.js", 13 | "build": "tsc", 14 | "test": "NODE_ENV=test jest test/**/*.spec.ts --forceExit --detectOpenHandles", 15 | "test:e2e": "NODE_ENV=test jest test/e2e/*.spec.ts --forceExit --detectOpenHandles", 16 | "test:unit": "NODE_ENV=test jest test/unit/*.spec.ts --forceExit --detectOpenHandles", 17 | "test:models": "NODE_ENV=test jest test/models/*.spec.ts --forceExit --detectOpenHandles", 18 | "tsc:ci": "tsc && rm -rf dist", 19 | "watch": "tsc --watch" 20 | }, 21 | "dependencies": { 22 | "@skyra/jaro-winkler": "^1.1.1", 23 | "debug": "4.4.1", 24 | "episode-parser": "2.0.2", 25 | "helmet": "^8.0.0", 26 | "koa": "3.0.0", 27 | "koa-bodyparser": "4.4.1", 28 | "koa-helmet": "8.0.1", 29 | "koa-qs": "3.0.0", 30 | "koa-router": "13.0.1", 31 | "lodash": "4.17.21", 32 | "mongoose": "^8.4.0", 33 | "moviedb-promise": "4.0.7", 34 | "object-mapper": "6.2.0", 35 | "typescript": "5.8" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^29.5.12", 39 | "@types/koa": "2.15.0", 40 | "@types/koa-bodyparser": "4.3.12", 41 | "@types/koa-helmet": "6.1.0", 42 | "@types/koa-router": "7.4.8", 43 | "@types/lodash": "4.17.17", 44 | "@types/node": "22.15.30", 45 | "@types/object-mapper": "6.2.2", 46 | "axios": "1.9.0", 47 | "eslint": "9.28.0", 48 | "inquirer": "12.6.3", 49 | "jest": "29.7.0", 50 | "mongodb-memory-server": "10.1.4", 51 | "nodemon": "3.1.10", 52 | "stoppable": "1.1.0", 53 | "ts-jest": "29.3.4", 54 | "typescript-eslint": "^8.6.0" 55 | }, 56 | "packageManager": "yarn@4.9.2" 57 | } 58 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "schedule": ["on friday"], 6 | "lockFileMaintenance": { 7 | "enabled": true, 8 | "automerge": true, 9 | "automergeType": "branch" 10 | }, 11 | "packageRules": [ 12 | { 13 | "matchUpdateTypes": ["patch"], 14 | "matchCurrentVersion": "!/^0/", 15 | "automerge": true, 16 | "automergeType": "branch" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /scripts/purge.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import * as inquirer from 'inquirer'; 3 | import axios from 'axios'; 4 | import connect from '../src/models/connection'; 5 | import CollectionMetadata from '../src/models/CollectionMetadata'; 6 | import EpisodeProcessing from '../src/models/EpisodeProcessing'; 7 | import FailedLookups from '../src/models/FailedLookups'; 8 | import LocalizeMetadata from '../src/models/LocalizeMetadata'; 9 | import MediaMetadata from '../src/models/MediaMetadata'; 10 | import SeasonMetadata from '../src/models/SeasonMetadata'; 11 | import SeriesMetadata from '../src/models/SeriesMetadata'; 12 | 13 | const db = process.env.MONGO_URL; 14 | 15 | const client = axios.create({ 16 | baseURL: 'https://api.cloudflare.com/client/v4', 17 | headers: { 18 | 'X-Auth-Email': process.env.CF_API_KEY_EMAIL, 19 | 'X-Auth-Key': process.env.CF_API_KEY, 20 | }, 21 | }); 22 | 23 | inquirer 24 | .prompt([ 25 | { 26 | type: 'checkbox', 27 | message: 'Select what to purge', 28 | name: 'purge_items', 29 | choices: [ 30 | new inquirer.Separator('MongoDB Collections'), 31 | { 32 | name: 'Collection Metadata', 33 | value: 'collection_metadata', 34 | }, 35 | { 36 | name: 'Media Metadata', 37 | value: 'media_metadata', 38 | }, 39 | { 40 | name: 'Series Metadata', 41 | value: 'series_metadata', 42 | }, 43 | { 44 | name: 'Season Metadata', 45 | value: 'season_metadata', 46 | }, 47 | { 48 | name: 'Localize Metadata', 49 | value: 'localize_metadata', 50 | }, 51 | { 52 | name: 'Failed Lookups', 53 | value: 'failed_lookups', 54 | }, 55 | { 56 | name: 'Episode Processing', 57 | value: 'episode_processing', 58 | }, 59 | new inquirer.Separator('Cloudflare'), 60 | { 61 | name: 'Cloudflare cache', 62 | value: 'cf_cache', 63 | }, 64 | ], 65 | }, 66 | ]) 67 | .then(async(answers) => { 68 | console.info('Starting purge\n'); 69 | connect(db); 70 | const purgeItems = answers['purge_items']; 71 | const promises = []; 72 | 73 | if (purgeItems.includes('collection_metadata')) { 74 | promises.push(CollectionMetadata.deleteMany()); 75 | } 76 | if (purgeItems.includes('media_metadata')) { 77 | promises.push(MediaMetadata.deleteMany()); 78 | } 79 | if (purgeItems.includes('series_metadata')) { 80 | promises.push(SeriesMetadata.deleteMany()); 81 | } 82 | if (purgeItems.includes('season_metadata')) { 83 | promises.push(SeasonMetadata.deleteMany()); 84 | } 85 | if (purgeItems.includes('localize_metadata')) { 86 | promises.push(LocalizeMetadata.deleteMany()); 87 | } 88 | if (purgeItems.includes('failed_lookups')) { 89 | promises.push(FailedLookups.deleteMany()); 90 | } 91 | if (purgeItems.includes('episode_processing')) { 92 | promises.push(EpisodeProcessing.deleteMany()); 93 | } 94 | if (purgeItems.includes('cf_cache')) { 95 | promises.push(client.post('/zones/9beea5376616e62f3dcda4ddcec62f79/purge_cache', { json: { 'purge_everything': true } })); 96 | } 97 | 98 | await Promise.all(promises); 99 | process.exit(0); 100 | }) 101 | .catch(e => { 102 | console.error(e); 103 | process.exit(1); 104 | }); 105 | -------------------------------------------------------------------------------- /sequence.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | participant "Universal Media Server Client" 3 | participant "API" 4 | database "API MongoDB" 5 | autonumber 6 | 7 | "Universal Media Server Client" -> API: UMS client requests metadata about a media file 8 | 9 | == Retrieve an existing metadata record == 10 | 11 | API -> "API MongoDB": Query for existing metadata record 12 | API -> "API MongoDB": Query for existing failed lookup record 13 | "API MongoDB" -> API: Existing metadata found 14 | API -> "Universal Media Server Client": If found, return API response 15 | 16 | == Create a new metadata record == 17 | 18 | API -> TMDB : Query source API 1 19 | 20 | API -> "API MongoDB": (3) Save metadata to MongoDB 21 | 22 | API -> "Universal Media Server Client": Return API response 23 | -------------------------------------------------------------------------------- /src/app.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"app.js","sourceRoot":"","sources":["app.ts"],"names":[],"mappings":"AAAA,IAAI,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;AACzC,IAAI,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;AACjC,IAAI,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AAC3B,IAAI,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;AAC5C,IAAI,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE/B,IAAI,WAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAC5C,IAAI,WAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAE5C,IAAI,GAAG,GAAG,OAAO,EAAE,CAAC;AAEpB,oBAAoB;AACpB,GAAG,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC;AAChD,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;AAE9B,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;AACvB,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AACxB,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AACjD,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;AACxB,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;AAExD,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;AAC1B,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;AAE/B,yCAAyC;AACzC,GAAG,CAAC,GAAG,CAAC,UAAS,GAAG,EAAE,GAAG,EAAE,IAAI;IAC7B,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC;AACzB,CAAC,CAAC,CAAC;AAEH,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,UAAS,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI;IAClC,kDAAkD;IAClD,GAAG,CAAC,MAAM,CAAC,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;IACjC,GAAG,CAAC,MAAM,CAAC,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IAEnE,wBAAwB;IACxB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC;IAC9B,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AACtB,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,OAAO,GAAG,GAAG,CAAC","sourcesContent":["var createError = require('http-errors');\nvar express = require('express');\nvar path = require('path');\nvar cookieParser = require('cookie-parser');\nvar logger = require('morgan');\n\nvar indexRouter = require('./routes/index');\nvar usersRouter = require('./routes/users');\n\nvar app = express();\n\n// view engine setup\napp.set('views', path.join(__dirname, 'views'));\napp.set('view engine', 'hbs');\n\napp.use(logger('dev'));\napp.use(express.json());\napp.use(express.urlencoded({ extended: false }));\napp.use(cookieParser());\napp.use(express.static(path.join(__dirname, 'public')));\n\napp.use('/', indexRouter);\napp.use('/users', usersRouter);\n\n// catch 404 and forward to error handler\napp.use(function(req, res, next) {\n next(createError(404));\n});\n\n// error handler\napp.use(function(err, req, res, next) {\n // set locals, only providing error in development\n res.locals.message = err.message;\n res.locals.error = req.app.get('env') === 'development' ? err : {};\n\n // render the error page\n res.status(err.status || 500);\n res.render('error');\n});\n\nmodule.exports = app;\n"]} -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import * as bodyParser from 'koa-bodyparser'; 3 | import helmet from "koa-helmet"; 4 | import * as mongoose from 'mongoose'; 5 | import { ParameterizedContext } from 'koa'; 6 | import * as koaQs from 'koa-qs'; 7 | import * as Debug from 'debug'; 8 | import * as fs from 'fs'; 9 | import * as http from 'http'; 10 | import * as https from 'https'; 11 | 12 | const debug = Debug('universalmediaserver-api:server'); 13 | import indexRouter from './routes/index'; 14 | import mediaRouter from './routes/media'; 15 | import deprecatedMediaRouter from './routes/deprecated/media'; 16 | import { ExternalAPIError, IMDbIDNotFoundError, MediaNotFoundError, RateLimitError, ValidationError } from './helpers/customErrors'; 17 | 18 | const app = new Koa(); 19 | 20 | koaQs(app, 'first'); 21 | 22 | import connect from './models/connection'; 23 | 24 | const db: string = process.env.MONGO_URL; 25 | export const PORT: string = process.env.PORT || '3000'; 26 | const bypassMongo: boolean = Boolean(process.env.BYPASS_MONGO) || false; 27 | if (process.env.NODE_ENV !== 'test') { 28 | connect(db); 29 | } 30 | 31 | app.use(helmet()); 32 | // error handler 33 | app.use(async(ctx, next) => { 34 | try { 35 | await next(); 36 | } catch (err) { 37 | if (err instanceof MediaNotFoundError || err instanceof IMDbIDNotFoundError) { 38 | ctx.status = 404; 39 | } 40 | if (err instanceof ValidationError) { 41 | ctx.status = 422; 42 | } 43 | if (err instanceof ExternalAPIError) { 44 | ctx.status = 503; 45 | } 46 | if (err instanceof RateLimitError) { 47 | ctx.status = 429; 48 | } 49 | ctx.status = ctx.status || err.status || 500; 50 | ctx.body = { 'error': err.message }; 51 | if (process.env.NODE_ENV !== 'production') { 52 | ctx.body.stack = err.stack; 53 | } 54 | if ( 55 | process.env.NODE_ENV !== 'test' && 56 | ( 57 | !(err instanceof MediaNotFoundError) && 58 | !(err instanceof IMDbIDNotFoundError) && 59 | !(err instanceof ExternalAPIError) && 60 | // Stop logging errors for the deprecated routes getBySanitizedTitle and getBySanitizedTitleV2 61 | !( 62 | err.stack && 63 | ( 64 | err.stack.includes('getBySanitizedTitle') || 65 | err.stack.includes('controllers/deprecated') 66 | ) 67 | ) 68 | ) 69 | ) { 70 | console.error(err); 71 | } 72 | } 73 | }); 74 | 75 | app.use(async(ctx, next) => { 76 | debug(`${ctx.method} ${ctx.url}`); 77 | await next(); 78 | }); 79 | 80 | app.use(async(_ctx: ParameterizedContext, next) => { 81 | if (bypassMongo) { 82 | await mongoose.connection.dropDatabase(); 83 | } 84 | await next(); 85 | }); 86 | 87 | app.use(bodyParser()); 88 | app.use(deprecatedMediaRouter.routes()); 89 | app.use(mediaRouter.routes()); 90 | app.use(indexRouter.routes()); 91 | 92 | export let server: http.Server; 93 | if (process.env.NODE_ENV !== 'test') { 94 | server = http.createServer(app.callback()).listen(PORT); 95 | console.log(`UMS API HTTP server is up and running on port ${PORT}`); 96 | 97 | if (process.env.UMS_API_PRIVATE_KEY_LOCATION && process.env.UMS_API_PUBLIC_KEY_LOCATION) { 98 | const httpsOptions = { 99 | key: fs.readFileSync(process.env.UMS_API_PRIVATE_KEY_LOCATION), 100 | cert: fs.readFileSync(process.env.UMS_API_PUBLIC_KEY_LOCATION), 101 | }; 102 | https.createServer(httpsOptions, app.callback()).listen(443); 103 | console.log('UMS API HTTPS server is up and running on port 443'); 104 | } 105 | } 106 | 107 | export default app; 108 | -------------------------------------------------------------------------------- /src/controllers/configuration.ts: -------------------------------------------------------------------------------- 1 | import { ParameterizedContext } from 'koa'; 2 | 3 | import TMDBConfiguration, { TMDBConfigurationInterface } from '../models/TMDBConfiguration'; 4 | import { tmdb } from '../services/tmdb-api'; 5 | 6 | let configuration: Partial; 7 | let configurationExpiryDate: Date; 8 | 9 | export const getTMDBImageBaseURL = async(): Promise => { 10 | // 1) Return in-memory value unless it has expired 11 | const today = new Date(); 12 | if (configuration && configurationExpiryDate && today < configurationExpiryDate) { 13 | return configuration.imageBaseURL; 14 | } 15 | 16 | // 2) See if it has already been fetched to our database 17 | configuration = await TMDBConfiguration.findOne().lean() 18 | .exec(); 19 | 20 | // 3) Last try, get it from TMDB directly, and persist to memory and database 21 | if (!configuration) { 22 | const configurationResponse = await tmdb.configuration(); 23 | const imageBaseURL = configurationResponse.images.secure_base_url; 24 | configuration = { imageBaseURL }; 25 | await TMDBConfiguration.create(configuration); 26 | } 27 | 28 | configurationExpiryDate = new Date(); 29 | configurationExpiryDate.setDate(today.getDate() + 3); 30 | 31 | return configuration.imageBaseURL; 32 | }; 33 | 34 | export const getConfiguration = async(ctx: ParameterizedContext): Promise<{ imageBaseURL: string }> => { 35 | const response = { imageBaseURL: await getTMDBImageBaseURL() }; 36 | return ctx.body = response; 37 | }; 38 | -------------------------------------------------------------------------------- /src/controllers/deprecated/media.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import { MediaNotFoundError, ValidationError } from '../../helpers/customErrors'; 4 | import FailedLookups, { FailedLookupsInterface } from '../../models/FailedLookups'; 5 | import MediaMetadata, { MediaMetadataInterface } from '../../models/MediaMetadata'; 6 | import SeriesMetadata, { SeriesMetadataInterface } from '../../models/SeriesMetadata'; 7 | import * as externalAPIHelper from '../../services/external-api-helper'; 8 | import * as deprecatedExternalAPIHelper from '../../services/deprecated/external-api-helper'; 9 | import { addSearchMatchByIMDbID } from '../media'; 10 | 11 | /** 12 | * We aren't connected to OpenSubtitles API anymore so this can never succeed 13 | * 14 | * @deprecated 15 | */ 16 | export const getByOsdbHash = async(): Promise => { 17 | throw new MediaNotFoundError(); 18 | }; 19 | 20 | /** 21 | * Since this is deprecated, it will only return a result that has been created 22 | * by the newer route. This will never add new information to the database. 23 | * 24 | * @deprecated 25 | */ 26 | export const getBySanitizedTitle = async(ctx): Promise => { 27 | const { title }: UmsQueryParams = ctx.query; 28 | 29 | if (!title) { 30 | throw new ValidationError('title is required'); 31 | } 32 | 33 | // If we already have a result, return it 34 | const existingResultFromSearchMatch: MediaMetadataInterface = await MediaMetadata.findOne({ searchMatches: { $in: [title] } }, null, { lean: true }).exec(); 35 | if (existingResultFromSearchMatch) { 36 | return ctx.body = existingResultFromSearchMatch; 37 | } 38 | 39 | throw new MediaNotFoundError(); 40 | }; 41 | 42 | /** 43 | * Looks up a video by its title, and optionally its year, season and episode number. 44 | * If it is an episode, it also sets the series data. 45 | * 46 | * Since this is deprecated, it will only return a result that has been created 47 | * by the newer route. This will never add new information to the database. 48 | * 49 | * @deprecated 50 | */ 51 | export const getBySanitizedTitleV2 = async(ctx): Promise => { 52 | const { episode, title }: UmsQueryParams = ctx.query; 53 | const season = ctx.query.season ? Number(ctx.query.season) : null; 54 | const year = ctx.query.year ? Number(ctx.query.year) : null; 55 | 56 | if (!title) { 57 | throw new ValidationError('title is required'); 58 | } 59 | 60 | // If we already have a result, return it 61 | const existingResultQuery: GetVideoFilter = { searchMatches: { $in: [title] } }; 62 | if (year) { 63 | existingResultQuery.year = year.toString(); 64 | } 65 | if (episode) { 66 | existingResultQuery.episode = episode.toString(); 67 | } 68 | if (season) { 69 | existingResultQuery.season = season.toString(); 70 | } 71 | const existingResultFromSearchMatch: MediaMetadataInterface = await MediaMetadata.findOne(existingResultQuery, null, { lean: true }).exec(); 72 | if (existingResultFromSearchMatch) { 73 | return ctx.body = existingResultFromSearchMatch; 74 | } 75 | 76 | throw new MediaNotFoundError(); 77 | }; 78 | 79 | /** 80 | * @deprecated 81 | */ 82 | export const getByImdbID = async(ctx): Promise => { 83 | const { imdbid }: UmsQueryParams = ctx.query; 84 | 85 | if (!imdbid) { 86 | throw new ValidationError('imdbid is required'); 87 | } 88 | 89 | const [mediaMetadata, seriesMetadata] = await Promise.all([ 90 | MediaMetadata.findOne({ imdbID: imdbid }, null, { lean: true }).exec(), 91 | SeriesMetadata.findOne({ imdbID: imdbid }, null, { lean: true }).exec(), 92 | ]); 93 | 94 | if (mediaMetadata) { 95 | return ctx.body = mediaMetadata; 96 | } 97 | 98 | if (seriesMetadata) { 99 | return ctx.body = seriesMetadata; 100 | } 101 | 102 | throw new MediaNotFoundError(); 103 | }; 104 | 105 | /** 106 | * @deprecated 107 | */ 108 | export const getSeries = async(ctx): Promise => { 109 | const { imdbID, title, year }: UmsQueryParams = ctx.query; 110 | if (!title && !imdbID) { 111 | throw new ValidationError('Either IMDb ID or title required'); 112 | } 113 | 114 | try { 115 | const dbMeta = await externalAPIHelper.getSeriesMetadata(imdbID, title, null, year); 116 | if (!dbMeta) { 117 | throw new MediaNotFoundError(); 118 | } 119 | 120 | const dbMetaWithPosters = await deprecatedExternalAPIHelper.addPosterFromImages(dbMeta); 121 | return ctx.body = dbMetaWithPosters; 122 | } catch (err) { 123 | // log unexpected errors 124 | if (!(err instanceof MediaNotFoundError)) { 125 | console.error(err); 126 | } 127 | throw new MediaNotFoundError(); 128 | } 129 | }; 130 | 131 | /** 132 | * @deprecated 133 | */ 134 | export const getVideo = async(ctx): Promise => { 135 | const { title, imdbID }: UmsQueryParams = ctx.query; 136 | const { episode, season, year }: UmsQueryParams = ctx.query; 137 | const [seasonNumber, yearNumber] = [season, year].map(param => param ? Number(param) : null); 138 | let episodeNumbers = null; 139 | if (episode) { 140 | const episodes = episode.split('-'); 141 | episodeNumbers = episodes.map(Number); 142 | } 143 | 144 | if (!title && !imdbID) { 145 | throw new ValidationError('title or imdbId is a required parameter'); 146 | } 147 | 148 | const query = []; 149 | const failedQuery = []; 150 | let imdbIdToSearch = imdbID; 151 | 152 | if (imdbIdToSearch) { 153 | query.push({ imdbID: imdbIdToSearch }); 154 | failedQuery.push({ imdbId: imdbIdToSearch }); 155 | } 156 | 157 | if (title) { 158 | const titleQuery: GetVideoFilter = { searchMatches: { $in: [title] } }; 159 | const titleFailedQuery: FailedLookupsInterface = { title }; 160 | 161 | if (year) { 162 | titleQuery.year = year; 163 | titleFailedQuery.year = year; 164 | } 165 | if (episode) { 166 | titleQuery.episode = episode; 167 | titleFailedQuery.episode = episode; 168 | } 169 | if (season) { 170 | titleQuery.season = season; 171 | titleFailedQuery.season = season; 172 | } 173 | query.push(titleQuery); 174 | failedQuery.push(titleFailedQuery); 175 | } 176 | 177 | const existingResult = await MediaMetadata.findOne({ $or: query }, null, { lean: true }).exec(); 178 | if (existingResult) { 179 | // we have an existing metadata record, so return it 180 | return ctx.body = existingResult; 181 | } 182 | 183 | const existingFailedResult = await FailedLookups.findOne({ $or: failedQuery }, null, { lean: true }).exec(); 184 | if (existingFailedResult) { 185 | // we have an existing failure record, so increment it, and throw not found error 186 | await FailedLookups.updateOne({ _id: existingFailedResult._id }, { $inc: { count: 1 } }).exec(); 187 | throw new MediaNotFoundError(); 188 | } 189 | 190 | // the database does not have a record of this file, so begin search for metadata on external apis. 191 | 192 | const failedLookupQuery = { episode, imdbID, season, title, year }; 193 | 194 | if (!title && !imdbIdToSearch) { 195 | // The APIs below require either a title or IMDb ID, so return if we don't have one 196 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 197 | throw new MediaNotFoundError(); 198 | } 199 | 200 | // Start TMDB lookups 201 | let tmdbData: MediaMetadataInterface; 202 | try { 203 | tmdbData = await externalAPIHelper.getFromTMDBAPI(title, null, imdbIdToSearch, yearNumber, seasonNumber, episodeNumbers); 204 | imdbIdToSearch = imdbIdToSearch || tmdbData?.imdbID; 205 | } catch (e) { 206 | // Log the error but continue 207 | if (e.message && e.message.includes('404') && e.response?.config?.url) { 208 | console.log('Received 404 response from ' + e.response.config.url); 209 | } else { 210 | console.log(e); 211 | } 212 | } 213 | 214 | // if the client did not pass an imdbID, but we found one on TMDB, see if we have an existing record for the now-known media. 215 | if (!imdbID && imdbIdToSearch) { 216 | { 217 | const existingResult = await MediaMetadata.findOne({ imdbID: imdbIdToSearch }, null, { lean: true }).exec(); 218 | if (existingResult) { 219 | return ctx.body = await addSearchMatchByIMDbID(imdbIdToSearch, title); 220 | } 221 | } 222 | } 223 | // End TMDB lookups 224 | 225 | if (!tmdbData || _.isEmpty(tmdbData)) { 226 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 227 | throw new MediaNotFoundError(); 228 | } 229 | 230 | try { 231 | if (title) { 232 | tmdbData.searchMatches = [title]; 233 | } 234 | 235 | // Ensure that we return and cache the same episode number that was searched for 236 | if (episodeNumbers && episodeNumbers.length > 1 && episodeNumbers[0] === tmdbData.episode) { 237 | tmdbData.episode = episode; 238 | } 239 | 240 | const dbMeta = await MediaMetadata.create(tmdbData); 241 | 242 | // TODO: Investigate why we need this "as" syntax 243 | let leanMeta = dbMeta.toObject({ useProjection: true }) as MediaMetadataInterface; 244 | leanMeta = await deprecatedExternalAPIHelper.addPosterFromImages(leanMeta); 245 | return ctx.body = leanMeta; 246 | } catch (e) { 247 | console.error(e,tmdbData); 248 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 249 | throw new MediaNotFoundError(); 250 | } 251 | }; 252 | -------------------------------------------------------------------------------- /src/controllers/media.ts: -------------------------------------------------------------------------------- 1 | import { ParameterizedContext } from 'koa'; 2 | import * as _ from 'lodash'; 3 | 4 | import { MediaNotFoundError, RateLimitError, ValidationError } from '../helpers/customErrors'; 5 | import { CollectionMetadataInterface } from '../models/CollectionMetadata'; 6 | import FailedLookups, { FailedLookupsInterface } from '../models/FailedLookups'; 7 | import LocalizeMetadata, { LocalizeMetadataInterface } from '../models/LocalizeMetadata'; 8 | import MediaMetadata, { MediaMetadataInterface } from '../models/MediaMetadata'; 9 | import { SeasonMetadataInterface } from '../models/SeasonMetadata'; 10 | import { SeriesMetadataInterface } from '../models/SeriesMetadata'; 11 | import * as externalAPIHelper from '../services/external-api-helper'; 12 | 13 | /** 14 | * Adds a searchMatch to an existing result by IMDb ID, and returns the result. 15 | * 16 | * @param imdbID the IMDb ID 17 | * @param title the title 18 | * @returns the updated record 19 | */ 20 | export const addSearchMatchByIMDbID = async(imdbID: string, title: string): Promise => { 21 | return MediaMetadata.findOneAndUpdate( 22 | { imdbID }, 23 | { $addToSet: { searchMatches: title } }, 24 | { new: true, lean: true }, 25 | ).exec(); 26 | }; 27 | 28 | /* 29 | * Gets localized information from TMDB since it's the only API 30 | * we use that has that functionality. 31 | */ 32 | export const getLocalize = async(ctx: ParameterizedContext): Promise> => { 33 | const { language, mediaType, imdbID, tmdbID }: UmsQueryParams = ctx.query; 34 | const { episode, season }: UmsQueryParams = ctx.query; 35 | let seasonNumber: number|undefined; 36 | if (season) { 37 | seasonNumber = Number(season); 38 | } 39 | 40 | if (!language || !mediaType || !(imdbID || tmdbID)) { 41 | throw new ValidationError('Language, media type and either IMDb ID or TMDB ID are required'); 42 | } 43 | if (!language.match(/^[a-z]{2}(-[a-z]{2})?$/i)) { 44 | throw new ValidationError('Language must have a minimum length of 2 and follow the case-insensitive pattern: ([a-z]{2})-([a-z]{2})'); 45 | } 46 | 47 | if (mediaType !== 'collection' && mediaType !== 'movie' && mediaType !== 'tv' && mediaType !== 'tv_season' && mediaType !== 'tv_episode') { 48 | throw new ValidationError('Media type' + mediaType + ' is not valid'); 49 | } 50 | if (mediaType === 'collection' && !tmdbID) { 51 | throw new ValidationError('TMDB Id is required for collection media'); 52 | } 53 | if (mediaType === 'tv_season' && tmdbID && (!seasonNumber || isNaN(seasonNumber))) { 54 | throw new ValidationError('Season number is required for season media'); 55 | } 56 | if (mediaType === 'tv_episode' && tmdbID && (!seasonNumber || isNaN(seasonNumber) || !episode)) { 57 | throw new ValidationError('Episode number and season number are required for episode media'); 58 | } 59 | 60 | const failedLookupQuery: FailedLookupsInterface = { language, type: mediaType, imdbID, tmdbID, season, episode }; 61 | // TODO: can this be one query instead of two? 62 | if (await FailedLookups.findOne(failedLookupQuery, '_id', { lean: true }).exec()) { 63 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }).exec(); 64 | return null; 65 | } 66 | 67 | let episodeNumber = null; 68 | if (episode) { 69 | const episodes = episode.split('-'); 70 | episodeNumber = Number(episodes[0]); 71 | } 72 | if (tmdbID) { 73 | const existingLocalize: LocalizeMetadataInterface = await LocalizeMetadata.findOne({ language, mediaType, tmdbID, seasonNumber, episodeNumber }, null, { lean: true }).exec(); 74 | if (existingLocalize) { 75 | return ctx.body = existingLocalize; 76 | } 77 | } 78 | 79 | if (imdbID) { 80 | const existingLocalize: LocalizeMetadataInterface = await LocalizeMetadata.findOne({ language, mediaType, imdbID }, null, { lean: true }).exec(); 81 | if (existingLocalize) { 82 | return ctx.body = existingLocalize; 83 | } 84 | } 85 | 86 | try { 87 | const findResult = await externalAPIHelper.getLocalizedMetadata(language, mediaType, imdbID, tmdbID, seasonNumber, episodeNumber); 88 | if (!findResult) { 89 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 90 | throw new MediaNotFoundError(); 91 | } 92 | return ctx.body = findResult; 93 | } catch (err) { 94 | if (!(err instanceof MediaNotFoundError)) { 95 | console.error(err); 96 | } 97 | 98 | if (err instanceof RateLimitError) { 99 | throw err; 100 | } else { 101 | throw new MediaNotFoundError(); 102 | } 103 | } 104 | }; 105 | 106 | export const getSeriesV2 = async(ctx: ParameterizedContext): Promise | MediaMetadataInterface> => { 107 | const { imdbID, title, year }: UmsQueryParams = ctx.query; 108 | let { language }: UmsQueryParams = ctx.query; 109 | if (!title && !imdbID) { 110 | throw new ValidationError('Either IMDb ID or title required'); 111 | } 112 | 113 | if (language && !language.match(/^[a-z]{2}(-[A-Z]{2})?$/)) { 114 | language = undefined; 115 | } 116 | 117 | try { 118 | const dbMeta = await externalAPIHelper.getSeriesMetadata(imdbID, title, language, year); 119 | if (!dbMeta) { 120 | throw new MediaNotFoundError(); 121 | } 122 | 123 | return ctx.body = dbMeta; 124 | } catch (err) { 125 | // log unexpected errors 126 | if (!(err instanceof MediaNotFoundError)) { 127 | console.error(err); 128 | } 129 | 130 | if (err instanceof RateLimitError) { 131 | throw err; 132 | } else { 133 | throw new MediaNotFoundError(); 134 | } 135 | } 136 | }; 137 | 138 | /* 139 | * Gets season information from TMDB since it's the only API 140 | * we use that has that functionality. 141 | */ 142 | export const getSeason = async(ctx: ParameterizedContext): Promise> => { 143 | const { season, title, year }: UmsQueryParams = ctx.query; 144 | let { tmdbID, language }: UmsQueryParams = ctx.query; 145 | if (!tmdbID && !title) { 146 | throw new ValidationError('title or tmdbID is required'); 147 | } 148 | if (!season) { 149 | throw new ValidationError('season is required'); 150 | } 151 | const seasonNumber = Number(season); 152 | if (isNaN(seasonNumber)) { 153 | throw new ValidationError('season as a number is required'); 154 | } 155 | if (language && !language.match(/^[a-z]{2}(-[A-Z]{2})?$/)) { 156 | language = undefined; 157 | } 158 | 159 | try { 160 | if (!tmdbID) { 161 | const seriesMetadata = await externalAPIHelper.getSeriesMetadata(null, title, language, year); 162 | if (!seriesMetadata?.tmdbID) { 163 | throw new MediaNotFoundError(); 164 | } 165 | tmdbID = seriesMetadata.tmdbID; 166 | } 167 | 168 | const seasonMetadata = await externalAPIHelper.getSeasonMetadata(tmdbID, seasonNumber); 169 | if (_.isEmpty(seasonMetadata)) { 170 | throw new MediaNotFoundError(); 171 | } 172 | return ctx.body = seasonMetadata; 173 | } catch (err) { 174 | // log unexpected errors 175 | if (!(err instanceof MediaNotFoundError)) { 176 | console.error(err); 177 | } 178 | 179 | if (err instanceof RateLimitError) { 180 | throw err; 181 | } else { 182 | throw new MediaNotFoundError(); 183 | } 184 | } 185 | }; 186 | 187 | /* 188 | * Gets collection information from TMDB since it's the only API 189 | * we use that has that functionality. 190 | */ 191 | export const getCollection = async(ctx: ParameterizedContext): Promise> => { 192 | const { tmdbID }: UmsQueryParams = ctx.query; 193 | if (!tmdbID) { 194 | throw new ValidationError('tmdbID is required'); 195 | } 196 | 197 | try { 198 | const collectionMetadata = await externalAPIHelper.getCollectionMetadata(tmdbID); 199 | if (_.isEmpty(collectionMetadata)) { 200 | throw new MediaNotFoundError(); 201 | } 202 | return ctx.body = collectionMetadata; 203 | } catch (err) { 204 | // log unexpected errors 205 | if (!(err instanceof MediaNotFoundError)) { 206 | console.error(err); 207 | } 208 | 209 | if (err instanceof RateLimitError) { 210 | throw err; 211 | } else { 212 | throw new MediaNotFoundError(); 213 | } 214 | } 215 | }; 216 | 217 | export const getVideoV2 = async(ctx: ParameterizedContext): Promise => { 218 | const { title, imdbID }: UmsQueryParams = ctx.query; 219 | const { episode, season, year }: UmsQueryParams = ctx.query; 220 | 221 | if (!title && !imdbID) { 222 | throw new ValidationError('title or imdbId is a required parameter'); 223 | } 224 | 225 | if (season && !episode) { 226 | throw new ValidationError('season must also have an episode number'); 227 | } 228 | 229 | let { language }: UmsQueryParams = ctx.query; 230 | const [yearNumber] = [year].map(param => param ? Number(param) : null); 231 | const seasonNumber = Number(season); 232 | let episodeNumbers = null; 233 | if (episode) { 234 | const episodes = episode.split('-'); 235 | episodeNumbers = episodes.map(Number); 236 | } 237 | 238 | if (language && !language.match(/^[a-z]{2}(-[A-Z]{2})?$/)) { 239 | language = undefined; 240 | } 241 | 242 | const query = []; 243 | const failedQuery = []; 244 | let imdbIdToSearch = imdbID; 245 | 246 | if (imdbIdToSearch) { 247 | query.push({ imdbID: imdbIdToSearch }); 248 | failedQuery.push({ imdbId: imdbIdToSearch }); 249 | } 250 | 251 | let searchMatch: string; 252 | if (title) { 253 | searchMatch = language ? language + '@' + title : title; 254 | const titleQuery: GetVideoFilter = { searchMatches: { $in: [searchMatch] } }; 255 | const titleFailedQuery: FailedLookupsInterface = { title }; 256 | 257 | if (language) { 258 | titleFailedQuery.language = language; 259 | } 260 | if (year) { 261 | titleQuery.year = year; 262 | titleFailedQuery.year = year; 263 | } 264 | if (episode) { 265 | titleQuery.episode = episode; 266 | titleFailedQuery.episode = episode; 267 | } 268 | if (season) { 269 | titleQuery.season = season; 270 | titleFailedQuery.season = season; 271 | } 272 | query.push(titleQuery); 273 | failedQuery.push(titleFailedQuery); 274 | } 275 | 276 | const existingResult = await MediaMetadata.findOne({ $or: query }, null, { lean: true }).exec(); 277 | if (existingResult) { 278 | // we have an existing metadata record, so return it 279 | return ctx.body = existingResult; 280 | } 281 | 282 | const existingFailedResult = await FailedLookups.findOne({ $or: failedQuery }, null, { lean: true }).exec(); 283 | if (existingFailedResult) { 284 | // we have an existing failure record, so increment it, and throw not found error 285 | await FailedLookups.updateOne({ _id: existingFailedResult._id }, { $inc: { count: 1 } }).exec(); 286 | throw new MediaNotFoundError(); 287 | } 288 | 289 | // the database does not have a record of this file, so begin search for metadata on external apis. 290 | 291 | const failedLookupQuery = { episode, imdbID, season, title, year }; 292 | 293 | if (!title && !imdbIdToSearch) { 294 | // The APIs below require either a title or IMDb ID, so return if we don't have one 295 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 296 | throw new MediaNotFoundError(); 297 | } 298 | 299 | // Start TMDB lookups 300 | let tmdbData: MediaMetadataInterface; 301 | try { 302 | tmdbData = await externalAPIHelper.getFromTMDBAPI(title, language, imdbIdToSearch, yearNumber, seasonNumber, episodeNumbers); 303 | imdbIdToSearch = imdbIdToSearch || tmdbData?.imdbID; 304 | } catch (err) { 305 | if (err instanceof RateLimitError) { 306 | throw err; 307 | } 308 | 309 | // Log the error but continue 310 | if (err.message && err.message.includes('404') && err.response?.config?.url) { 311 | console.log('Received 404 response from ' + err.response.config.url); 312 | } else { 313 | console.error(err); 314 | } 315 | } 316 | 317 | // if the client did not pass an imdbID, but we found one on TMDB, see if we have an existing record for the now-known media. 318 | if (!imdbID && imdbIdToSearch) { 319 | const existingResult = await MediaMetadata.findOne({ imdbID: imdbIdToSearch }, null, { lean: true }).exec(); 320 | if (existingResult) { 321 | return ctx.body = await addSearchMatchByIMDbID(imdbIdToSearch, searchMatch); 322 | } 323 | } 324 | // End TMDB lookups 325 | 326 | if (!tmdbData || _.isEmpty(tmdbData)) { 327 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 328 | throw new MediaNotFoundError(); 329 | } 330 | 331 | try { 332 | if (searchMatch) { 333 | tmdbData.searchMatches = [searchMatch]; 334 | } 335 | 336 | // Ensure that we return and cache the same episode number that was searched for 337 | if (episode && episodeNumbers && episodeNumbers.length > 1 && episodeNumbers[0] === tmdbData.episode) { 338 | tmdbData.episode = episode; 339 | } 340 | 341 | const dbMeta = await MediaMetadata.create(tmdbData); 342 | 343 | // TODO: Investigate why we need this "as" syntax 344 | const leanMeta = dbMeta.toObject({ useProjection: true }) as MediaMetadataInterface; 345 | return ctx.body = leanMeta; 346 | } catch (e) { 347 | console.error(e,tmdbData); 348 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 349 | throw new MediaNotFoundError(); 350 | } 351 | }; 352 | -------------------------------------------------------------------------------- /src/helpers/customErrors.ts: -------------------------------------------------------------------------------- 1 | export class ValidationError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = this.constructor.name; 5 | Error.captureStackTrace(this, this.constructor); 6 | } 7 | } 8 | 9 | export class ExternalAPIError extends Error { 10 | constructor(message: string) { 11 | super(message); 12 | Error.captureStackTrace(this, ExternalAPIError); 13 | this.name = 'ExternalAPIError'; 14 | } 15 | } 16 | 17 | export class MediaNotFoundError extends Error { 18 | constructor() { 19 | super(); 20 | Error.captureStackTrace(this, MediaNotFoundError); 21 | this.message = 'Metadata not found.'; 22 | this.name = 'MediaNotFoundError'; 23 | } 24 | } 25 | 26 | export class RateLimitError extends Error { 27 | constructor() { 28 | super(); 29 | Error.captureStackTrace(this, RateLimitError); 30 | this.message = 'Request was prevented by an upstream rate-limit, try again.'; 31 | this.name = 'RateLimitError'; 32 | } 33 | } 34 | 35 | export class IMDbIDNotFoundError extends MediaNotFoundError { 36 | constructor() { 37 | super(); 38 | Error.captureStackTrace(this, IMDbIDNotFoundError); 39 | this.name = 'IMDbIDNotFoundError'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/helpers/subversioning.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * These versions get bumped to tell the client that 3 | * it should update its relevant data. 4 | * e.g. if we did an API change that improved our results 5 | * for videos in any way, we should bump that version by 1. 6 | */ 7 | export const subversions = { 8 | 'collection': '1', 9 | 'configuration': '1', 10 | 'localize': '6', 11 | 'season': '1', 12 | 'series': '8', 13 | 'video': '10', 14 | }; 15 | -------------------------------------------------------------------------------- /src/interfaces.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomError extends Error { 2 | status?: number; 3 | } 4 | 5 | interface UmsQueryParams { 6 | title?: string; 7 | year?: string; 8 | imdbid?: string; 9 | imdbID?: string; 10 | episode?: string; 11 | season?: string; 12 | language?: string; 13 | mediaType?: string; 14 | tmdbID?: number; 15 | } 16 | 17 | interface GetVideoFilter { 18 | year?: string; 19 | episode?: string; 20 | searchMatches?: object; 21 | season?: string; 22 | } 23 | 24 | interface GetSeriesFilter { 25 | startYear?: string; 26 | searchMatches?: object; 27 | } 28 | 29 | interface CaseInsensitiveSearchQuery { 30 | title: { 31 | $regex: RegExp; 32 | $options: string; 33 | }; 34 | startYear?: string; 35 | } 36 | 37 | interface TmdbIdentifyResponse { 38 | mediaType: string; 39 | tmdbID: number; 40 | seasonNumber?: number; 41 | episodeNumber?: number; 42 | } -------------------------------------------------------------------------------- /src/models/CollectionMetadata.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, InferSchemaType } from 'mongoose'; 2 | 3 | const CollectionMetadataSchema: Schema = new Schema({ 4 | images: { type: Array }, 5 | name: { type: String }, 6 | overview: { type: String }, 7 | movieTmdbIds: { type: Array }, 8 | posterRelativePath: { type: String }, 9 | tmdbID: { type: Number, index: true, required: true }, 10 | }, { 11 | collection: 'collection_metadata', 12 | timestamps: true, 13 | versionKey: false, 14 | }); 15 | 16 | export type CollectionMetadataInterface = InferSchemaType; 17 | 18 | const CollectionMetadata = mongoose.model('CollectionMetadata', CollectionMetadataSchema); 19 | 20 | CollectionMetadata.on('index', function(err) { 21 | if (err) { 22 | console.error('CollectionMetadata index error: %s', err); 23 | } else { 24 | console.info('CollectionMetadata indexing complete'); 25 | } 26 | }); 27 | 28 | export default CollectionMetadata; 29 | -------------------------------------------------------------------------------- /src/models/EpisodeProcessing.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Schema } from 'mongoose'; 3 | 4 | export interface EpisodeProcessingInterface { 5 | seriesimdbid: string; 6 | } 7 | 8 | const opts = { 9 | collection: 'episode_processing', 10 | timestamps: true, 11 | }; 12 | 13 | const EpisodeProcessingSchema = new Schema({ seriesimdbid: { required: true, type: String, unique: true } }, opts); 14 | 15 | const EpisodeProcessing = mongoose.model('EpisodeProcessing', EpisodeProcessingSchema); 16 | export default EpisodeProcessing; 17 | -------------------------------------------------------------------------------- /src/models/FailedLookups.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Schema } from 'mongoose'; 3 | 4 | const THIRTY_DAYS_IN_SECONDS = 2592000; // 30 days 5 | 6 | export interface FailedLookupsInterface { 7 | episode?: string; 8 | failedValidation?: boolean; 9 | imdbID?: string; 10 | language?: string; 11 | season?: string; 12 | startYear?: string; 13 | title?: string; 14 | tmdbID?: number; 15 | type?: string; 16 | year?: string; 17 | count?: number; 18 | 19 | // Added automatically: 20 | createdAt?: string; 21 | updatedAt?: string; 22 | } 23 | 24 | export interface FailedLookupsInterfaceDocument extends mongoose.Document, FailedLookupsInterface {} 25 | 26 | const FailedLookupsSchema = new Schema({ 27 | count: { type: Number, default: 1 }, 28 | episode: { type: String }, 29 | imdbID: { type: String, index: true }, 30 | language: { type: String }, 31 | season: { type: String }, 32 | startYear: { type: String }, 33 | title: { type: String, required: true }, 34 | tmdbID: { index: true, type: Number }, 35 | year: { type: String }, 36 | failedValidation: { type: Boolean, default: false }, 37 | type: { type: String }, 38 | createdAt: { 39 | default: Date.now, 40 | expires: THIRTY_DAYS_IN_SECONDS, 41 | type: Date, 42 | }, 43 | }, { 44 | collection: 'failed_lookups', 45 | timestamps: true, 46 | }); 47 | 48 | FailedLookupsSchema.index({ title: 1, language: 1, year: 1 }); 49 | FailedLookupsSchema.index({ title: 1, episode: 1, season: 1 }); 50 | FailedLookupsSchema.index({ title: 1, language: 1, episode: 1, season: 1 }); 51 | FailedLookupsSchema.index({ title: 1, type: 1 }); 52 | FailedLookupsSchema.index({ title: 1, type: 1, startYear: 1 }); 53 | FailedLookupsSchema.index({ language: 1, type: 1, imdbID: 1, tmdbID: 1, season: 1, episode: 1 }) 54 | 55 | const FailedLookups = mongoose.model('FailedLookups', FailedLookupsSchema); 56 | 57 | FailedLookups.on('index', function(err) { 58 | if (err) { 59 | console.error('FailedLookups index error: %s', err); 60 | } else { 61 | console.info('FailedLookups indexing complete'); 62 | } 63 | }); 64 | 65 | export default FailedLookups; 66 | -------------------------------------------------------------------------------- /src/models/LocalizeMetadata.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, InferSchemaType } from 'mongoose'; 2 | 3 | const LocalizeMetadataSchema = new Schema({ 4 | episodeNumber: { index: true, type: Number }, 5 | homepage: { type: String }, 6 | imdbID: { index: true, type: String }, 7 | language: { required: true, type: String }, 8 | mediaType: { required: true, index: true, type: String }, 9 | overview: { type: String }, 10 | posterRelativePath: { type: String }, 11 | seasonNumber: { index: true, type: Number }, 12 | tagline: { type: String }, 13 | title: { type: String }, 14 | tmdbID: { index: true, type: Number }, 15 | }, { 16 | collection: 'localize_metadata', 17 | timestamps: true, 18 | versionKey: false, 19 | }); 20 | 21 | LocalizeMetadataSchema.index({ language: 1, mediaType: 1, tmdbID: 1, seasonNumber: 1, episodeNumber: 1 }) 22 | 23 | export type LocalizeMetadataInterface = InferSchemaType; 24 | 25 | const LocalizeMetadata = mongoose.model('LocalizeMetadata', LocalizeMetadataSchema); 26 | 27 | LocalizeMetadata.on('index', function(err) { 28 | if (err) { 29 | console.error('LocalizeMetadata index error: %s', err); 30 | } else { 31 | console.info('LocalizeMetadata indexing complete'); 32 | } 33 | }); 34 | 35 | export default LocalizeMetadata; -------------------------------------------------------------------------------- /src/models/MediaMetadata.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Schema } from 'mongoose'; 3 | import { CreditsResponse, EpisodeCreditsResponse, EpisodeExternalIdsResponse, EpisodeImagesResponse, MovieExternalIdsResponse, MovieImagesResponse } from 'moviedb-promise/dist/request-types'; 4 | import { ProductionCompany, ProductionCountry, SpokenLanguage } from 'moviedb-promise/dist/types'; 5 | 6 | export interface MediaMetadataInterface { 7 | actors?: Array; 8 | awards?: string; 9 | boxoffice?: string; 10 | budget?: number; 11 | collectionTmdbID?: number; 12 | country?: string; 13 | credits?: CreditsResponse | EpisodeCreditsResponse; 14 | directors?: Array; 15 | episode?: string; 16 | externalIDs?: MovieExternalIdsResponse | EpisodeExternalIdsResponse; 17 | genres?: Array; 18 | goofs?: string; 19 | homepage?: string; 20 | images?: MovieImagesResponse | EpisodeImagesResponse; 21 | imdbID?: string; 22 | metascore?: string; 23 | originalLanguage?: string; 24 | originalTitle?: string; 25 | plot?: string; 26 | poster?: string; 27 | posterRelativePath?: string; 28 | production?: string; 29 | productionCompanies?: Array; 30 | productionCountries?: Array; 31 | rated?: string; // e.g 'PG-13' 32 | rating?: number; // e.g. 6.7 33 | released?: Date; 34 | revenue?: string; 35 | runtime?: string; 36 | searchMatches?: Array; 37 | season?: string; 38 | seriesIMDbID?: string; 39 | spokenLanguages?: Array; 40 | tagline?: string; 41 | title: string; 42 | tmdbID?: number; 43 | tmdbTvID?: number; 44 | trivia?: string; 45 | type: string; 46 | votes?: string; 47 | year?: string; 48 | } 49 | 50 | const isTypeEpisode = function(context?: MediaMetadataInterface): boolean { 51 | return context ? context.type === 'episode' : this.type === 'episode'; 52 | }; 53 | 54 | const MediaMetadataSchema: Schema = new Schema({ 55 | actors: { type: Array }, 56 | awards: { type: String }, 57 | boxoffice: { type: String }, 58 | budget: { type: Number }, 59 | collectionTmdbID: { type: Number }, 60 | country: { type: String }, 61 | createdAt: { 62 | type: Date, 63 | default: Date.now, 64 | select: false, 65 | }, 66 | credits: { type: Array }, 67 | directors: { type: Array }, 68 | episode: { 69 | required: isTypeEpisode, 70 | type: String, 71 | }, 72 | externalIDs: { type: Array }, 73 | genres: { type: Array }, 74 | goofs: { type: String }, 75 | homepage: { type: String }, 76 | images: { type: Array }, 77 | imdbID: { type: String, index: true }, 78 | metascore: { type: String }, 79 | originalLanguage: { type: String }, 80 | originalTitle: { type: String }, 81 | productionCompanies: { type: Array }, 82 | productionCountries: { type: Array }, 83 | plot: { type: String }, 84 | poster: { type: String }, 85 | posterRelativePath: { type: String }, 86 | production: { type: String }, 87 | rated: { type: String }, 88 | rating: { type: Number }, 89 | released: { type: Date }, 90 | revenue: { type: String }, 91 | runtime: { type: String }, 92 | searchMatches: { type: Array, select: false }, 93 | season: { 94 | index: true, 95 | required: isTypeEpisode, 96 | type: String, 97 | }, 98 | seriesIMDbID: { type: String }, 99 | tagline: { type: String }, 100 | title: { type: String, index: true, required: function(): boolean { return !isTypeEpisode(this); } }, 101 | tmdbID: { type: Number }, 102 | tmdbTvID: { type: Number }, 103 | trivia: { type: String }, 104 | type: { type: String, required: true }, 105 | votes: { type: String }, 106 | year: { type: String }, 107 | }, { 108 | collection: 'media_metadata', 109 | timestamps: true, 110 | versionKey: false, 111 | }); 112 | 113 | MediaMetadataSchema.index({ episode: 1, season: 1, searchMatches: 1 }); 114 | MediaMetadataSchema.index({ episode: 1, season: 1, searchMatches: 1, year: 1 }); 115 | MediaMetadataSchema.index({ searchMatches: 1, year: 1 }); 116 | 117 | MediaMetadataSchema.pre('save', function(next) { 118 | if (this.title && this.title.startsWith('Episode #')) { 119 | this.title = undefined; 120 | } 121 | next(); 122 | }); 123 | 124 | MediaMetadataSchema.virtual('imdburl').get(function() { 125 | return `https://www.imdb.com/title/${this.imdbID}`; 126 | }); 127 | 128 | const MediaMetadata = mongoose.model('MediaMetadata', MediaMetadataSchema); 129 | 130 | MediaMetadata.on('index', function(err) { 131 | if (err) { 132 | console.error('MediaMetadata index error: %s', err); 133 | } else { 134 | console.info('MediaMetadata indexing complete'); 135 | } 136 | }); 137 | 138 | export default MediaMetadata; 139 | -------------------------------------------------------------------------------- /src/models/SeasonMetadata.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, InferSchemaType } from 'mongoose'; 2 | 3 | const castSubdocument = new Schema({ 4 | adult: { type: Boolean }, 5 | cast_id: { type: Number }, 6 | character: { type: String }, 7 | credit_id: { type: String }, 8 | gender: { type: Number }, 9 | id: { type: Number }, 10 | known_for_department: { type: String }, 11 | name: { type: String }, 12 | order: { type: Number }, 13 | original_name: { type: String }, 14 | popularity: { type: Number }, 15 | profile_path: { type: String }, 16 | }); 17 | 18 | const crewSubdocument = new Schema({ 19 | adult: { type: Boolean }, 20 | credit_id: { type: String }, 21 | department: { type: String }, 22 | gender: { type: Number }, 23 | id: { type: Number }, 24 | known_for_department: { type: String }, 25 | job: { type: String }, 26 | name: { type: String }, 27 | original_name: { type: String }, 28 | popularity: { type: Number }, 29 | profile_path: { type: String }, 30 | }); 31 | 32 | const creditsSubdocument = new Schema({ 33 | id: Number, 34 | cast: [castSubdocument], 35 | crew: [crewSubdocument], 36 | }); 37 | const externalIdsSubdocument = new Schema({ 38 | id: { type: Number }, 39 | freebase_mid: { type: String }, 40 | freebase_id: { type: String }, 41 | tvdb_id: { type: Number }, 42 | tvrage_id: { type: Number }, 43 | wikidata_id: { type: String }, 44 | }); 45 | const posterSubdocument = new Schema({ 46 | aspect_ratio: { type: Number }, 47 | file_path: { type: String }, 48 | height: { type: Number }, 49 | iso_639_1: { type: String }, 50 | vote_average: { type: Number }, 51 | vote_count: { type: Number }, 52 | width: { type: Number }, 53 | }); 54 | const imagesSubdocument = new Schema({ 55 | id: { type: Number }, 56 | posters: [posterSubdocument], 57 | }); 58 | 59 | const SeasonMetadataSchema: Schema = new Schema({ 60 | airDate: { type: String }, 61 | credits: creditsSubdocument, 62 | externalIDs: externalIdsSubdocument, 63 | images: imagesSubdocument, 64 | name: { type: String }, 65 | overview: { type: String }, 66 | posterRelativePath: { type: String }, 67 | seasonNumber: { type: Number, index: true, required: true }, 68 | tmdbID: { type: Number }, 69 | tmdbTvID: { type: Number, index: true, required: true }, 70 | }, { 71 | collection: 'season_metadata', 72 | timestamps: true, 73 | versionKey: false, 74 | }); 75 | 76 | export type SeasonMetadataInterface = InferSchemaType; 77 | 78 | const SeasonMetadata = mongoose.model('SeasonMetadata', SeasonMetadataSchema); 79 | 80 | SeasonMetadata.on('index', function(err) { 81 | if (err) { 82 | console.error('SeasonMetadata index error: %s', err); 83 | } else { 84 | console.info('SeasonMetadata indexing complete'); 85 | } 86 | }); 87 | 88 | export default SeasonMetadata; 89 | -------------------------------------------------------------------------------- /src/models/SeriesMetadata.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Schema, Model } from 'mongoose'; 3 | import { Network, SimplePerson, SimpleSeason, TvImagesResponse, TvExternalIdsResponse, CreditsResponse } from 'moviedb-promise/dist/request-types'; 4 | import { ProductionCompany, ProductionCountry, SpokenLanguage } from 'moviedb-promise/dist/types'; 5 | 6 | export interface SeriesMetadataInterface { 7 | actors?: Array; 8 | awards?: string; 9 | country?: string; 10 | createdBy?: Array; 11 | credits?: CreditsResponse; 12 | directors?: Array; 13 | endYear?: string; 14 | externalIDs?: TvExternalIdsResponse; 15 | genres: Array; 16 | homepage?: string; 17 | images?: TvImagesResponse; 18 | imdbID?: string; 19 | inProduction?: boolean; 20 | languages?: Array; 21 | lastAirDate?: string; 22 | metascore?: string; 23 | networks?: Array; 24 | numberOfEpisodes?: number; 25 | originCountry?: Array; 26 | originalLanguage?: string; 27 | originalTitle?: string; 28 | plot?: string; 29 | poster?: string; 30 | posterRelativePath?: string; 31 | productionCompanies?: Array; 32 | productionCountries?: Array; 33 | rated?: string; // e.g 'PG-13' 34 | rating?: number; // e.g. 6.7 35 | ratings?: Array<{Source: string; Value: string}>; 36 | released?: Date; 37 | searchMatches?: Array; 38 | seasons?: Array; 39 | seriesType?: string; 40 | spokenLanguages?: Array; 41 | startYear?: string; 42 | status?: string; 43 | tagline?: string; 44 | title: string; 45 | type: string; 46 | tmdbID?: number; 47 | totalSeasons?: number; 48 | votes?: string; 49 | year: string; 50 | } 51 | 52 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 53 | export interface SeriesMetadataModel extends Model { 54 | } 55 | 56 | const SeriesMetadataSchema: Schema = new Schema({ 57 | actors: { type: Array }, 58 | awards: { type: String }, 59 | country: { type: String }, 60 | createdAt: { 61 | type: Date, 62 | default: Date.now, 63 | }, 64 | createdBy: { type: Array }, 65 | credits: { type: Array }, 66 | directors: { type: Array }, 67 | endYear: { type: String }, 68 | externalIDs: { type: Array }, 69 | genres: { type: Array }, 70 | homepage: { type: String }, 71 | images: { type: Array }, 72 | imdbID: { type: String, index: true }, 73 | inProduction: { type: Boolean }, 74 | languages: { type: Array }, 75 | lastAirDate: { type: String }, 76 | metascore: { type: String }, 77 | networks: { type: Array }, 78 | numberOfEpisodes: { type: Number }, 79 | originCountry: { type: Array }, 80 | originalLanguage: { type: String }, 81 | originalTitle: { type: String }, 82 | plot: { type: String }, 83 | poster: { type: String }, 84 | posterRelativePath: { type: String }, 85 | productionCompanies: { type: Array }, 86 | productionCountries: { type: Array }, 87 | rated: { type: String }, 88 | rating: { type: Number }, 89 | ratings: { type: [new mongoose.Schema({ 'Source': String, 'Value': String })] }, 90 | released: { type: Date }, 91 | searchMatches: { type: Array, index: true, select: false }, 92 | seasons: { type: Array }, 93 | seriesType: { type: String }, 94 | spokenLanguages: { type: Array }, 95 | startYear: { type: String, index: true }, 96 | status: { type: String }, 97 | tagline: { type: String }, 98 | title: { type: String, index: true, required: true }, 99 | tmdbID: { type: Number, index: true }, 100 | totalSeasons: { 101 | type: Number, 102 | default: 0, 103 | }, 104 | type: { 105 | type: String, 106 | validate: { 107 | validator: (t: string): boolean => t === 'series', 108 | required: [true, 'Series Metadata must have a type, of "series".'], 109 | }, 110 | }, 111 | votes: { type: String }, 112 | year: { type: String }, 113 | }, { 114 | collection: 'series_metadata', 115 | timestamps: true, 116 | versionKey: false, 117 | }); 118 | 119 | SeriesMetadataSchema.virtual('imdburl').get(function() { 120 | return `https://www.imdb.com/title/${this.imdbID}`; 121 | }); 122 | 123 | const SeriesMetadata = mongoose.model('SeriesMetadata', SeriesMetadataSchema); 124 | 125 | SeriesMetadata.on('index', function(err) { 126 | if (err) { 127 | console.error('SeriesMetadata index error: %s', err); 128 | } else { 129 | console.info('SeriesMetadata indexing complete'); 130 | } 131 | }); 132 | 133 | export default SeriesMetadata; 134 | -------------------------------------------------------------------------------- /src/models/TMDBConfiguration.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { InferSchemaType, Schema } from 'mongoose'; 2 | 3 | const THREE_DAYS_IN_SECONDS = 259200; 4 | 5 | const TMDBConfigurationSchema = new Schema({ 6 | imageBaseURL: { type: String, required: true }, 7 | createdAt: { 8 | default: Date.now, 9 | expires: THREE_DAYS_IN_SECONDS, 10 | type: Date, 11 | }, 12 | }, { 13 | collection: 'tmdb_configuration', 14 | timestamps: true, 15 | versionKey: false, 16 | }); 17 | 18 | export type TMDBConfigurationInterface = InferSchemaType; 19 | 20 | const TMDBConfiguration = mongoose.model('TMDBConfiguration', TMDBConfigurationSchema); 21 | 22 | export default TMDBConfiguration; 23 | -------------------------------------------------------------------------------- /src/models/connection.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Mongoose } from 'mongoose'; 2 | 3 | mongoose.set('strictQuery', true); 4 | mongoose.set('strict', 'throw'); 5 | 6 | export default (db: string): void => { 7 | if (!db) { 8 | throw new Error('MONGO_URL required'); 9 | } 10 | 11 | const connect = async(): Promise => { 12 | const options = { 13 | maxPoolSize: 30, 14 | minPoolSize: 15, 15 | }; 16 | return await mongoose.connect(db, options); 17 | }; 18 | connect().catch(error => console.error(error)); 19 | 20 | mongoose.connection.on('connected', () => { 21 | console.log(`Successfully connected to ${new URL(db).hostname}`); 22 | }); 23 | 24 | mongoose.connection.on('disconnected', () => { 25 | console.log(`Disconnected from ${new URL(db).hostname}, reconnecting`); 26 | connect().catch(error => console.error(error)); 27 | }); 28 | 29 | process.on('SIGINT', async () => { 30 | await mongoose.connection.close(); 31 | console.log('Mongoose default connection disconnected through app termination'); 32 | process.exit(0); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /src/routes/deprecated/media.ts: -------------------------------------------------------------------------------- 1 | import * as Router from 'koa-router'; 2 | import * as DeprecatedMediaController from '../../controllers/deprecated/media'; 3 | import { subversions } from '../../helpers/subversioning'; 4 | 5 | const router = new Router({ prefix: '/api/media' }); 6 | 7 | router.get('/osdbhash/:osdbhash/:filebytesize', async() => { 8 | await DeprecatedMediaController.getByOsdbHash(); 9 | }); 10 | 11 | router.get('/title', async(ctx) => { 12 | await DeprecatedMediaController.getBySanitizedTitle(ctx); 13 | }); 14 | 15 | router.get('/v2/title', async(ctx) => { 16 | await DeprecatedMediaController.getBySanitizedTitleV2(ctx); 17 | }); 18 | 19 | router.get('/imdbid', async(ctx) => { 20 | await DeprecatedMediaController.getByImdbID(ctx); 21 | }); 22 | 23 | router.get('/seriestitle', async(ctx) => { 24 | ctx.set('X-Api-Subversion', subversions['series']); 25 | await DeprecatedMediaController.getSeries(ctx); 26 | }); 27 | 28 | router.get('/video', async(ctx) => { 29 | ctx.set('X-Api-Subversion', subversions['video']); 30 | await DeprecatedMediaController.getVideo(ctx); 31 | }); 32 | 33 | export default router; 34 | -------------------------------------------------------------------------------- /src/routes/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,IAAI,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;AACjC,IAAI,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;AAE9B,oBAAoB;AACpB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,UAAS,GAAG,EAAE,GAAG,EAAE,IAAI;IACrC,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;AAC5C,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,OAAO,GAAG,MAAM,CAAC","sourcesContent":["var express = require('express');\nvar router = express.Router();\n\n/* GET home page. */\nrouter.get('/', function(req, res, next) {\n res.render('index', { title: 'Express' });\n});\n\nmodule.exports = router;\n"]} -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import * as Router from 'koa-router'; 2 | import * as v8 from 'v8'; 3 | 4 | import { getConfiguration } from '../controllers/configuration'; 5 | import { subversions } from '../helpers/subversioning'; 6 | 7 | const { SYSTEM_ADMIN_KEY, NODE_APP_INSTANCE } = process.env!; 8 | 9 | const router = new Router(); 10 | 11 | router.get('/', (ctx) => { 12 | ctx.body = { status: 'OK' }; 13 | }); 14 | 15 | router.get('/api/subversions', (ctx) => { 16 | ctx.body = subversions; 17 | }); 18 | 19 | router.get('/api/configuration', async(ctx) => { 20 | ctx.set('X-Api-Subversion', subversions['configuration']); 21 | await getConfiguration(ctx); 22 | }); 23 | 24 | // add this route to one process 25 | if (NODE_APP_INSTANCE === '0') { 26 | router.get('/_system/heapdump', async (ctx) => { 27 | const { key } = ctx.query; 28 | if (SYSTEM_ADMIN_KEY && (key === SYSTEM_ADMIN_KEY)) { 29 | v8.writeHeapSnapshot(`/tmp/${Date.now()}.heapsnapshot`); 30 | ctx.status = 201; 31 | return; 32 | } 33 | ctx.status = 401; 34 | ctx.body = 'Unauthorized'; 35 | }); 36 | } 37 | 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /src/routes/media.ts: -------------------------------------------------------------------------------- 1 | import * as Router from 'koa-router'; 2 | 3 | import * as MediaController from '../controllers/media'; 4 | import { subversions } from '../helpers/subversioning'; 5 | 6 | const router = new Router({ prefix: '/api/media' }); 7 | 8 | router.get('/series/v2', async(ctx) => { 9 | ctx.set('X-Api-Subversion', subversions['series']); 10 | await MediaController.getSeriesV2(ctx); 11 | }); 12 | 13 | router.get('/video/v2', async(ctx) => { 14 | ctx.set('X-Api-Subversion', subversions['video']); 15 | await MediaController.getVideoV2(ctx); 16 | }); 17 | 18 | router.get('/season', async(ctx) => { 19 | ctx.set('X-Api-Subversion', subversions['season']); 20 | await MediaController.getSeason(ctx); 21 | }); 22 | 23 | router.get('/localize', async(ctx) => { 24 | ctx.set('X-Api-Subversion', subversions['localize']); 25 | await MediaController.getLocalize(ctx); 26 | }); 27 | 28 | router.get('/collection', async(ctx) => { 29 | ctx.set('X-Api-Subversion', subversions['collection']); 30 | await MediaController.getCollection(ctx); 31 | }); 32 | 33 | export default router; 34 | -------------------------------------------------------------------------------- /src/services/deprecated/external-api-helper.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import { getTMDBImageBaseURL } from '../../controllers/configuration'; 4 | import { MediaMetadataInterface } from '../../models/MediaMetadata'; 5 | import { SeriesMetadataInterface } from '../../models/SeriesMetadata'; 6 | 7 | /* 8 | * If the incoming metadata contains a poster image within the images 9 | * array, we populate the poster value with that, and return the whole object. 10 | * 11 | * This must be done on-the-fly like this because the imageBaseURL can change. 12 | */ 13 | export const addPosterFromImages = async(metadata): Promise => { 14 | if (!metadata) { 15 | throw new Error('Metadata is required'); 16 | } 17 | 18 | if (metadata.poster) { 19 | // There is already a poster 20 | return metadata; 21 | } 22 | 23 | let posterRelativePath: string; 24 | 25 | if (metadata.posterRelativePath) { 26 | posterRelativePath = metadata.posterRelativePath; 27 | } else { 28 | const potentialPosters = metadata?.images?.posters ? metadata?.images?.posters : []; 29 | const potentialStills = metadata?.images?.stills || []; 30 | const potentialImagesCombined = _.concat(potentialPosters, potentialStills); 31 | if (_.isEmpty(potentialImagesCombined)) { 32 | // There are no potential images 33 | return metadata; 34 | } 35 | 36 | const englishImages = _.filter(potentialImagesCombined, { 'iso_639_1': 'en' }) || []; 37 | const noLanguageImages = _.filter(potentialImagesCombined, { 'iso_639_1': null }) || []; 38 | const posterCandidates = _.merge(noLanguageImages, englishImages); 39 | if (!posterCandidates || _.isEmpty(posterCandidates)) { 40 | // There are no English or non-language images 41 | return metadata; 42 | } 43 | 44 | const firstPoster = _.first(posterCandidates); 45 | posterRelativePath = firstPoster.file_path; 46 | } 47 | 48 | if (posterRelativePath) { 49 | const imageBaseURL = await getTMDBImageBaseURL(); 50 | metadata.poster = imageBaseURL + 'w500' + posterRelativePath; 51 | } 52 | 53 | return metadata; 54 | }; 55 | -------------------------------------------------------------------------------- /src/services/external-api-helper.ts: -------------------------------------------------------------------------------- 1 | import { jaroWinkler } from '@skyra/jaro-winkler'; 2 | import * as episodeParser from 'episode-parser'; 3 | import * as _ from 'lodash'; 4 | import { FlattenMaps, Types } from 'mongoose'; 5 | import { Episode, EpisodeRequest, ExternalId, SearchMovieRequest, SearchTvRequest, SimpleEpisode, TvExternalIdsResponse, TvResult } from 'moviedb-promise/dist/request-types'; 6 | 7 | import { tmdb } from './tmdb-api'; 8 | import { ValidationError } from '../helpers/customErrors'; 9 | import CollectionMetadata, { CollectionMetadataInterface } from '../models/CollectionMetadata'; 10 | import FailedLookups, { FailedLookupsInterface } from '../models/FailedLookups'; 11 | import LocalizeMetadata, { LocalizeMetadataInterface } from '../models/LocalizeMetadata'; 12 | import MediaMetadata, { MediaMetadataInterface } from '../models/MediaMetadata'; 13 | import SeasonMetadata, { SeasonMetadataInterface } from '../models/SeasonMetadata'; 14 | import SeriesMetadata, { SeriesMetadataInterface } from '../models/SeriesMetadata'; 15 | import { mapper } from '../utils/data-mapper'; 16 | 17 | const getSeriesTMDBIDFromTMDBAPI = async(imdbID?: string, seriesTitle?: string, language?: string, year?: number): Promise => { 18 | if (imdbID) { 19 | const findResult = await tmdb.find({ id: imdbID, external_source: ExternalId.ImdbId }); 20 | if (findResult?.tv_results && findResult?.tv_results[0]) { 21 | const tvEpisodeResult = findResult.tv_results[0]; 22 | return tvEpisodeResult?.id; 23 | } 24 | } else if (seriesTitle) { 25 | const tmdbQuery: SearchTvRequest = { query: seriesTitle }; 26 | if (year) { 27 | tmdbQuery.year = year; 28 | } 29 | if (language) { 30 | tmdbQuery.language = language; 31 | } 32 | 33 | const searchResults = await tmdb.searchTv(tmdbQuery); 34 | 35 | if (searchResults?.results && searchResults.results[0]) { 36 | let searchResult: TvResult; 37 | let didMatchYear: boolean; 38 | if (year) { 39 | const resultWithMatchingYear = _.find(searchResults.results, function(result) { 40 | return result.first_air_date.substring(0, 4) === year.toString(); 41 | }); 42 | if (resultWithMatchingYear?.id) { 43 | searchResult = resultWithMatchingYear; 44 | didMatchYear = true; 45 | } 46 | } 47 | 48 | if (!searchResult) { 49 | searchResult = searchResults.results[0]; 50 | } 51 | 52 | if (didMatchYear) { 53 | // a wrong year could cause a wrong result, so also do a similarity check to be sure 54 | const resultNameLowerCase = searchResult.name.toLowerCase(); 55 | const requestNameLowerCase = seriesTitle.toLowerCase(); 56 | if (jaroWinkler(resultNameLowerCase, requestNameLowerCase) < 0.75) { 57 | return null; 58 | } 59 | } 60 | 61 | return searchResult.id; 62 | } 63 | } 64 | 65 | return null; 66 | }; 67 | 68 | /** 69 | * Gets series metadata. Performs API lookups if we 70 | * don't already have it. 71 | * 72 | * @param [imdbID] the IMDb ID of the series 73 | * @param [title] the title of the series 74 | * @param [language] the language of the query. 75 | * @param [year] the first year of the series 76 | * @param [titleToCache] the original title, used for caching if this method is calling itself 77 | * @returns series metadata 78 | */ 79 | export const getSeriesMetadata = async( 80 | imdbID?: string, 81 | title?: string, 82 | language?: string, 83 | year?: string, 84 | titleToCache?: string, 85 | ): Promise & { _id: Types.ObjectId; } | null> => { 86 | if (!imdbID && !title) { 87 | throw new Error('Either IMDb ID or title required'); 88 | } 89 | let searchMatch: string; 90 | let parsedTitle: string; 91 | if (title) { 92 | // Extract the series name from the incoming string (usually not necessary) 93 | const parsed = episodeParser(title); 94 | parsedTitle = parsed?.show ? parsed.show : title; 95 | searchMatch = language ? language + '@' + parsedTitle : parsedTitle; 96 | } 97 | let failedLookupQuery: FailedLookupsInterface; 98 | let tmdbData: Partial = {}; 99 | let yearNumber = null; 100 | if (year) { 101 | yearNumber = Number(year); 102 | } 103 | 104 | if (imdbID) { 105 | failedLookupQuery = { imdbID }; 106 | // We shouldn't have failures since we got this IMDb ID from their API 107 | if (await FailedLookups.findOne(failedLookupQuery, '_id', { lean: true }).exec()) { 108 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }).exec(); 109 | return null; 110 | } 111 | 112 | const existingSeries = await SeriesMetadata.findOne({ imdbID }, null, { lean: true }).exec(); 113 | if (existingSeries) { 114 | return existingSeries; 115 | } 116 | 117 | // Start TMDB lookups 118 | const seriesID = await getSeriesTMDBIDFromTMDBAPI(imdbID); 119 | 120 | if (seriesID) { 121 | const seriesRequest = { 122 | append_to_response: 'images,external_ids,credits', 123 | id: seriesID, 124 | }; 125 | 126 | const tmdbResponse = await tmdb.tvInfo(seriesRequest); 127 | tmdbData = mapper.parseTMDBAPISeriesResponse(tmdbResponse); 128 | 129 | /* 130 | * We matched based on IMDb ID, not title, so we prevent the 131 | * later code from saving those potentially mismatched values. 132 | */ 133 | if (tmdbData && !_.isEmpty(tmdbData)) { 134 | titleToCache = null; 135 | searchMatch = null; 136 | } 137 | } 138 | // End TMDB lookups 139 | } else { 140 | const sortBy = {}; 141 | const titleQuery: GetSeriesFilter = { searchMatches: { $in: [searchMatch] } }; 142 | failedLookupQuery = { title: title, type: 'series' }; 143 | if (language) { 144 | failedLookupQuery.language = language; 145 | } 146 | if (year) { 147 | failedLookupQuery.startYear = year; 148 | titleQuery.startYear = year; 149 | } else { 150 | sortBy['startYear'] = 1; 151 | } 152 | 153 | // Return early for previously-failed lookups 154 | if (await FailedLookups.findOne(failedLookupQuery, '_id', { lean: true }).exec()) { 155 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }).exec(); 156 | 157 | // Also store a failed result for the title that the client sent 158 | if (titleToCache) { 159 | await FailedLookups.updateOne({ title: titleToCache, type: 'series' }, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 160 | } 161 | 162 | return null; 163 | } 164 | 165 | // Return any previous match 166 | const seriesMetadata = await SeriesMetadata.findOne(titleQuery, null, { lean: true }).sort(sortBy) 167 | .exec(); 168 | if (seriesMetadata) { 169 | // Also cache the result for the title that the client sent, if this is an automatic re-attempt with an appended year (see below) 170 | if (titleToCache) { 171 | return await SeriesMetadata.findOneAndUpdate( 172 | { _id: seriesMetadata._id }, 173 | { $addToSet: { searchMatches: titleToCache } }, 174 | { new: true, lean: true }, 175 | ).exec(); 176 | } 177 | 178 | return seriesMetadata; 179 | } 180 | 181 | // Start TMDB lookups 182 | const seriesTMDBID = await getSeriesTMDBIDFromTMDBAPI(null, parsedTitle, language, yearNumber); 183 | if (seriesTMDBID) { 184 | // See if we have an existing record for the now-known media. 185 | const existingResult = await SeriesMetadata.findOne({ tmdbID: seriesTMDBID }, null, { lean: true }).exec(); 186 | if (existingResult) { 187 | return await SeriesMetadata.findOneAndUpdate( 188 | { tmdbID: seriesTMDBID }, 189 | { $addToSet: { searchMatches: searchMatch } }, 190 | { new: true, lean: true }, 191 | ).exec(); 192 | } 193 | 194 | // We do not have an existing record for that series, get the full result from the TMDB API 195 | const seriesRequest = { 196 | append_to_response: 'images,external_ids,credits', 197 | id: seriesTMDBID, 198 | }; 199 | 200 | const tmdbResponse = await tmdb.tvInfo(seriesRequest); 201 | tmdbData = mapper.parseTMDBAPISeriesResponse(tmdbResponse); 202 | } 203 | // End TMDB lookups 204 | 205 | if (!tmdbData && year) { 206 | /** 207 | * If the client specified a year, it may have been incorrect because of 208 | * the way filename parsing works; the filename Galactica.1980.S01E01 might 209 | * be about a series called "Galactica 1980", or a series called "Galactica" 210 | * from 1980. 211 | * So, we attempt the lookup again with the year appended to the title. 212 | */ 213 | return getSeriesMetadata(null, parsedTitle + ' ' + year, language, parsedTitle); 214 | } 215 | } 216 | 217 | if (!tmdbData || _.isEmpty(tmdbData)) { 218 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 219 | 220 | // Also store a failed result for the title that the client sent 221 | if (titleToCache) { 222 | failedLookupQuery.title = titleToCache; 223 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 224 | } 225 | 226 | return null; 227 | } 228 | 229 | if (searchMatch) { 230 | tmdbData.searchMatches = [searchMatch]; 231 | } 232 | 233 | let response = await SeriesMetadata.create(tmdbData); 234 | 235 | // Cache the result for the title that the client sent 236 | if (titleToCache) { 237 | tmdbData.searchMatches = tmdbData.searchMatches || []; 238 | tmdbData.searchMatches.push(titleToCache); 239 | response = await SeriesMetadata.create(tmdbData); 240 | } 241 | 242 | return response; 243 | }; 244 | 245 | /** 246 | * Gets season metadata. 247 | * Performs API lookups if we don't already have it. 248 | * 249 | * @param [tmdbTvID] the TMDB ID of the series 250 | * @param [seasonNumber] the season number on the series 251 | * @returns season metadata 252 | */ 253 | export const getSeasonMetadata = async(tmdbTvID?: number, seasonNumber?: number): Promise | null> => { 254 | if (!tmdbTvID && !seasonNumber) { 255 | throw new Error('Either tmdbTvID or seasonNumber required'); 256 | } 257 | 258 | // Return early for previously-failed lookups 259 | const failedLookupQuery: FailedLookupsInterface = { tmdbID: tmdbTvID, season: String(seasonNumber), type: 'season' }; 260 | if (await FailedLookups.findOne(failedLookupQuery, '_id', { lean: true }).exec()) { 261 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }).exec(); 262 | return null; 263 | } 264 | 265 | // Return any previous match 266 | const seasonMetadata = await SeasonMetadata.findOne({ tmdbTvID, seasonNumber }, null, { lean: true }).exec(); 267 | if (seasonMetadata) { 268 | return seasonMetadata; 269 | } 270 | 271 | // Start TMDB lookups 272 | const seasonRequest = { 273 | append_to_response: 'images,external_ids,credits', 274 | id: tmdbTvID, 275 | season_number: seasonNumber, 276 | }; 277 | 278 | const tmdbResponse = await tmdb.seasonInfo(seasonRequest); 279 | if (tmdbResponse) { 280 | const metadata = mapper.parseTMDBAPISeasonResponse(tmdbResponse); 281 | metadata.tmdbTvID = tmdbTvID; 282 | return await SeasonMetadata.create(metadata); 283 | } else { 284 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 285 | } 286 | return null; 287 | }; 288 | 289 | /** 290 | * Gets collection metadata. 291 | * Performs API lookups if we don't already have it. 292 | * 293 | * @param [tmdbID] the TMDB ID of the collection 294 | * @returns collection metadata 295 | */ 296 | export const getCollectionMetadata = async(tmdbID?: number): Promise | null> => { 297 | if (!tmdbID) { 298 | throw new Error('tmdbID is required'); 299 | } 300 | 301 | // Return early for previously-failed lookups 302 | const failedLookupQuery: FailedLookupsInterface = { tmdbID: tmdbID, type: 'collection' }; 303 | if (await FailedLookups.findOne(failedLookupQuery, '_id', { lean: true }).exec()) { 304 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }).exec(); 305 | return null; 306 | } 307 | 308 | // Return any previous match 309 | const collectionMetadata = await CollectionMetadata.findOne({ tmdbID }, null, { lean: true }).exec(); 310 | if (collectionMetadata) { 311 | return collectionMetadata; 312 | } 313 | 314 | // Start TMDB lookups 315 | const collectionRequest = { 316 | append_to_response: 'images', 317 | id: tmdbID, 318 | }; 319 | 320 | const tmdbResponse = await tmdb.collectionInfo(collectionRequest); 321 | if (tmdbResponse) { 322 | const metadata = mapper.parseTMDBAPICollectionResponse(tmdbResponse); 323 | return await CollectionMetadata.create(metadata); 324 | } else { 325 | await FailedLookups.updateOne(failedLookupQuery, { $inc: { count: 1 } }, { upsert: true, setDefaultsOnInsert: true }).exec(); 326 | } 327 | return null; 328 | }; 329 | 330 | /** 331 | * Attempts a query to the TMDB API and standardizes the response 332 | * before returning. 333 | * 334 | * @param [movieOrSeriesTitle] the title of the movie or series 335 | * @param [language] the language of the query. 336 | * @param [movieOrEpisodeIMDbID] the IMDb ID of the movie or episode 337 | * @param [year] the year of first release 338 | * @param [seasonNumber] the season number if this is an episode 339 | * @param [episodeNumber] the episode number if this is an episode 340 | */ 341 | export const getFromTMDBAPI = async(movieOrSeriesTitle?: string, language?: string, movieOrEpisodeIMDbID?: string, year?: number, seasonNumber?: number, episodeNumbers?: number[]): Promise => { 342 | if (!movieOrSeriesTitle && !movieOrEpisodeIMDbID) { 343 | throw new Error('Either movieOrSeriesTitle or movieOrEpisodeIMDbID must be specified'); 344 | } 345 | // If the client specified episode number/s, this is episode/s 346 | const isExpectingTVEpisode = Boolean(episodeNumbers); 347 | const yearString = year ? year.toString() : null; 348 | 349 | let metadata; 350 | if (isExpectingTVEpisode) { 351 | const episodeIMDbID = movieOrEpisodeIMDbID; 352 | let seriesTMDBID: string | number; 353 | if (episodeIMDbID) { 354 | const findResult = await tmdb.find({ id: episodeIMDbID, external_source: ExternalId.ImdbId }); 355 | // Using any here to make up for missing interface, should submit fix 356 | if (findResult?.tv_episode_results && findResult?.tv_episode_results[0]) { 357 | const tvEpisodeResult = findResult.tv_episode_results[0] as SimpleEpisode; 358 | seriesTMDBID = tvEpisodeResult?.show_id; 359 | } 360 | } else { 361 | const seriesMetadata = await getSeriesMetadata(null, movieOrSeriesTitle, language, yearString); 362 | seriesTMDBID = seriesMetadata?.tmdbID; 363 | } 364 | 365 | if (!seriesTMDBID) { 366 | return null; 367 | } 368 | 369 | for (let i = 0; i < episodeNumbers.length; i++) { 370 | const episodeRequest: EpisodeRequest = { 371 | append_to_response: 'images,external_ids,credits', 372 | episode_number: episodeNumbers[i], 373 | id: seriesTMDBID, 374 | season_number: seasonNumber, 375 | }; 376 | 377 | /* 378 | * Parse the full episode data for the first episode, and 379 | * append the title for subsequent ones. 380 | */ 381 | const tmdbData: Episode = await tmdb.episodeInfo(episodeRequest); 382 | if (tmdbData) { 383 | if (i === 0) { 384 | metadata = mapper.parseTMDBAPIEpisodeResponse(tmdbData); 385 | metadata.tmdbTvID = seriesTMDBID; 386 | // get series IMDbID 387 | const tmdbSeriesData: TvExternalIdsResponse = await tmdb.tvExternalIds(seriesTMDBID); 388 | if (tmdbSeriesData?.imdb_id) { 389 | metadata.seriesIMDbID = tmdbSeriesData.imdb_id; 390 | } 391 | } else { 392 | metadata.title = metadata.title ? metadata.title + ' & ' + tmdbData.name : tmdbData.name; 393 | } 394 | } 395 | } 396 | } else { 397 | const movieIMDbID = movieOrEpisodeIMDbID; 398 | 399 | let movieTMDBID: string | number; 400 | if (movieIMDbID) { 401 | const findResult = await tmdb.find({ id: movieIMDbID, external_source: ExternalId.ImdbId }); 402 | // Using any here to make up for missing interface, should submit fix 403 | if (findResult?.movie_results && findResult?.movie_results[0]) { 404 | const movieResult = findResult.movie_results[0]; 405 | movieTMDBID = movieResult?.id; 406 | } 407 | } else { 408 | const tmdbQuery: SearchMovieRequest = { query: movieOrSeriesTitle }; 409 | if (year) { 410 | tmdbQuery.year = year; 411 | } 412 | if (language) { 413 | tmdbQuery.language = language; 414 | } 415 | const searchResults = await tmdb.searchMovie(tmdbQuery); 416 | if (searchResults?.results && searchResults.results[0] && searchResults.results[0].id) { 417 | movieTMDBID = searchResults.results[0].id; 418 | } 419 | } 420 | 421 | if (!movieTMDBID) { 422 | return null; 423 | } 424 | 425 | const tmdbData = await tmdb.movieInfo({ 426 | append_to_response: 'images,external_ids,credits', 427 | id: movieTMDBID, 428 | }); 429 | if (tmdbData) { 430 | metadata = mapper.parseTMDBAPIMovieResponse(tmdbData); 431 | } 432 | } 433 | 434 | return metadata; 435 | }; 436 | 437 | /** 438 | * Gets media localized metadata. 439 | * Performs TMDB API lookups and standardizes the response before returning. 440 | * 441 | * @param [language] the language. 442 | * @param [mediaType] the media type. 443 | * @param [imdbID] the IMDb ID of the media. 444 | * @param [tmdbId] the TMDB movie ID for movie, the TMDB tv ID for tv series, season, episode. 445 | * @param [season] the season if type is season or episode. 446 | * @param [episode] the episode if type is episode. 447 | * @returns series metadata 448 | */ 449 | export const getLocalizedMetadata = async(language?: string, mediaType?: string, imdbID?: string, tmdbID?: number, seasonNumber?: number, episodeNumber?: number): Promise | null> => { 450 | if (!language || !mediaType || !(imdbID || tmdbID)) { 451 | throw new ValidationError('Language, media type and either IMDb ID or TMDB Id are required'); 452 | } 453 | if (!language.match(/^[a-z]{2}(-[a-z]{2})?$/i)) { 454 | throw new ValidationError('Language must have a minimum length of 2 and follow the case-insensitive pattern: ([a-z]{2})-([a-z]{2})'); 455 | } 456 | if (mediaType === 'tv_season' && tmdbID && !seasonNumber) { 457 | throw new ValidationError('Season number is required for season media'); 458 | } 459 | if (mediaType === 'tv_episode' && tmdbID && !(seasonNumber && episodeNumber)) { 460 | throw new ValidationError('Episode number and season number are required for episode media'); 461 | } 462 | if (mediaType === 'collection' && !tmdbID) { 463 | throw new ValidationError('TMDB Id is required for collection media'); 464 | } 465 | if (mediaType !== 'movie' && mediaType !== 'tv' && mediaType !== 'tv_season' && mediaType !== 'tv_episode' && mediaType !== 'collection') { 466 | throw new ValidationError('Media type' + mediaType + ' is not valid'); 467 | } 468 | 469 | let metadata: Partial; 470 | 471 | if (!tmdbID && imdbID) { 472 | const identifyResult = await getTmdbIdFromIMDbID(imdbID, mediaType); 473 | if (identifyResult !== null) { 474 | mediaType = identifyResult.mediaType; 475 | tmdbID = identifyResult.tmdbID; 476 | seasonNumber = identifyResult.seasonNumber; 477 | episodeNumber = identifyResult.episodeNumber; 478 | // if we are here, that means the request was made without tmdbId. 479 | // update tmdbId as we do not saved it before. 480 | if (tmdbID) { 481 | if (mediaType === 'movie') { 482 | MediaMetadata.updateOne({ imdbID }, { tmdbID: tmdbID }).exec(); 483 | } else if (mediaType === 'tv_episode') { 484 | MediaMetadata.updateOne({ imdbID }, { tmdbTvID: tmdbID }).exec(); 485 | } 486 | } 487 | } 488 | } 489 | if (!tmdbID) { 490 | return null; 491 | } 492 | // Start TMDB lookups 493 | let tmdbData; 494 | switch (mediaType) { 495 | case 'collection': 496 | tmdbData = await tmdb.collectionInfo({ 497 | id: tmdbID, 498 | language: language, 499 | }); 500 | break; 501 | case 'movie': 502 | tmdbData = await tmdb.movieInfo({ 503 | id: tmdbID, 504 | language: language, 505 | append_to_response: 'external_ids', 506 | }); 507 | break; 508 | case 'tv': 509 | tmdbData = await tmdb.tvInfo({ 510 | id: tmdbID, 511 | language: language, 512 | append_to_response: 'external_ids', 513 | }); 514 | break; 515 | case 'tv_season': 516 | tmdbData = await tmdb.seasonInfo({ 517 | id: tmdbID, 518 | season_number: seasonNumber, 519 | language: language, 520 | append_to_response: 'external_ids', 521 | }); 522 | break; 523 | case 'tv_episode': 524 | tmdbData = await tmdb.episodeInfo({ 525 | id: tmdbID, 526 | season_number: seasonNumber, 527 | episode_number: episodeNumber, 528 | language: language, 529 | append_to_response: 'external_ids', 530 | }); 531 | break; 532 | default: 533 | return null; 534 | } 535 | if (tmdbData) { 536 | metadata = mapper.parseTMDBAPILocalizeResponse(tmdbData); 537 | } 538 | if (!metadata || _.isEmpty(metadata)) { 539 | return null; 540 | } 541 | //put back tmdb ID for tv show season and episode 542 | metadata.tmdbID = tmdbID; 543 | metadata.language = language; 544 | metadata.mediaType = mediaType; 545 | return await LocalizeMetadata.create(metadata); 546 | }; 547 | 548 | /** 549 | * Gets TMDB media identified from IMDb ID. 550 | * 551 | * @param [imdbID] the IMDb ID of the media. 552 | * @param [mediaType] the media type, if any. 553 | * @returns TMDB media identified 554 | */ 555 | export const getTmdbIdFromIMDbID = async(imdbID: string, mediaType?: string): Promise> | null => { 556 | mediaType = mediaType || ''; 557 | const findResult = await tmdb.find({ id: imdbID, external_source: ExternalId.ImdbId }); 558 | 559 | switch (mediaType) { 560 | case 'movie': 561 | if (findResult.movie_results && !_.isEmpty(findResult.movie_results)) { 562 | return mapper.parseTMDBAPIIdentifyResponse(findResult.movie_results[0]); 563 | } 564 | break; 565 | case 'tv': 566 | if (findResult.tv_results && !_.isEmpty(findResult.tv_results)) { 567 | return mapper.parseTMDBAPIIdentifyResponse(findResult.tv_results[0]); 568 | } 569 | break; 570 | case 'tv_season': 571 | // should never happen as IMDb do not store season 572 | if (findResult.tv_season_results && !_.isEmpty(findResult.tv_season_results)) { 573 | return mapper.parseTMDBAPIIdentifyTvChildsResponse(findResult.tv_season_results[0]); 574 | } 575 | break; 576 | case 'tv_episode': 577 | if (findResult.tv_episode_results && !_.isEmpty(findResult.tv_episode_results)) { 578 | return mapper.parseTMDBAPIIdentifyTvChildsResponse(findResult.tv_episode_results[0]); 579 | } 580 | break; 581 | default: 582 | // we don't know the type, let try to find it in order movie, tv, episode, season 583 | if (findResult.movie_results && !_.isEmpty(findResult.movie_results)) { 584 | return mapper.parseTMDBAPIIdentifyResponse(findResult.movie_results[0]); 585 | } else if (findResult.tv_results && !_.isEmpty(findResult.tv_results)) { 586 | return mapper.parseTMDBAPIIdentifyResponse(findResult.tv_results[0]); 587 | } else if (findResult.tv_episode_results && !_.isEmpty(findResult.tv_episode_results)) { 588 | return mapper.parseTMDBAPIIdentifyTvChildsResponse(findResult.tv_episode_results[0]); 589 | } else if (findResult.tv_season_results && !_.isEmpty(findResult.tv_season_results)) { 590 | return mapper.parseTMDBAPIIdentifyTvChildsResponse(findResult.tv_season_results[0]); 591 | } 592 | break; 593 | } 594 | return null; 595 | }; 596 | -------------------------------------------------------------------------------- /src/services/tmdb-api.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { MovieDb } from 'moviedb-promise'; 3 | import { CollectionInfoResponse, CollectionRequest, ConfigurationResponse, Episode, EpisodeRequest, FindRequest, FindResponse, IdAppendToResponseRequest, MovieResultsResponse, SearchMovieRequest, SearchTvRequest, ShowResponse, TvResultsResponse, TvSeasonRequest, TvSeasonResponse } from 'moviedb-promise/dist/request-types'; 4 | import { ExternalAPIError, RateLimitError } from '../helpers/customErrors'; 5 | 6 | if (process.env.NODE_ENV === 'production' && !process.env.TMDB_API_KEY) { 7 | throw new Error('TMDB_API_KEY not set'); 8 | } 9 | 10 | const apiKey = process.env.TMDB_API_KEY || 'foo'; 11 | const baseUrl = apiKey === 'foo' ? 'https://local.themoviedb.org/3/' : undefined; 12 | const originalModule = new MovieDb(apiKey, baseUrl); 13 | export const tmdb = _.cloneDeep(originalModule); 14 | 15 | const handleError = (err: Error): void => { 16 | const responseStatus = _.get(err, 'response.status'); 17 | let responseStatusString: string; 18 | if (responseStatus) { 19 | responseStatusString = String(responseStatus); 20 | } 21 | if (responseStatusString && /^5/.exec(responseStatusString)) { 22 | throw new ExternalAPIError('TMDB API is offline'); 23 | } else if (responseStatusString && /^429/.exec(responseStatusString)) { 24 | throw new RateLimitError(); 25 | } 26 | }; 27 | 28 | tmdb.collectionInfo = async(params?: CollectionRequest): Promise => { 29 | try { 30 | return await originalModule.collectionInfo(params); 31 | } catch (err) { 32 | handleError(err); 33 | } 34 | }; 35 | 36 | tmdb.configuration = async(): Promise => { 37 | try { 38 | return await originalModule.configuration(); 39 | } catch (err) { 40 | handleError(err); 41 | } 42 | }; 43 | 44 | tmdb.episodeInfo = async(params?: EpisodeRequest): Promise => { 45 | try { 46 | return await originalModule.episodeInfo(params); 47 | } catch (err) { 48 | handleError(err); 49 | } 50 | }; 51 | 52 | tmdb.find = async(params?: FindRequest): Promise => { 53 | try { 54 | return await originalModule.find(params); 55 | } catch (err) { 56 | handleError(err); 57 | } 58 | }; 59 | 60 | tmdb.searchMovie = async(params?: SearchMovieRequest): Promise => { 61 | try { 62 | return await originalModule.searchMovie(params); 63 | } catch (err) { 64 | handleError(err); 65 | } 66 | }; 67 | 68 | tmdb.searchTv = async(params?: SearchTvRequest): Promise => { 69 | try { 70 | return await originalModule.searchTv(params); 71 | } catch (err) { 72 | handleError(err); 73 | } 74 | }; 75 | 76 | tmdb.seasonInfo = async(params?: TvSeasonRequest): Promise => { 77 | try { 78 | return await originalModule.seasonInfo(params); 79 | } catch (err) { 80 | handleError(err); 81 | } 82 | }; 83 | 84 | tmdb.tvInfo = async(params: string | number | IdAppendToResponseRequest): Promise => { 85 | try { 86 | return await originalModule.tvInfo(params); 87 | } catch (err) { 88 | handleError(err); 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /src/utils/data-mapper.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { CollectionInfoResponse, CreditsResponse, EpisodeCreditsResponse, Genre } from 'moviedb-promise'; 3 | import * as objectMapper from 'object-mapper'; 4 | 5 | import { CollectionMetadataInterface } from '../models/CollectionMetadata'; 6 | import { LocalizeMetadataInterface } from '../models/LocalizeMetadata'; 7 | import { MediaMetadataInterface } from '../models/MediaMetadata'; 8 | import { SeasonMetadataInterface } from '../models/SeasonMetadata'; 9 | import { SeriesMetadataInterface } from '../models/SeriesMetadata'; 10 | 11 | const tmdbEpisodeMap = { 12 | 'air_date': [ 13 | { key: 'released' }, 14 | { 15 | key: 'year', 16 | transform: (releaseDate: string): string => { 17 | // Store the year part of the date 18 | return releaseDate ? releaseDate.substring(0, 4) : null; 19 | }, 20 | }, 21 | ], 22 | 'credits': [ 23 | { key: 'credits' }, 24 | { 25 | key: 'actors', 26 | transform: (credits: EpisodeCreditsResponse): string[] => { 27 | // populate the old "actors" array which came from OpenSubtitles 28 | if (!credits?.cast) { 29 | return []; 30 | } 31 | const actors = []; 32 | for (const castEntry of credits.cast) { 33 | actors.push(castEntry.name); 34 | if (actors.length > 4) { 35 | break; 36 | } 37 | } 38 | return actors; 39 | }, 40 | }, 41 | { 42 | key: 'directors', 43 | transform: (credits: EpisodeCreditsResponse): string[] => { 44 | // populate the old "directors" array which came from OpenSubtitles 45 | if (!credits?.crew) { 46 | return []; 47 | } 48 | 49 | const directors = []; 50 | for (const crewEntry of credits.crew) { 51 | if (crewEntry.job === 'Director' && crewEntry.department === 'Directing') { 52 | directors.push(crewEntry.name); 53 | } 54 | } 55 | return directors; 56 | }, 57 | }, 58 | ], 59 | 'episode_number': 'episode', 60 | 'external_ids.imdb_id': 'imdbID', 61 | 'external_ids': 'externalIDs', 62 | 'id': 'tmdbID', 63 | 'images': 'images', 64 | 'name': 'title', 65 | 'overview': 'plot', 66 | 'season_number': 'season', 67 | 'still_path': 'posterRelativePath', 68 | 'type': { 69 | key: 'type', 70 | transform: (): string => 'episode', 71 | }, 72 | }; 73 | 74 | const tmdbIdentifyMap = { 75 | 'id': 'tmdbID', 76 | 'media_type': 'mediaType', 77 | }; 78 | 79 | const tmdbIdentifyTvChildsMap = { 80 | 'episode_number': 'episodeNumber', 81 | 'media_type': 'mediaType', 82 | 'season_number': 'seasonNumber', 83 | 'show_id': 'tmdbID', 84 | }; 85 | 86 | const tmdbLocalizeMap = { 87 | 'episode_number': 'episodeNumber', 88 | 'homepage': 'homepage', 89 | 'id': 'tmdbID', 90 | 'external_ids.imdb_id': 'imdbID', 91 | 'name': 'title', 92 | 'overview': 'overview', 93 | 'poster_path': 'posterRelativePath', 94 | 'season_number': 'seasonNumber', 95 | 'tagline': 'tagline', 96 | 'title': 'title', 97 | }; 98 | 99 | const tmdbSeasonMap = { 100 | 'air_date': 'airDate', 101 | 'credits': 'credits', 102 | 'external_ids': 'externalIDs', 103 | 'id': 'tmdbID', 104 | 'images': 'images', 105 | 'name': 'name', 106 | 'overview': 'overview', 107 | 'poster_path': 'posterRelativePath', 108 | 'season_number': 'seasonNumber', 109 | }; 110 | 111 | const tmdbSeriesMap = { 112 | 'created_by': 'createdBy', 113 | 'credits': [ 114 | { key: 'credits' }, 115 | { 116 | key: 'actors', 117 | transform: (credits: CreditsResponse): string[] => { 118 | // populate the old "actors" array which came from OpenSubtitles 119 | if (!credits?.cast) { 120 | return []; 121 | } 122 | const actors = []; 123 | for (const castEntry of credits.cast) { 124 | actors.push(castEntry.name); 125 | if (actors.length > 4) { 126 | break; 127 | } 128 | } 129 | return actors; 130 | }, 131 | }, 132 | { 133 | key: 'directors', 134 | transform: (credits: CreditsResponse): string[] => { 135 | // populate the old "directors" array which came from OpenSubtitles 136 | if (!credits?.crew) { 137 | return []; 138 | } 139 | 140 | const directors = []; 141 | for (const crewEntry of credits.crew) { 142 | if (crewEntry.job === 'Director' && crewEntry.department === 'Directing') { 143 | directors.push(crewEntry.name); 144 | } 145 | } 146 | return directors; 147 | }, 148 | }, 149 | ], 150 | 'external_ids.imdb_id': 'imdbID', 151 | 'external_ids': 'externalIDs', 152 | 'first_air_date': [ 153 | { key: 'released' }, 154 | { 155 | key: 'year', 156 | transform: (releaseDate: string): string => { 157 | // Store the year part of the date 158 | return releaseDate ? releaseDate.substring(0, 4) : null; 159 | }, 160 | }, 161 | { key: 'startYear' }, 162 | ], 163 | 'genres': { 164 | key: 'genres?', 165 | transform: (genres: Array): Array => { 166 | return genres.map(genre => genre.name); 167 | }, 168 | }, 169 | 'homepage': 'homepage', 170 | 'id': 'tmdbID', 171 | 'images': 'images', 172 | 'in_production': 'inProduction', 173 | 'languages': 'languages', 174 | 'last_air_date': 'lastAirDate', 175 | 'name': 'title', 176 | 'networks': 'networks', 177 | 'number_of_episodes': 'numberOfEpisodes', 178 | 'number_of_seasons': 'totalSeasons', 179 | 'origin_country': 'originCountry', 180 | 'original_language': 'originalLanguage', 181 | 'original_title': 'originalTitle', 182 | 'overview': 'plot', 183 | 'poster_path': 'posterRelativePath', 184 | 'production_companies': 'productionCompanies', 185 | 'production_countries': 'productionCountries', 186 | 'seasons': 'seasons', 187 | 'spoken_languages': 'spokenLanguages', 188 | 'status': 'status', 189 | 'tagline': 'tagline', 190 | 'type': [ 191 | { key: 'seriesType' }, // tmdb uses "type" to mean the type of series 192 | { 193 | key: 'type', 194 | transform: (): string => 'series', 195 | }, 196 | ], 197 | }; 198 | 199 | const tmdbCollectionMap = { 200 | 'id': 'tmdbID', 201 | 'images': 'images', 202 | 'name': 'name', 203 | 'overview': 'overview', 204 | 'poster_path': 'posterRelativePath', 205 | 'parts': { 206 | key: 'movieTmdbIds?', 207 | transform: (parts: Array<{id?: number}>): Array => { 208 | return parts.map(part => part.id); 209 | }, 210 | }, 211 | }; 212 | 213 | const tmdbMovieMap = { 214 | 'belongs_to_collection.id': 'collectionTmdbID', 215 | 'budget': 'budget', 216 | 'credits': [ 217 | { key: 'credits' }, 218 | { 219 | key: 'actors', 220 | transform: (credits: CreditsResponse): string[] => { 221 | // populate the old "actors" array which came from OpenSubtitles 222 | if (!credits?.cast) { 223 | return []; 224 | } 225 | const actors = []; 226 | for (const castEntry of credits.cast) { 227 | actors.push(castEntry.name); 228 | if (actors.length > 4) { 229 | break; 230 | } 231 | } 232 | return actors; 233 | }, 234 | }, 235 | { 236 | key: 'directors', 237 | transform: (credits: CreditsResponse): string[] => { 238 | // populate the old "directors" array which came from OpenSubtitles 239 | if (!credits?.crew) { 240 | return []; 241 | } 242 | 243 | const directors = []; 244 | for (const crewEntry of credits.crew) { 245 | if (crewEntry.job === 'Director' && crewEntry.department === 'Directing') { 246 | directors.push(crewEntry.name); 247 | } 248 | } 249 | return directors; 250 | }, 251 | }, 252 | ], 253 | 'external_ids': 'externalIDs', 254 | 'genres': { 255 | key: 'genres?', 256 | transform: (genres: Array): Array => { 257 | return genres.map(genre => genre.name); 258 | }, 259 | }, 260 | 'id': 'tmdbID', 261 | 'images': 'images', 262 | 'imdb_id': 'imdbID', 263 | 'original_language': 'originalLanguage', 264 | 'original_title': 'originalTitle', 265 | 'overview': 'plot', 266 | 'poster_path': 'posterRelativePath', 267 | 'production_companies': 'productionCompanies', 268 | 'release_date': [ 269 | { key: 'released' }, 270 | { 271 | key: 'year', 272 | transform: (releaseDate: string): string => { 273 | // Store the year part of the date 274 | return releaseDate ? releaseDate.substring(0, 4) : null; 275 | }, 276 | }, 277 | ], 278 | 'revenue': 'revenue', 279 | 'runtime': 'runtime', 280 | 'spoken_languages': 'spokenLanguages', 281 | 'tagline': 'tagline', 282 | 'title': 'title', 283 | 'type': { 284 | key: 'type', 285 | transform: (): string => 'movie', 286 | }, 287 | }; 288 | 289 | const filterUnwantedValues = (obj): Partial => { 290 | return _.pickBy(obj, (v) => { 291 | if (typeof v === 'object') { 292 | return _.pull(v, 'N/A'); 293 | } 294 | return v !== 'N/A' && v !== 'NaN' && v !== undefined && v !== null; 295 | }); 296 | }; 297 | 298 | const filterUnwantedLocalizeValues = (obj): Partial => { 299 | return _.pickBy(obj, (v) => { 300 | if (typeof v === 'object') { 301 | return _.pull(v, 'N/A'); 302 | } 303 | return v !== 'N/A' && v !== 'NaN' && v !== undefined && v !== null; 304 | }); 305 | }; 306 | 307 | class UmsDataMapper { 308 | parseTMDBAPIEpisodeResponse(tmdbData): Partial { 309 | const mappedData = objectMapper.merge(tmdbData, tmdbEpisodeMap); 310 | return filterUnwantedValues(mappedData); 311 | } 312 | 313 | parseTMDBAPISeasonResponse(tmdbData): Partial { 314 | const mappedData = objectMapper.merge(tmdbData, tmdbSeasonMap); 315 | return filterUnwantedValues(mappedData); 316 | } 317 | 318 | parseTMDBAPISeriesResponse(tmdbData): Partial { 319 | const mappedData = objectMapper.merge(tmdbData, tmdbSeriesMap); 320 | return filterUnwantedValues(mappedData); 321 | } 322 | 323 | parseTMDBAPICollectionResponse(tmdbData: CollectionInfoResponse): Partial { 324 | const mappedData = objectMapper.merge(tmdbData, tmdbCollectionMap); 325 | return filterUnwantedValues(mappedData); 326 | } 327 | 328 | parseTMDBAPIMovieResponse(tmdbData): Partial { 329 | const mappedData = objectMapper.merge(tmdbData, tmdbMovieMap); 330 | return filterUnwantedValues(mappedData); 331 | } 332 | 333 | parseTMDBAPIIdentifyResponse(tmdbData): Partial { 334 | const mappedData = objectMapper.merge(tmdbData, tmdbIdentifyMap); 335 | return filterUnwantedLocalizeValues(mappedData); 336 | } 337 | 338 | parseTMDBAPIIdentifyTvChildsResponse(tmdbData): Partial { 339 | const mappedData = objectMapper.merge(tmdbData, tmdbIdentifyTvChildsMap); 340 | return filterUnwantedLocalizeValues(mappedData); 341 | } 342 | 343 | parseTMDBAPILocalizeResponse(tmdbData): Partial { 344 | const mappedData = objectMapper.merge(tmdbData, tmdbLocalizeMap); 345 | return filterUnwantedLocalizeValues(mappedData); 346 | } 347 | } 348 | 349 | export const mapper = new UmsDataMapper(); 350 | -------------------------------------------------------------------------------- /test/e2e/index.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import mongoose from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import * as stoppable from 'stoppable'; 5 | 6 | import app, { PORT } from '../../src/app'; 7 | import TMDBConfiguration, { TMDBConfigurationInterface } from '../../src/models/TMDBConfiguration'; 8 | import { tmdb } from '../../src/services/tmdb-api'; 9 | 10 | interface UmsApiAxiosResponse { 11 | status: number; 12 | data?: object; 13 | headers?: object; 14 | } 15 | 16 | interface UmsApiConfigurationAxiosResponse { 17 | status: number; 18 | data: TMDBConfigurationInterface; 19 | headers?: object; 20 | } 21 | 22 | const appUrl = 'http://localhost:3000'; 23 | let server: stoppable; 24 | let mongod: MongoMemoryServer; 25 | 26 | describe('Info endpoint', () => { 27 | beforeAll((done) => { 28 | MongoMemoryServer.create() 29 | .then((value) => { 30 | mongod = value; 31 | const mongoUrl = mongod.getUri(); 32 | process.env.MONGO_URL = mongoUrl; 33 | return mongoose.connect(mongoUrl); 34 | }) 35 | .then(() => { 36 | mongoose.set('strictQuery', true); 37 | server = app.listen(PORT, () => { 38 | stoppable(server, 0); 39 | done(); 40 | }); 41 | }); 42 | }); 43 | 44 | beforeEach(async() => { 45 | await TMDBConfiguration.deleteMany({}); 46 | }); 47 | 48 | afterAll(async() => { 49 | server.stop(); 50 | await mongoose.connection.dropDatabase(); 51 | }); 52 | 53 | describe('root endpoint', () => { 54 | it('should return OK status', async() => { 55 | const response = await axios.get(`${appUrl}`) as UmsApiAxiosResponse; 56 | expect(response.data).toHaveProperty('status', 'OK'); 57 | }); 58 | }); 59 | 60 | describe('API configuration endpoint', () => { 61 | it('should return configuration and store it', async() => { 62 | const spyGetFromTmdb = jest.spyOn(tmdb, 'configuration'); 63 | const response = await axios.get(`${appUrl}/api/configuration`) as UmsApiConfigurationAxiosResponse; 64 | expect(response.data).toHaveProperty('imageBaseURL'); 65 | expect(response.data.imageBaseURL).toContain('https://image.tmdb.org/'); 66 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(1); 67 | }); 68 | 69 | it('should return configuration from in-memory on multiple call', async() => { 70 | const spyGetFromTmdb = jest.spyOn(tmdb, 'configuration'); 71 | const response = await axios.get(`${appUrl}/api/configuration`) as UmsApiConfigurationAxiosResponse; 72 | expect(response.data).toHaveProperty('imageBaseURL'); 73 | expect(response.data.imageBaseURL).toContain('https://image.tmdb.org/'); 74 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(0); 75 | }); 76 | }); 77 | 78 | describe('API subversions endpoint', () => { 79 | it('should return subversions', async() => { 80 | const response = await axios.get(`${appUrl}/api/subversions`) as UmsApiAxiosResponse; 81 | expect(response.data).toHaveProperty('collection'); 82 | expect(response.data).toHaveProperty('configuration'); 83 | expect(response.data).toHaveProperty('localize'); 84 | expect(response.data).toHaveProperty('season'); 85 | expect(response.data).toHaveProperty('series'); 86 | expect(response.data).toHaveProperty('video'); 87 | }); 88 | }); 89 | 90 | }); 91 | -------------------------------------------------------------------------------- /test/e2e/media-collection.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import mongoose from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import * as stoppable from 'stoppable'; 5 | 6 | import app, { PORT } from '../../src/app'; 7 | import CollectionMetadata, { CollectionMetadataInterface } from '../../src/models/CollectionMetadata'; 8 | import FailedLookups from '../../src/models/FailedLookups'; 9 | import * as apihelper from '../../src/services/external-api-helper'; 10 | import { tmdb } from '../../src/services/tmdb-api'; 11 | 12 | interface UmsApiCollectionAxiosResponse { 13 | status: number; 14 | data: CollectionMetadataInterface; 15 | headers?: object; 16 | } 17 | 18 | const appUrl = 'http://localhost:3000'; 19 | let server : stoppable; 20 | let mongod: MongoMemoryServer; 21 | 22 | const COLLECTION_BLADE_RUNNER = { 23 | mediaType: 'collection', 24 | name: 'Blade Runner Collection', 25 | overview: 'An American neo-noir science-fiction film series', 26 | tmdbID:422837, 27 | movieTmdbIds:[78, 335984], 28 | }; 29 | 30 | describe('Collection Metadata endpoint', () => { 31 | beforeAll((done) => { 32 | MongoMemoryServer.create() 33 | .then((value) => { 34 | mongod = value; 35 | const mongoUrl = mongod.getUri(); 36 | process.env.MONGO_URL = mongoUrl; 37 | return mongoose.connect(mongoUrl); 38 | }) 39 | .then(() => { 40 | mongoose.set('strictQuery', true); 41 | server = app.listen(PORT, () => { 42 | stoppable(server, 0); 43 | done(); 44 | }); 45 | }); 46 | }); 47 | 48 | beforeEach(async() => { 49 | await FailedLookups.deleteMany({}); 50 | await CollectionMetadata.deleteMany({}); 51 | }); 52 | 53 | afterAll(async() => { 54 | server.stop(); 55 | await mongoose.connection.dropDatabase(); 56 | }); 57 | 58 | describe('get collection metadata', () => { 59 | it('should return collection metadata by TMDb ID', async() => { 60 | const response = await axios.get(`${appUrl}/api/media/collection?tmdbID=${COLLECTION_BLADE_RUNNER.tmdbID}`) as UmsApiCollectionAxiosResponse; 61 | expect(response.data.tmdbID).toBe(COLLECTION_BLADE_RUNNER.tmdbID); 62 | expect(response.data.name).toBe(COLLECTION_BLADE_RUNNER.name); 63 | expect(response.data.overview).toContain(COLLECTION_BLADE_RUNNER.overview); 64 | expect(response.data.movieTmdbIds).toStrictEqual(COLLECTION_BLADE_RUNNER.movieTmdbIds); 65 | }); 66 | it('should return stored collection metadata on subsequent calls', async() => { 67 | const spyGetFromApi = jest.spyOn(apihelper, 'getCollectionMetadata'); 68 | const spyGetFromTmdb = jest.spyOn(tmdb, 'collectionInfo'); 69 | let response = await axios.get(`${appUrl}/api/media/collection?tmdbID=${COLLECTION_BLADE_RUNNER.tmdbID}`) as UmsApiCollectionAxiosResponse; 70 | expect(response.data.name).toBe(COLLECTION_BLADE_RUNNER.name); 71 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 72 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(1); 73 | spyGetFromApi.mockClear(); 74 | spyGetFromTmdb.mockClear(); 75 | 76 | // subsequent calls should return MongoDB result rather than calling external apis 77 | response = await axios.get(`${appUrl}/api/media/collection?tmdbID=${COLLECTION_BLADE_RUNNER.tmdbID}`) as UmsApiCollectionAxiosResponse; 78 | expect(response.data.name).toBe(COLLECTION_BLADE_RUNNER.name); 79 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 80 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(0); 81 | }); 82 | }); 83 | 84 | describe('Indexes', () => { 85 | test('Indexes should succeed and log to console', async() => { 86 | console.info = jest.fn(); 87 | await CollectionMetadata.ensureIndexes(); 88 | expect(console.info).toHaveBeenCalledWith('CollectionMetadata indexing complete'); 89 | }); 90 | test('should show error messages to console on fail', async() => { 91 | console.error = jest.fn(); 92 | CollectionMetadata.emit('index', 'jest errored'); 93 | expect(console.error).toHaveBeenCalledWith('CollectionMetadata index error: %s', 'jest errored'); 94 | }); 95 | }); 96 | 97 | describe('Failures', () => { 98 | test('should find a failed lookup collection then store', async() => { 99 | expect(await FailedLookups.countDocuments()).toEqual(0); 100 | const spyGetFromApi = jest.spyOn(apihelper, 'getCollectionMetadata'); 101 | const spyGetFromTmdb = jest.spyOn(tmdb, 'collectionInfo'); 102 | let error; 103 | try { 104 | await axios.get(`${appUrl}/api/media/collection?tmdbID=15`); 105 | } catch (e) { 106 | error = e; 107 | } 108 | expect(error.message).toEqual('Request failed with status code 404'); 109 | expect(await FailedLookups.countDocuments()).toEqual(1); 110 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 111 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(1); 112 | spyGetFromApi.mockClear(); 113 | spyGetFromTmdb.mockClear(); 114 | 115 | try { 116 | await axios.get(`${appUrl}/api/media/collection?tmdbID=15`); 117 | } catch (e) { 118 | error = e; 119 | } 120 | expect(error.message).toEqual('Request failed with status code 404'); 121 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 122 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(0); 123 | }); 124 | }); 125 | 126 | describe('Validation', () => { 127 | test('should require tmdbID param', async() => { 128 | let error; 129 | try { 130 | await axios.get(`${appUrl}/api/media/collection`); 131 | } catch (e) { 132 | error = e; 133 | } 134 | expect(error.message).toEqual('Request failed with status code 422'); 135 | try { 136 | await axios.get(`${appUrl}/api/media/collection?imdbID=15`); 137 | } catch (e) { 138 | error = e; 139 | } 140 | expect(error.message).toEqual('Request failed with status code 422'); 141 | }); 142 | }); 143 | 144 | }); 145 | -------------------------------------------------------------------------------- /test/e2e/media-localize.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import mongoose from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import * as stoppable from 'stoppable'; 5 | 6 | import app, { PORT } from '../../src/app'; 7 | import FailedLookups from '../../src/models/FailedLookups'; 8 | import LocalizeMetadata, { LocalizeMetadataInterface } from '../../src/models/LocalizeMetadata'; 9 | import * as apihelper from '../../src/services/external-api-helper'; 10 | 11 | interface UmsApiLocalizeAxiosResponse { 12 | status: number; 13 | data: LocalizeMetadataInterface; 14 | headers?: object; 15 | } 16 | 17 | const appUrl = 'http://localhost:3000'; 18 | let server: stoppable; 19 | let mongod: MongoMemoryServer; 20 | 21 | const MOVIE_BLADE_RUNNER_FRENCH = { 22 | language: 'fr', 23 | imdbID: 'tt1856101', 24 | mediaType: 'movie', 25 | title: 'Blade Runner 2049', 26 | overview: 'les nombreuses tensions entre les humains', 27 | tagline: 'La clé de l’avenir est enfin découverte.', 28 | tmdbID:335984, 29 | }; 30 | 31 | const COLLECTION_BLADE_RUNNER_FRENCH = { 32 | language: 'fr', 33 | mediaType: 'collection', 34 | title: 'Blade Runner - Saga', 35 | overview: 'Une série de films de science-fiction', 36 | tmdbID:422837, 37 | }; 38 | 39 | const SERIES_AVATAR_FRENCH = { 40 | language: 'fr', 41 | imdbID: 'tt0417299', 42 | title: 'Avatar : Le Dernier Maître de l\'air', 43 | mediaType: 'tv', 44 | overview: 'Ang est un jeune Maître de l', 45 | tagline: 'L\'eau. La terre. Le feu. L\'air.', 46 | tmdbID: 246, 47 | }; 48 | 49 | const SEASON_AVATAR_FRENCH = { 50 | language: 'fr', 51 | title: 'Livre 3 - Le feu', 52 | mediaType: 'tv_season', 53 | overview: 'grosses surprises attendent Aang', 54 | seasonNumber: 3, 55 | tmdbID: 246, 56 | }; 57 | 58 | const EPISODE_AVATAR_FRENCH = { 59 | language: 'fr', 60 | imdbID: 'tt1176477', 61 | title: 'Le rocher bouillant (1)', 62 | mediaType: 'tv_episode', 63 | overview: 'Sokka questionne Zuko sur', 64 | seasonNumber: 3, 65 | tmdbID: 246, 66 | episodeNumber: 14, 67 | }; 68 | 69 | const EPISODE_AVATAR_PORTUGUESE_BRAZILIAN = { 70 | language: 'pt-br', 71 | imdbID: 'tt1176477', 72 | title: 'A Rocha Fervente (Parte 1)', 73 | mediaType: 'tv_episode', 74 | overview: 'Sokka e Zuko vão à prisão mais vigiada da Nação do Fogo, a Rocha Fervente, com a esperança de encontrar e fazer a força invasora escapar.', 75 | seasonNumber: 3, 76 | tmdbID: 246, 77 | episodeNumber: 14, 78 | }; 79 | 80 | describe('Localize Metadata endpoint', () => { 81 | beforeAll((done) => { 82 | MongoMemoryServer.create() 83 | .then((value) => { 84 | mongod = value; 85 | const mongoUrl = mongod.getUri(); 86 | process.env.MONGO_URL = mongoUrl; 87 | return mongoose.connect(mongoUrl); 88 | }) 89 | .then(() => { 90 | mongoose.set('strictQuery', true); 91 | server = app.listen(PORT, () => { 92 | stoppable(server, 0); 93 | done(); 94 | }); 95 | }); 96 | }); 97 | 98 | beforeEach(async() => { 99 | await FailedLookups.deleteMany({}); 100 | await LocalizeMetadata.deleteMany({}); 101 | }); 102 | 103 | afterAll(async() => { 104 | server.stop(); 105 | await mongoose.connection.dropDatabase(); 106 | }); 107 | 108 | describe('get movie localized metadata', () => { 109 | it('should return movie metadata localized by IMDb ID', async() => { 110 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 111 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 112 | let response = await axios.get(`${appUrl}/api/media/localize?language=${MOVIE_BLADE_RUNNER_FRENCH.language}&mediaType=${MOVIE_BLADE_RUNNER_FRENCH.mediaType}&imdbID=${MOVIE_BLADE_RUNNER_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 113 | 114 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 115 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(1); 116 | expect(response.data.imdbID).toBe(MOVIE_BLADE_RUNNER_FRENCH.imdbID); 117 | expect(response.data.tmdbID).toBe(MOVIE_BLADE_RUNNER_FRENCH.tmdbID); 118 | expect(response.data.title).toBe(MOVIE_BLADE_RUNNER_FRENCH.title); 119 | expect(response.data.tagline).toBe(MOVIE_BLADE_RUNNER_FRENCH.tagline); 120 | expect(response.data.overview).toContain(MOVIE_BLADE_RUNNER_FRENCH.overview); 121 | spyGetFromApi.mockReset(); 122 | spyIdentifyTmdb.mockReset(); 123 | 124 | // subsequent calls should return MongoDB result rather than calling external apis 125 | response = await axios.get(`${appUrl}/api/media/localize?language=${MOVIE_BLADE_RUNNER_FRENCH.language}&mediaType=${MOVIE_BLADE_RUNNER_FRENCH.mediaType}&imdbID=${MOVIE_BLADE_RUNNER_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 126 | expect(response.data.title).toEqual(MOVIE_BLADE_RUNNER_FRENCH.title); 127 | expect(response.data.imdbID).toBe(MOVIE_BLADE_RUNNER_FRENCH.imdbID); 128 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 129 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 130 | spyGetFromApi.mockReset(); 131 | spyIdentifyTmdb.mockReset(); 132 | 133 | // call with TMDB ID should return MongoDB result rather than calling external apis 134 | response = await axios.get(`${appUrl}/api/media/localize?language=${MOVIE_BLADE_RUNNER_FRENCH.language}&mediaType=${MOVIE_BLADE_RUNNER_FRENCH.mediaType}&tmdbID=${MOVIE_BLADE_RUNNER_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 135 | expect(response.data.title).toEqual(MOVIE_BLADE_RUNNER_FRENCH.title); 136 | expect(response.data.imdbID).toBe(MOVIE_BLADE_RUNNER_FRENCH.imdbID); 137 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 138 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 139 | }); 140 | 141 | it('should return movie metadata localized by TMDB ID', async() => { 142 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 143 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 144 | let response = await axios.get(`${appUrl}/api/media/localize?language=${MOVIE_BLADE_RUNNER_FRENCH.language}&mediaType=${MOVIE_BLADE_RUNNER_FRENCH.mediaType}&tmdbID=${MOVIE_BLADE_RUNNER_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 145 | 146 | expect(response.data.imdbID).toBe(MOVIE_BLADE_RUNNER_FRENCH.imdbID); 147 | expect(response.data.tmdbID).toBe(MOVIE_BLADE_RUNNER_FRENCH.tmdbID); 148 | expect(response.data.title).toBe(MOVIE_BLADE_RUNNER_FRENCH.title); 149 | expect(response.data.tagline).toBe(MOVIE_BLADE_RUNNER_FRENCH.tagline); 150 | expect(response.data.overview).toContain(MOVIE_BLADE_RUNNER_FRENCH.overview); 151 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 152 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 153 | spyGetFromApi.mockReset(); 154 | spyIdentifyTmdb.mockReset(); 155 | 156 | // call with IMDB ID should return MongoDB result rather than calling external apis 157 | response = await axios.get(`${appUrl}/api/media/localize?language=${MOVIE_BLADE_RUNNER_FRENCH.language}&mediaType=${MOVIE_BLADE_RUNNER_FRENCH.mediaType}&imdbID=${MOVIE_BLADE_RUNNER_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 158 | expect(response.data.title).toEqual(MOVIE_BLADE_RUNNER_FRENCH.title); 159 | expect(response.data.imdbID).toBe(MOVIE_BLADE_RUNNER_FRENCH.imdbID); 160 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 161 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 162 | }); 163 | 164 | }); 165 | 166 | describe('get collection localized metadata', () => { 167 | it('should return collection metadata localized by TMDB ID', async() => { 168 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 169 | let response = await axios.get(`${appUrl}/api/media/localize?language=${COLLECTION_BLADE_RUNNER_FRENCH.language}&mediaType=${COLLECTION_BLADE_RUNNER_FRENCH.mediaType}&tmdbID=${COLLECTION_BLADE_RUNNER_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 170 | expect(response.data.tmdbID).toBe(COLLECTION_BLADE_RUNNER_FRENCH.tmdbID); 171 | expect(response.data.title).toBe(COLLECTION_BLADE_RUNNER_FRENCH.title); 172 | expect(response.data.overview).toContain(COLLECTION_BLADE_RUNNER_FRENCH.overview); 173 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 174 | spyGetFromApi.mockReset(); 175 | 176 | // subsequent calls should return MongoDB result rather than calling external apis 177 | response = await axios.get(`${appUrl}/api/media/localize?language=${COLLECTION_BLADE_RUNNER_FRENCH.language}&mediaType=${COLLECTION_BLADE_RUNNER_FRENCH.mediaType}&tmdbID=${COLLECTION_BLADE_RUNNER_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 178 | expect(response.data.tmdbID).toBe(COLLECTION_BLADE_RUNNER_FRENCH.tmdbID); 179 | expect(response.data.title).toBe(COLLECTION_BLADE_RUNNER_FRENCH.title); 180 | expect(response.data.overview).toContain(COLLECTION_BLADE_RUNNER_FRENCH.overview); 181 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 182 | spyGetFromApi.mockReset(); 183 | }); 184 | }); 185 | 186 | describe('get tv series localized metadata', () => { 187 | it('should return tv series metadata localized by IMDb ID', async() => { 188 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 189 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 190 | let response = await axios.get(`${appUrl}/api/media/localize?language=${SERIES_AVATAR_FRENCH.language}&mediaType=${SERIES_AVATAR_FRENCH.mediaType}&imdbID=${SERIES_AVATAR_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 191 | expect(response.data.imdbID).toBe(SERIES_AVATAR_FRENCH.imdbID); 192 | expect(response.data.tmdbID).toBe(SERIES_AVATAR_FRENCH.tmdbID); 193 | expect(response.data.title).toBe(SERIES_AVATAR_FRENCH.title); 194 | expect(response.data.tagline).toBe(SERIES_AVATAR_FRENCH.tagline); 195 | expect(response.data.overview).toContain(SERIES_AVATAR_FRENCH.overview); 196 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 197 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(1); 198 | spyGetFromApi.mockReset(); 199 | spyIdentifyTmdb.mockReset(); 200 | 201 | // subsequent calls should return MongoDB result rather than calling external apis 202 | response = await axios.get(`${appUrl}/api/media/localize?language=${SERIES_AVATAR_FRENCH.language}&mediaType=${SERIES_AVATAR_FRENCH.mediaType}&imdbID=${SERIES_AVATAR_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 203 | expect(response.data.tmdbID).toBe(SERIES_AVATAR_FRENCH.tmdbID); 204 | expect(response.data.title).toBe(SERIES_AVATAR_FRENCH.title); 205 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 206 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 207 | spyGetFromApi.mockReset(); 208 | spyIdentifyTmdb.mockReset(); 209 | 210 | // call with TMDB ID should return MongoDB result rather than calling external apis 211 | response = await axios.get(`${appUrl}/api/media/localize?language=${SERIES_AVATAR_FRENCH.language}&mediaType=${SERIES_AVATAR_FRENCH.mediaType}&tmdbID=${SERIES_AVATAR_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 212 | expect(response.data.tmdbID).toBe(SERIES_AVATAR_FRENCH.tmdbID); 213 | expect(response.data.title).toBe(SERIES_AVATAR_FRENCH.title); 214 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 215 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 216 | }); 217 | 218 | it('should return tv series metadata localized by TMDB ID', async() => { 219 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 220 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 221 | let response = await axios.get(`${appUrl}/api/media/localize?language=${SERIES_AVATAR_FRENCH.language}&mediaType=${SERIES_AVATAR_FRENCH.mediaType}&tmdbID=${SERIES_AVATAR_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 222 | expect(response.data.imdbID).toBe(SERIES_AVATAR_FRENCH.imdbID); 223 | expect(response.data.tmdbID).toBe(SERIES_AVATAR_FRENCH.tmdbID); 224 | expect(response.data.title).toBe(SERIES_AVATAR_FRENCH.title); 225 | expect(response.data.tagline).toBe(SERIES_AVATAR_FRENCH.tagline); 226 | expect(response.data.overview).toContain(SERIES_AVATAR_FRENCH.overview); 227 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 228 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 229 | spyGetFromApi.mockReset(); 230 | spyIdentifyTmdb.mockReset(); 231 | 232 | // call with IMDB ID should return MongoDB result rather than calling external apis 233 | response = await axios.get(`${appUrl}/api/media/localize?language=${SERIES_AVATAR_FRENCH.language}&mediaType=${SERIES_AVATAR_FRENCH.mediaType}&imdbID=${SERIES_AVATAR_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 234 | expect(response.data.tmdbID).toBe(SERIES_AVATAR_FRENCH.tmdbID); 235 | expect(response.data.title).toBe(SERIES_AVATAR_FRENCH.title); 236 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 237 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 238 | }); 239 | }); 240 | 241 | describe('get tv season localized metadata', () => { 242 | it('should return tv season metadata localized by TMDB ID', async() => { 243 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 244 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 245 | let response = await axios.get(`${appUrl}/api/media/localize?language=${SEASON_AVATAR_FRENCH.language}&mediaType=${SEASON_AVATAR_FRENCH.mediaType}&tmdbID=${SEASON_AVATAR_FRENCH.tmdbID}&season=${SEASON_AVATAR_FRENCH.seasonNumber}`) as UmsApiLocalizeAxiosResponse; 246 | expect(response.data.tmdbID).toBe(SEASON_AVATAR_FRENCH.tmdbID); 247 | expect(response.data.seasonNumber).toBe(SEASON_AVATAR_FRENCH.seasonNumber); 248 | expect(response.data.title).toBe(SEASON_AVATAR_FRENCH.title); 249 | expect(response.data.overview).toContain(SEASON_AVATAR_FRENCH.overview); 250 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 251 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 252 | spyGetFromApi.mockReset(); 253 | spyIdentifyTmdb.mockReset(); 254 | 255 | // subsequent calls should return MongoDB result rather than calling external apis 256 | response = await axios.get(`${appUrl}/api/media/localize?language=${SEASON_AVATAR_FRENCH.language}&mediaType=${SEASON_AVATAR_FRENCH.mediaType}&tmdbID=${SEASON_AVATAR_FRENCH.tmdbID}&season=${SEASON_AVATAR_FRENCH.seasonNumber}`) as UmsApiLocalizeAxiosResponse; 257 | expect(response.data.title).toBe(SEASON_AVATAR_FRENCH.title); 258 | expect(response.data.tmdbID).toBe(SEASON_AVATAR_FRENCH.tmdbID); 259 | expect(response.data.seasonNumber).toBe(SEASON_AVATAR_FRENCH.seasonNumber); 260 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 261 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 262 | spyGetFromApi.mockReset(); 263 | spyIdentifyTmdb.mockReset(); 264 | }); 265 | }); 266 | 267 | describe('get tv episode localized metadata', () => { 268 | it('should return tv episode metadata localized by IMDb ID', async() => { 269 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 270 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 271 | let response = await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&imdbID=${EPISODE_AVATAR_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 272 | expect(response.data.imdbID).toBe(EPISODE_AVATAR_FRENCH.imdbID); 273 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_FRENCH.tmdbID); 274 | expect(response.data.seasonNumber).toBe(EPISODE_AVATAR_FRENCH.seasonNumber); 275 | expect(response.data.episodeNumber).toBe(EPISODE_AVATAR_FRENCH.episodeNumber); 276 | expect(response.data.title).toBe(EPISODE_AVATAR_FRENCH.title); 277 | expect(response.data.overview).toContain(EPISODE_AVATAR_FRENCH.overview); 278 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 279 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(1); 280 | spyGetFromApi.mockReset(); 281 | spyIdentifyTmdb.mockReset(); 282 | 283 | // subsequent calls should return MongoDB result rather than calling external apis 284 | response = await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&imdbID=${EPISODE_AVATAR_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 285 | expect(response.data.title).toBe(EPISODE_AVATAR_FRENCH.title); 286 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_FRENCH.tmdbID); 287 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 288 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 289 | spyGetFromApi.mockReset(); 290 | spyIdentifyTmdb.mockReset(); 291 | 292 | // call with TMDB ID should return MongoDB result rather than calling external apis 293 | response = await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&tmdbID=${EPISODE_AVATAR_FRENCH.tmdbID}&season=${EPISODE_AVATAR_FRENCH.seasonNumber}&episode=${EPISODE_AVATAR_FRENCH.episodeNumber}`) as UmsApiLocalizeAxiosResponse; 294 | expect(response.data.title).toBe(EPISODE_AVATAR_FRENCH.title); 295 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_FRENCH.tmdbID); 296 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 297 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 298 | }); 299 | 300 | it('should return tv episode metadata localized by TMDB ID', async() => { 301 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 302 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 303 | let response = await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&tmdbID=${EPISODE_AVATAR_FRENCH.tmdbID}&season=${EPISODE_AVATAR_FRENCH.seasonNumber}&episode=${EPISODE_AVATAR_FRENCH.episodeNumber}`) as UmsApiLocalizeAxiosResponse; 304 | expect(response.data.imdbID).toBe(EPISODE_AVATAR_FRENCH.imdbID); 305 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_FRENCH.tmdbID); 306 | expect(response.data.seasonNumber).toBe(EPISODE_AVATAR_FRENCH.seasonNumber); 307 | expect(response.data.episodeNumber).toBe(EPISODE_AVATAR_FRENCH.episodeNumber); 308 | expect(response.data.title).toBe(EPISODE_AVATAR_FRENCH.title); 309 | expect(response.data.overview).toContain(EPISODE_AVATAR_FRENCH.overview); 310 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 311 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 312 | spyGetFromApi.mockReset(); 313 | spyIdentifyTmdb.mockReset(); 314 | 315 | // call with IMDB ID should return MongoDB result rather than calling external apis 316 | response = await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&imdbID=${EPISODE_AVATAR_FRENCH.imdbID}`) as UmsApiLocalizeAxiosResponse; 317 | expect(response.data.title).toBe(EPISODE_AVATAR_FRENCH.title); 318 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_FRENCH.tmdbID); 319 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 320 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 321 | }); 322 | 323 | it('should return tv episode metadata localized by TMDB ID with lowercase locale-specific language', async() => { 324 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 325 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 326 | let response = await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.language}&mediaType=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.mediaType}&tmdbID=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.tmdbID}&season=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.seasonNumber}&episode=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.episodeNumber}`) as UmsApiLocalizeAxiosResponse; 327 | expect(response.data.imdbID).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.imdbID); 328 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.tmdbID); 329 | expect(response.data.seasonNumber).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.seasonNumber); 330 | expect(response.data.episodeNumber).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.episodeNumber); 331 | expect(response.data.title).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.title); 332 | expect(response.data.overview).toContain(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.overview); 333 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 334 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 335 | spyGetFromApi.mockReset(); 336 | spyIdentifyTmdb.mockReset(); 337 | 338 | // call with IMDB ID should return MongoDB result rather than calling external apis 339 | response = await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.language}&mediaType=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.mediaType}&imdbID=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.imdbID}`) as UmsApiLocalizeAxiosResponse; 340 | expect(response.data.title).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.title); 341 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.tmdbID); 342 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 343 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 344 | }); 345 | 346 | it('should return tv episode metadata localized by TMDB ID with mixed-case locale-specific language', async() => { 347 | const spyGetFromApi = jest.spyOn(apihelper, 'getLocalizedMetadata'); 348 | const spyIdentifyTmdb = jest.spyOn(apihelper, 'getTmdbIdFromIMDbID'); 349 | let response = await axios.get(`${appUrl}/api/media/localize?language=pt-BR&mediaType=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.mediaType}&tmdbID=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.tmdbID}&season=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.seasonNumber}&episode=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.episodeNumber}`) as UmsApiLocalizeAxiosResponse; 350 | expect(response.data.imdbID).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.imdbID); 351 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.tmdbID); 352 | expect(response.data.seasonNumber).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.seasonNumber); 353 | expect(response.data.episodeNumber).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.episodeNumber); 354 | expect(response.data.title).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.title); 355 | expect(response.data.overview).toContain(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.overview); 356 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 357 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 358 | spyGetFromApi.mockReset(); 359 | spyIdentifyTmdb.mockReset(); 360 | 361 | // call with IMDB ID should return MongoDB result rather than calling external apis 362 | response = await axios.get(`${appUrl}/api/media/localize?language=pt-BR&mediaType=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.mediaType}&imdbID=${EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.imdbID}`) as UmsApiLocalizeAxiosResponse; 363 | expect(response.data.title).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.title); 364 | expect(response.data.tmdbID).toBe(EPISODE_AVATAR_PORTUGUESE_BRAZILIAN.tmdbID); 365 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 366 | expect(spyIdentifyTmdb).toHaveBeenCalledTimes(0); 367 | }); 368 | }); 369 | 370 | describe('Indexes', () => { 371 | test('Indexes should succeed and log to console', async() => { 372 | console.info = jest.fn(); 373 | await LocalizeMetadata.ensureIndexes(); 374 | expect(console.info).toHaveBeenCalledWith('LocalizeMetadata indexing complete'); 375 | }); 376 | 377 | test('should show error messages to console on fail', async() => { 378 | console.error = jest.fn(); 379 | LocalizeMetadata.emit('index', 'jest errored'); 380 | expect(console.error).toHaveBeenCalledWith('LocalizeMetadata index error: %s', 'jest errored'); 381 | }); 382 | }); 383 | 384 | describe('Validation', () => { 385 | test('should require language, media type and either IMDb ID or TMDB Id type param', async() => { 386 | let error; 387 | 388 | //no param 389 | try { 390 | await axios.get(`${appUrl}/api/media/localize`); 391 | } catch (e) { 392 | error = e; 393 | } 394 | expect(error.message).toEqual('Request failed with status code 422'); 395 | 396 | //no language 397 | try { 398 | await axios.get(`${appUrl}/api/media/localize?mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&imdbID=${EPISODE_AVATAR_FRENCH.imdbID}`); 399 | } catch (e) { 400 | error = e; 401 | } 402 | expect(error.message).toEqual('Request failed with status code 422'); 403 | 404 | //no mediaType 405 | try { 406 | await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&imdbID=${EPISODE_AVATAR_FRENCH.imdbID}`); 407 | } catch (e) { 408 | error = e; 409 | } 410 | expect(error.message).toEqual('Request failed with status code 422'); 411 | 412 | //no imdbID or tmdbID 413 | try { 414 | await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}`); 415 | } catch (e) { 416 | error = e; 417 | } 418 | expect(error.message).toEqual('Request failed with status code 422'); 419 | }); 420 | 421 | test('should require well formatted language', async() => { 422 | let error; 423 | 424 | //bad language 425 | const badLanguages = ['french', 'f', 'fr-fr']; 426 | for (let i = 0; i < badLanguages.length; i++) { 427 | try { 428 | await axios.get(`${appUrl}/api/media/localize?language=${badLanguages[i]}&mediaType=${MOVIE_BLADE_RUNNER_FRENCH.mediaType}&tmdbID=${MOVIE_BLADE_RUNNER_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 429 | } catch (e) { 430 | error = e; 431 | } 432 | expect(error.message).toEqual('Request failed with status code 422'); 433 | } 434 | }); 435 | 436 | test('should require a known media type', async() => { 437 | let error; 438 | 439 | //bad media type 440 | const badMediaTypes = ['test', 'movies']; 441 | for (let i = 0; i < badMediaTypes.length; i++) { 442 | try { 443 | await axios.get(`${appUrl}/api/media/localize?language=${SEASON_AVATAR_FRENCH.language}&mediaType=${badMediaTypes[i]}&tmdbID=${MOVIE_BLADE_RUNNER_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 444 | } catch (e) { 445 | error = e; 446 | } 447 | expect(error.message).toEqual('Request failed with status code 422'); 448 | } 449 | }); 450 | 451 | test('should require season number param for season media with TMDB Id', async() => { 452 | let error; 453 | 454 | //no saison number 455 | try { 456 | await axios.get(`${appUrl}/api/media/localize?language=${SEASON_AVATAR_FRENCH.language}&mediaType=${SEASON_AVATAR_FRENCH.mediaType}&tmdbID=${SEASON_AVATAR_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 457 | } catch (e) { 458 | error = e; 459 | } 460 | expect(error.message).toEqual('Request failed with status code 422'); 461 | 462 | //bad saison number 463 | try { 464 | await axios.get(`${appUrl}/api/media/localize?language=${SEASON_AVATAR_FRENCH.language}&mediaType=${SEASON_AVATAR_FRENCH.mediaType}&tmdbID=${SEASON_AVATAR_FRENCH.tmdbID}&season=notanumber`) as UmsApiLocalizeAxiosResponse; 465 | } catch (e) { 466 | error = e; 467 | } 468 | expect(error.message).toEqual('Request failed with status code 422'); 469 | }); 470 | 471 | test('should require season and episode number params for episode media with TMDB Id', async() => { 472 | let error; 473 | 474 | //no season number 475 | try { 476 | await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&tmdbID=${EPISODE_AVATAR_FRENCH.tmdbID}&season=${EPISODE_AVATAR_FRENCH.seasonNumber}`) as UmsApiLocalizeAxiosResponse; 477 | } catch (e) { 478 | error = e; 479 | } 480 | expect(error.message).toEqual('Request failed with status code 422'); 481 | 482 | //no episode number 483 | try { 484 | await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&tmdbID=${EPISODE_AVATAR_FRENCH.tmdbID}&episode=${EPISODE_AVATAR_FRENCH.episodeNumber}`) as UmsApiLocalizeAxiosResponse; 485 | } catch (e) { 486 | error = e; 487 | } 488 | expect(error.message).toEqual('Request failed with status code 422'); 489 | 490 | //bad season number 491 | try { 492 | await axios.get(`${appUrl}/api/media/localize?language=${EPISODE_AVATAR_FRENCH.language}&mediaType=${EPISODE_AVATAR_FRENCH.mediaType}&tmdbID=${EPISODE_AVATAR_FRENCH.tmdbID}&season=notanumber&episode=${EPISODE_AVATAR_FRENCH.episodeNumber}`) as UmsApiLocalizeAxiosResponse; 493 | } catch (e) { 494 | error = e; 495 | } 496 | expect(error.message).toEqual('Request failed with status code 422'); 497 | }); 498 | 499 | test('should require TMDB Id params for collection media', async() => { 500 | let error; 501 | 502 | //no tmdbID 503 | try { 504 | await axios.get(`${appUrl}/api/media/localize?language=${COLLECTION_BLADE_RUNNER_FRENCH.language}&mediaType=${COLLECTION_BLADE_RUNNER_FRENCH.mediaType}&imdbID=${EPISODE_AVATAR_FRENCH.tmdbID}`) as UmsApiLocalizeAxiosResponse; 505 | } catch (e) { 506 | error = e; 507 | } 508 | expect(error.message).toEqual('Request failed with status code 422'); 509 | }); 510 | 511 | }); 512 | 513 | }); 514 | -------------------------------------------------------------------------------- /test/e2e/media-season.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import mongoose from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import * as stoppable from 'stoppable'; 5 | 6 | import app, { PORT } from '../../src/app'; 7 | import FailedLookups from '../../src/models/FailedLookups'; 8 | import SeasonMetadata, { SeasonMetadataInterface } from '../../src/models/SeasonMetadata'; 9 | import * as apihelper from '../../src/services/external-api-helper'; 10 | import { tmdb } from '../../src/services/tmdb-api'; 11 | 12 | interface UmsApiSeasonAxiosResponse { 13 | status: number; 14 | data: SeasonMetadataInterface; 15 | headers?: object; 16 | } 17 | 18 | const appUrl = 'http://localhost:3000'; 19 | let server : stoppable; 20 | let mongod: MongoMemoryServer; 21 | 22 | const SEASON_AVATAR = { 23 | airDate: '2007-09-21', 24 | externalIDs: { 25 | 'freebase_mid': '/m/05dh3gn', 26 | 'freebase_id': null, 27 | 'tvdb_id': 16658, 28 | 'tvrage_id': null, 29 | 'wikidata_id': 'Q13517027', 30 | }, 31 | name: 'Book Three: Fire', 32 | overview: 'Aang wakes up from his battle with Azula to discover', 33 | seasonNumber: 3, 34 | seriesTitle: 'Avatar: The Last Airbender', 35 | tmdbID: 786, 36 | tmdbTvID: 246, 37 | year: 2005, 38 | }; 39 | 40 | describe('Season Metadata endpoint', () => { 41 | beforeAll((done) => { 42 | MongoMemoryServer.create() 43 | .then((value) => { 44 | mongod = value; 45 | const mongoUrl = mongod.getUri(); 46 | process.env.MONGO_URL = mongoUrl; 47 | return mongoose.connect(mongoUrl); 48 | }) 49 | .then(() => { 50 | mongoose.set('strictQuery', true); 51 | server = app.listen(PORT, () => { 52 | stoppable(server, 0); 53 | done(); 54 | }); 55 | }); 56 | }); 57 | 58 | beforeEach(async() => { 59 | await FailedLookups.deleteMany({}); 60 | await SeasonMetadata.deleteMany({}); 61 | }); 62 | 63 | afterAll(async() => { 64 | server.stop(); 65 | await mongoose.connection.dropDatabase(); 66 | }); 67 | 68 | describe('get season metadata', () => { 69 | it('should return season metadata by series title', async() => { 70 | const response = await axios.get(`${appUrl}/api/media/season?title=${SEASON_AVATAR.seriesTitle}&season=${SEASON_AVATAR.seasonNumber}&year=${SEASON_AVATAR.year}`) as UmsApiSeasonAxiosResponse; 71 | expect(response.data.airDate).toBe(SEASON_AVATAR.airDate); 72 | expect(response.data).toHaveProperty('credits'); 73 | expect(response.data).toHaveProperty('externalIDs'); 74 | expect(response.data.externalIDs).toHaveProperty('freebase_id', SEASON_AVATAR.externalIDs.freebase_id); 75 | expect(response.data.externalIDs).toHaveProperty('freebase_mid', SEASON_AVATAR.externalIDs.freebase_mid); 76 | expect(response.data.externalIDs).toHaveProperty('tvdb_id', SEASON_AVATAR.externalIDs.tvdb_id); 77 | expect(response.data.externalIDs).toHaveProperty('tvrage_id', SEASON_AVATAR.externalIDs.tvrage_id); 78 | expect(response.data.externalIDs).toHaveProperty('wikidata_id', SEASON_AVATAR.externalIDs.wikidata_id); 79 | expect(response.data).toHaveProperty('images'); 80 | expect(response.data.name).toBe(SEASON_AVATAR.name); 81 | expect(response.data.tmdbTvID).toBe(SEASON_AVATAR.tmdbTvID); 82 | expect(response.data.tmdbID).toBe(SEASON_AVATAR.tmdbID); 83 | expect(response.data.seasonNumber).toBe(SEASON_AVATAR.seasonNumber); 84 | expect(response.data.overview).toContain(SEASON_AVATAR.overview); 85 | }); 86 | it('should return season metadata by TMDb ID', async() => { 87 | const response = await axios.get(`${appUrl}/api/media/season?tmdbID=${SEASON_AVATAR.tmdbTvID}&season=${SEASON_AVATAR.seasonNumber}`) as UmsApiSeasonAxiosResponse; 88 | expect(response.data.airDate).toBe(SEASON_AVATAR.airDate); 89 | expect(response.data).toHaveProperty('credits'); 90 | expect(response.data).toHaveProperty('externalIDs'); 91 | expect(response.data).toHaveProperty('images'); 92 | expect(response.data.name).toBe(SEASON_AVATAR.name); 93 | expect(response.data.tmdbTvID).toBe(SEASON_AVATAR.tmdbTvID); 94 | expect(response.data.tmdbID).toBe(SEASON_AVATAR.tmdbID); 95 | expect(response.data.seasonNumber).toBe(SEASON_AVATAR.seasonNumber); 96 | expect(response.data.overview).toContain(SEASON_AVATAR.overview); 97 | }); 98 | it('should return stored season metadata on subsequent calls', async() => { 99 | const spyGetFromApi = jest.spyOn(apihelper, 'getSeasonMetadata'); 100 | const spyGetFromTmdb = jest.spyOn(tmdb, 'seasonInfo'); 101 | let response = await axios.get(`${appUrl}/api/media/season?tmdbID=${SEASON_AVATAR.tmdbTvID}&season=${SEASON_AVATAR.seasonNumber}`) as UmsApiSeasonAxiosResponse; 102 | expect(response.data.tmdbID).toBe(SEASON_AVATAR.tmdbID); 103 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 104 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(1); 105 | spyGetFromApi.mockClear(); 106 | spyGetFromTmdb.mockClear(); 107 | 108 | // subsequent calls should return MongoDB result rather than calling external apis 109 | response = await axios.get(`${appUrl}/api/media/season?tmdbID=${SEASON_AVATAR.tmdbTvID}&season=${SEASON_AVATAR.seasonNumber}`) as UmsApiSeasonAxiosResponse; 110 | expect(response.data.tmdbID).toBe(SEASON_AVATAR.tmdbID); 111 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 112 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(0); 113 | }); 114 | }); 115 | 116 | describe('Indexes', () => { 117 | test('Indexes should succeed and log to console', async() => { 118 | console.info = jest.fn(); 119 | await SeasonMetadata.ensureIndexes(); 120 | expect(console.info).toHaveBeenCalledWith('SeasonMetadata indexing complete'); 121 | }); 122 | test('should show error messages to console on fail', async() => { 123 | console.error = jest.fn(); 124 | SeasonMetadata.emit('index', 'jest errored'); 125 | expect(console.error).toHaveBeenCalledWith('SeasonMetadata index error: %s', 'jest errored'); 126 | }); 127 | }); 128 | 129 | describe('Failures', () => { 130 | test('should find a failed lookup season from TMDb ID then store', async() => { 131 | expect(await FailedLookups.countDocuments()).toEqual(0); 132 | const spyGetFromApi = jest.spyOn(apihelper, 'getSeasonMetadata'); 133 | const spyGetFromTmdb = jest.spyOn(tmdb, 'seasonInfo'); 134 | let error; 135 | try { 136 | await axios.get(`${appUrl}/api/media/season?tmdbID=${SEASON_AVATAR.tmdbTvID}&season=999`); 137 | } catch (e) { 138 | error = e; 139 | } 140 | expect(error.message).toEqual('Request failed with status code 404'); 141 | expect(await FailedLookups.countDocuments()).toEqual(1); 142 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 143 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(1); 144 | spyGetFromApi.mockClear(); 145 | spyGetFromTmdb.mockClear(); 146 | 147 | try { 148 | await axios.get(`${appUrl}/api/media/season?tmdbID=${SEASON_AVATAR.tmdbTvID}&season=999`); 149 | } catch (e) { 150 | error = e; 151 | } 152 | expect(error.message).toEqual('Request failed with status code 404'); 153 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 154 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(0); 155 | spyGetFromApi.mockClear(); 156 | spyGetFromTmdb.mockClear(); 157 | 158 | try { 159 | await axios.get(`${appUrl}/api/media/season?tmdbID=0&season=${SEASON_AVATAR.seasonNumber}`); 160 | } catch (e) { 161 | error = e; 162 | } 163 | expect(error.message).toEqual('Request failed with status code 404'); 164 | expect(await FailedLookups.countDocuments()).toEqual(2); 165 | expect(spyGetFromApi).toHaveBeenCalledTimes(1); 166 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(1); 167 | }); 168 | test('should find a failed lookup season from title then store', async() => { 169 | expect(await FailedLookups.countDocuments()).toEqual(0); 170 | const spyGetFromApi = jest.spyOn(apihelper, 'getSeasonMetadata'); 171 | const spyGetFromTmdb = jest.spyOn(tmdb, 'seasonInfo'); 172 | let error; 173 | try { 174 | await axios.get(`${appUrl}/api/media/season?title=Not A Series Type&season=${SEASON_AVATAR.seasonNumber}`); 175 | } catch (e) { 176 | error = e; 177 | } 178 | expect(error.message).toEqual('Request failed with status code 404'); 179 | expect(await FailedLookups.countDocuments()).toEqual(1); 180 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 181 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(0); 182 | spyGetFromApi.mockClear(); 183 | spyGetFromTmdb.mockClear(); 184 | 185 | try { 186 | await axios.get(`${appUrl}/api/media/season?title==Not A Series Type&season=${SEASON_AVATAR.seasonNumber}`); 187 | } catch (e) { 188 | error = e; 189 | } 190 | expect(error.message).toEqual('Request failed with status code 404'); 191 | expect(spyGetFromApi).toHaveBeenCalledTimes(0); 192 | expect(spyGetFromTmdb).toHaveBeenCalledTimes(0); 193 | }); 194 | }); 195 | 196 | describe('Validation', () => { 197 | test('should require tmdbID or title param', async() => { 198 | let error; 199 | try { 200 | await axios.get(`${appUrl}/api/media/season`); 201 | } catch (e) { 202 | error = e; 203 | } 204 | expect(error.message).toEqual('Request failed with status code 422'); 205 | try { 206 | await axios.get(`${appUrl}/api/media/season?imdbID=15`); 207 | } catch (e) { 208 | error = e; 209 | } 210 | expect(error.message).toEqual('Request failed with status code 422'); 211 | }); 212 | test('should require saison number param', async() => { 213 | let error; 214 | //season is missing 215 | try { 216 | await axios.get(`${appUrl}/api/media/season?tmdbID=${SEASON_AVATAR.tmdbTvID}`); 217 | } catch (e) { 218 | error = e; 219 | } 220 | //season is not a number 221 | expect(error.message).toEqual('Request failed with status code 422'); 222 | try { 223 | await axios.get(`${appUrl}/api/media/season?tmdbID=${SEASON_AVATAR.tmdbTvID}&season=notanumber`); 224 | } catch (e) { 225 | error = e; 226 | } 227 | expect(error.message).toEqual('Request failed with status code 422'); 228 | }); 229 | }); 230 | 231 | }); 232 | -------------------------------------------------------------------------------- /test/e2e/media-series.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import mongoose, { HydratedDocument } from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import * as stoppable from 'stoppable'; 5 | 6 | import app, { PORT } from '../../src/app'; 7 | import FailedLookups from '../../src/models/FailedLookups'; 8 | import SeriesMetadata, { SeriesMetadataInterface } from '../../src/models/SeriesMetadata'; 9 | import { tmdb } from '../../src/services/tmdb-api'; 10 | 11 | interface UmsApiSeriesAxiosResponse { 12 | status: number; 13 | data: HydratedDocument; 14 | headers?: object; 15 | } 16 | 17 | const appUrl = 'http://localhost:3000'; 18 | let server : stoppable; 19 | let mongod: MongoMemoryServer; 20 | 21 | const thirdRockFromTheSunSeries = { 22 | title: '3rd Rock from the Sun', 23 | imdbID: 'tt0115082', 24 | startYear: '1996', 25 | }; 26 | 27 | const americanHorrorStorySeries = { 28 | title: 'American Horror Story', 29 | imdbID: 'tt1844624', 30 | }; 31 | 32 | const fromSeries = { 33 | title: 'From', 34 | imdbID: 'tt9813792', 35 | startYear: '2022', 36 | }; 37 | 38 | describe('Media Metadata endpoints', () => { 39 | beforeAll((done) => { 40 | MongoMemoryServer.create() 41 | .then((value) => { 42 | mongod = value; 43 | const mongoUrl = mongod.getUri(); 44 | process.env.MONGO_URL = mongoUrl; 45 | return mongoose.connect(mongoUrl); 46 | }) 47 | .then(() => { 48 | mongoose.set('strictQuery', true); 49 | server = app.listen(PORT, () => { 50 | stoppable(server, 0); 51 | done(); 52 | }); 53 | }); 54 | }); 55 | 56 | beforeEach(async() => { 57 | await FailedLookups.deleteMany({}); 58 | await SeriesMetadata.deleteMany({}); 59 | }); 60 | 61 | afterAll(async() => { 62 | server.stop(); 63 | await mongoose.connection.dropDatabase(); 64 | }); 65 | 66 | describe('get series', () => { 67 | it('should return series metadata by title', async() => { 68 | // this request populates the series metadata 69 | let response = await axios.get(`${appUrl}/api/media/series/v2?title=Homeland S02E05`) as UmsApiSeriesAxiosResponse; 70 | const newDocumentId = response.data._id; 71 | expect(response.data.totalSeasons).toBe(8); 72 | expect(response.data.title).toBe('Homeland'); 73 | expect(response.data.startYear).toBe('2011'); 74 | 75 | response = await axios.get(`${appUrl}/api/media/series/v2?title=HoMelAnD `); 76 | expect(response.data._id).toEqual(newDocumentId); 77 | }); 78 | 79 | it('should return series metadata by IMDb ID', async() => { 80 | // This is the method that finds the TMDB ID from the IMDb ID 81 | const spy = jest.spyOn(tmdb, 'find'); 82 | 83 | const response = await axios.get(`${appUrl}/api/media/series/v2?title=${americanHorrorStorySeries.title}&imdbID=${americanHorrorStorySeries.imdbID}`) as UmsApiSeriesAxiosResponse; 84 | expect(response.data).toHaveProperty('credits'); 85 | expect(response.data).toHaveProperty('totalSeasons'); 86 | expect(response.data).toHaveProperty('title', americanHorrorStorySeries.title); 87 | expect(response.data).toHaveProperty('startYear', '2011'); 88 | expect(spy).toHaveBeenCalledTimes(1); 89 | }); 90 | 91 | it('should return series with exact name instead of partial match', async() => { 92 | // populate the cache 93 | let response = await axios.get(`${appUrl}/api/media/series/v2?title=${thirdRockFromTheSunSeries.title}`) as UmsApiSeriesAxiosResponse; 94 | expect(response.data).toHaveProperty('credits'); 95 | expect(response.data).toHaveProperty('totalSeasons'); 96 | expect(response.data).toHaveProperty('title', thirdRockFromTheSunSeries.title); 97 | expect(response.data).toHaveProperty('startYear', thirdRockFromTheSunSeries.startYear); 98 | 99 | response = await axios.get(`${appUrl}/api/media/series/v2?title=${fromSeries.title}`) as UmsApiSeriesAxiosResponse; 100 | expect(response.data).toHaveProperty('credits'); 101 | expect(response.data).toHaveProperty('totalSeasons'); 102 | expect(response.data).toHaveProperty('title', 'FROM'); 103 | expect(response.data).toHaveProperty('startYear', fromSeries.startYear); 104 | }); 105 | 106 | it('should fail to save non series type', async() => { 107 | expect(await SeriesMetadata.countDocuments()).toBe(0); 108 | let err; 109 | try { 110 | await axios.get(`${appUrl}/api/media/series/v2?title=Not A Series Type`) as UmsApiSeriesAxiosResponse; 111 | } catch (e) { 112 | err = e; 113 | } 114 | expect(err).toBeTruthy(); 115 | expect(await SeriesMetadata.countDocuments()).toBe(0); 116 | }); 117 | 118 | it('should return series with correct year', async() => { 119 | // this request populates the series metadata 120 | let response = await axios.get(`${appUrl}/api/media/series/v2?title=Ben 10&year=2016`) as UmsApiSeriesAxiosResponse; 121 | let newDocumentId = response.data._id; 122 | expect(response.data).toHaveProperty('title', 'Ben 10'); 123 | expect(response.data).toHaveProperty('startYear', '2016'); 124 | 125 | // and cached 126 | response = await axios.get(`${appUrl}/api/media/series/v2?title=Ben 10&year=2016`); 127 | expect(response.data._id).toEqual(newDocumentId); 128 | 129 | // now a different year 130 | response = await axios.get(`${appUrl}/api/media/series/v2?title=Ben 10&year=2005`); 131 | newDocumentId = response.data._id; 132 | expect(response.data).toHaveProperty('title', 'Ben 10'); 133 | expect(response.data).toHaveProperty('startYear', '2005'); 134 | 135 | // and cached 136 | response = await axios.get(`${appUrl}/api/media/series/v2?title=Ben 10&year=2005`); 137 | expect(response.data._id).toEqual(newDocumentId); 138 | 139 | // with no year, we should receive the earliest year 140 | response = await axios.get(`${appUrl}/api/media/series/v2?title=Ben 10`); 141 | expect(response.data._id).toEqual(newDocumentId); 142 | }); 143 | 144 | it('should return series with a year in the title', async() => { 145 | await mongoose.connection.db.collection('series_metadata').insertOne({ imdbID: 'tt0080221', title: 'Galactica 1980' }); 146 | 147 | // this request should find the result even though it's the wrong title 148 | const response = await axios.get(`${appUrl}/api/media/series/v2?title=Galactica&year=1980`) as UmsApiSeriesAxiosResponse; 149 | expect(response.data).toHaveProperty('title', 'Galactica 1980'); 150 | }); 151 | 152 | // this used to return a result but it was really a workaround for a client bug, it should not return 153 | it('should NOT return series when the year is when the episode aired, not the series start year', async() => { 154 | let error; 155 | try { 156 | await axios.get(`${appUrl}/api/media/series/v2?title=From&year=2023`) as UmsApiSeriesAxiosResponse; 157 | } catch (err) { 158 | error = err; 159 | } 160 | expect(error.message).toBe('Request failed with status code 404'); 161 | }); 162 | 163 | it('should not return series when the year has no overlap with episode air dates', async() => { 164 | // this test also makes sure the Jaro-Winkler comparison is correctly filtering results 165 | // because TMDB returns the series "Tokyo MPD – From ZERO to HERO" and "Sveta From the Other World" 166 | // and we discard them 167 | let error; 168 | try { 169 | await axios.get(`${appUrl}/api/media/series/v2?title=From&year=2021`) as UmsApiSeriesAxiosResponse; 170 | } catch (err) { 171 | error = err; 172 | } 173 | expect(error.message).toBe('Request failed with status code 404'); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /test/e2e/media-video.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import mongoose from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import * as stoppable from 'stoppable'; 5 | 6 | import app, { PORT } from '../../src/app'; 7 | import FailedLookupsModel from '../../src/models/FailedLookups'; 8 | import { MediaMetadataInterface } from '../../src/models/MediaMetadata'; 9 | import * as apihelper from '../../src/services/external-api-helper'; 10 | 11 | interface UmsApiMediaAxiosResponse { 12 | status: number; 13 | data: MediaMetadataInterface; 14 | headers?: object; 15 | } 16 | 17 | const appUrl = 'http://localhost:3000'; 18 | let server: stoppable; 19 | let mongod: MongoMemoryServer; 20 | 21 | const MOVIE_INTERSTELLAR = { 22 | 'imdbID': 'tt0816692', 23 | 'title': 'Interstellar', 24 | 'osdbHash': '0f0f4c9f3416e24f', 25 | 'filebytesize': '2431697820', 26 | 'plot': 'The adventures of a group of explorers who make use of a newly discovered wormhole to surpass the limitations on human space travel and conquer the vast distances involved in an interstellar voyage.' 27 | }; 28 | 29 | const MOVIE_BLADE_RUNNER = { 30 | imdbID: 'tt1856101', 31 | tmdbID:335984, 32 | collectionTmdbID:422837, 33 | }; 34 | 35 | const EPISODE_LOST = { 36 | 'imdbID': 'tt0994359', 37 | 'title': 'Confirmed Dead', 38 | 'season': '4', 39 | 'episode': '2', 40 | 'seriesTitle': 'Lost', 41 | 'seriesIMDbID': 'tt0411008', 42 | }; 43 | 44 | const EPISODE_PRISONBREAK = { 45 | 'osdbHash': '35acba68a9dcfc8f', 46 | 'filebytesize': '271224190', 47 | 'title': 'Prison Break', 48 | 'episodeTitle': 'Behind the Eyes', 49 | 'season': '5', 50 | 'episode': '9', 51 | 'imdbID': 'tt5538198', 52 | 'year': 2005, 53 | 'seriesIMDbID': 'tt0455275', 54 | }; 55 | 56 | const EPISODE_DOCTORWHO = { 57 | 'episodeTitle': 'Lucky Day', 58 | 'title': 'Doctor Who', 59 | 'season': '2', 60 | 'episode': '4', 61 | "seriesIMDbID": "tt31433814", 62 | 'year': 2024, 63 | }; 64 | 65 | const EPISODE_AVATAR = { 66 | 'episodeTitle': 'The Boiling Rock (1) & The Boiling Rock (2)', 67 | 'osdbHash': 'de334f38f153fb6f', 68 | 'filebytesize': '4695739425', 69 | 'season': '3', 70 | 'episode': '14-15', 71 | 'imdbID': 'tt1176477', 72 | 'year': 2005, 73 | 'seriesIMDbID': 'tt0417299', 74 | 'title': 'Avatar: The Last Airbender', 75 | }; 76 | 77 | describe('get by all', () => { 78 | beforeAll((done) => { 79 | MongoMemoryServer.create() 80 | .then((value) => { 81 | mongod = value; 82 | const mongoUrl = mongod.getUri(); 83 | process.env.MONGO_URL = mongoUrl; 84 | return mongoose.connect(mongoUrl); 85 | }) 86 | .then(() => { 87 | mongoose.set('strictQuery', true); 88 | server = app.listen(PORT, () => { 89 | stoppable(server, 0); 90 | done(); 91 | }); 92 | }); 93 | }); 94 | 95 | beforeEach(async() => { 96 | await mongoose.connection.dropDatabase(); 97 | }); 98 | 99 | afterAll(() => { 100 | if (server) { 101 | server.stop(); 102 | } 103 | }); 104 | 105 | describe('Movies', () => { 106 | test('should return a movie by imdbid, from source APIs then store', async() => { 107 | let response = await axios.get(`${appUrl}/api/media/video/v2?imdbID=${MOVIE_INTERSTELLAR.imdbID}`) as UmsApiMediaAxiosResponse; 108 | expect(response.headers['x-api-subversion']).toBeTruthy(); 109 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 110 | expect(response.data.type).toEqual('movie'); 111 | expect(response.data.searchMatches).toBeUndefined(); 112 | 113 | // subsequent calls should return MongoDB result rather than calling external apis 114 | response = await axios.get(`${appUrl}/api/media/video/v2?imdbID=${MOVIE_INTERSTELLAR.imdbID}`); 115 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 116 | expect(response.data.type).toEqual('movie'); 117 | }); 118 | 119 | test('should return a movie by title, from source APIs then store', async() => { 120 | const tmdbSpy = jest.spyOn(apihelper, 'getFromTMDBAPI'); 121 | let response = await axios.get(`${appUrl}/api/media/video/v2?title=${MOVIE_INTERSTELLAR.title}`) as UmsApiMediaAxiosResponse; 122 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 123 | expect(response.data.type).toEqual('movie'); 124 | 125 | // subsequent calls should return MongoDB result rather than calling external apis 126 | response = await axios.get(`${appUrl}/api/media/video/v2?title=${MOVIE_INTERSTELLAR.title}`); 127 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 128 | expect(response.data.type).toEqual('movie'); 129 | expect(tmdbSpy).toHaveBeenCalledTimes(1); 130 | 131 | /* 132 | * Should also return the result for a similar title search with the same IMDb ID 133 | * when the returned result from the external API matches an existing IMDb ID. 134 | */ 135 | response = await axios.get(`${appUrl}/api/media/video/v2?title=${MOVIE_INTERSTELLAR.title.toLowerCase()}`); 136 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 137 | expect(response.data.type).toEqual('movie'); 138 | expect(tmdbSpy).toHaveBeenCalledTimes(2); 139 | }); 140 | 141 | test('should return a movie by title AND imdbId from source APIs then store', async() => { 142 | let response = await axios.get(`${appUrl}/api/media/video/v2?title=${MOVIE_INTERSTELLAR.title}&imdbID=${MOVIE_INTERSTELLAR.imdbID}`) as UmsApiMediaAxiosResponse; 143 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 144 | expect(response.data.type).toEqual('movie'); 145 | 146 | // subsequent calls should return MongoDB result rather than calling external apis 147 | response = await axios.get(`${appUrl}/api/media/video/v2?title=${MOVIE_INTERSTELLAR.title}&imdbID=${MOVIE_INTERSTELLAR.imdbID}`); 148 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 149 | expect(response.data.type).toEqual('movie'); 150 | }); 151 | 152 | test('should return a movie by all possible params, from source APIs then store', async() => { 153 | let response = await axios.get(`${appUrl}/api/media/video/v2?osdbHash=${MOVIE_INTERSTELLAR.osdbHash}&filebytesize=${MOVIE_INTERSTELLAR.filebytesize}&title=${MOVIE_INTERSTELLAR.title}&imdbID=${MOVIE_INTERSTELLAR.imdbID}`) as UmsApiMediaAxiosResponse; 154 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 155 | expect(response.data.type).toEqual('movie'); 156 | 157 | // subsequent calls should return MongoDB result rather than calling external apis 158 | response = await axios.get(`${appUrl}/api/media/video/v2?osdbHash=${MOVIE_INTERSTELLAR.osdbHash}&filebytesize=${MOVIE_INTERSTELLAR.filebytesize}&title=${MOVIE_INTERSTELLAR.title}&imdbID=${MOVIE_INTERSTELLAR.imdbID}`); 159 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 160 | expect(response.data.type).toEqual('movie'); 161 | }); 162 | 163 | test('should return a movie (en-US) by title AND language, from source APIs then store', async() => { 164 | const tmdbSpy = jest.spyOn(apihelper, 'getFromTMDBAPI'); 165 | let response = await axios.get(`${appUrl}/api/media/video/v2?title=${MOVIE_INTERSTELLAR.title}&language=fr`) as UmsApiMediaAxiosResponse; 166 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 167 | expect(response.data.plot).toEqual(MOVIE_INTERSTELLAR.plot); 168 | expect(response.data.type).toEqual('movie'); 169 | expect(tmdbSpy).toHaveBeenCalledTimes(1); 170 | tmdbSpy.mockReset(); 171 | 172 | // subsequent calls should return MongoDB result rather than calling external apis 173 | response = await axios.get(`${appUrl}/api/media/video/v2?title=${MOVIE_INTERSTELLAR.title}&language=fr`) as UmsApiMediaAxiosResponse; 174 | expect(response.data.title).toEqual(MOVIE_INTERSTELLAR.title); 175 | expect(response.data.plot).toEqual(MOVIE_INTERSTELLAR.plot); 176 | expect(tmdbSpy).toHaveBeenCalledTimes(0); 177 | }); 178 | 179 | test('should return a movie with collection, from source APIs then store', async() => { 180 | const tmdbSpy = jest.spyOn(apihelper, 'getFromTMDBAPI'); 181 | let response = await axios.get(`${appUrl}/api/media/video/v2?imdbID=${MOVIE_BLADE_RUNNER.imdbID}`) as UmsApiMediaAxiosResponse; 182 | expect(response.data.tmdbID).toEqual(MOVIE_BLADE_RUNNER.tmdbID); 183 | expect(response.data.collectionTmdbID).toEqual(MOVIE_BLADE_RUNNER.collectionTmdbID); 184 | expect(response.data.type).toEqual('movie'); 185 | expect(tmdbSpy).toHaveBeenCalledTimes(1); 186 | tmdbSpy.mockReset(); 187 | 188 | // subsequent calls should return MongoDB result rather than calling external apis 189 | response = await axios.get(`${appUrl}/api/media/video/v2?imdbID=${MOVIE_BLADE_RUNNER.imdbID}`) as UmsApiMediaAxiosResponse; 190 | expect(response.data.tmdbID).toEqual(MOVIE_BLADE_RUNNER.tmdbID); 191 | expect(response.data.collectionTmdbID).toEqual(MOVIE_BLADE_RUNNER.collectionTmdbID); 192 | expect(tmdbSpy).toHaveBeenCalledTimes(0); 193 | }); 194 | 195 | }); 196 | 197 | describe('Episodes', () => { 198 | test('should return an episode by imdbid, from source APIs then store', async() => { 199 | let response = await axios.get(`${appUrl}/api/media/video/v2?imdbID=${EPISODE_LOST.imdbID}&season=${EPISODE_LOST.season}&episode=${EPISODE_LOST.episode}`) as UmsApiMediaAxiosResponse; 200 | expect(response.data.title).toEqual(EPISODE_LOST.title); 201 | expect(response.data.type).toEqual('episode'); 202 | 203 | // subsequent calls should return MongoDB result rather than calling external apis 204 | response = await axios.get(`${appUrl}/api/media/video/v2?imdbID=${EPISODE_LOST.imdbID}&season=${EPISODE_LOST.season}&episode=${EPISODE_LOST.episode}`); 205 | expect(response.data.title).toEqual(EPISODE_LOST.title); 206 | expect(response.data.type).toEqual('episode'); 207 | }); 208 | 209 | test('should return an episode by series title, from source APIs then store', async() => { 210 | let response = await axios.get(`${appUrl}/api/media/video/v2?title=${EPISODE_LOST.seriesTitle}&season=${EPISODE_LOST.season}&episode=${EPISODE_LOST.episode}`) as UmsApiMediaAxiosResponse; 211 | expect(response.data.title).toEqual(EPISODE_LOST.title); 212 | expect(response.data.type).toEqual('episode'); 213 | expect(response.data.imdbID).toEqual(EPISODE_LOST.imdbID); 214 | expect(response.data.seriesIMDbID).toEqual(EPISODE_LOST.seriesIMDbID); 215 | 216 | // subsequent calls should return MongoDB result rather than calling external apis 217 | response = await axios.get(`${appUrl}/api/media/video/v2?title=${EPISODE_LOST.seriesTitle}&season=${EPISODE_LOST.season}&episode=${EPISODE_LOST.episode}`); 218 | expect(response.data.title).toEqual(EPISODE_LOST.title); 219 | expect(response.data.type).toEqual('episode'); 220 | expect(response.data.seriesIMDbID).toEqual(EPISODE_LOST.seriesIMDbID); 221 | }); 222 | 223 | test('should return an episode by all possible params, from source APIs then store', async() => { 224 | const url = `${appUrl}/api/media/video/v2?`+ 225 | `osdbHash=${EPISODE_PRISONBREAK.osdbHash}`+ 226 | `&filebytesize=${EPISODE_PRISONBREAK.filebytesize}`+ 227 | `&title=${EPISODE_PRISONBREAK.title}`+ 228 | `&season=${EPISODE_PRISONBREAK.season}`+ 229 | `&episode=${EPISODE_PRISONBREAK.episode}`+ 230 | `&year=${EPISODE_PRISONBREAK.year}`; 231 | let response = await axios.get(url) as UmsApiMediaAxiosResponse; 232 | expect(response.data.title).toEqual(EPISODE_PRISONBREAK.episodeTitle); 233 | expect(response.data.type).toEqual('episode'); 234 | 235 | expect(response.data.seriesIMDbID).toEqual(EPISODE_PRISONBREAK.seriesIMDbID); 236 | 237 | // subsequent calls should return MongoDB result rather than calling external apis 238 | response = await axios.get(url); 239 | expect(response.data.title).toEqual(EPISODE_PRISONBREAK.episodeTitle); 240 | expect(response.data.type).toEqual('episode'); 241 | expect(response.data.seriesIMDbID).toEqual(EPISODE_PRISONBREAK.seriesIMDbID); 242 | }); 243 | 244 | test('should return the episode from the correct series, when multiple series exist with different years, from source APIs then store', async() => { 245 | const url = `${appUrl}/api/media/video/v2?`+ 246 | `title=${EPISODE_DOCTORWHO.title}`+ 247 | `&season=${EPISODE_DOCTORWHO.season}`+ 248 | `&episode=${EPISODE_DOCTORWHO.episode}`+ 249 | `&year=${EPISODE_DOCTORWHO.year}`; 250 | let response = await axios.get(url) as UmsApiMediaAxiosResponse; 251 | expect(response.data.title).toEqual(EPISODE_DOCTORWHO.episodeTitle); 252 | expect(response.data.type).toEqual('episode'); 253 | 254 | expect(response.data.seriesIMDbID).toEqual(EPISODE_DOCTORWHO.seriesIMDbID); 255 | 256 | // subsequent calls should return MongoDB result rather than calling external apis 257 | response = await axios.get(url); 258 | expect(response.data.title).toEqual(EPISODE_DOCTORWHO.episodeTitle); 259 | expect(response.data.type).toEqual('episode'); 260 | expect(response.data.seriesIMDbID).toEqual(EPISODE_DOCTORWHO.seriesIMDbID); 261 | }); 262 | 263 | test('should return two episodes when passed all possible params, from source APIs then store', async() => { 264 | const url = `${appUrl}/api/media/video/v2?`+ 265 | `osdbHash=${EPISODE_AVATAR.osdbHash}`+ 266 | `&filebytesize=${EPISODE_AVATAR.filebytesize}`+ 267 | `&title=${EPISODE_AVATAR.title}`+ 268 | `&season=${EPISODE_AVATAR.season}`+ 269 | `&episode=${EPISODE_AVATAR.episode}`+ 270 | `&year=${EPISODE_AVATAR.year}`; 271 | let response = await axios.get(url) as UmsApiMediaAxiosResponse; 272 | expect(response.data.title).toEqual(EPISODE_AVATAR.episodeTitle); 273 | expect(response.data.type).toEqual('episode'); 274 | expect(response.data.episode).toEqual(EPISODE_AVATAR.episode); 275 | 276 | expect(response.data.seriesIMDbID).toEqual(EPISODE_AVATAR.seriesIMDbID); 277 | 278 | // subsequent calls should return MongoDB result rather than calling external apis 279 | response = await axios.get(url); 280 | expect(response.data.title).toEqual(EPISODE_AVATAR.episodeTitle); 281 | expect(response.data.type).toEqual('episode'); 282 | expect(response.data.episode).toEqual(EPISODE_AVATAR.episode); 283 | expect(response.data.seriesIMDbID).toEqual(EPISODE_AVATAR.seriesIMDbID); 284 | }); 285 | }); 286 | 287 | describe('Failures', () => { 288 | test('should find a failed lookup - movie', async() => { 289 | expect(await FailedLookupsModel.countDocuments()).toEqual(0); 290 | let error; 291 | try { 292 | await axios.get(`${appUrl}/api/media/video/v2?title=areallylongtitlethatsurelywontmatchanymoviename`); 293 | } catch (e) { 294 | error = e; 295 | } 296 | expect(error.message).toEqual('Request failed with status code 404'); 297 | expect(await FailedLookupsModel.countDocuments()).toEqual(1); 298 | 299 | try { 300 | await axios.get(`${appUrl}/api/media/video/v2?title=areallylongtitlethatsurelywontmatchanymoviename`); 301 | } catch (e) { 302 | error = e; 303 | } 304 | expect(error.message).toEqual('Request failed with status code 404'); 305 | }); 306 | 307 | test('should find a failed lookup - episode', async() => { 308 | expect(await FailedLookupsModel.countDocuments()).toEqual(0); 309 | let error; 310 | try { 311 | await axios.get(`${appUrl}/api/media/video/v2?title=${EPISODE_LOST.seriesTitle}&season=999&episode=999`); 312 | } catch (e) { 313 | error = e; 314 | } 315 | expect(error.message).toEqual('Request failed with status code 404'); 316 | expect(await FailedLookupsModel.countDocuments()).toEqual(1); 317 | 318 | try { 319 | await axios.get(`${appUrl}/api/media/video/v2?title=${EPISODE_LOST.seriesTitle}&season=999&episode=999`); 320 | } catch (e) { 321 | error = e; 322 | } 323 | expect(error.message).toEqual('Request failed with status code 404'); 324 | }); 325 | }); 326 | 327 | describe('Validation', () => { 328 | test('should require title or imdbID param', async() => { 329 | let error; 330 | try { 331 | await axios.get(`${appUrl}/api/media/video/v2`); 332 | } catch (e) { 333 | error = e; 334 | } 335 | expect(error.message).toEqual('Request failed with status code 422'); 336 | }); 337 | }); 338 | }); 339 | -------------------------------------------------------------------------------- /test/load/ums-api-http.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | target: "https://api.universalmediaserver.com" 3 | http: 4 | extendedMetrics: true 5 | phases: 6 | - duration: 120 7 | arrivalRate: 10 8 | rampTo: 50 9 | payload: 10 | path: "movies.csv" 11 | fields: 12 | - "title" 13 | order: sequence 14 | skipHeader: true 15 | scenarios: 16 | - flow: 17 | - get: 18 | url: "/" 19 | - get: 20 | url: "/api/media/video?title={{title}}" 21 | -------------------------------------------------------------------------------- /test/models/FailedLookups.spec.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as mongoose from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | 5 | import FailedLookups from '../../src/models/FailedLookups'; 6 | 7 | let mongod: MongoMemoryServer; 8 | 9 | describe('Failed Lookups Model', () => { 10 | beforeAll(async() => { 11 | mongod = await MongoMemoryServer.create(); 12 | const mongoUrl = mongod.getUri(); 13 | process.env.MONGO_URL = mongoUrl; 14 | await mongoose.connect(mongoUrl); 15 | mongoose.set('strictQuery', true); 16 | }); 17 | 18 | afterAll(async() => { 19 | await mongoose.disconnect(); 20 | }); 21 | 22 | it('should require title', async() => { 23 | let err: Error; 24 | try { 25 | await FailedLookups.create({}); 26 | } catch (e) { 27 | err = e; 28 | } 29 | expect(err.message).toBe('FailedLookups validation failed: title: Path `title` is required.'); 30 | }); 31 | 32 | describe('Indexes', () => { 33 | it('should use index when find by title', async() => { 34 | await FailedLookups.init(); 35 | await FailedLookups.create({ title: 'Jackass 2' }); 36 | const response = await FailedLookups.findOne({ title: 'Jackass 2' }, {}, { explain: true }).exec(); 37 | expect(_.get(response, ['queryPlanner', 'winningPlan', 'inputStage', 'inputStage', 'stage'])).toEqual('IXSCAN'); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/models/MediaMetadata.spec.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as mongoose from 'mongoose'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | 5 | import MediaMetadata from '../../src/models/MediaMetadata'; 6 | 7 | let mongod: MongoMemoryServer; 8 | 9 | const interstellarMetaData = { 10 | actors: ['Matthew McConaughey', 'Anne Hathaway', 'Jessica Chastain'], 11 | directors: ['Christopher Nolan'], 12 | episode: '3', 13 | genres: ['Adventure', 'Drama', 'Sci-Fi'], 14 | imdbID: 'tt0816692', 15 | searchMatches: ['Interstellar (2014)'], 16 | season: '2', 17 | title: 'Interstellar', 18 | type: 'episode', 19 | year: '2014', 20 | }; 21 | 22 | describe('Media Metadata Model', () => { 23 | beforeAll(async() => { 24 | mongod = await MongoMemoryServer.create(); 25 | const mongoUrl = mongod.getUri(); 26 | process.env.MONGO_URL = mongoUrl; 27 | await mongoose.connect(mongoUrl); 28 | mongoose.set('strictQuery', true); 29 | }); 30 | 31 | beforeEach(async() => { 32 | await MediaMetadata.deleteMany({}); 33 | }); 34 | 35 | afterAll(async() => { 36 | await mongoose.disconnect(); 37 | }); 38 | 39 | it('should create Media Metadata record successfully', async() => { 40 | const savedMedia = await MediaMetadata.create(interstellarMetaData); 41 | expect(savedMedia._id).toBeDefined(); 42 | expect(savedMedia.title).toBe('Interstellar'); 43 | expect(savedMedia.genres).toBeInstanceOf(Array); 44 | }); 45 | 46 | it('should require title in a movie', async() => { 47 | const doc = _.cloneDeep(interstellarMetaData); 48 | delete doc.title; 49 | doc.type = 'movie'; 50 | let err: Error = new Error(); 51 | try { 52 | await MediaMetadata.create(doc); 53 | } catch (e) { 54 | err = e; 55 | } 56 | expect(err.message).toBe('MediaMetadata validation failed: title: Path `title` is required.'); 57 | }); 58 | 59 | it('should allow empty title in an episode', async() => { 60 | const doc = _.cloneDeep(interstellarMetaData); 61 | delete doc.title; 62 | const response = await MediaMetadata.create(doc); 63 | expect(response.year).toBe('2014'); 64 | }); 65 | 66 | it('should require episode for episodes but not for movies', async() => { 67 | const doc = _.cloneDeep(interstellarMetaData); 68 | delete doc.season; 69 | let err: Error = new Error(); 70 | try { 71 | await MediaMetadata.create(doc); 72 | } catch (e) { 73 | err = e; 74 | } 75 | expect(err.message).toBe('MediaMetadata validation failed: season: Path `season` is required.'); 76 | 77 | let err2: Error; 78 | doc.type = 'movie'; 79 | try { 80 | await MediaMetadata.create(doc); 81 | } catch (e) { 82 | err2 = e; 83 | } 84 | expect(err2).toBeUndefined(); 85 | }); 86 | 87 | it('should not store dummy episode titles', async() => { 88 | const doc = _.cloneDeep(interstellarMetaData); 89 | doc.title = 'Episode #51'; 90 | const record = await MediaMetadata.create(doc); 91 | expect(record.title).toBeUndefined(); 92 | }); 93 | 94 | it('should store real episode titles', async() => { 95 | const doc = _.cloneDeep(interstellarMetaData); 96 | const record = await MediaMetadata.create(doc); 97 | expect(record).toHaveProperty('title', 'Interstellar'); 98 | }); 99 | 100 | describe('Virtuals', () => { 101 | it('should return imdburl', async() => { 102 | const doc = Object.assign({}, interstellarMetaData); 103 | const record = await MediaMetadata.create(doc); 104 | expect(record).toHaveProperty('imdburl', 'https://www.imdb.com/title/tt0816692'); 105 | }); 106 | }); 107 | 108 | describe('Indexes', () => { 109 | it('should use index when find by title', async() => { 110 | await MediaMetadata.create(interstellarMetaData); 111 | const response = await MediaMetadata.findOne({ title: interstellarMetaData.title }, null, { explain: true }).exec(); 112 | expect(_.get(response, ['queryPlanner', 'winningPlan', 'inputStage', 'inputStage', 'inputStage', 'stage'])).toEqual('IXSCAN'); 113 | expect(_.get(response, ['queryPlanner', 'winningPlan', 'inputStage', 'inputStage', 'stage'])).toEqual('FETCH'); 114 | expect(_.get(response, ['queryPlanner', 'winningPlan', 'inputStage', 'stage'])).toEqual('PROJECTION_SIMPLE'); 115 | }); 116 | 117 | it('should use index when find by searchMatches', async() => { 118 | await MediaMetadata.create(interstellarMetaData); 119 | const response = await MediaMetadata.findOne({ searchMatches: { $in: [interstellarMetaData.searchMatches[0]] } }, null, { explain: true }).exec(); 120 | expect(_.get(response, ['queryPlanner', 'winningPlan', 'inputStage', 'inputStage', 'inputStage', 'stage'])).toEqual('IXSCAN'); 121 | expect(_.get(response, ['queryPlanner', 'winningPlan', 'inputStage', 'inputStage', 'stage'])).toEqual('FETCH'); 122 | expect(_.get(response, ['queryPlanner', 'winningPlan', 'inputStage', 'stage'])).toEqual('PROJECTION_SIMPLE'); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/unit/data-mapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { mapper } from '../../src/utils/data-mapper'; 2 | 3 | const tmdbApiMovieResponse = { 4 | 'adult': false, 5 | 'backdrop_path': '/2KjPXUYDoSbAtIQGjbEIcX6b7x5.jpg', 6 | 'belongs_to_collection': { 7 | 'id':422837, 8 | 'name':'Blade Runner Collection', 9 | 'poster_path':'/qTcATCpiFDcgY8snQIfS2j0bFP7.jpg', 10 | 'backdrop_path':'/bSHZIvLoPBWyGLeiAudN1mXdvQX.jpg' 11 | }, 12 | 'budget': 15000000, 13 | 'genres': [ 14 | { 15 | 'id': 12, 16 | 'name': 'Adventure', 17 | }, 18 | { 19 | 'id': 878, 20 | 'name': 'Science Fiction', 21 | }, 22 | { 23 | 'id': 28, 24 | 'name': 'Action', 25 | }, 26 | ], 27 | 'homepage': '', 28 | 'id': 11884, 29 | 'imdb_id': 'tt0087597', 30 | 'original_language': 'en', 31 | 'original_title': 'The Last Starfighter', 32 | 'overview': 'A video game expert Alex Rogan finds himself transported to another planet after conquering The Last Starfighter video game only to find out it was just a test. He was recruited to join the team of best Starfighters to defend their world from the attack.', 33 | 'popularity': 8.993, 34 | 'poster_path': '/an1H0DPADLDlEsiUy8vE9AWqrhm.jpg', 35 | 'production_companies': [ 36 | { 37 | 'id': 33, 38 | 'logo_path': '/8lvHyhjr8oUKOOy2dKXoALWKdp0.png', 39 | 'name': 'Universal Pictures', 40 | 'origin_country': 'US', 41 | }, 42 | ], 43 | 'production_countries': [ 44 | { 45 | 'iso_3166_1': 'US', 46 | 'name': 'United States of America', 47 | }, 48 | ], 49 | 'release_date': '1984-07-13', 50 | 'revenue': 28733290, 51 | 'runtime': 101, 52 | 'spoken_languages': [ 53 | { 54 | 'english_name': 'English', 55 | 'iso_639_1': 'en', 56 | 'name': 'English', 57 | }, 58 | ], 59 | 'status': 'Released', 60 | 'tagline': 'He didn\'t find his dreams... his dreams found him.', 61 | 'title': 'The Last Starfighter', 62 | 'video': false, 63 | 'vote_average': 6.6, 64 | 'vote_count': 495, 65 | 'images': { 66 | 'backdrops': [ 67 | { 68 | 'aspect_ratio': 1.778, 69 | 'height': 1440, 70 | 'iso_639_1': null, 71 | 'file_path': '/2KjPXUYDoSbAtIQGjbEIcX6b7x5.jpg', 72 | 'vote_average': 5.456, 73 | 'vote_count': 5, 74 | 'width': 2560, 75 | }, 76 | ], 77 | 'logos': [], 78 | 'posters': [ 79 | { 80 | 'aspect_ratio': 0.667, 81 | 'height': 1500, 82 | 'iso_639_1': 'en', 83 | 'file_path': '/an1H0DPADLDlEsiUy8vE9AWqrhm.jpg', 84 | 'vote_average': 5.138, 85 | 'vote_count': 8, 86 | 'width': 1000, 87 | }, 88 | ], 89 | }, 90 | 'external_ids': { 91 | 'imdb_id': 'tt0087597', 92 | 'facebook_id': null, 93 | 'instagram_id': null, 94 | 'twitter_id': null, 95 | }, 96 | 'credits': { 97 | 'cast': [ 98 | { 99 | 'adult': false, 100 | 'gender': 2, 101 | 'id': 16213, 102 | 'known_for_department': 'Acting', 103 | 'name': 'Lance Guest', 104 | 'original_name': 'Lance Guest', 105 | 'popularity': 1.285, 106 | 'profile_path': '/rfLT0dxqWcAsN5rcEia6tHt2UpW.jpg', 107 | 'cast_id': 1, 108 | 'character': 'Alex Rogan', 109 | 'credit_id': '52fe449a9251416c7503a87b', 110 | 'order': 0, 111 | }, 112 | ], 113 | 'crew': [ 114 | { 115 | 'adult': false, 116 | 'gender': 0, 117 | 'id': 375, 118 | 'known_for_department': 'Sound', 119 | 'name': 'Bub Asman', 120 | 'original_name': 'Bub Asman', 121 | 'popularity': 1.286, 122 | 'profile_path': null, 123 | 'credit_id': '5d5ec71ef263ba001495033f', 124 | 'department': 'Directing', 125 | 'job': 'Director', 126 | }, 127 | ], 128 | }, 129 | }; 130 | 131 | // this response has been shortened by removing all but one object from each array of objects 132 | const tmdbApiSeriesResponse = { 133 | 'backdrop_path': '/kaUuV7mq8eJkLu4mI5iIt2vfxgq.jpg', 134 | 'created_by': [ 135 | { 136 | 'id': 5174, 137 | 'credit_id': '587a988e9251413eba00706e', 138 | 'name': 'Barry Sonnenfeld', 139 | 'gender': 2, 140 | 'profile_path': '/n6y6vaAFfSqsJodgkwTxAey4NoG.jpg', 141 | }, 142 | ], 143 | 'episode_run_time': [ 144 | 45, 145 | ], 146 | 'first_air_date': '2017-01-13', 147 | 'genres': [ 148 | { 149 | 'id': 10759, 150 | 'name': 'Action & Adventure', 151 | }, 152 | ], 153 | 'homepage': 'https://www.netflix.com/title/80050008', 154 | 'id': 65294, 155 | 'in_production': false, 156 | 'languages': [ 157 | 'en', 158 | ], 159 | 'last_air_date': '2019-01-01', 160 | 'last_episode_to_air': { 161 | 'air_date': '2019-01-01', 162 | 'episode_number': 7, 163 | 'id': 1624595, 164 | 'name': 'The End', 165 | 'overview': 'The final chapter takes the orphans to a deserted island: a place of lost lives, old stories and new beginnings. It all ends here.', 166 | 'production_code': '', 167 | 'season_number': 3, 168 | 'still_path': '/ivzLVNFmKAQ8n7B1UBz2vGxuhSu.jpg', 169 | 'vote_average': 7.2, 170 | 'vote_count': 5, 171 | }, 172 | 'name': 'A Series of Unfortunate Events', 173 | 'next_episode_to_air': null, 174 | 'networks': [ 175 | { 176 | 'name': 'Netflix', 177 | 'id': 213, 178 | 'logo_path': '/wwemzKWzjKYJFfCeiB57q3r4Bcm.png', 179 | 'origin_country': '', 180 | }, 181 | ], 182 | 'number_of_episodes': 25, 183 | 'number_of_seasons': 3, 184 | 'origin_country': [ 185 | 'US', 186 | ], 187 | 'original_language': 'en', 188 | 'original_name': 'A Series of Unfortunate Events', 189 | 'overview': 'The orphaned Baudelaire children face trials, tribulations and the evil Count Olaf, all in their quest to uncover the secret of their parents\' death.', 190 | 'popularity': 42.843, 191 | 'poster_path': '/qg7WXAatXyQq6zO3SnWnRJEayeZ.jpg', 192 | 'production_companies': [ 193 | { 194 | 'id': 39520, 195 | 'logo_path': '/h5qMfjH9jaJ5835UGdq2PZQ1a33.png', 196 | 'name': 'Take 5 Productions', 197 | 'origin_country': 'CA', 198 | }, 199 | ], 200 | 'production_countries': [ 201 | { 202 | 'iso_3166_1': 'CA', 203 | 'name': 'Canada', 204 | }, 205 | ], 206 | 'seasons': [ 207 | { 208 | 'air_date': '2017-01-13', 209 | 'episode_count': 8, 210 | 'id': 73892, 211 | 'name': 'Season 1', 212 | 'overview': '', 213 | 'poster_path': '/sTtt875W3YbBnPooiPPmCvHienS.jpg', 214 | 'season_number': 1, 215 | }, 216 | ], 217 | 'spoken_languages': [ 218 | { 219 | 'english_name': 'English', 220 | 'iso_639_1': 'en', 221 | 'name': 'English', 222 | }, 223 | ], 224 | 'status': 'Ended', 225 | 'tagline': '', 226 | 'type': 'Scripted', 227 | 'vote_average': 7.3, 228 | 'vote_count': 497, 229 | 'images': { 230 | 'backdrops': [ 231 | { 232 | 'aspect_ratio': 1.778, 233 | 'height': 720, 234 | 'iso_639_1': null, 235 | 'file_path': '/kaUuV7mq8eJkLu4mI5iIt2vfxgq.jpg', 236 | 'vote_average': 5.312, 237 | 'vote_count': 1, 238 | 'width': 1280, 239 | }, 240 | ], 241 | 'logos': [ 242 | { 243 | 'aspect_ratio': 9.076, 244 | 'height': 131, 245 | 'iso_639_1': 'en', 246 | 'file_path': '/ewr9YgBxxrYA0jRRshUW44DrYfv.png', 247 | 'vote_average': 5.312, 248 | 'vote_count': 1, 249 | 'width': 1189, 250 | }, 251 | ], 252 | 'posters': [ 253 | { 254 | 'aspect_ratio': 0.667, 255 | 'height': 2700, 256 | 'iso_639_1': 'en', 257 | 'file_path': '/qg7WXAatXyQq6zO3SnWnRJEayeZ.jpg', 258 | 'vote_average': 5.318, 259 | 'vote_count': 3, 260 | 'width': 1800, 261 | }, 262 | ], 263 | }, 264 | 'external_ids': { 265 | 'imdb_id': 'tt4834206', 266 | 'freebase_mid': null, 267 | 'freebase_id': null, 268 | 'tvdb_id': 306304, 269 | 'tvrage_id': null, 270 | 'facebook_id': 'UnfortunateEventsNetflix', 271 | 'instagram_id': null, 272 | 'twitter_id': 'Unfortunate', 273 | }, 274 | 'credits': { 275 | 'cast': [ 276 | { 277 | 'adult': false, 278 | 'gender': 2, 279 | 'id': 41686, 280 | 'known_for_department': 'Acting', 281 | 'name': 'Neil Patrick Harris', 282 | 'original_name': 'Neil Patrick Harris', 283 | 'popularity': 6.135, 284 | 'profile_path': '/oyy0Enz4ZX8KYRYihgSgOA18Xc.jpg', 285 | 'character': 'Count Olaf', 286 | 'credit_id': '569a14fc9251414803000d3c', 287 | 'order': 0, 288 | }, 289 | ], 290 | 'crew': [ 291 | { 292 | 'adult': false, 293 | 'gender': 2, 294 | 'id': 59722, 295 | 'known_for_department': 'Writing', 296 | 'name': 'Daniel Handler', 297 | 'original_name': 'Daniel Handler', 298 | 'popularity': 0.847, 299 | 'profile_path': '/A83HnjYRdSlmuWybm8jgCuOCTt7.jpg', 300 | 'credit_id': '5762af4bc3a3682fa70002dc', 301 | 'department': 'Production', 302 | 'job': 'Executive Producer', 303 | }, 304 | ], 305 | }, 306 | }; 307 | 308 | describe('Data mapper for TMDB API responses', () => { 309 | describe('series', () => { 310 | it('should parse as expected', () => { 311 | const parsed = mapper.parseTMDBAPISeriesResponse(tmdbApiSeriesResponse); 312 | expect(parsed.actors).toEqual(['Neil Patrick Harris']); 313 | expect(parsed.createdBy).toEqual([ 314 | { 315 | id: 5174, 316 | credit_id: '587a988e9251413eba00706e', 317 | name: 'Barry Sonnenfeld', 318 | gender: 2, 319 | profile_path: '/n6y6vaAFfSqsJodgkwTxAey4NoG.jpg', 320 | }, 321 | ]); 322 | expect(parsed.credits).toEqual({ 323 | cast: [{ 324 | 'adult': false, 325 | 'character': 'Count Olaf', 326 | 'credit_id': '569a14fc9251414803000d3c', 327 | 'gender': 2, 328 | 'id': 41686, 329 | 'known_for_department': 'Acting', 330 | 'name': 'Neil Patrick Harris', 331 | 'order': 0, 332 | 'original_name': 'Neil Patrick Harris', 333 | 'popularity': 6.135, 334 | 'profile_path': '/oyy0Enz4ZX8KYRYihgSgOA18Xc.jpg', 335 | }], 336 | crew: [ 337 | { 338 | 'adult': false, 339 | 'credit_id': '5762af4bc3a3682fa70002dc', 340 | 'department': 'Production', 341 | 'gender': 2, 342 | 'id': 59722, 343 | 'job': 'Executive Producer', 344 | 'known_for_department': 'Writing', 345 | 'name': 'Daniel Handler', 346 | 'original_name': 'Daniel Handler', 347 | 'popularity': 0.847, 348 | 'profile_path': '/A83HnjYRdSlmuWybm8jgCuOCTt7.jpg', 349 | }, 350 | ], 351 | }); 352 | 353 | expect(parsed.imdbID).toBe('tt4834206'); 354 | expect(parsed.externalIDs).toEqual({ 355 | imdb_id: 'tt4834206', 356 | freebase_mid: null, 357 | freebase_id: null, 358 | tvdb_id: 306304, 359 | tvrage_id: null, 360 | facebook_id: 'UnfortunateEventsNetflix', 361 | instagram_id: null, 362 | twitter_id: 'Unfortunate', 363 | }); 364 | expect(parsed.genres).toEqual(['Action & Adventure']); 365 | expect(parsed.homepage).toBe('https://www.netflix.com/title/80050008'); 366 | expect(parsed.images).toEqual({ 367 | backdrops: [{ 368 | 'aspect_ratio': 1.778, 369 | 'file_path': '/kaUuV7mq8eJkLu4mI5iIt2vfxgq.jpg', 370 | 'height': 720, 371 | 'iso_639_1': null, 372 | 'vote_average': 5.312, 373 | 'vote_count': 1, 374 | 'width': 1280, 375 | }], 376 | logos: [{ 377 | 'aspect_ratio': 9.076, 378 | 'file_path': '/ewr9YgBxxrYA0jRRshUW44DrYfv.png', 379 | 'height': 131, 380 | 'iso_639_1': 'en', 381 | 'vote_average': 5.312, 382 | 'vote_count': 1, 383 | 'width': 1189, 384 | }], 385 | posters: [{ 386 | 'aspect_ratio': 0.667, 387 | 'file_path': '/qg7WXAatXyQq6zO3SnWnRJEayeZ.jpg', 388 | 'height': 2700, 389 | 'iso_639_1': 'en', 390 | 'vote_average': 5.318, 391 | 'vote_count': 3, 392 | 'width': 1800, 393 | }], 394 | }); 395 | expect(parsed.inProduction).toBe(false); 396 | expect(parsed.languages).toEqual(['en']); 397 | expect(parsed.lastAirDate).toBe('2019-01-01'); 398 | expect(parsed.title).toBe('A Series of Unfortunate Events'); 399 | expect(parsed.networks).toEqual([ 400 | { 401 | name: 'Netflix', 402 | id: 213, 403 | logo_path: '/wwemzKWzjKYJFfCeiB57q3r4Bcm.png', 404 | origin_country: '', 405 | }, 406 | ]); 407 | expect(parsed.numberOfEpisodes).toBe(25); 408 | expect(parsed.originCountry).toEqual(['US']); 409 | expect(parsed.originalLanguage).toBe('en'); 410 | expect(parsed.plot).toBe('The orphaned Baudelaire children face trials, tribulations and the evil Count Olaf, all in their quest to uncover the secret of their parents\' death.'); 411 | expect(parsed.productionCompanies).toEqual([ 412 | { 413 | id: 39520, 414 | logo_path: '/h5qMfjH9jaJ5835UGdq2PZQ1a33.png', 415 | name: 'Take 5 Productions', 416 | origin_country: 'CA', 417 | }, 418 | ]); 419 | expect(parsed.productionCountries).toEqual([{ iso_3166_1: 'CA', name: 'Canada' }]); 420 | expect(parsed.released).toBe('2017-01-13'); 421 | expect(parsed.seasons).toEqual([ 422 | { 423 | air_date: '2017-01-13', 424 | episode_count: 8, 425 | id: 73892, 426 | name: 'Season 1', 427 | overview: '', 428 | poster_path: '/sTtt875W3YbBnPooiPPmCvHienS.jpg', 429 | season_number: 1, 430 | }, 431 | ]); 432 | expect(parsed.seriesType).toBe('Scripted'); 433 | expect(parsed.spokenLanguages).toEqual([{ english_name: 'English', iso_639_1: 'en', name: 'English' }]); 434 | expect(parsed.startYear).toBe('2017'); 435 | expect(parsed.status).toBe('Ended'); 436 | expect(parsed.tagline).toBe(''); 437 | expect(parsed.tmdbID).toBe(65294); 438 | expect(parsed.totalSeasons).toBe(3); 439 | expect(parsed.type).toBe('series'); 440 | expect(parsed.year).toBe('2017'); 441 | }); 442 | }); 443 | describe('movies', () => { 444 | it('should parse as expected', () => { 445 | const parsed = mapper.parseTMDBAPIMovieResponse(tmdbApiMovieResponse); 446 | 447 | expect(parsed.actors).toBeDefined(); 448 | expect(parsed.actors[0]).toBe('Lance Guest'); 449 | expect(parsed.budget).toBe(15000000); 450 | expect(parsed.collectionTmdbID).toBe(422837); 451 | expect(parsed.credits).toEqual({ 452 | cast: [{ 453 | 'adult': false, 454 | 'cast_id': 1, 455 | 'character': 'Alex Rogan', 456 | 'credit_id': '52fe449a9251416c7503a87b', 457 | 'gender': 2, 458 | 'id': 16213, 459 | 'known_for_department': 'Acting', 460 | 'name': 'Lance Guest', 461 | 'order': 0, 462 | 'original_name': 'Lance Guest', 463 | 'popularity': 1.285, 464 | 'profile_path': '/rfLT0dxqWcAsN5rcEia6tHt2UpW.jpg', 465 | }], 466 | crew: [{ 467 | 'adult': false, 468 | 'credit_id': '5d5ec71ef263ba001495033f', 469 | 'department': 'Directing', 470 | 'gender': 0, 471 | 'id': 375, 472 | 'job': 'Director', // changed this from the actual result in order to test the directors array 473 | 'known_for_department': 'Sound', 474 | 'name': 'Bub Asman', 475 | 'original_name': 'Bub Asman', 476 | 'popularity': 1.286, 477 | 'profile_path': null, 478 | }], 479 | }); 480 | expect(parsed.directors).toBeDefined(); 481 | expect(parsed.directors[0]).toBe('Bub Asman'); 482 | expect(parsed.externalIDs).toEqual({ 483 | imdb_id: 'tt0087597', 484 | facebook_id: null, 485 | instagram_id: null, 486 | twitter_id: null, 487 | }); 488 | expect(parsed.genres).toEqual(['Adventure', 'Science Fiction', 'Action']); 489 | expect(parsed.tmdbID).toBe(11884); 490 | expect(parsed.images).toEqual({ 491 | backdrops: [{ 492 | 'aspect_ratio': 1.778, 493 | 'file_path': '/2KjPXUYDoSbAtIQGjbEIcX6b7x5.jpg', 494 | 'height': 1440, 495 | 'iso_639_1': null, 496 | 'vote_average': 5.456, 497 | 'vote_count': 5, 498 | 'width': 2560, 499 | }], 500 | logos: [], 501 | posters: [{ 502 | 'aspect_ratio': 0.667, 503 | 'file_path': '/an1H0DPADLDlEsiUy8vE9AWqrhm.jpg', 504 | 'height': 1500, 505 | 'iso_639_1': 'en', 506 | 'vote_average': 5.138, 507 | 'vote_count': 8, 508 | 'width': 1000, 509 | }], 510 | }); 511 | expect(parsed.imdbID).toBe('tt0087597'); 512 | expect(parsed.originalLanguage).toBe('en'); 513 | expect(parsed.originalTitle).toBe('The Last Starfighter'); 514 | expect(parsed.plot).toBe('A video game expert Alex Rogan finds himself transported to another planet after conquering The Last Starfighter video game only to find out it was just a test. He was recruited to join the team of best Starfighters to defend their world from the attack.'); 515 | expect(parsed.productionCompanies).toEqual([{ 516 | id: 33, 517 | logo_path: '/8lvHyhjr8oUKOOy2dKXoALWKdp0.png', 518 | name: 'Universal Pictures', 519 | origin_country: 'US', 520 | }]); 521 | expect(parsed.year).toBe('1984'); 522 | expect(parsed.released).toBe('1984-07-13'); 523 | expect(parsed.revenue).toBe(28733290); 524 | expect(parsed.runtime).toBe(101); 525 | expect(parsed.spokenLanguages).toEqual([{ english_name: 'English', iso_639_1: 'en', name: 'English' }]); 526 | expect(parsed.tagline).toBe('He didn\'t find his dreams... his dreams found him.'); 527 | expect(parsed.title).toBe('The Last Starfighter'); 528 | expect(parsed.type).toBe('movie'); 529 | }); 530 | }); 531 | }); 532 | -------------------------------------------------------------------------------- /test/unit/episodeParser.spec.ts: -------------------------------------------------------------------------------- 1 | // ensures the third party module is doing what we expect in our context. 2 | import * as episodeParser from 'episode-parser'; 3 | 4 | describe('TV Series parsing', () => { 5 | it('should parse common episode and season from filenames', () => { 6 | let title = 'Dora.The.Explorer.S03E19.The.Super.Silly.Fiesta.480p.AMZN.WEBRip.DD2.0.x264-AR.avi'; 7 | let result = episodeParser(title); 8 | expect(result.season).toEqual(3); 9 | expect(result.episode).toEqual(19); 10 | expect(result.show).toEqual('Dora The Explorer'); 11 | 12 | title = 'My.Cat.From.Hell.S10E12.Phillys.Forgotten.Cats.WEB.x264-CAFFEiNE[eztv].mkv'; 13 | result = episodeParser(title); 14 | expect(result.season).toEqual(10); 15 | expect(result.episode).toEqual(12); 16 | expect(result.show).toEqual('My Cat From Hell'); 17 | 18 | title = 'Game.of.Thrones.S08E06.1080p.AMZN.WEB-DL.x264-MkvCage.ws.mkv'; 19 | result = episodeParser(title); 20 | expect(result.season).toEqual(8); 21 | expect(result.episode).toEqual(6); 22 | expect(result.show).toEqual('Game of Thrones'); 23 | 24 | title = 'Prison.Break.S05E05.HDTV.x264-KILLERS[ettv].mkv'; 25 | result = episodeParser(title); 26 | expect(result.season).toEqual(5); 27 | expect(result.episode).toEqual(5); 28 | expect(result.show).toEqual('Prison Break'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "outDir": "build" 7 | }, 8 | "files": [ 9 | "./src/interfaces.d.ts" 10 | ], 11 | "include": [ 12 | "src/**/*" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------