From 2633fdf56b4331b2ff5e00c3c0c145ac3a39344b Mon Sep 17 00:00:00 2001 From: Amila Welihinda Date: Fri, 19 Mar 2021 17:55:47 -0700 Subject: [PATCH] v1.5.0 (#607) * infra updates, bump deps latest semver * fix TheMovieDbMetadataProvider and PctTorrentProvider * fix color of loading indicator when using light theme * temporarily edisable ci e2e tests * complete trending implementation (#605) * fix tarvis linux ci config * fix travis ci config * Squashed commit of the following: commit a1b359b212a29779283d09ec8797d411cfb5d366 Author: Amila Welihinda Date: Thu Apr 16 16:31:57 2020 -0700 misc commit 78a864c6517d4d84ae33b006aed1432c9570c2c3 Author: Amila Welihinda Date: Tue Apr 14 18:52:50 2020 -0700 initial typescript migration * fix all linter errors, initial API refactor * finish refactoring * refactor routing * migrate to PlayerAdapter architecture * fix more typescript errors * misc refactors * rewrite caching implementation * fix routing bugs * fix show page itemView bug * add settings page, allow changing theme from settings modal * refactor Config * migrate all flags to Settings flags * use native bootstrap spacing classes, deprecate row-margin * fix all linter errors * fix: remove duplicate call to startPlayback * fix: fix all linter errors * fix: upgrade deps to work around plyr bug * fix: fix carousel wrapAround issue * fix: fix CI by upgrading to node@12 * fix: fix deprecate targets lacking autoupdate support * fix: deps update * fix: fix watchlist and favorites buttons * fix: fix theme change bugs, fix all linter errors * fix: fix theme related ts errors * infra: migrate to github actions * infra: migrate from node-sass to sass * fix: normalize left carriage on windows * fix: remove duplicate react types dependencies * hack: temporarily disable eslint in ci * hack: bump electron-builder to get ci to pass * fix: only publish assets on push to fix socket hangup * fix: separate ci into publish and test workflows --- .eslintignore | 7 +- .eslintrc.js | 28 + .flowconfig | 28 - .gitattributes | 8 +- .github/workflows/publish.yml | 33 + .github/workflows/test.yml | 34 + .gitignore | 8 +- .stylelintrc | 6 - .travis.yml | 80 - .vscode/extensions.json | 3 + .vscode/settings.json | 12 +- CONTRIBUTING.md | 13 +- README.md | 4 +- __mocks__/electron.js | 11 + app/actions/homePageActions.js | 46 - app/api/Butter.js | 80 - app/api/Butter.ts | 22 + app/api/Player.js | 113 - app/api/Subtitle.js | 123 - app/api/helpers/cache-decorator.ts | 38 + app/api/metadata/BaseMetadataProvider.js | 46 - app/api/metadata/BaseMetadataProvider.ts | 76 + app/api/metadata/MetadataAdapter.js | 257 - app/api/metadata/MetadataAdapter.ts | 1 + app/api/metadata/MetadataProviderInterface.js | 108 - app/api/metadata/MetadataProviderInterface.ts | 114 + app/api/metadata/Subtitle.ts | 98 + .../metadata/TheMovieDbMetadataProvider.js | 278 - .../metadata/TheMovieDbMetadataProvider.ts | 405 + app/api/metadata/example.js | 92 - app/api/metadata/helpers.js | 26 - app/api/metadata/helpers.ts | 21 + app/api/metadata/rndm.d.ts | 3 + app/api/metadata/speedtest-net.d.ts | 5 + app/api/metadata/srt2vtt.d.ts | 6 + app/api/metadata/yifysubtitles.d.ts | 16 + app/api/players/BasePlayerProvider.ts | 20 + app/api/players/ChromecastPlayerProvider.js | 151 - app/api/players/ChromecastPlayerProvider.ts | 157 + app/api/players/DlnaPlayerProvider.js | 0 app/api/players/PlayerAdapter.js | 28 - app/api/players/PlayerAdapter.ts | 90 + app/api/players/PlayerProviderInterface.js | 60 - app/api/players/PlayerProviderInterface.ts | 46 + app/api/players/PlyrPlayerProvider.ts | 84 + app/api/players/VlcPlayerProvider.js | 0 app/api/torrents/BaseTorrentProvider.js | 277 - app/api/torrents/BaseTorrentProvider.ts | 222 + app/api/torrents/Cache.ts | 52 + app/api/torrents/KatTorrentProvider.js | 104 - app/api/torrents/KatTorrentProvider.ts | 113 + ...orrentProvider.js => PbTorrentProvider.ts} | 83 +- app/api/torrents/PctTorrentProvider.js | 138 - app/api/torrents/PctTorrentProvider.ts | 151 + app/api/torrents/RarbgTorrentProvider.js | 0 .../torrents/RarbgTorrentProvider.ts} | 0 app/api/{Torrent.js => torrents/Torrent.ts} | 155 +- app/api/torrents/TorrentAdapter.js | 187 - app/api/torrents/TorrentAdapter.ts | 176 + app/api/torrents/TorrentProviderInterface.js | 31 - app/api/torrents/TorrentProviderInterface.ts | 62 + app/api/torrents/YtsTorrentProvider.js | 74 - app/api/torrents/YtsTorrentProvider.ts | 93 + app/api/torrents/example.js | 32 - app/app.global.scss | 16 +- app/app.html | 4 +- app/components/card/Card.js | 60 - app/components/card/CardList.js | 59 - app/components/card/Rating.js | 38 - app/components/header/Header.js | 142 - app/components/header/OfflineAlert.js | 36 - app/components/home/Home.js | 256 - app/components/item/BackButton.js | 21 - app/components/item/Description.js | 109 - app/components/item/Item.js | 776 - app/components/item/Poster.js | 33 - app/components/item/SelectPlayer.js | 44 - app/components/item/Similar.js | 75 - app/components/item/VideoPlayer.js | 47 - app/components/metadata/SaveItem.js | 120 - app/components/show/Show.js | 100 - app/containers/App.js | 16 - app/containers/HomePage.js | 40 - app/containers/ItemPage.js | 21 - app/containers/Root.js | 20 - app/features/app/App.tsx | 76 + app/features/app/ConnectivityListener.tsx | 71 + app/features/app/Navbar.tsx | 173 + app/features/app/Settings.tsx | 86 + app/features/app/SkeletonLoader.tsx | 131 + app/features/app/reducer.ts | 37 + app/features/app/theme-context.ts | 10 + app/features/card/Card.tsx | 56 + app/features/card/CardsGrid.tsx | 66 + app/features/card/Rating.tsx | 39 + app/features/home/Home.tsx | 308 + app/features/home/HomePage.tsx | 43 + app/features/home/actions.ts | 35 + app/features/home/reducer.ts | 105 + app/features/item/BackButton.tsx | 28 + app/features/item/Description.tsx | 133 + app/features/item/Item.tsx | 723 + app/features/item/ItemPage.tsx | 14 + app/features/item/Poster.tsx | 45 + app/features/item/SaveItem.tsx | 47 + app/features/item/SelectPlayer.tsx | 36 + app/features/item/Show.tsx | 96 + app/features/item/TorrentSelector.tsx | 48 + app/features/item/VideoPlayer.tsx | 53 + .../Loader.js => features/loader/Loader.tsx} | 16 +- app/index.js | 118 - app/index.tsx | 28 + app/main.dev.js | 123 - app/main.dev.ts | 120 + app/menu.js | 260 - app/menu.ts | 291 + app/package.json | 11 +- app/reducers/homePageReducer.js | 96 - app/reducers/index.js | 13 - app/reducers/itemPageReducer.js | 0 app/root.tsx | 22 + app/rootReducer.ts | 13 + app/routes.js | 46 - app/routes.tsx | 39 + app/store.ts | 48 + app/store/configureStore.dev.js | 71 - app/store/configureStore.js | 12 - app/store/configureStore.prod.js | 17 - app/styles/components/Button.scss | 7 +- .../{CardList.scss => CardsGrid.scss} | 35 +- .../{Movie.scss => Description.scss} | 8 +- app/styles/components/Item.scss | 12 +- app/styles/components/Loader.scss | 2 +- app/styles/components/Rating.scss | 4 - app/styles/components/Show.scss | 1 - app/styles/variables.scss | 173 +- app/types/castv2-client.d.ts | 22 + app/types/free-port.d.ts | 3 + app/types/match.ts | 15 + app/types/mdns-js.t.ts | 12 + app/types/network-address.d.ts | 3 + app/types/plyr.d.ts | 279 + app/types/react-rating-star-component.ts | 5 + app/utils/AutoUpdate.js | 35 - app/utils/AutoUpdater.ts | 8 + app/utils/CheckUpdate.js | 29 - app/utils/CheckUpdate.ts | 59 + app/utils/Config.js | 38 - app/utils/Config.ts | 33 + app/utils/{Network.js => Network.ts} | 13 +- app/utils/Settings.ts | 90 + app/utils/Theme.ts | 169 + app/utils/WindowsInstallEvents.js | 0 .../WindowsInstallEvents.ts} | 0 app/utils/__test__/CheckUpdate.spec.ts | 17 + app/yarn.lock | 972 - appveyor.yml | 47 - babel.config.js | 40 +- configs/.eslintrc | 7 + configs/.eslintrc copy | 7 + configs/webpack.config.base.babel.js | 63 - configs/webpack.config.base.js | 50 + configs/webpack.config.eslint.babel.js | 3 - configs/webpack.config.eslint.js | 4 + configs/webpack.config.main.prod.babel.js | 48 +- configs/webpack.config.renderer.dev.babel.js | 207 +- .../webpack.config.renderer.dev.dll.babel.js | 57 +- configs/webpack.config.renderer.prod.babel.js | 181 +- internals/.eslintrc | 18 + internals/flow/CSSModule.js.flow | 3 - internals/flow/WebpackAsset.js.flow | 2 - internals/mocks/fileMock.js | 1 - internals/mocks/fileMock.ts | 1 + internals/scripts/BabelRegister.js | 6 + internals/scripts/CheckBuildsExist.js | 30 + internals/scripts/CheckBuiltsExist.js | 19 +- internals/scripts/CheckNodeEnv.js | 5 +- internals/scripts/CheckPortInUse.js | 8 +- internals/scripts/DeleteSourceMaps.js | 9 + internals/scripts/ElectronRebuild.js | 19 +- internals/scripts/PostInstall.js | 72 + package.json | 369 +- postinstall.js | 76 - test/.eslintrc | 7 +- test/api/__snapshots__/butter.spec.js.snap | 1817 -- test/api/butter.benchmark.js | 78 +- test/api/butter.mock.js | 100 +- test/api/butter.spec.js | 627 +- test/components/Card.spec.js | 37 +- .../__snapshots__/Card.spec.js.snap | 3 +- test/e2e/HomePage.e2e.js | 105 +- test/e2e/ItemPage.e2e.js | 130 +- test/e2e/helpers.js | 42 +- test/screenshot.e2e.js | 154 +- test/setup.js | 4 +- test/utils.spec.js | 30 +- tsconfig.json | 40 + yarn.lock | 15942 +++++++++------- 198 files changed, 16500 insertions(+), 16683 deletions(-) create mode 100644 .eslintrc.js delete mode 100644 .flowconfig create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml delete mode 100644 .stylelintrc delete mode 100644 .travis.yml create mode 100644 .vscode/extensions.json create mode 100644 __mocks__/electron.js delete mode 100644 app/actions/homePageActions.js delete mode 100644 app/api/Butter.js create mode 100644 app/api/Butter.ts delete mode 100644 app/api/Player.js delete mode 100644 app/api/Subtitle.js create mode 100644 app/api/helpers/cache-decorator.ts delete mode 100644 app/api/metadata/BaseMetadataProvider.js create mode 100644 app/api/metadata/BaseMetadataProvider.ts delete mode 100644 app/api/metadata/MetadataAdapter.js create mode 100644 app/api/metadata/MetadataAdapter.ts delete mode 100644 app/api/metadata/MetadataProviderInterface.js create mode 100644 app/api/metadata/MetadataProviderInterface.ts create mode 100644 app/api/metadata/Subtitle.ts delete mode 100644 app/api/metadata/TheMovieDbMetadataProvider.js create mode 100644 app/api/metadata/TheMovieDbMetadataProvider.ts delete mode 100644 app/api/metadata/example.js delete mode 100644 app/api/metadata/helpers.js create mode 100644 app/api/metadata/helpers.ts create mode 100644 app/api/metadata/rndm.d.ts create mode 100644 app/api/metadata/speedtest-net.d.ts create mode 100644 app/api/metadata/srt2vtt.d.ts create mode 100644 app/api/metadata/yifysubtitles.d.ts create mode 100644 app/api/players/BasePlayerProvider.ts delete mode 100644 app/api/players/ChromecastPlayerProvider.js create mode 100644 app/api/players/ChromecastPlayerProvider.ts delete mode 100644 app/api/players/DlnaPlayerProvider.js delete mode 100644 app/api/players/PlayerAdapter.js create mode 100644 app/api/players/PlayerAdapter.ts delete mode 100644 app/api/players/PlayerProviderInterface.js create mode 100644 app/api/players/PlayerProviderInterface.ts create mode 100644 app/api/players/PlyrPlayerProvider.ts delete mode 100644 app/api/players/VlcPlayerProvider.js delete mode 100644 app/api/torrents/BaseTorrentProvider.js create mode 100644 app/api/torrents/BaseTorrentProvider.ts create mode 100644 app/api/torrents/Cache.ts delete mode 100644 app/api/torrents/KatTorrentProvider.js create mode 100644 app/api/torrents/KatTorrentProvider.ts rename app/api/torrents/{PbTorrentProvider.js => PbTorrentProvider.ts} (51%) delete mode 100644 app/api/torrents/PctTorrentProvider.js create mode 100644 app/api/torrents/PctTorrentProvider.ts delete mode 100644 app/api/torrents/RarbgTorrentProvider.js rename app/{actions/itemPageActions.js => api/torrents/RarbgTorrentProvider.ts} (100%) rename app/api/{Torrent.js => torrents/Torrent.ts} (54%) delete mode 100644 app/api/torrents/TorrentAdapter.js create mode 100644 app/api/torrents/TorrentAdapter.ts delete mode 100644 app/api/torrents/TorrentProviderInterface.js create mode 100644 app/api/torrents/TorrentProviderInterface.ts delete mode 100644 app/api/torrents/YtsTorrentProvider.js create mode 100644 app/api/torrents/YtsTorrentProvider.ts delete mode 100644 app/api/torrents/example.js delete mode 100644 app/components/card/Card.js delete mode 100644 app/components/card/CardList.js delete mode 100644 app/components/card/Rating.js delete mode 100644 app/components/header/Header.js delete mode 100644 app/components/header/OfflineAlert.js delete mode 100644 app/components/home/Home.js delete mode 100644 app/components/item/BackButton.js delete mode 100644 app/components/item/Description.js delete mode 100644 app/components/item/Item.js delete mode 100644 app/components/item/Poster.js delete mode 100644 app/components/item/SelectPlayer.js delete mode 100644 app/components/item/Similar.js delete mode 100644 app/components/item/VideoPlayer.js delete mode 100644 app/components/metadata/SaveItem.js delete mode 100644 app/components/show/Show.js delete mode 100644 app/containers/App.js delete mode 100644 app/containers/HomePage.js delete mode 100644 app/containers/ItemPage.js delete mode 100644 app/containers/Root.js create mode 100644 app/features/app/App.tsx create mode 100644 app/features/app/ConnectivityListener.tsx create mode 100644 app/features/app/Navbar.tsx create mode 100644 app/features/app/Settings.tsx create mode 100644 app/features/app/SkeletonLoader.tsx create mode 100644 app/features/app/reducer.ts create mode 100644 app/features/app/theme-context.ts create mode 100644 app/features/card/Card.tsx create mode 100644 app/features/card/CardsGrid.tsx create mode 100644 app/features/card/Rating.tsx create mode 100644 app/features/home/Home.tsx create mode 100644 app/features/home/HomePage.tsx create mode 100644 app/features/home/actions.ts create mode 100644 app/features/home/reducer.ts create mode 100644 app/features/item/BackButton.tsx create mode 100644 app/features/item/Description.tsx create mode 100644 app/features/item/Item.tsx create mode 100644 app/features/item/ItemPage.tsx create mode 100644 app/features/item/Poster.tsx create mode 100644 app/features/item/SaveItem.tsx create mode 100644 app/features/item/SelectPlayer.tsx create mode 100644 app/features/item/Show.tsx create mode 100644 app/features/item/TorrentSelector.tsx create mode 100644 app/features/item/VideoPlayer.tsx rename app/{components/loader/Loader.js => features/loader/Loader.tsx} (63%) delete mode 100644 app/index.js create mode 100644 app/index.tsx delete mode 100644 app/main.dev.js create mode 100644 app/main.dev.ts delete mode 100644 app/menu.js create mode 100644 app/menu.ts delete mode 100644 app/reducers/homePageReducer.js delete mode 100644 app/reducers/index.js delete mode 100644 app/reducers/itemPageReducer.js create mode 100644 app/root.tsx create mode 100644 app/rootReducer.ts delete mode 100644 app/routes.js create mode 100644 app/routes.tsx create mode 100644 app/store.ts delete mode 100644 app/store/configureStore.dev.js delete mode 100644 app/store/configureStore.js delete mode 100644 app/store/configureStore.prod.js rename app/styles/components/{CardList.scss => CardsGrid.scss} (79%) rename app/styles/components/{Movie.scss => Description.scss} (88%) create mode 100644 app/types/castv2-client.d.ts create mode 100644 app/types/free-port.d.ts create mode 100644 app/types/match.ts create mode 100644 app/types/mdns-js.t.ts create mode 100644 app/types/network-address.d.ts create mode 100644 app/types/plyr.d.ts create mode 100644 app/types/react-rating-star-component.ts delete mode 100644 app/utils/AutoUpdate.js create mode 100644 app/utils/AutoUpdater.ts delete mode 100644 app/utils/CheckUpdate.js create mode 100644 app/utils/CheckUpdate.ts delete mode 100644 app/utils/Config.js create mode 100644 app/utils/Config.ts rename app/utils/{Network.js => Network.ts} (62%) create mode 100644 app/utils/Settings.ts create mode 100644 app/utils/Theme.ts delete mode 100644 app/utils/WindowsInstallEvents.js rename app/{api/players/AppleTvPlayerProvider.js => utils/WindowsInstallEvents.ts} (100%) create mode 100644 app/utils/__test__/CheckUpdate.spec.ts delete mode 100644 appveyor.yml create mode 100644 configs/.eslintrc create mode 100644 configs/.eslintrc copy delete mode 100644 configs/webpack.config.base.babel.js create mode 100644 configs/webpack.config.base.js delete mode 100644 configs/webpack.config.eslint.babel.js create mode 100644 configs/webpack.config.eslint.js create mode 100644 internals/.eslintrc delete mode 100644 internals/flow/CSSModule.js.flow delete mode 100644 internals/flow/WebpackAsset.js.flow delete mode 100644 internals/mocks/fileMock.js create mode 100644 internals/mocks/fileMock.ts create mode 100644 internals/scripts/BabelRegister.js create mode 100644 internals/scripts/CheckBuildsExist.js create mode 100644 internals/scripts/DeleteSourceMaps.js create mode 100644 internals/scripts/PostInstall.js delete mode 100644 postinstall.js delete mode 100644 test/api/__snapshots__/butter.spec.js.snap create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore index aeffce56..88b134f5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -31,10 +31,6 @@ app/node_modules # OSX .DS_Store -# flow-typed -flow-typed/npm/* -!flow-typed/npm/module_vx.x.x.js - # App packaged app/main.prod.js app/main.prod.js.map @@ -46,3 +42,6 @@ resources .idea npm-debug.log.* __snapshots__ +*.css.d.ts +*.sass.d.ts +*.scss.d.ts diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..acc673a9 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,28 @@ +module.exports = { + extends: "erb", + rules: { + // A temporary hack related to IDE not resolving correct package.json + "import/no-extraneous-dependencies": "off", + "@typescript-eslint/camelcase": "off", + "react/jsx-props-no-spreading": "off", + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + createDefaultProgram: true, + }, + settings: { + "import/resolver": { + // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below + node: {}, + webpack: { + config: require.resolve("./configs/webpack.config.eslint.js"), + }, + }, + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"], + }, + }, +}; diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index a7e63f9f..00000000 --- a/.flowconfig +++ /dev/null @@ -1,28 +0,0 @@ -[ignore] -.*/node_modules/fbjs/.* -.*/app/main.js -.*/app/dist/.* -.*/release/.* -.*/git/.* - -[include] -app - -[libs] - -[options] -esproposal.class_static_fields=enable -esproposal.class_instance_fields=enable -esproposal.export_star_as=enable - -module.name_mapper.extension='css' -> '/internals/flow/CSSModule.js.flow' -module.name_mapper.extension='styl' -> '/internals/flow/CSSModule.js.flow' -module.name_mapper.extension='scss' -> '/internals/flow/CSSModule.js.flow' -module.name_mapper.extension='png' -> '/internals/flow/WebpackAsset.js.flow' -module.name_mapper.extension='jpg' -> '/internals/flow/WebpackAsset.js.flow' - -suppress_type=$FlowIssue -suppress_type=$FlowFixMe -suppress_type=$FixMe - -munge_underscores=true diff --git a/.gitattributes b/.gitattributes index 176a458f..9328e35d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,7 @@ -* text=auto +* text eol=lf +*.exe binary +*.png binary +*.jpg binary +*.jpeg binary +*.ico binary +*.icns binary diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..4af4635e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,33 @@ +name: Publish + +on: + push: + branches: + - master + +jobs: + publish: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [macos-latest] + + steps: + - name: Checkout git repo + uses: actions/checkout@v1 + + - name: Install Node, NPM and Yarn + uses: actions/setup-node@v1 + with: + node-version: 15 + + - name: Install dependencies + run: | + yarn install + + - name: Publish releases + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + yarn build && yarn electron-builder --publish always --win --mac --linux diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..0f34d77c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Test + +on: [push, pull_request] + +jobs: + release: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + + steps: + - name: Check out Git repository + uses: actions/checkout@v1 + + - name: Install Node.js, NPM and Yarn + uses: actions/setup-node@v1 + with: + node-version: 15 + + - name: yarn install + run: | + yarn install --frozen-lockfile --network-timeout 300000 + + - name: yarn test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + yarn package + + # yarn lint + # yarn tsc + # yarn test diff --git a/.gitignore b/.gitignore index e9654079..192c47a3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,10 +31,6 @@ app/node_modules # OSX .DS_Store -# flow-typed -flow-typed/npm/* -!flow-typed/npm/module_vx.x.x.js - # App packaged release app/main.prod.js @@ -51,3 +47,7 @@ npm-debug.log.* # Project specific .env + +*.css.d.ts +*.sass.d.ts +*.scss.d.ts diff --git a/.stylelintrc b/.stylelintrc deleted file mode 100644 index 016378bc..00000000 --- a/.stylelintrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "stylelint-config-standard", - "rules": { - "color-hex-case": "upper" - } -} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 55f5793f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,80 +0,0 @@ -sudo: true - -matrix: - include: - - os: osx - osx_image: xcode9.4 - language: node_js - node_js: - - "10" - env: - - ELECTRON_CACHE=$HOME/.cache/electron - - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder - - os: linux - language: node_js - node_js: - - "10" - addons: - apt: - sources: - - ubuntu-toolchain-r-test - packages: - - g++-8 - - icnsutils - - graphicsmagick - - xz-utils - - xorriso - - gcc-multilib - - g++-multilib - - rpm - - libavahi-compat-libdnssd-dev - - avahi-daemon - - avahi-discover - - libnss-mdns - -before_cache: - - rm -rf $HOME/.cache/electron-builder/wine - -cache: - yarn: true - directories: - - node_modules - - app/node_modules - - "$(npm config get prefix)/lib/node_modules" - - flow-typed - - "$HOME/.cache/electron" - - "$HOME/.cache/electron-builder" - - "$HOME/docker" - -install: - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export CXX="g++-8"; fi - - yarn - - | - if [ "$TRAVIS_OS_NAME" == "linux" ]; then - /sbin/start-stop-daemon \ - --start \ - --quiet \ - --pidfile /tmp/custom_xvfb_99.pid \ - --make-pidfile \ - --background \ - --exec /usr/bin/Xvfb \ - -- :99 -ac -screen 0 1280x1024x16 - else - : - fi - -before_script: - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export DISPLAY=:99.0; fi - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sh -e /etc/init.d/xvfb start; fi - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sleep 3; fi - -script: - - yarn lint - # - yarn test - - yarn package-ci - - yarn build-e2e - - yarn test-e2e --quarantine-mode - -env: - global: - secure: h08lERRuaRxR+8HLA8Z7DD/ZqK3iKL5l3mokNK18RxY5iFRdlZxRoIMLck8oXsrkiqIzI0Ah9a8I524wW9rqiuy0nw8V0QpbXyPQI7UXs1ZqiUcivyinc59juURE6rXFtnQVmL3W5bjjpiJRNSgw6T1j9Csls4cdH/OEEgUczP1a0ijFCAWszs1v3FYykhQ/2v5Dsji5ZV2IkXMelVu6FdWGjWHknmpfemCogqGB9z6wHCONfGXBF+HGI1pJPnva0pnLVVUb8Qy8RK/sQ8Qn3eGYDlSMDZiRwd9zDmqQvCTMeEbU9Gs8QmmgelKqmNBN6FPHElNsJq1B8Z4X7rnaR/jNbbfZanmZvt+nL5j6SRVn96IwDlXufS4IfhSo56d9Eervvyz1nWQB0KnUVBf8UV7s2U9dpw/RCYDKR1mVFGYpQT+0d9cbxrEau1pVDbl9Hj5dubG99fUHNS5uwef/UuSn/oAOw9crAzOCtfSfOBc5jrpDlj4/QEcCGcR58GvR2awF2mxw39WlgQt5YJr2Zbzzrhr2pd/F991J5wmynhxjfD2DXEwxqteaEDJa4fTPjS2UDIRHjLqxWwirXzFCaHFV9449Tnp7SpYuzbYeu81yzq1XIS8zzla3nQ/GY+By78ny6twgnNSPdNAk8dAA3MPGEpWzRphOmGrM1ubKt34= diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..d9165248 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 89968ae8..f37ebb26 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,19 +2,10 @@ "files.associations": { ".stylelintrc": "jsonc", ".testcafe-electron-rc": "jsonc", - ".env": "ignore", ".env.example": "ignore", - ".eslintignore": "ignore", - ".flowconfig": "ignore" + ".eslintignore": "ignore" }, - - "javascript.validate.enable": false, - "javascript.format.enable": false, - "typescript.validate.enable": false, - "typescript.format.enable": false, - - "flow.useNPMPackagedFlow": true, "search.exclude": { ".git": true, ".eslintcache": true, @@ -23,7 +14,6 @@ "app/main.prod.js.map": true, "bower_components": true, "dll": true, - "flow-typed": true, "release": true, "node_modules": true, "npm-debug.log.*": true, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a6f88cb..a25f94c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,17 +5,8 @@ The branch to be PR'd against depends on what the feature is. If the PR is addin This project has **VERY** strict eslint rules. Adding eslint support to your text-editor will make contributing a lot easier. ## Editor Configuration -### Atom -Recommended Development Packages: -```bash -apm install editorconfig es6-javascript javascript-snippets linter linter-eslint language-babel autocomplete-flow -``` - -### Sublime -* https://github.com/sindresorhus/editorconfig-sublime#readme -* https://github.com/SublimeLinter/SublimeLinter3 -* https://github.com/roadhump/SublimeLinter-eslint -* https://github.com/babel/babel-sublime + +See the [electron-react-boilerplate docs](https://electron-react-boilerplate.js.org/docs/editor-configuration/) ## FAQ * `CALL_AND_RETRY_LAST Allocation failed`: If your node process's heap runs out of memory (`CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory`), kill close the electron app and restart the electron process. A proper solution hasn't been found for this yet. diff --git a/README.md b/README.md index e8a1dc7d..1f76b9d9 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ - 🎞 **Subtitles**: Subtitle integration for movies -## Getting started: +## Installation: - **I am a tester:** Download the latest build from the [releases](https://github.com/amilajack/popcorn-time-desktop/releases) section. @@ -50,7 +50,7 @@ - For packaging, see [packaging requirements](https://github.com/amilajack/popcorn-time-desktop/wiki/Packaging-Requirements) - For casting support, you will need to [satisfy mdns's requirements](https://github.com/agnat/node_mdns#installation) -## Installation: +## Local Setup: ```bash git clone https://github.com/amilajack/popcorn-time-desktop.git diff --git a/__mocks__/electron.js b/__mocks__/electron.js new file mode 100644 index 00000000..d1f2f002 --- /dev/null +++ b/__mocks__/electron.js @@ -0,0 +1,11 @@ +module.exports = { + require: jest.fn(), + match: jest.fn(), + app: jest.fn(), + remote: { + app: { + getVersion: () => "v1.3.0", + }, + }, + dialog: jest.fn(), +}; diff --git a/app/actions/homePageActions.js b/app/actions/homePageActions.js deleted file mode 100644 index e37eb423..00000000 --- a/app/actions/homePageActions.js +++ /dev/null @@ -1,46 +0,0 @@ -// @flow -import type { activeModeOptionsType, itemType } from '../components/home/Home'; - -export function setActiveMode( - activeMode: string, - activeModeOptions?: activeModeOptionsType = {} -) { - return { - type: 'SET_ACTIVE_MODE', - activeMode, - activeModeOptions - }; -} - -export function paginate(items: Array) { - return { - type: 'PAGINATE', - items - }; -} - -export function clearItems() { - return { - type: 'CLEAR_ITEMS' - }; -} - -export function clearAllItems() { - return { - type: 'CLEAR_ALL_ITEMS' - }; -} - -export function setLoading(isLoading: boolean) { - return { - type: 'SET_LOADING', - isLoading - }; -} - -export function setCurrentPlayer(player: string) { - return { - type: 'SET_CURRENT_PLAYER', - player - }; -} diff --git a/app/api/Butter.js b/app/api/Butter.js deleted file mode 100644 index 1b63aab3..00000000 --- a/app/api/Butter.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * The highest level abstraction layer for querying torrents and metadata - * @flow - */ -import TorrentAdapter from './torrents/TorrentAdapter'; -import MetadataAdapter from './metadata/MetadataAdapter'; - -export default class Butter { - getMovies(page: number = 1, limit: number = 50) { - return MetadataAdapter.getMovies(page, limit); - } - - getMovie(itemId: string) { - return MetadataAdapter.getMovie(itemId); - } - - getShows(page: number = 1, limit: number = 50) { - return MetadataAdapter.getShows(page, limit); - } - - getShow(itemId: string) { - return MetadataAdapter.getShow(itemId); - } - - getSeasons(itemId: string) { - return MetadataAdapter.getSeasons(itemId); - } - - getSeason(itemId: string, season: number) { - return MetadataAdapter.getSeason(itemId, season); - } - - getEpisode(itemId: string, season: number, episode: number) { - return MetadataAdapter.getEpisode(itemId, season, episode); - } - - getSimilar(type: string = 'movies', itemId: string) { - return MetadataAdapter.getSimilar(type, itemId, 5); - } - - /** - * @param {string} itemId - * @param {string} type | Type of torrent: movie or show - * @param {object} extendedDetails | Additional details provided for heuristics - * @param {boolean} returnAll - */ - getTorrent( - itemId: string, - type: string, - extendedDetails: { [option: string]: string | number } = {}, - returnAll: boolean = false - ) { - return TorrentAdapter(itemId, type, extendedDetails, returnAll); - } - - search(query: string, page: number = 1) { - return MetadataAdapter.search(query, page); - } - - getSubtitles( - itemId: string, - filename: string, - length: number, - metadata: Object - ) { - return MetadataAdapter.getSubtitles(itemId, filename, length, metadata); - } - - favorites(method: string, metadata: Object) { - return MetadataAdapter.favorites(method, metadata); - } - - recentlyWatched(method: string, metadata: Object) { - return MetadataAdapter.recentlyWatched(method, metadata); - } - - watchList(method: string, metadata: Object) { - return MetadataAdapter.watchList(method, metadata); - } -} diff --git a/app/api/Butter.ts b/app/api/Butter.ts new file mode 100644 index 00000000..29e3820b --- /dev/null +++ b/app/api/Butter.ts @@ -0,0 +1,22 @@ +/** + * Butter is single API that abstracts over all the APIs + */ +import TorrentAdapter from "./torrents/TorrentAdapter"; +import MetadataAdapter from "./metadata/MetadataAdapter"; +import Cache, { PctCache } from "./torrents/Cache"; +import { Torrent } from "./torrents/TorrentProviderInterface"; +import { Item } from "./metadata/MetadataProviderInterface"; + +interface Butter extends TorrentAdapter, MetadataAdapter {} + +const cache: PctCache = new Cache(); + +const torrentAdapter = new TorrentAdapter({ + cache, +}); + +class Butter extends MetadataAdapter { + getTorrent = torrentAdapter.getTorrent; +} + +export default Butter; diff --git a/app/api/Player.js b/app/api/Player.js deleted file mode 100644 index fe4c96dd..00000000 --- a/app/api/Player.js +++ /dev/null @@ -1,113 +0,0 @@ -// @flow -import { remote } from 'electron'; -import plyr from 'plyr'; -import childProcess from 'child_process'; -import network from 'network-address'; -import vlcCommand from 'vlc-command'; -import ChromecastPlayerProvider from './players/ChromecastPlayerProvider'; -import type { metadataType } from './players/PlayerProviderInterface'; - -export type subtitleType = { kind: string, src: string, srclang: string }; - -const { powerSaveBlocker } = remote; - -export default class Player { - currentPlayer = 'plyr'; - - powerSaveBlockerId: number; - - /** - * @private - */ - player: plyr; - - static nativePlaybackFormats = [ - 'mp4', - 'ogg', - 'mov', - 'webmv', - 'mkv', - 'wmv', - 'avi' - ]; - - static experimentalPlaybackFormats = []; - - /** - * Cleanup all traces of the player UI - */ - destroy() { - if (this.powerSaveBlockerId) { - powerSaveBlocker.stop(this.powerSaveBlockerId); - } - if (this.player && this.player.destroy) { - this.player.destroy(); - } - } - - /** - * restart they player's state - */ - restart() { - this.player.restart(); - } - - static isFormatSupported( - filename: string, - mimeTypes: Array - ): boolean { - return !!mimeTypes.find(mimeType => - filename.toLowerCase().includes(mimeType) - ); - } - - async initCast( - provider: ChromecastPlayerProvider, - streamingUrl: string, - metadata: metadataType, - subtitles: Array - ) { - this.powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension'); - const addr = streamingUrl.replace('localhost', network()); - return provider.play(addr, metadata, subtitles); - } - - initYouTube() { - console.info('Initializing plyr...'); - this.currentPlayer = 'plyr'; - this.player = {}; - return this.player; - } - - initPlyr(): plyr { - console.info('Initializing plyr...'); - this.currentPlayer = 'plyr'; - this.powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension'); - this.player = {}; - return this.player; - } - - initVLC(servingUrl: string) { - vlcCommand((error, cmd: string) => { - if (error) return console.error('Could not find vlc command path'); - - if (process.platform === 'win32') { - childProcess.execFile(cmd, [servingUrl], (_error, stdout) => { - if (_error) return console.error(_error); - return console.log(stdout); - }); - } else { - childProcess.exec(`${cmd} ${servingUrl}`, (_error, stdout) => { - if (_error) return console.error(_error); - return console.log(stdout); - }); - } - - this.powerSaveBlockerId = powerSaveBlocker.start( - 'prevent-app-suspension' - ); - - return true; - }); - } -} diff --git a/app/api/Subtitle.js b/app/api/Subtitle.js deleted file mode 100644 index 96c40e4f..00000000 --- a/app/api/Subtitle.js +++ /dev/null @@ -1,123 +0,0 @@ -// @flow -import express from 'express'; -import path from 'path'; -import os from 'os'; -import fs from 'fs'; -import srt2vtt from 'srt2vtt'; -import rndm from 'rndm'; -import getPort from 'get-port'; - -export type subtitleType = { - filename: string, - basePath: string, - port: number, - fullPath: string, - buffer: Buffer -}; - -export default class SubtitleServer { - basePath = os.tmpdir(); - - server: express; - - port: ?number; - - async startServer(): Promise { - // Find a port at runtime. Default to 4000 if it is available - this.port = - typeof process.env.SUBTITLES_PORT === 'number' - ? parseInt(process.env.SUBTITLES_PORT, 10) - : await getPort({ port: 4000 }); - - // Start the static file server for the subtitle files - const server = express(); - // Enable CORS - // https://github.com/thibauts/node-castv2-client/wiki/How-to-use-subtitles-with-the-DefaultMediaReceiver-app#subtitles - server.use((req, res, next) => { - if (req.headers.origin) { - res.headers['Access-Control-Allow-Origin'] = req.headers.origin; - } - next(); - }); - server.use(express.static(this.basePath)); - this.server = server.listen(this.port); - - console.info( - `Subtitle server serving on http://localhost:${this.port}, serving ${ - this.basePath - }` - ); - } - - closeServer() { - if (this.server) { - this.server.close(); - } - } - - convertFromBuffer(srtBuffer: Buffer): Promise { - const randomString = rndm(16); - const filename = `${randomString}.vtt`; - const { basePath, port } = this; - const fullPath = path.join(basePath, filename); - - return new Promise((resolve, reject) => { - srt2vtt(srtBuffer, (error?: Error, vttBuffer: Buffer) => { - if (error) reject(error); - - fs.writeFile(fullPath, vttBuffer, () => { - resolve({ - filename, - basePath, - port, - fullPath, - buffer: vttBuffer - }); - }); - }); - }); - } -} - -export const languages = [ - // 'sq', - 'ar', - // 'bn', - // 'pb', - // 'bg', - 'zh', - // 'hr', - // 'cs', - // 'da', - // 'nl', - 'en', - // 'et', - // 'fa', - // 'fi', - // 'fr', - // 'de', - // 'el', - // 'he', - // 'hu', - // 'id', - // 'it', - // 'ja', - // 'ko', - // 'lt', - // 'mk', - // 'ms', - // 'no', - // 'pl', - // 'pt', - // 'ro', - 'ru', - // 'sr', - // 'sl', - 'es' - // 'sv', - // 'th', - // 'tr', - // 'ur', - // 'uk', - // 'vi' -]; diff --git a/app/api/helpers/cache-decorator.ts b/app/api/helpers/cache-decorator.ts new file mode 100644 index 00000000..984c6d1f --- /dev/null +++ b/app/api/helpers/cache-decorator.ts @@ -0,0 +1,38 @@ +/* eslint-disable */ +import { PctCacheValue, PctCache } from "../torrents/Cache"; + +function getNewFunction( + name: string, + originalFunction: (...args: any[]) => Promise +) { + return async function decorator(this: CacheFunction, ...args: any[]) { + const { cache } = this; + const cacheKey = JSON.stringify({ name, args }); + + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const returnedValue = await originalFunction.apply(this, args); + console.log(cache.dump(), cacheKey); + cache.set(cacheKey, returnedValue); + + return returnedValue; + }; +} +export default function cacheDecorator() { + return function cacheFn(target, name, descriptor) { + if (descriptor.value != null) + descriptor.value = getNewFunction(name, descriptor.value); + else if (descriptor.get != null) + descriptor.get = getNewFunction(name, descriptor.get); + else + throw new Error( + "Only put a Memoize decorator on a method or get accessor." + ); + }; +} + +interface CacheFunction { + cache: PctCache; +} diff --git a/app/api/metadata/BaseMetadataProvider.js b/app/api/metadata/BaseMetadataProvider.js deleted file mode 100644 index 77d492b9..00000000 --- a/app/api/metadata/BaseMetadataProvider.js +++ /dev/null @@ -1,46 +0,0 @@ -// @flow -import { set, get } from '../../utils/Config'; -import type { contentType, methodType } from './MetadataProviderInterface'; - -type configType = 'favorites' | 'recentlyWatched' | 'watchList'; - -export default class BaseMetadataProvider { - /** - * Temporarily store the 'favorites', 'recentlyWatched', 'watchList' items - * in config file. The cache can't be used because this data needs to be - * persisted. - * - * @private - */ - updateConfig(type: configType, method: methodType, metadata: contentType) { - const property = String(type); - - switch (method) { - case 'set': - set(property, [...(get(property) || []), metadata]); - return get(property); - case 'get': - return get(property); - case 'remove': { - const items = [ - ...(get(property) || []).filter(item => item.id !== metadata.id) - ]; - return set(property, items); - } - default: - return set(property, [...(get(property) || []), metadata]); - } - } - - favorites(...args: Array) { - return this.updateConfig('favorites', ...args); - } - - recentlyWatched(...args: Array) { - return this.updateConfig('recentlyWatched', ...args); - } - - watchList(...args: Array) { - return this.updateConfig('watchList', ...args); - } -} diff --git a/app/api/metadata/BaseMetadataProvider.ts b/app/api/metadata/BaseMetadataProvider.ts new file mode 100644 index 00000000..75e42a12 --- /dev/null +++ b/app/api/metadata/BaseMetadataProvider.ts @@ -0,0 +1,76 @@ +/* eslint class-methods-use-this: off */ +import os from "os"; +import yifysubtitles from "@amilajack/yifysubtitles"; +import Config from "../../utils/Config"; +import { Item, UserList } from "./MetadataProviderInterface"; +import { Subtitle } from "./Subtitle"; + +type ConfigKind = "favorites" | "recentlyWatched" | "watchList"; + +export function userListsHelper(listName: ConfigKind): UserList { + return { + async add(item: Item): Promise { + const items: Item[] = Config.get(listName) || []; + const newItems = [...items, item]; + Config.set(listName, newItems); + }, + async remove(item: Item) { + const items: Item[] = Config.get(listName) || []; + if (!item?.id) throw new Error("id not passed"); + const newItems = items.filter((_item) => _item.id !== item.id); + Config.set(listName, newItems); + }, + async get() { + const items: Item[] = Config.get(listName) || []; + return items; + }, + async has(item: Item): Promise { + const items: Item[] = Config.get(listName); + return items.some( + (_item) => _item.ids.tmdbId === item.id || _item.ids.imdbId === item.id + ); + }, + async clear() { + Config.set(listName, []); + }, + }; +} + +export default class BaseMetadataProvider { + favorites = userListsHelper("favorites"); + + recentlyWatched = userListsHelper("recentlyWatched"); + + watchList = userListsHelper("watchList"); + + /** + * 1. Retrieve list of subtitles + * 2. If the torrent has subtitles, get the subtitle buffer + * 3. Convert the buffer (srt) to vtt, save the file to a tmp dir + * 4. Serve the file through http + * 5. Override the default subtitle retrieved from the API + */ + async getSubtitles( + item: Item, + langs: string[] = ["en"], + port: number + ): Promise { + const itemId = item.ids.imdbId || item.id; + if (!itemId) throw new Error("itemId not set"); + + const subtitles = await yifysubtitles(itemId, { + path: os.tmpdir(), + langs, + }); + + return subtitles.map((subtitle) => ({ + ...subtitle, + default: subtitle.langShort === process.env.DEFAULT_TORRENT_LANG, + language: subtitle.langShort, + basePath: `http://localhost:${port}`, + port, + filename: subtitle.fileName, + fullPath: `http://localhost:${port}/${subtitle.fileName}`, + })); + } +} diff --git a/app/api/metadata/MetadataAdapter.js b/app/api/metadata/MetadataAdapter.js deleted file mode 100644 index e844d8de..00000000 --- a/app/api/metadata/MetadataAdapter.js +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Resolve requests from cache - * @flow - */ -import OpenSubtitles from 'opensubtitles-api'; -import { merge, resolveCache, setCache } from '../torrents/BaseTorrentProvider'; -import TheMovieDbMetadataProvider from './TheMovieDbMetadataProvider'; - -type subtitlesType = { - kind: 'captions', - label: string, - srclang: string, - src: string, - default: boolean -}; - -const subtitlesEndpoint = - 'https://popcorn-time-api-server.herokuapp.com/subtitles'; - -const openSubtitles = new OpenSubtitles({ - useragent: 'OSTestUserAgent', - username: '', - password: '', - ssl: true -}); - -function MetadataAdapter() { - return [new TheMovieDbMetadataProvider()]; -} - -async function interceptAndHandleRequest( - method: string, - args: Array, - cache: boolean = true -) { - const key = JSON.stringify(method) + JSON.stringify(args); - - if (cache && resolveCache(key)) { - return Promise.resolve(resolveCache(key)); - } - - const results = await Promise.all( - MetadataAdapter().map(provider => provider[method].apply(provider, args)) // eslint-disable-line - ); - - const mergedResults = merge(results); - - if (cache) { - setCache(key, mergedResults); - } - - return mergedResults; -} - -/** - * Get list of movies with specific paramaters - * - * @param {string} query - * @param {number} limit - * @param {string} genre - * @param {string} sortBy - */ -function search(...args: Array) { - return interceptAndHandleRequest('search', args); -} - -/** - * Get details about a specific movie - * - * @param {string} itemId - */ -function getMovie(...args: Array) { - return interceptAndHandleRequest('getMovie', args); -} - -/** - * Get list of movies with specific paramaters - * - * @param {number} page - * @param {number} limit - * @param {string} genre - * @param {string} sortBy - */ -function getMovies(...args: Array) { - return interceptAndHandleRequest('getMovies', args); -} - -/** - * Get list of movies with specific paramaters - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getSimilar(...args: Array) { - return interceptAndHandleRequest('getSimilar', args); -} - -/** - * Get a specific season of a show - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getSeason(...args: Array) { - return interceptAndHandleRequest('getSeason', args); -} - -/** - * Get a list of seasons of a show - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getSeasons(...args: Array) { - return interceptAndHandleRequest('getSeasons', args); -} - -/** - * Get a single episode of a season - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getEpisode(...args: Array) { - return interceptAndHandleRequest('getEpisode', args); -} - -/** - * Get a single show - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getShow(...args: Array) { - return interceptAndHandleRequest('getShow', args); -} - -/** - * Get a list of shows - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getShows(...args: Array) { - return interceptAndHandleRequest('getShows', args); -} - -function formatSubtitle(subtitle) { - return { - kind: 'captions', - label: subtitle.langName, - srclang: subtitle.lang, - src: `${subtitlesEndpoint}/${encodeURIComponent(subtitle.url)}`, - default: subtitle.lang === 'en' - }; -} - -/** - * Get the subtitles for a movie or show - * - * @param {string} itemId - * @param {string} filename - * @param {object} metadata - */ -async function getSubtitles( - imdbId: string, - filename: string, - length: number, - metadata: { season?: number, episode?: number, activeMode?: string } = {} -): Promise> { - const { activeMode } = metadata; - - const defaultOptions = { - sublanguageid: 'eng', - // sublanguageid: 'all', // @TODO - // hash: '8e245d9679d31e12', // @TODO - filesize: length || undefined, - filename: filename || undefined, - season: metadata.season || undefined, - episode: metadata.episode || undefined, - extensions: ['srt', 'vtt'], - imdbid: imdbId - }; - - const subtitles = (() => { - switch (activeMode) { - case 'shows': { - const { season, episode } = metadata; - return openSubtitles.search({ - ...defaultOptions, - ...{ season, episode } - }); - } - default: - return openSubtitles.search(defaultOptions); - } - })(); - - return subtitles.then(res => - Object.values(res).map(subtitle => formatSubtitle(subtitle)) - ); -} - -/** - * Handle actions for favorites: addition, deletion, list all - * - * @param {string} method | Ex. 'set', 'get', 'remove' - * @param {object} metadata | Required only for `set` and `remove` - * @param {object} metadata | 'id', Required only remove - */ -function favorites(...args: Array) { - return interceptAndHandleRequest('favorites', args, false); -} - -/** - * Handle actions for watchList: addition, deletion, list all - * - * @param {string} method | Ex. 'set', 'get', 'remove' - * @param {object} metadata | Required only for `set` and `remove` - * @param {object} metadata | 'id', Required only remove - */ -function watchList(...args: Array) { - return interceptAndHandleRequest('watchList', args, false); -} - -/** - * Handle actions for recentlyWatched: addition, deletion, list all - * - * @param {string} method | Ex. 'set', 'get', 'remove' - * @param {object} metadata | Required only for `set` and `remove` - * @param {object} metadata | 'id', Required only remove - */ -function recentlyWatched(...args) { - return interceptAndHandleRequest('recentlyWatched', args, false); -} - -export default { - getMovie, - getMovies, - getShow, - getShows, - getSeason, - getSeasons, - getEpisode, - search, - getSimilar, - getSubtitles, - favorites, - watchList, - recentlyWatched -}; diff --git a/app/api/metadata/MetadataAdapter.ts b/app/api/metadata/MetadataAdapter.ts new file mode 100644 index 00000000..99db53b6 --- /dev/null +++ b/app/api/metadata/MetadataAdapter.ts @@ -0,0 +1 @@ +export { default } from "./TheMovieDbMetadataProvider"; diff --git a/app/api/metadata/MetadataProviderInterface.js b/app/api/metadata/MetadataProviderInterface.js deleted file mode 100644 index 84e30e64..00000000 --- a/app/api/metadata/MetadataProviderInterface.js +++ /dev/null @@ -1,108 +0,0 @@ -// @flow -type seasonType = { - // @DEPRECATE (in favor of .ids) - id: string, - ids: { - imdbId?: string, - tmdbId?: string - }, - title: string, - season: number, - overview: string, - rating: number | 'n/a', - images: { - full: string, - medium: string, - thumb: string - } -}; - -type episodeType = seasonType & { - episode: number -}; - -export type runtimeType = { - full: string, - hours: number, - minutes: number -}; - -export type certificationType = 'G' | 'PG' | 'PG-13' | 'R' | 'n/a'; - -export type imagesType = { - fanart?: { - full: string, - medium: string, - thumb: string - }, - poster?: { - full: string, - medium: string, - thumb: string - } -}; - -export type contentType = { - title: string, - year: number, - // @DEPRECATE (in favor of .ids) - imdbId?: string, - // @DEPRECATE (in favor of .ids) - id: string, - ids: { - imdbId?: string, - tmdbId?: string - }, - type: 'movies' | 'shows', - certification: certificationType, - summary: string, - genres: Array, - rating: number | 'n/a', - runtime: runtimeType, - trailer: string | 'n/a', - images: imagesType -}; - -type optionsType = { - sort?: 'ratings' | 'popular' | 'trending', - genres?: Array -}; - -export type methodType = 'set' | 'get' | 'remove'; - -export interface MetadataProviderInterface { - getMovies: ( - page: number, - limit: number, - options: optionsType - ) => Promise; - getMovie: (itemId: string) => contentType; - getShows: (page: number, limit: number) => Promise; - getShow: (itemId: string) => contentType; - getSimilar: ( - type: string, - itemId: string, - limit: number - ) => Promise>; - - supportedIdTypes: Array<'tmdb' | 'imdb'>; - - getSeasons: (itemId: string) => Promise>; - getSeason: (itemId: string, season: number) => Promise; - getEpisode: (itemId: string, season: number, episode: number) => episodeType; - - search: (query: string, page: number) => Promise>; - - favorites: ( - method: methodType, - item?: contentType - ) => void | Array; - recentlyWatched: ( - method: methodType, - item?: contentType - ) => void | Array; - watchList: ( - method: methodType, - item?: contentType - ) => void | Array; -} diff --git a/app/api/metadata/MetadataProviderInterface.ts b/app/api/metadata/MetadataProviderInterface.ts new file mode 100644 index 00000000..b4566b3b --- /dev/null +++ b/app/api/metadata/MetadataProviderInterface.ts @@ -0,0 +1,114 @@ +export enum ItemKind { + Movie = "movies", + Show = "shows", +} + +export enum ShowKind { + Episode = "episode", + Episodes = "episodes", + Season = "season", + Seasons = "seasons", +} + +export type Season = { + // @DEPRECATE (in favor of .ids) + id: string; + ids: { + imdbId?: string; + tmdbId?: string; + }; + title: string; + season: number; + overview: string; + rating: number; + images: { + full: string; + medium: string; + thumb: string; + }; +}; + +export type Episode = Season & { + episode: number; +}; + +export type Runtime = { + full: string; + hours: number; + minutes: number; +}; + +export type Certification = "G" | "PG" | "PG-13" | "R" | "n/a"; + +export type Images = { + fanart?: { + full: string; + medium: string; + thumb: string; + }; + poster?: { + full: string; + medium: string; + thumb: string; + }; +}; + +export type Item = { + title: string; + year: number; + // @DEPRECATE (in favor of .ids) + imdbId?: string; + // @DEPRECATE (in favor of .ids) + id: string; + ids: { + imdbId?: string; + tmdbId?: string; + }; + type: ItemKind; + certification: Certification; + summary: string; + genres: Array; + rating: number; + runtime: Runtime; + trailer: string | "n/a"; + images: Images; +}; + +type Options = { + sort?: "ratings" | "popular" | "trending"; + genres?: Array; +}; + +export type Method = "set" | "get" | "add" | "remove"; + +export type UserList = { + add: (content: T) => Promise; + get: () => Promise; + remove: (content: Item) => Promise; + clear: () => Promise; + has: (item: Item) => Promise; +}; + +export interface MetadataProviderInterface { + supportedIdTypes: Array<"tmdb" | "imdb">; + + getMovies: (page: number, limit: number, options: Options) => Promise; + getMovie: (itemId: string) => Promise; + getShows: (page: number, limit: number) => Promise; + getShow: (itemId: string) => Promise; + getSimilar: (type: ItemKind, itemId: string) => Promise; + + getSeasons: (itemId: string) => Promise; + getSeason: (itemId: string, season: number) => Promise; + getEpisode: ( + itemId: string, + season: number, + episode: number + ) => Promise; + + search: (query: string, page: number) => Promise; + + favorites: UserList; + recentlyWatched: UserList; + watchList: UserList; +} diff --git a/app/api/metadata/Subtitle.ts b/app/api/metadata/Subtitle.ts new file mode 100644 index 00000000..14c59303 --- /dev/null +++ b/app/api/metadata/Subtitle.ts @@ -0,0 +1,98 @@ +import express from "express"; +import os from "os"; +import getPort from "get-port"; + +export type Subtitle = { + default: boolean; + filename: string; + basePath: string; + port: number; + fullPath: string; + language: string; +}; + +/** + * The subtitles for the player + * These are different from the subtitles from the API + */ + +export default class SubtitleServer { + basePath = os.tmpdir(); + + server?: Express.Application; + + port?: number; + + async startServer(): Promise { + // Find a port at runtime. Default to 4000 if it is available + this.port = process.env.SUBTITLES_PORT + ? parseInt(process.env.SUBTITLES_PORT, 10) + : await getPort({ port: 4_000 }); + + // Start the static file server for the subtitle files + const server = express(); + // Enable CORS + // https://github.com/thibauts/node-castv2-client/wiki/How-to-use-subtitles-with-the-DefaultMediaReceiver-app#subtitles + server.use((req, res, next) => { + if (req.headers.origin) { + res.headers["Access-Control-Allow-Origin"] = req.headers.origin; + } + next(); + }); + server.use(express.static(this.basePath)); + this.server = server.listen(this.port); + + console.info( + `Subtitle server serving on http://localhost:${this.port}, serving ${this.basePath}` + ); + } + + closeServer() { + if (this.server) { + this.server.close(); + } + } +} + +export const languages = [ + // 'sq', + "ar", + // 'bn', + // 'pb', + // 'bg', + "zh", + // 'hr', + // 'cs', + // 'da', + // 'nl', + "en", + // 'et', + // 'fa', + // 'fi', + // 'fr', + // 'de', + // 'el', + // 'he', + // 'hu', + // 'id', + // 'it', + // 'ja', + // 'ko', + // 'lt', + // 'mk', + // 'ms', + // 'no', + // 'pl', + // 'pt', + // 'ro', + "ru", + // 'sr', + // 'sl', + "es", + // 'sv', + // 'th', + // 'tr', + // 'ur', + // 'uk', + // 'vi' +]; diff --git a/app/api/metadata/TheMovieDbMetadataProvider.js b/app/api/metadata/TheMovieDbMetadataProvider.js deleted file mode 100644 index d058e9b7..00000000 --- a/app/api/metadata/TheMovieDbMetadataProvider.js +++ /dev/null @@ -1,278 +0,0 @@ -// @flow -import axios from 'axios'; -import { parseRuntimeMinutesToObject } from './helpers'; -import BaseMetadataProvider from './BaseMetadataProvider'; -import type { MetadataProviderInterface } from './MetadataProviderInterface'; - -function formatImage( - imageUri, - path: string, - size: string = 'original' -): string { - return `${imageUri}${size}/${path}`; -} - -function formatMetadata(item, type: string, imageUri: string, genres) { - return { - // 'title' property is on movies only. 'name' property is on - // shows only - title: item.name || item.title, - year: new Date(item.release_date || item.first_air_date).getFullYear(), - // @DEPRECATE - id: String(item.id), - ids: { - tmdbId: String(item.id), - imdbId: - item.imdb_id || - (item.external_ids && item.external_ids.imdb_id - ? item.external_ids.imdb_id - : '') - }, - type, - certification: 'n/a', - summary: item.overview, - genres: item.genres - ? item.genres.map(genre => genre.name) - : item.genre_ids - ? item.genre_ids.map(genre => genres[String(genre)]) - : [], - rating: item.vote_average, - runtime: - item.runtime || - (item.episode_run_time && item.episode_run_time.length > 0) - ? parseRuntimeMinutesToObject( - type === 'movies' ? item.runtime : item.episode_run_time[0] - ) - : { - full: 'n/a', - hours: 'n/a', - minutes: 'n/a' - }, - trailer: - item.videos && item.videos.results && item.videos.results.length > 0 - ? `http://youtube.com/watch?v=${item.videos.results[0].key}` - : 'n/a', - images: { - fanart: { - full: formatImage(imageUri, item.backdrop_path, 'original'), - medium: formatImage(imageUri, item.backdrop_path, 'w780'), - thumb: formatImage(imageUri, item.backdrop_path, 'w342') - }, - poster: { - full: formatImage(imageUri, item.poster_path, 'original'), - medium: formatImage(imageUri, item.poster_path, 'w780'), - thumb: formatImage(imageUri, item.poster_path, 'w342') - } - } - }; -} - -function formatSeasons(show) { - const firstSeasonIsZero = - show.seasons.length > 0 ? show.seasons[0].season_number === 0 : false; - - return show.seasons.map(season => ({ - season: firstSeasonIsZero ? season.season_number + 1 : season.season_number, - overview: show.overview, - id: String(season.id), - ids: { - tmdbId: String(season.id) - }, - images: { - full: season.poster_path, - medium: season.poster_path, - thumb: season.poster_path - } - })); -} - -function formatSeason(season) { - return season.episodes.map(episode => ({ - id: String(episode.id), - ids: { - tmdbId: String(episode.id) - }, - title: episode.name, - season: episode.season_number, - episode: episode.episode_number, - overview: episode.overview, - rating: episode.vote_average, - // rating: episode.rating ? roundRating(episode.rating) : 'n/a', - images: { - full: episode.poster_path, - medium: episode.poster_path, - thumb: episode.poster_path - } - })); -} - -function formatEpisode(episode) { - return { - id: String(episode.id), - ids: { - tmdbId: String(episode.id) - }, - title: episode.name, - season: episode.season_number, - episode: episode.episode_number, - overview: episode.overview, - rating: episode.vote_average, - // rating: episode.rating ? roundRating(episode.rating) : 'n/a', - images: { - full: episode.still_path, - medium: episode.still_path, - thumb: episode.still_path - } - }; -} - -export default class TheMovieDbMetadataProvider extends BaseMetadataProvider - implements MetadataProviderInterface { - apiKey = '809858c82322872e2be9b2c127ccdcf7'; - - imageUri = 'https://image.tmdb.org/t/p/'; - - apiUri = 'https://api.themoviedb.org/3/'; - - genres = { - '12': 'Adventure', - '14': 'Fantasy', - '16': 'Animation', - '18': 'Drama', - '27': 'Horror', - '28': 'Action', - '35': 'Comedy', - '36': 'History', - '37': 'Western', - '53': 'Thriller', - '80': 'Crime', - '99': 'Documentary', - '878': 'Science Fiction', - '9648': 'Mystery', - '10402': 'Music', - '10749': 'Romance', - '10751': 'Family', - '10752': 'War', - '10770': 'TV Movie', - '10759': 'Action & Adventure', - '10762': 'Kids', - '10763': 'News', - '10764': 'Reality', - '10765': 'Sci-Fi & Fantasy', - '10766': 'Soap', - '10767': 'Talk', - '10768': 'War & Politics' - }; - - theMovieDb: axios; - - movieGenres: { - [genre: string]: string - }; - - constructor() { - super(); - this.theMovieDb = axios.create({ - baseURL: this.apiUri, - timeout: 10000, - params: { - api_key: this.apiKey, - append_to_response: 'external_ids,videos' - } - }); - } - - getMovies(page: number = 1) { - return this.theMovieDb - .get('movie/popular', { params: { page } }) - .then(({ data }) => - data.results.map(movie => - formatMetadata(movie, 'movies', this.imageUri, this.genres) - ) - ); - } - - getMovie(itemId: string) { - return this.theMovieDb - .get(`movie/${itemId}`) - .then(({ data }) => - formatMetadata(data, 'movies', this.imageUri, this.genres) - ); - } - - getShows(page: number = 1) { - return this.theMovieDb - .get('tv/popular', { params: { page } }) - .then(({ data }) => - data.results.map(show => - formatMetadata(show, 'shows', this.imageUri, this.genres) - ) - ); - } - - getShow(itemId: string) { - return this.theMovieDb - .get(`tv/${itemId}`) - .then(({ data }) => - formatMetadata(data, 'shows', this.imageUri, this.genres) - ); - } - - getSeasons(itemId: string) { - return this.theMovieDb - .get(`tv/${itemId}`) - .then(({ data }) => formatSeasons(data)); - } - - getSeason(itemId: string, season: number) { - return this.theMovieDb - .get(`tv/${itemId}/season/${season}`) - .then(({ data }) => formatSeason(data)); - } - - getEpisode(itemId: string, season: number, episode: number) { - return this.theMovieDb - .get(`tv/${itemId}/season/${season}/episode/${episode}`) - .then(({ data }) => formatEpisode(data)); - } - - search(query: string, page: number = 1) { - return this.theMovieDb - .get('search/multi', { params: { page, include_adult: true, query } }) - .then(({ data }) => - data.results.map(result => - formatMetadata( - result, - result.media_type === 'movie' ? 'movies' : 'shows', - this.imageUri, - this.genres - ) - ) - ); - } - - getSimilar(type: string = 'movies', itemId: string, limit: number = 5) { - const urlType = (() => { - switch (type) { - case 'movies': - return 'movie'; - case 'shows': - return 'tv'; - default: { - throw new Error(`Unexpected type "${type}"`); - } - } - })(); - - return this.theMovieDb - .get(`${urlType}/${itemId}/recommendations`) - .then(({ data }) => - data.results - .map(movie => formatMetadata(movie, type, this.imageUri, this.genres)) - .filter((each, index) => index <= limit - 1) - ); - } - - // @TODO: Properly implement provider architecture - provide() {} -} diff --git a/app/api/metadata/TheMovieDbMetadataProvider.ts b/app/api/metadata/TheMovieDbMetadataProvider.ts new file mode 100644 index 00000000..b5c7dcab --- /dev/null +++ b/app/api/metadata/TheMovieDbMetadataProvider.ts @@ -0,0 +1,405 @@ +import axios, { AxiosInstance } from "axios"; +import { parseRuntimeMinutesToObject } from "./helpers"; +import BaseMetadataProvider from "./BaseMetadataProvider"; +import { + MetadataProviderInterface, + Item, + Season, + Episode, + Runtime, + ItemKind, +} from "./MetadataProviderInterface"; +import cache from "../helpers/cache-decorator"; +import Cache, { PctCache } from "../torrents/Cache"; + +function formatImage( + imageUri: string, + path: string, + size = "original" +): string { + return `${imageUri}/${size}/${path}`; +} + +type RawItem = { + name: string; + title: string; + release_date: number; + first_air_date: number; + videos?: { + results?: [ + { + key: string; + } + ]; + }; + id: string; + imdb_id?: string; + media_type: "movie" | "shows"; + external_ids?: { + imdb_id?: string; + }; + overview: string; + genres?: [{ name: string }]; + genre_ids?: number[]; + backdrop_path: string; + poster_path: string; + vote_average: number; + runtime?: number; + episode_run_time?: number[]; +}; + +type Genres = Record; + +function formatGenres(item: RawItem, genres: Genres): string[] { + if (item.genres) { + return item.genres.map((genre) => genre.name); + } + return item.genre_ids ? item.genre_ids.map((genre) => genres[genre]) : []; +} + +function formatItem( + item: RawItem, + type: ItemKind, + imageUri: string, + genres: Genres +): Item { + const runtime: Runtime = + item.runtime || item.episode_run_time?.[0] + ? parseRuntimeMinutesToObject( + item.runtime || item.episode_run_time?.[0] || 0 + ) + : { + full: "", + hours: 0, + minutes: 0, + }; + + return { + // 'title' property is on movies only. 'name' property is on + // shows only + title: item.name || item.title, + year: new Date(item.release_date || item.first_air_date).getFullYear(), + // @DEPRECATE + id: String(item.id), + ids: { + tmdbId: String(item.id), + // eslint-disable-next-line camelcase + imdbId: item.imdb_id || item.external_ids?.imdb_id || "", + }, + type, + certification: "n/a", + summary: item.overview, + genres: formatGenres(item, genres), + rating: item.vote_average, + runtime, + trailer: item.videos?.results?.length + ? `http://youtube.com/watch?v=${item.videos.results[0].key}` + : "n/a", + images: { + fanart: { + full: formatImage(imageUri, item.backdrop_path, "original"), + medium: formatImage(imageUri, item.backdrop_path, "w780"), + thumb: formatImage(imageUri, item.backdrop_path, "w342"), + }, + poster: { + full: formatImage(imageUri, item.poster_path, "original"), + medium: formatImage(imageUri, item.poster_path, "w780"), + thumb: formatImage(imageUri, item.poster_path, "w342"), + }, + }, + }; +} + +type RawSeasons = { + id: string; + name: string; + overview: string; + seasons: RawSeason[]; +}; + +function formatSeasons(show: RawSeasons): Season[] { + const firstSeasonIsZero = + show.seasons.length > 0 ? show.seasons[0].season_number === 0 : false; + + return show.seasons.map((season) => ({ + title: show.name, + rating: 0, + season: firstSeasonIsZero ? season.season_number + 1 : season.season_number, + overview: show.overview, + id: String(season.id), + ids: { + tmdbId: String(season.id), + }, + images: { + full: season.poster_path, + medium: season.poster_path, + thumb: season.poster_path, + }, + })); +} + +type RawSeason = { + id: string; + poster_path: string; + season_number: number; + episodes: RawEpisode[]; +}; + +function formatSeason(season: RawSeason): Episode[] { + return season.episodes.map((episode) => ({ + id: String(episode.id), + ids: { + tmdbId: String(episode.id), + }, + title: episode.name, + season: episode.season_number, + episode: episode.episode_number, + overview: episode.overview, + rating: episode.vote_average, + // rating: episode.rating ? roundRating(episode.rating) : 'n/a', + images: { + full: episode.poster_path, + medium: episode.poster_path, + thumb: episode.poster_path, + }, + })); +} + +type RawEpisode = { + id: string; + name: string; + season_number: number; + episode_number: number; + overview: string; + vote_average: number; + still_path: string; + poster_path: string; +}; + +function formatEpisode(episode: RawEpisode, imageUri: string): Episode { + return { + id: String(episode.id), + ids: { + tmdbId: String(episode.id), + }, + title: episode.name, + season: episode.season_number, + episode: episode.episode_number, + overview: episode.overview, + rating: episode.vote_average, + // rating: episode.rating ? roundRating(episode.rating) : 'n/a', + images: { + full: formatImage(imageUri, episode.still_path, "original"), + medium: formatImage(imageUri, episode.still_path, "w780"), + thumb: formatImage(imageUri, episode.still_path, "w342"), + }, + }; +} + +type Results = { + results: T; +}; + +export default class TheMovieDbMetadataProvider + extends BaseMetadataProvider + implements MetadataProviderInterface { + private cache: PctCache; + + private readonly apiKey: string = "c8cd3c25956bd78c687685e6dcb82a64"; + + private readonly imageUri: string = "https://image.tmdb.org/t/p"; + + private readonly apiUri: string = "https://api.themoviedb.org/3"; + + readonly supportedIdTypes: Array<"tmdb" | "imdb"> = ["tmdb", "imdb"]; + + private readonly genres: Genres = { + 12: "Adventure", + 14: "Fantasy", + 16: "Animation", + 18: "Drama", + 27: "Horror", + 28: "Action", + 35: "Comedy", + 36: "History", + 37: "Western", + 53: "Thriller", + 80: "Crime", + 99: "Documentary", + 878: "Science Fiction", + 9648: "Mystery", + 10402: "Music", + 10749: "Romance", + 10751: "Family", + 10752: "War", + 10770: "TV Movie", + 10759: "Action & Adventure", + 10762: "Kids", + 10763: "News", + 10764: "Reality", + 10765: "Sci-Fi & Fantasy", + 10766: "Soap", + 10767: "Talk", + 10768: "War & Politics", + }; + + private readonly params = { + api_key: this.apiKey, + append_to_response: "external_ids,videos", + }; + + private readonly theMovieDb: AxiosInstance = axios.create({ + baseURL: this.apiUri, + timeout: 10_000, + params: this.params, + }); + + constructor(opts: { cache?: PctCache } = {}) { + super(); + this.cache = opts.cache || new Cache(); + } + + @cache() + getMovies(page = 1) { + return this.theMovieDb + .get>("movie/popular", { + params: { + page, + ...this.params, + }, + }) + .then(({ data }) => + data.results.map((movie) => + formatItem(movie, ItemKind.Movie, this.imageUri, this.genres) + ) + ); + } + + @cache() + getTrending(limit = 5) { + return this.theMovieDb + .get>("trending/all/week", { + params: { ...this.params, limit: 5 }, + }) + .then(({ data }) => + data.results + .map((movie) => + formatItem(movie, ItemKind.Movie, this.imageUri, this.genres) + ) + .slice(0, limit) + ); + } + + @cache() + getMovie(itemId: string) { + return this.theMovieDb + .get(`movie/${itemId}`, { + params: this.params, + }) + .then(({ data }) => + formatItem(data, ItemKind.Movie, this.imageUri, this.genres) + ); + } + + @cache() + getShows(page = 1) { + return this.theMovieDb + .get>("tv/popular", { + params: { + page, + ...this.params, + }, + }) + .then(({ data }) => + data.results.map((show) => + formatItem(show, ItemKind.Show, this.imageUri, this.genres) + ) + ); + } + + @cache() + getShow(itemId: string): Promise { + return this.theMovieDb + .get(`tv/${itemId}`, { + params: this.params, + }) + .then(({ data }) => + formatItem(data, ItemKind.Show, this.imageUri, this.genres) + ); + } + + @cache() + getSeasons(itemId: string) { + return this.theMovieDb + .get(`tv/${itemId}`, { + params: this.params, + }) + .then(({ data }) => formatSeasons(data)); + } + + @cache() + getSeason(itemId: string, season: number) { + return this.theMovieDb + .get(`tv/${itemId}/season/${season}`, { + params: this.params, + }) + .then(({ data }) => formatSeason(data)); + } + + @cache() + getEpisode(itemId: string, season: number, episode: number) { + return this.theMovieDb + .get(`tv/${itemId}/season/${season}/episode/${episode}`, { + params: this.params, + }) + .then(({ data }) => formatEpisode(data, this.imageUri)); + } + + @cache() + search(query: string, page = 1) { + return this.theMovieDb + .get>("search/multi", { + params: { + page, + include_adult: true, + query, + ...this.params, + }, + }) + .then(({ data }) => + data.results.map((result) => + formatItem( + result, + result.media_type === "movie" ? ItemKind.Movie : ItemKind.Show, + this.imageUri, + this.genres + ) + ) + ); + } + + @cache() + getSimilar(type: ItemKind = ItemKind.Movie, itemId: string): Promise { + const urlType = (() => { + switch (type) { + case ItemKind.Movie: + return "movie"; + case ItemKind.Show: + return "tv"; + default: { + throw new Error(`Unexpected type "${type}"`); + } + } + })(); + + return this.theMovieDb + .get>(`${urlType}/${itemId}/recommendations`, { + params: this.params, + }) + .then(({ data }) => + data.results.map((movie) => + formatItem(movie, type, this.imageUri, this.genres) + ) + ); + } +} diff --git a/app/api/metadata/example.js b/app/api/metadata/example.js deleted file mode 100644 index afce8263..00000000 --- a/app/api/metadata/example.js +++ /dev/null @@ -1,92 +0,0 @@ -// Here is an example of the structure of a MetadataProvider -// All providers should take advantage of the Cache -// -// -// Here is an example of the structure of a MetadataProvider -// -// getMovie(itemId) {} -// -// { -// title: , -// year: , -// ids: { -// imdbId: 'tt134145', -// tmdbId: '2414140' -// } -// summary: , -// genres: , -// runtime: { -// full: , -// hours: , -// minutes: -// }, -// trailer: , A link to the trailer of the movie -// rating: , 1 - 5, round to 1 decimal place || n/a -// images: { -// fanart: { -// full: -// medium: -// thumb: -// }, -// poster: { -// full: -// medium: -// thumb: -// }, -// } -// } -// -// getMovie(itemId) {} -// -// getMovies(pageumber, limit, genre, sortMethod) {} -// -// search(searchQuery, genre, sortMethod) {} -// -// similar(itemId) {} -// -// getSeasons(itemId) {} -// -// [ -// { -// season: 1, -// images: { -// full: , -// medium: , -// thumb: -// } -// } -// ... -// ] -// -// getSeason(itemId, season) {} -// -// [ -// { -// title: 'Winter Is Coming', -// id: 'tt1480055', -// season: 1, -// episode: 1, -// rating: , 1 - 5, round to 1 decimal place || n/a -// images: { -// full: , -// medium: , -// thumb: -// } -// } -// ... -// ] -// -// getEpisode(itemId, season, episode) {} -// -// { -// title: 'Winter Is Coming', -// id: itemId, -// season: 1, -// episode: 1, -// overview: 'Ned Stark, Lord of Winterfell learns...' -// images: { -// full: , -// medium: , -// thumb: -// } -// } diff --git a/app/api/metadata/helpers.js b/app/api/metadata/helpers.js deleted file mode 100644 index 2de6479b..00000000 --- a/app/api/metadata/helpers.js +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint import/prefer-default-export: off */ -import type { runtimeType } from './MetadataProviderInterface'; - -/** - * Convert runtime from minutes to hours - * - * @param {number} runtimeInMinutes - * @return {object} - */ -export function parseRuntimeMinutesToObject( - runtimeInMinutes: number -): runtimeType { - const hours = runtimeInMinutes >= 60 ? Math.round(runtimeInMinutes / 60) : 0; - const minutes = runtimeInMinutes % 60; - - return { - full: - hours > 0 - ? `${hours} ${hours > 1 ? 'hours' : 'hour'}${ - minutes > 0 ? ` ${minutes} minutes` : '' - }` - : `${minutes} minutes`, - hours, - minutes - }; -} diff --git a/app/api/metadata/helpers.ts b/app/api/metadata/helpers.ts new file mode 100644 index 00000000..0a785b8f --- /dev/null +++ b/app/api/metadata/helpers.ts @@ -0,0 +1,21 @@ +/* eslint import/prefer-default-export: off */ +import { Runtime } from "./MetadataProviderInterface"; + +/** + * Convert runtime from minutes to hours + */ +export function parseRuntimeMinutesToObject(runtimeInMinutes: number): Runtime { + const hours = runtimeInMinutes >= 60 ? Math.round(runtimeInMinutes / 60) : 0; + const minutes = runtimeInMinutes % 60; + + return { + full: + hours > 0 + ? `${hours} ${hours > 1 ? "hours" : "hour"}${ + minutes > 0 ? ` ${minutes} minutes` : "" + }` + : `${minutes} minutes`, + hours, + minutes, + }; +} diff --git a/app/api/metadata/rndm.d.ts b/app/api/metadata/rndm.d.ts new file mode 100644 index 00000000..a8154ba9 --- /dev/null +++ b/app/api/metadata/rndm.d.ts @@ -0,0 +1,3 @@ +declare module "rndm" { + export default function rndm(len?: number): string; +} diff --git a/app/api/metadata/speedtest-net.d.ts b/app/api/metadata/speedtest-net.d.ts new file mode 100644 index 00000000..e8a8c718 --- /dev/null +++ b/app/api/metadata/speedtest-net.d.ts @@ -0,0 +1,5 @@ +declare module "speedtest-net" { + import { EventEmitter } from "events"; + + export default function speedTest(args: { maxTime?: number }): EventEmitter; +} diff --git a/app/api/metadata/srt2vtt.d.ts b/app/api/metadata/srt2vtt.d.ts new file mode 100644 index 00000000..0d57054c --- /dev/null +++ b/app/api/metadata/srt2vtt.d.ts @@ -0,0 +1,6 @@ +declare module "srt2vtt" { + export default function srt2vtt( + srtBuffer: Buffer, + fn: (error: Error | undefined, vttBuffer: Buffer) => void + ): void; +} diff --git a/app/api/metadata/yifysubtitles.d.ts b/app/api/metadata/yifysubtitles.d.ts new file mode 100644 index 00000000..0c74d17b --- /dev/null +++ b/app/api/metadata/yifysubtitles.d.ts @@ -0,0 +1,16 @@ +declare module "@amilajack/yifysubtitles" { + export interface YifySubtitle { + lang: string; + langShort: string; + path: string; + fileName: string; + } + export interface Opts { + path?: string; + langs?: string[]; + } + export default function yifysubtitles( + itemId: string, + opts?: Opts + ): Promise; +} diff --git a/app/api/players/BasePlayerProvider.ts b/app/api/players/BasePlayerProvider.ts new file mode 100644 index 00000000..3abeca20 --- /dev/null +++ b/app/api/players/BasePlayerProvider.ts @@ -0,0 +1,20 @@ +import { Item } from "../metadata/MetadataProviderInterface"; +import { Subtitle } from "../metadata/Subtitle"; + +export default class BasePlayerProvider { + public isPlaying = false; + + async play(contentUrl: string, item: Item, subtitles: Subtitle[]) { + if (!this.isPlaying) { + this.play(contentUrl, item, subtitles); + this.isPlaying = true; + } + } + + async pause() { + if (this.isPlaying) { + this.pause(); + this.isPlaying = false; + } + } +} diff --git a/app/api/players/ChromecastPlayerProvider.js b/app/api/players/ChromecastPlayerProvider.js deleted file mode 100644 index 662f1071..00000000 --- a/app/api/players/ChromecastPlayerProvider.js +++ /dev/null @@ -1,151 +0,0 @@ -// @flow -import { Client, DefaultMediaReceiver } from 'castv2-client'; -import mdns from 'mdns'; -import network from 'network-address'; -import type { - PlayerProviderInterface, - deviceType, - metadataType, - subtitleType -} from './PlayerProviderInterface'; - -type castv2DeviceType = { - fullname: string, - addresses: Array, - port: number, - txtRecord: { - fn: string - } -}; - -class ChromecastPlayerProvider implements PlayerProviderInterface { - provider = 'Chromecast'; - - providerId = 'chromecast'; - - supportsSubtitles = true; - - selectedDevice: deviceType; - - devices: Array = []; - - browser: { - on: (event: string, cb: (device: castv2DeviceType) => void) => void, - start: () => void, - stop: () => void, - removeAllListeners: () => void - }; - - constructor() { - this.browser = mdns.createBrowser(mdns.tcp('googlecast')); - } - - destroy() { - if (this.browser) { - this.browser.stop(); - } - } - - getDevices(timeout: number = 2000) { - return new Promise(resolve => { - const devices = []; - - this.browser.on('serviceUp', service => { - devices.push({ - name: service.txtRecord.fn, - id: service.fullname, - address: service.addresses[0], - port: service.port - }); - }); - - try { - this.browser.start(); - } catch (e) { - console.log(e); - } - - setTimeout(() => { - this.browser.stop(); - this.browser.removeAllListeners(); - resolve(devices); - this.devices = devices; - }, timeout); - }); - } - - selectDevice(deviceId: string) { - const selectedDevice = this.devices.find(device => device.id === deviceId); - if (!selectedDevice) { - throw new Error('Cannot find selected device'); - } - this.selectedDevice = selectedDevice; - return selectedDevice; - } - - play( - contentUrl: string, - metadata: metadataType, - subtitles: Array - ) { - const client = new Client(); - - if (!this.selectDevice) { - throw new Error('No device selected'); - } - - const networkAddress = network(); - const tracks = subtitles.map((subtitle, index) => ({ - trackId: index, // This is an unique ID, used to reference the track - type: 'TEXT', // Default Media Receiver currently only supports TEXT - trackContentId: subtitle.src.replace('localhost', networkAddress), // the URL of the VTT (enabled CORS and the correct ContentType are required) - trackContentType: 'text/vtt', // Currently only VTT is supported - name: subtitle.srclang, // a Name for humans - language: subtitle.srclang, // the language - subtype: 'SUBTITLES' // should be SUBTITLES - })); - - return new Promise((resolve, reject) => { - client.connect(this.selectedDevice.address, () => { - client.launch(DefaultMediaReceiver, (err, player) => { - if (err) reject(err); - - const media = { - // Here you can plug an URL to any mp4, webm, mp3 or jpg file with the proper contentType. - contentId: contentUrl, - contentType: 'video/mp4', - streamType: 'BUFFERED', // or LIVE - - tracks, - - // Title and cover displayed while buffering - metadata: { - type: 0, - metadataType: 0, - title: metadata.title, - images: [ - { - url: metadata.images.poster.full - }, - { - url: metadata.images.fanart.full - } - ] - } - }; - - player.load( - media, - { autoplay: true, activeTrackIds: tracks.map(e => e.trackId) }, - _err => { - if (_err) reject(_err); - resolve(); - } - ); - }); - }); - }); - } -} - -export default ChromecastPlayerProvider; diff --git a/app/api/players/ChromecastPlayerProvider.ts b/app/api/players/ChromecastPlayerProvider.ts new file mode 100644 index 00000000..778d8cbd --- /dev/null +++ b/app/api/players/ChromecastPlayerProvider.ts @@ -0,0 +1,157 @@ +import { Client, DefaultMediaReceiver, Player } from "castv2-client"; +import mdns, { Browser } from "mdns-js"; +import network from "network-address"; +import { PlayerProviderInterface, Device } from "./PlayerProviderInterface"; +import { Subtitle } from "../metadata/Subtitle"; +import { Item } from "../metadata/MetadataProviderInterface"; + +type RawDevice = { + addresses: string[]; + fullname: string; + host: string; + interfaceIndex: number; + networkInterface: string; + port: number; + type: Array<{ + description: string; + name: string; + protocol: string; + }>; +}; + +type DeviceMap = Map; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export default class ChromecastPlayerProvider + implements PlayerProviderInterface { + provider = "Chromecast"; + + providerId = "chromecast"; + + private selectedDevice?: Device; + + private devices: DeviceMap = new Map(); + + private browser: Browser; + + constructor() { + this.browser = mdns.createBrowser(mdns.tcp("googlecast")); + this.browser.on("ready", () => { + this.browser.discover(); + }); + } + + async cleanup() { + if (this.browser) { + this.browser.stop(); + } + } + + public async getDevices(timeout = 2_000): Promise { + const devices: DeviceMap = new Map(); + + this.browser.on("update", (data: RawDevice[]) => { + data.forEach((device) => { + devices.set(device.fullname, { + id: device.fullname, + address: device.addresses[0], + port: device.port, + name: "", + }); + }); + }); + + await delay(timeout); + this.browser.stop(); + this.browser.removeAllListeners(); + this.devices = devices; + + const deviceList = Array.from(devices.values()); + + if (deviceList.length) { + this.selectDevice(deviceList[0].id); + } + + return deviceList; + } + + private async selectDevice(deviceId: string): Promise { + const selectedDevice = Array.from(this.devices.values()).find( + (device) => device.id === deviceId + ); + if (!selectedDevice) { + throw new Error("Cannot find selected device"); + } + this.selectedDevice = selectedDevice; + } + + async play( + contentUrl: string, + item: Item, + subtitles: Subtitle[] + ): Promise { + const client = new Client(); + + if (!this.selectedDevice) { + throw new Error("No device selected"); + } + + const networkAddress = network(); + const tracks = subtitles.map((subtitle, index) => ({ + trackId: index, // This is an unique ID, used to reference the track + type: "TEXT", // Default Media Receiver currently only supports TEXT + trackContentId: subtitle.fullPath.replace("localhost", networkAddress), // the URL of the VTT (enabled CORS and the correct ContentType are required) + trackContentType: "text/vtt", // Currently only VTT is supported + name: subtitle.language, // a Name for humans + language: subtitle.language, // the language + subtype: "SUBTITLES", // should be SUBTITLES + })); + + await new Promise((resolve, reject) => { + if (!this.selectedDevice) { + throw new Error("No device selected"); + } + + client.connect(this.selectedDevice.address, () => { + client.launch(DefaultMediaReceiver, (err?: Error, player?: Player) => { + if (err) throw err; + if (!player) throw new Error("Player not set"); + + const media = { + // Here you can plug an URL to any mp4, webm, mp3 or jpg file with the proper contentType. + contentId: contentUrl, + contentType: "video/mp4", + streamType: "BUFFERED", // or LIVE + + tracks, + + // Title and cover displayed while buffering + metadata: { + type: 0, + metadataType: 0, + title: item.title, + images: [ + { + url: item.images.poster?.full || "", + }, + { + url: item.images.fanart?.full || "", + }, + ], + }, + }; + + player.load( + media, + { autoplay: true, activeTrackIds: tracks.map((e) => e.trackId) }, + (_err?: Error) => { + if (_err) reject(_err); + resolve(); + } + ); + }); + }); + }); + } +} diff --git a/app/api/players/DlnaPlayerProvider.js b/app/api/players/DlnaPlayerProvider.js deleted file mode 100644 index e69de29b..00000000 diff --git a/app/api/players/PlayerAdapter.js b/app/api/players/PlayerAdapter.js deleted file mode 100644 index b9d11f56..00000000 --- a/app/api/players/PlayerAdapter.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Provide a single API interface for all the providers - * @flow - */ -import ChromecastPlayerProvider from './ChromecastPlayerProvider'; -import type { - PlayerProviderInterface, - deviceType -} from './PlayerProviderInterface'; - -export default class PlayerAdapter { - providers: Array = [new ChromecastPlayerProvider()]; - - devices: Array; - - selectedDevice: deviceType; - - getDevices() { - return Promise.all( - this.providers.map(provider => provider.getDevices(2000)) - ); - } - - /** - * @TODO: Proxy all other method calls (ex. play, etc) to the selectedDevice - * instance - */ -} diff --git a/app/api/players/PlayerAdapter.ts b/app/api/players/PlayerAdapter.ts new file mode 100644 index 00000000..723678f6 --- /dev/null +++ b/app/api/players/PlayerAdapter.ts @@ -0,0 +1,90 @@ +import ChromecastPlayerProvider from "./ChromecastPlayerProvider"; +import PlyrPlayerProvider from "./PlyrPlayerProvider"; +import { + PlayerProviderInterface, + PlayerSelectMetadata, + PlayerKind, + Device, +} from "./PlayerProviderInterface"; +import { Subtitle } from "../metadata/Subtitle"; +import { Item } from "../metadata/MetadataProviderInterface"; + +/** + * Provide a single API interface for all the providers + * + * @example + * ```ts + * const player = new PlayerAdapter(); + * await player.selectPlayer(PlayerKind.ChromeCast); + * await player.play(); + * await player.pause(); + * ``` + */ +export default class PlayerAdapter { + static nativePlaybackFormats = [ + "mp4", + "ogg", + "mov", + "webmv", + "mkv", + "wmv", + "avi", + ]; + + static experimentalPlaybackFormats = []; + + private providers: Map = new Map([ + [PlayerKind.Plyr, new PlyrPlayerProvider()], + [PlayerKind.Chromecast, new ChromecastPlayerProvider()], + ]); + + private provider: PlayerProviderInterface = this.providers.get( + PlayerKind.Plyr + ) as PlayerProviderInterface; + + public isPlaying = false; + + public async selectPlayer( + playerKind: PlayerKind, + metadata: PlayerSelectMetadata = {} + ): Promise { + if (!this.providers.has(playerKind)) { + throw new Error(`Player "${playerKind}" not supported`); + } + await this.provider.cleanup(); + this.provider = this.providers.get(playerKind) as PlayerProviderInterface; + return this.provider.setup(metadata); + } + + public play( + url: string, + metadata: Item, + subtitles: Subtitle[] = [] + ): Promise { + return this.provider.play(url, metadata, subtitles); + } + + public pause(): Promise { + return this.provider.pause(); + } + + public setup(arg: Record): Promise { + return this.provider.setup(arg); + } + + public cleanup(): Promise { + return this.provider.cleanup(); + } + + getPlayerName(): PlayerKind { + return this.provider.name; + } + + public async getDevices(): Promise { + const providers = Array.from(this.providers.values()); + const devices = await Promise.all( + providers.map((provider) => provider.getDevices()) + ); + return devices.flat(); + } +} diff --git a/app/api/players/PlayerProviderInterface.js b/app/api/players/PlayerProviderInterface.js deleted file mode 100644 index cbeb723c..00000000 --- a/app/api/players/PlayerProviderInterface.js +++ /dev/null @@ -1,60 +0,0 @@ -// @flow -// Initialize the player - -import type { imagesType } from '../metadata/MetadataProviderInterface'; - -export type deviceType = { - id: string, - name: string, - address: string, - port: number -}; - -export type metadataType = { - title: string, - images: imagesType -}; - -export interface PlayerProviderInterface { - provider: string; - - providerId: string; - - selectedDevice?: deviceType; - - devices: Array; - - supportedFormats: Array; - - supportsSubtitles: boolean; - - svgIconFilename: string; - - contentUrl: string; - - port: number; - - constructor: () => void; - - getDevices: (timeout: number) => Promise>; - - seek: (seconds: number) => void; - - selectDevice: (deviceId: string) => deviceType; - - play: (contentUrl: string, metadata: metadataType) => Promise; - - pause: () => Promise; - - restart: () => Promise; - - /** - * Handle any logic to remove the traces of the player from memory - */ - destroy: () => Promise; - - /** - * Check if the plugin is supported on the machine - */ - isSupported: () => Promise; -} diff --git a/app/api/players/PlayerProviderInterface.ts b/app/api/players/PlayerProviderInterface.ts new file mode 100644 index 00000000..9dc49401 --- /dev/null +++ b/app/api/players/PlayerProviderInterface.ts @@ -0,0 +1,46 @@ +import { Item } from "../metadata/MetadataProviderInterface"; +import { Subtitle } from "../metadata/Subtitle"; + +export type PlayerSelectMetadata = Record; + +export enum PlayerKind { + Plyr = "plyr", + Chromecast = "chromecast", + YouTube = "youtube", +} + +export type Device = { + id: string; + name: string; + address: string; + port: number; +}; + +export type PlayerKindNames = "plyr" | "chromecast" | "youtube"; + +export interface PlayerProviderInterface { + name: PlayerKind; + + getDevices: () => Promise; + + selectDevice: (id: string) => Promise; + + seek: (seconds: number) => Promise; + + play: ( + contentUrl: string, + item: Item, + subtitles: Subtitle[] + ) => Promise; + + pause: () => Promise; + + restart: () => Promise; + + setup: (metadata?: PlayerSelectMetadata) => Promise; + + /** + * Handle any logic to remove the traces of the player from memory + */ + cleanup: () => Promise; +} diff --git a/app/api/players/PlyrPlayerProvider.ts b/app/api/players/PlyrPlayerProvider.ts new file mode 100644 index 00000000..e6c1171e --- /dev/null +++ b/app/api/players/PlyrPlayerProvider.ts @@ -0,0 +1,84 @@ +/* eslint class-methods-use-this: off */ +import { remote } from "electron"; +import Plyr from "plyr"; +import BaseTorrentProvider from "./BasePlayerProvider"; +import { Subtitle } from "../metadata/Subtitle"; +import { Item } from "../metadata/MetadataProviderInterface"; + +const { powerSaveBlocker } = remote; + +type PlyrSubtitle = { kind: string; src: string; srclang: string }; + +export default class PlayerProviderInterface + extends BaseTorrentProvider + implements PlayerProviderInterface { + private powerSaveBlockerId?: number; + + private plyr?: Plyr; + + private isSetup = false; + + public readonly name = "plyr"; + + async getDevices() { + return []; + } + + private formatSubtitles(subtitles: Subtitle[]): PlyrSubtitle[] { + return subtitles.map((subtitle) => ({ + // Set the default language for subtitles + default: subtitle.language === process.env.DEFAULT_TORRENT_LANG, + kind: "captions", + label: subtitle.language, + srclang: subtitle.language, + src: subtitle.fullPath, + })); + } + + async play(url: string, item: Item, _subtitles: Subtitle[]) { + const subtitles = this.formatSubtitles(_subtitles); + if (!this.plyr) throw new Error("plyr not setup"); + this.plyr.updateHtmlVideoSource( + url, + "video", + item.title, + undefined, + subtitles + ); + // this.plyr.play(); + } + + async restart() { + if (!this.plyr) throw new Error("plyr not setup"); + this.plyr.restart(); + } + + async pause() { + if (!this.plyr) throw new Error("plyr not setup"); + this.plyr.pause(); + } + + async setup({ plyr }: { plyr: Plyr }) { + if (this.isSetup) return; + this.plyr = plyr; + this.powerSaveBlockerId = powerSaveBlocker.start("prevent-app-suspension"); + this.isSetup = true; + } + + async cleanup() { + if (!this.isSetup) return; + if (!this.plyr) throw new Error("plyr not setup"); + + if (this.isPlaying) { + this.pause(); + } + // Plyr sometimes does not have destroy method + if (this.plyr.destroy) { + this.plyr.destroy(); + } + if (this.powerSaveBlockerId) { + powerSaveBlocker.stop(this.powerSaveBlockerId); + } + this.isSetup = false; + } +} diff --git a/app/api/players/VlcPlayerProvider.js b/app/api/players/VlcPlayerProvider.js deleted file mode 100644 index e69de29b..00000000 diff --git a/app/api/torrents/BaseTorrentProvider.js b/app/api/torrents/BaseTorrentProvider.js deleted file mode 100644 index 13c1af92..00000000 --- a/app/api/torrents/BaseTorrentProvider.js +++ /dev/null @@ -1,277 +0,0 @@ -// @flow -/* eslint prefer-template: 0 */ -import Cache from 'lru-cache'; -import url from 'url'; -import TheMovieDbMetadataProvider from '../metadata/TheMovieDbMetadataProvider'; -import type { torrentType } from './TorrentProviderInterface'; - -export const providerCache = new Cache({ - maxAge: process.env.CONFIG_CACHE_TIMEOUT - ? parseInt(process.env.CONFIG_CACHE_TIMEOUT, 10) * 1000 * 60 * 60 - : 1000 * 60 * 60 // 1 hr -}); - -// Create a promise that rejects in milliseconds -export function timeout(promise: Promise, ms: number = 20000) { - const timeoutPromise = new Promise((resolve, reject) => { - const id = setTimeout( - () => { - clearTimeout(id); - reject(new Error('Torrent Provider timeout exceeded')); - }, - process.env.CONFIG_API_TIMEOUT - ? parseInt(process.env.CONFIG_API_TIMEOUT, 10) - : ms - ); - }); - - // Returns a race between our timeout and the passed in promise - return Promise.race([promise, timeoutPromise]); -} - -export function getHealth(seeders: number, leechers: number = 0): string { - const ratio = seeders && !!leechers ? seeders / leechers : seeders; - - if (seeders < 50) { - return 'poor'; - } - - if (ratio > 1 && seeders >= 50 && seeders < 500) { - return 'decent'; - } - - if (ratio > 1 && seeders >= 100) { - return 'healthy'; - } - - return 'poor'; -} - -export function hasNonEnglishLanguage(metadata: string): boolean { - if (metadata.includes('french')) return true; - if (metadata.includes('german')) return true; - if (metadata.includes('greek')) return true; - if (metadata.includes('dutch')) return true; - if (metadata.includes('hindi')) return true; - if (metadata.includes('português')) return true; - if (metadata.includes('portugues')) return true; - if (metadata.includes('spanish')) return true; - if (metadata.includes('español')) return true; - if (metadata.includes('espanol')) return true; - if (metadata.includes('latino')) return true; - if (metadata.includes('russian')) return true; - if (metadata.includes('subtitulado')) return true; - - return false; -} - -export function hasSubtitles(metadata: string): boolean { - return metadata.includes('sub'); -} - -export function sortTorrentsBySeeders(torrents: Array): Array { - return torrents.sort((prev: Object, next: Object) => - prev.seeders === next.seeders ? 0 : prev.seeders > next.seeders ? -1 : 1 - ); -} - -export function constructMovieQueries( - title: string, - itemId: string -): Array { - const queries = [ - title, // default - itemId - ]; - - return title.includes("'") ? [...queries, title.replace(/'/g, '')] : queries; -} - -export function formatSeasonEpisodeToObject( - season: number, - episode: ?number -): Object { - return { - season: String(season).length === 1 ? '0' + String(season) : String(season), - episode: - String(episode).length === 1 ? '0' + String(episode) : String(episode) - }; -} - -export function constructSeasonQueries( - title: string, - season: number -): Array { - const formattedSeasonNumber = `s${ - formatSeasonEpisodeToObject(season, 1).season - }`; - - return [ - `${title} season ${season}`, - `${title} season ${season} complete`, - `${title} season ${formattedSeasonNumber} complete` - ]; -} - -/** - * @param {array} results | A two-dimentional array containing arrays of results - */ -export function merge(results: Array) { - return results.reduce((previous, current) => [...previous, ...current]); -} - -export function resolveEndpoint(defaultEndpoint: string, providerId: string) { - const endpointEnvVariable = `CONFIG_ENDPOINT_${providerId}`; - - switch (process.env[endpointEnvVariable]) { - case undefined: - return defaultEndpoint; - default: - return url.format({ - ...url.parse(defaultEndpoint), - hostname: process.env[endpointEnvVariable], - host: process.env[endpointEnvVariable] - }); - } -} - -export function getIdealTorrent(torrents: Array): torrentType { - const idealTorrent = torrents - .filter(torrent => !!torrent) - .filter( - torrent => - !!torrent && !!torrent.magnet && typeof torrent.seeders === 'number' - ); - - return idealTorrent.sort((prev: torrentType, next: torrentType) => { - if (prev.seeders === next.seeders) { - return 0; - } - - if (!next.seeders || !prev.seeders) return 1; - - return prev.seeders > next.seeders ? -1 : 1; - })[0]; -} - -export function handleProviderError(error: Error) { - if (process.env.NODE_ENV === 'development') { - console.log(error); - } -} - -export function resolveCache(key: string): boolean | any { - if (process.env.API_USE_MOCK_DATA === 'true') { - const mock = { - ...require('../../../test/api/metadata.mock'), // eslint-disable-line global-require - ...require('../../../test/api/torrent.mock') // eslint-disable-line global-require - }; - - const resolvedCacheItem = Object.keys(mock).find( - (mockKey: string): boolean => - key.includes(`${mockKey}"`) && !!Object.keys(mock[mockKey]).length - ); - - if (resolvedCacheItem) { - return resolvedCacheItem; - } - - console.warn('Fetching from network:', key); - - return false; - } - - return providerCache.has(key) ? providerCache.get(key) : false; -} - -export function setCache(key: string, value: any) { - if (process.env.NODE_ENV === 'development') { - console.info('Setting cache key:', key); - } - return providerCache.set(key, value); -} - -export function hasNonNativeCodec(metadata: string): boolean { - return metadata.includes('avi') || metadata.includes('mkv'); -} - -export function determineQuality( - magnet: string, - metadata: string = '' -): string { - const lowerCaseMetadata = (metadata || magnet).toLowerCase(); - - if (process.env.FLAG_UNVERIFIED_TORRENTS === 'true') { - return '480p'; - } - - // Filter non-english languages - if (hasNonEnglishLanguage(lowerCaseMetadata)) { - return ''; - } - - // Filter videos with 'rendered' subtitles - if (hasSubtitles(lowerCaseMetadata)) { - return process.env.FLAG_SUBTITLE_EMBEDDED_MOVIES === 'true' ? '480p' : ''; - } - - // Most accurate categorization - if (lowerCaseMetadata.includes('1080')) return '1080p'; - if (lowerCaseMetadata.includes('720')) return '720p'; - if (lowerCaseMetadata.includes('480')) return '480p'; - - // Guess the quality 1080p - if (lowerCaseMetadata.includes('bluray')) return '1080p'; - if (lowerCaseMetadata.includes('blu-ray')) return '1080p'; - - // Guess the quality 720p, prefer english - if (lowerCaseMetadata.includes('dvd')) return '720p'; - if (lowerCaseMetadata.includes('rip')) return '720p'; - if (lowerCaseMetadata.includes('mp4')) return '720p'; - if (lowerCaseMetadata.includes('web')) return '720p'; - if (lowerCaseMetadata.includes('hdtv')) return '720p'; - if (lowerCaseMetadata.includes('eng')) return '720p'; - - if (hasNonNativeCodec(lowerCaseMetadata)) { - return process.env.FLAG_SUPPORTED_PLAYBACK_FILTERING === 'true' - ? '720p' - : ''; - } - - if (process.env.NODE_ENV === 'development') { - console.warn(`${magnet}, could not be verified`); - } - - return ''; -} - -export async function convertTmdbToImdb(tmdbId: string): Promise { - const theMovieDbProvider = new TheMovieDbMetadataProvider(); - const movie = await theMovieDbProvider.getMovie(tmdbId); - if (!movie.ids.imdbId) { - throw new Error('Cannot convert tmdbId to imdbId'); - } - return movie.ids.imdbId; -} - -export function formatSeasonEpisodeToString( - season: number, - episode: number -): string { - return ( - 's' + - (String(season).length === 1 ? '0' + String(season) : String(season)) + - ('e' + - (String(episode).length === 1 ? '0' + String(episode) : String(episode))) - ); -} - -export function isExactEpisode( - title: string, - season: number, - episode: number -): boolean { - return title - .toLowerCase() - .includes(formatSeasonEpisodeToString(season, episode)); -} diff --git a/app/api/torrents/BaseTorrentProvider.ts b/app/api/torrents/BaseTorrentProvider.ts new file mode 100644 index 00000000..0fb8aabb --- /dev/null +++ b/app/api/torrents/BaseTorrentProvider.ts @@ -0,0 +1,222 @@ +/* eslint prefer-template: off */ +import url from "url"; +import TheMovieDbMetadataProvider from "../metadata/TheMovieDbMetadataProvider"; +import { Torrent, Health } from "./TorrentProviderInterface"; +import SettingsManager from "../../utils/Settings"; + +// Create a promise that rejects in milliseconds +export function timeout(promise: Promise, ms = 20_000): Promise { + const timeoutPromise = new Promise((resolve, reject) => { + const id = setTimeout( + () => { + clearTimeout(id); + reject(new Error("Torrent Provider timeout exceeded")); + }, + process.env.CONFIG_API_TIMEOUT + ? parseInt(process.env.CONFIG_API_TIMEOUT, 10) + : ms + ); + }); + + // Returns a race between our timeout and the passed in promise + return Promise.race([promise, timeoutPromise]); +} + +export function getHealth(seeders = 0, leechers = 1): Health { + const ratio = seeders / leechers; + + if (seeders < 50) { + return "poor"; + } + + if (ratio > 0.4 && seeders >= 50 && seeders < 500) { + return "decent"; + } + + if (ratio > 0.7 && seeders >= 100) { + return "healthy"; + } + + return "poor"; +} + +export function hasNonEnglishLanguage(language: string): boolean { + return [ + "french", + "german", + "greek", + "dutch", + "hindi", + "português", + "portugues", + "spanish", + "español", + "espanol", + "latino", + "russian", + "subtitulado", + ].includes(language); +} + +export function hasSubtitles(metadata: string): boolean { + return metadata.includes("sub"); +} + +export function sortTorrentsBySeeders(torrents: Torrent[]): Torrent[] { + return torrents.sort((a, b) => b.seeders - a.seeders); +} + +export function constructMovieQueries( + title: string, + itemId: string +): Array { + const queries = [ + title, // default + itemId, + ]; + + return title.includes("'") ? [...queries, title.replace(/'/g, "")] : queries; +} + +export function formatSeasonEpisodeToObject( + season: number, + episode?: number +): { season: string; episode: string } { + return { + season: String(season).length === 1 ? "0" + String(season) : String(season), + episode: + String(episode).length === 1 ? "0" + String(episode) : String(episode), + }; +} + +export function constructSeasonQueries( + title: string, + season: number +): Array { + const formattedSeasonNumber = `s${ + formatSeasonEpisodeToObject(season, 1).season + }`; + + return [ + `${title} season ${season}`, + `${title} season ${season} complete`, + `${title} season ${formattedSeasonNumber} complete`, + ]; +} + +export function resolveEndpoint(defaultEndpoint: string, providerId: string) { + const endpointEnvVariable = `CONFIG_ENDPOINT_${providerId}`; + + switch (process.env[endpointEnvVariable]) { + case undefined: + return defaultEndpoint; + default: + return url.format({ + ...url.parse(defaultEndpoint), + hostname: process.env[endpointEnvVariable], + host: process.env[endpointEnvVariable], + }); + } +} + +/** + * Sort the torrents by seeders in descending order + */ +export function selectIdealTorrent( + torrents: Array +): Torrent | undefined { + const idealTorrent = torrents + .filter((torrent) => !!torrent && torrent.seeders) + .filter( + (torrent) => + !!torrent && !!torrent.magnet && typeof torrent.seeders === "number" + ); + + return idealTorrent.sort( + (a: Torrent, b: Torrent) => b.seeders - a.seeders + )[0]; +} + +export function handleProviderError(error: Error) { + if (process.env.NODE_ENV === "development") { + console.log(error); + } +} + +export function hasNonNativeCodec(metadata: string): boolean { + return metadata.includes("avi") || metadata.includes("mkv"); +} + +export function determineQuality(magnet: string, metadata = ""): string { + const lowerCaseMetadata = (metadata || magnet).toLowerCase(); + + // Filter non-english languages + if (hasNonEnglishLanguage(lowerCaseMetadata)) { + return ""; + } + + // Filter videos with 'rendered' subtitles + if (hasSubtitles(lowerCaseMetadata)) { + return SettingsManager.isFlagEnabled("subtitle_embedded_movies") + ? "480p" + : ""; + } + + // Most accurate categorization + if (lowerCaseMetadata.includes("1080")) return "1080p"; + if (lowerCaseMetadata.includes("720")) return "720p"; + if (lowerCaseMetadata.includes("480")) return "480p"; + + // Guess the quality 1080p + if (lowerCaseMetadata.includes("bluray")) return "1080p"; + if (lowerCaseMetadata.includes("blu-ray")) return "1080p"; + + // Guess the quality 720p, prefer english + if (lowerCaseMetadata.includes("dvd")) return "720p"; + if (lowerCaseMetadata.includes("rip")) return "720p"; + if (lowerCaseMetadata.includes("mp4")) return "720p"; + if (lowerCaseMetadata.includes("web")) return "720p"; + if (lowerCaseMetadata.includes("hdtv")) return "720p"; + if (lowerCaseMetadata.includes("eng")) return "720p"; + + if (hasNonNativeCodec(lowerCaseMetadata)) { + return "720p"; + } + + if (process.env.NODE_ENV === "development") { + console.warn(`${magnet}, could not be verified`); + } + + return SettingsManager.isFlagEnabled("unverified_torrents") ? "7200p" : ""; +} + +export async function convertTmdbToImdb(tmdbId: string): Promise { + const theMovieDbProvider = new TheMovieDbMetadataProvider(); + const movie = await theMovieDbProvider.getMovie(tmdbId); + if (!movie.ids.imdbId) { + throw new Error("Cannot convert tmdbId to imdbId"); + } + return movie.ids.imdbId; +} + +export function formatSeasonEpisodeToString( + season: number, + episode: number +): string { + return ( + "s" + + (String(season).length === 1 ? "0" + String(season) : String(season)) + + ("e" + + (String(episode).length === 1 ? "0" + String(episode) : String(episode))) + ); +} + +export function isExactEpisode( + title: string, + season: number, + episode: number +): boolean { + return title + .toLowerCase() + .includes(formatSeasonEpisodeToString(season, episode)); +} diff --git a/app/api/torrents/Cache.ts b/app/api/torrents/Cache.ts new file mode 100644 index 00000000..5033dc60 --- /dev/null +++ b/app/api/torrents/Cache.ts @@ -0,0 +1,52 @@ +import { remote } from "electron"; +import LruCache from "lru-cache"; +import { Item } from "../metadata/MetadataProviderInterface"; +import { Torrent, TorrentSelection } from "./TorrentProviderInterface"; +import Config from "../../utils/Config"; + +export type PctCacheValue = Item | Item[] | Torrent | TorrentSelection; +export type PctCache = Cache; + +export default class Cache extends LruCache { + constructor() { + // CONFIG_CACHE_TIMEOUT is given as hours + const maxAge = process.env.CONFIG_CACHE_TIMEOUT + ? parseInt(process.env.CONFIG_CACHE_TIMEOUT, 10) * 1_000 * 60 * 60 + : 1_000 * 60 * 60; + + super({ + maxAge, + }); + + this.load(Config.get("cache") || []); + + remote.getCurrentWindow().on("close", () => { + this.write(); + }); + } + + /** + * Write the cache to the user's config + */ + write() { + if (process.env.NODE_ENV === "development") { + console.log("Setting cache config"); + } + Config.set("cache", this.dump()); + } + + set(key: K, value: V): boolean { + if (process.env.NODE_ENV === "development") { + console.log(`Setting cache key: ${JSON.stringify({ key, value })}`); + } + return super.set(key, value); + } + + get(key: K): V | undefined { + const value = super.get(key); + if (process.env.NODE_ENV === "development" && value) { + console.log(`Getting cache value: ${JSON.stringify({ key, value })}`); + } + return super.get(key); + } +} diff --git a/app/api/torrents/KatTorrentProvider.js b/app/api/torrents/KatTorrentProvider.js deleted file mode 100644 index 8b99aae9..00000000 --- a/app/api/torrents/KatTorrentProvider.js +++ /dev/null @@ -1,104 +0,0 @@ -// @flow -import { search } from 'super-kat'; -import { - formatSeasonEpisodeToString, - constructSeasonQueries, - constructMovieQueries, - merge, - timeout, - handleProviderError, - resolveEndpoint -} from './BaseTorrentProvider'; -import type { TorrentProviderInterface } from './TorrentProviderInterface'; - -const endpoint = 'https://katproxy.al'; -const providerId = 'KAT'; -const resolvedEndpoint = resolveEndpoint(endpoint, providerId); - -export default class KatTorrentProvider implements TorrentProviderInterface { - static providerName = 'Kat'; - - static fetch(query: string) { - return search(query) - .then(torrents => - torrents - .map(torrent => this.formatTorrent(torrent)) - .filter(torrent => !!torrent.magnet) - ) - .catch(error => { - handleProviderError(error); - return []; - }); - } - - static formatTorrent(torrent) { - return { - magnet: torrent.magnet, - seeders: torrent.seeders, - leechers: torrent.leechers, - metadata: - String(torrent.title + torrent.magnet) || String(torrent.magnet), - _provider: 'kat' - }; - } - - static getStatus() { - return fetch(resolvedEndpoint) - .then(res => res.ok) - .catch(() => false); - } - - static provide(itemId: string, type: string, extendedDetails: Object = {}) { - const { searchQuery } = extendedDetails; - - switch (type) { - case 'movies': - return ( - timeout( - Promise.all( - constructMovieQueries(searchQuery, itemId).map(query => - this.fetch(query) - ) - ) - ) - // Flatten array of arrays to an array with no empty arrays - .then(res => merge(res).filter(array => array.length !== 0)) - .catch(error => { - handleProviderError(error); - return []; - }) - ); - case 'shows': { - const { season, episode } = extendedDetails; - - return this.fetch( - `${searchQuery} ${formatSeasonEpisodeToString(season, episode)}` - ).catch(error => { - handleProviderError(error); - return []; - }); - } - case 'season_complete': { - const { season } = extendedDetails; - const queries = constructSeasonQueries(searchQuery, season); - - return timeout(Promise.all(queries.map(query => this.fetch(query)))) - .then(res => - res.reduce((previous, current) => - previous.length && current.length - ? [...previous, ...current] - : previous.length && !current.length - ? previous - : current - ) - ) - .catch(error => { - handleProviderError(error); - return []; - }); - } - default: - return []; - } - } -} diff --git a/app/api/torrents/KatTorrentProvider.ts b/app/api/torrents/KatTorrentProvider.ts new file mode 100644 index 00000000..cfdaf8e8 --- /dev/null +++ b/app/api/torrents/KatTorrentProvider.ts @@ -0,0 +1,113 @@ +// @ts-nocheck +import { search } from "super-kat"; +import { + formatSeasonEpisodeToString, + constructSeasonQueries, + constructMovieQueries, + timeout, + handleProviderError, + resolveEndpoint, +} from "./BaseTorrentProvider"; +import { + TorrentProviderInterface, + ExtendedDetails, + ProviderTorrent, +} from "./TorrentProviderInterface"; +import { ItemKind } from "../metadata/MetadataProviderInterface"; + +const endpoint = "https://katproxy.al"; +const providerId = "KAT"; +const resolvedEndpoint = resolveEndpoint(endpoint, providerId); + +type RawTorrent = { + magnet: string; + seeders: number; + leechers: number; + metadata: string; + title: string; +}; + +export default class KatTorrentProvider implements TorrentProviderInterface { + static providerName = "Kat"; + + static fetch(query: string): ProviderTorrent[] { + return search(query) + .then((torrents: RawTorrent[]) => + torrents + .map((torrent) => this.formatTorrent(torrent)) + .filter((torrent) => !!torrent.magnet) + ) + .catch((error: Error) => { + handleProviderError(error); + return []; + }); + } + + static formatTorrent(torrent: RawTorrent): ProviderTorrent { + return { + quality: "1080p", + magnet: torrent.magnet, + seeders: torrent.seeders, + leechers: torrent.leechers, + metadata: + String(torrent.title + torrent.magnet) || String(torrent.magnet), + _provider: "kat", + }; + } + + static getStatus(): Promise { + return fetch(resolvedEndpoint) + .then((res) => res.ok) + .catch(() => false); + } + + static provide( + itemId: string, + type: ItemKind, + extendedDetails: ExtendedDetails = {} + ): Promise { + const { searchQuery } = extendedDetails; + + switch (type) { + case ItemKind.Movie: + return ( + timeout( + Promise.all( + constructMovieQueries(searchQuery, itemId).map((query) => + this.fetch(query) + ) + ) + ) + // Flatten array of arrays to an array with no empty arrays + .then((res) => res.flat().filter((array) => array.length !== 0)) + .catch((error) => { + handleProviderError(error); + return []; + }) + ); + case ItemKind.Show: { + const { season, episode } = extendedDetails; + + return this.fetch( + `${searchQuery} ${formatSeasonEpisodeToString(season, episode)}` + ).catch((error: Error) => { + handleProviderError(error); + return []; + }); + } + case "season_complete": { + const { season } = extendedDetails; + const queries = constructSeasonQueries(searchQuery, season); + + return timeout(Promise.all(queries.map((query) => this.fetch(query)))) + .then((res) => res.flat()) + .catch((error) => { + handleProviderError(error); + return []; + }); + } + default: + return Promise.resolve([]); + } + } +} diff --git a/app/api/torrents/PbTorrentProvider.js b/app/api/torrents/PbTorrentProvider.ts similarity index 51% rename from app/api/torrents/PbTorrentProvider.js rename to app/api/torrents/PbTorrentProvider.ts index 2cdef47f..209dfa33 100644 --- a/app/api/torrents/PbTorrentProvider.js +++ b/app/api/torrents/PbTorrentProvider.ts @@ -1,107 +1,126 @@ +// @ts-nocheck /** * Pirate Bay torrent provider - * @flow */ -import fetch from 'isomorphic-fetch'; +import fetch from "node-fetch"; import { formatSeasonEpisodeToString, constructSeasonQueries, constructMovieQueries, - merge, timeout, handleProviderError, - resolveEndpoint -} from './BaseTorrentProvider'; -import type { TorrentProviderInterface } from './TorrentProviderInterface'; + resolveEndpoint, +} from "./BaseTorrentProvider"; +import { + TorrentProviderInterface, + ProviderTorrent, + SearchDetail, +} from "./TorrentProviderInterface"; +import { ItemKind } from "../metadata/MetadataProviderInterface"; -const endpoint = 'https://pirate-bay-endpoint.herokuapp.com'; -const providerId = 'PB'; +const endpoint = "https://pirate-bay-endpoint.herokuapp.com"; +const providerId = "PB"; const resolvedEndpoint = resolveEndpoint(endpoint, providerId); +type RawTorrent = { + magnetLink: string; + seeders: string; + leechers: string; + name: string; + link: string; +}; + export default class PbTorrentProvider implements TorrentProviderInterface { - static providerName = 'PirateBay'; + static providerName = "PirateBay"; - static fetch(searchQuery: string) { + static fetch(searchQuery: string): Promise { // HACK: Temporary solution to improve performance by side stepping // PirateBay's database errors. const searchQueryUrl = `${resolvedEndpoint}/search/${searchQuery}`; return timeout(fetch(searchQueryUrl)) - .then(res => res.json()) - .then(torrents => torrents.map(torrent => this.formatTorrent(torrent))) - .catch(error => { + .then((res) => res.json()) + .then((torrents) => + torrents.map((torrent: RawTorrent) => this.formatTorrent(torrent)) + ) + .catch((error) => { handleProviderError(error); return []; }); } - static formatTorrent(torrent) { + static formatTorrent(torrent: RawTorrent): ProviderTorrent { return { + quality: "1080p", magnet: torrent.magnetLink, seeders: parseInt(torrent.seeders, 10), leechers: parseInt(torrent.leechers, 10), metadata: - (String(torrent.name) || '') + - (String(torrent.magnetLink) || '') + - (String(torrent.link) || ''), - _provider: 'pb' + (String(torrent.name) || "") + + (String(torrent.magnetLink) || "") + + (String(torrent.link) || ""), + _provider: "pb", }; } static getStatus(): Promise { return fetch(resolvedEndpoint) - .then(res => res.ok) + .then((res) => res.ok) .catch(() => false); } - static provide(itemId: string, type: string, extendedDetails: Object = {}) { + static provide( + itemId: string, + type: ItemKind, + extendedDetails: SearchDetail + ): Promise { if (!extendedDetails.searchQuery) { - return new Promise(resolve => resolve([])); + return new Promise((resolve) => resolve([])); } const { searchQuery } = extendedDetails; switch (type) { - case 'movies': { + case ItemKind.Movie: { return ( Promise.all( - constructMovieQueries(searchQuery, itemId).map(query => + constructMovieQueries(searchQuery, itemId).map((query) => this.fetch(query) ) ) // Flatten array of arrays to an array with no empty arrays - .then(res => merge(res).filter(array => array.length !== 0)) - .catch(error => { + .then((res) => res.flat().filter((array) => array.length !== 0)) + .catch((error) => { handleProviderError(error); return []; }) ); } - case 'shows': { + case ItemKind.Show: { const { season, episode } = extendedDetails; return this.fetch( `${searchQuery} ${formatSeasonEpisodeToString(season, episode)}` - ).catch(error => { + ).catch((error) => { handleProviderError(error); return []; }); } - case 'season_complete': { + case "season_complete": { const { season } = extendedDetails; const queries = constructSeasonQueries(searchQuery, season); return ( - Promise.all(queries.map(query => this.fetch(query))) + Promise.all(queries.map((query) => this.fetch(query))) // Flatten array of arrays to an array with no empty arrays - .then(res => merge(res).filter(array => array.length !== 0)) - .catch(error => { + .then((res) => res.flat().filter((array) => array.length !== 0)) + .catch((error) => { handleProviderError(error); return []; }) ); } default: - return []; + return Promise.resolve([]); } } } diff --git a/app/api/torrents/PctTorrentProvider.js b/app/api/torrents/PctTorrentProvider.js deleted file mode 100644 index 582f03fe..00000000 --- a/app/api/torrents/PctTorrentProvider.js +++ /dev/null @@ -1,138 +0,0 @@ -// @flow -import fetch from 'isomorphic-fetch'; -import { - handleProviderError, - timeout, - resolveEndpoint -} from './BaseTorrentProvider'; -import type { TorrentProviderInterface } from './TorrentProviderInterface'; - -const endpoint = 'http://api-fetch.website/tv'; -const providerId = 'PCT'; -const resolvedEndpoint = resolveEndpoint(endpoint, providerId); - -export default class PctTorrentProvider implements TorrentProviderInterface { - static providerName = 'PopcornTime API'; - - static shows = {}; - - static async fetch( - itemId: string, - type: string, - extendedDetails: Object = {} - ) { - const urlTypeParam = type === 'movies' ? 'movie' : 'show'; - const request = timeout( - fetch(`${resolvedEndpoint}/${urlTypeParam}/${itemId}`).then(res => - res.json() - ) - ); - - switch (type) { - case 'movies': - return request.then(movie => - [ - { ...movie.torrents.en['1080p'], quality: '1080p' }, - { ...movie.torrents.en['720p'], quality: '720p' } - ].map(torrent => this.formatMovieTorrent(torrent)) - ); - case 'shows': { - const { season, episode } = extendedDetails; - - const show = await request - .then(res => - res.episodes.map(eachEpisode => this.formatEpisode(eachEpisode)) - ) - .catch(error => { - handleProviderError(error); - return []; - }); - - this.shows[itemId] = show; - - return this.filterTorrents(show, season, episode); - } - default: - return []; - } - } - - /** - * Filter torrent from episodes - * - * @param {array} | Episodes - * @param {number} | season - * @param {number} | episode - * @return {array} | Array of torrents - */ - static filterTorrents(show, season: number, episode: number) { - const filterTorrents = show - .filter( - eachEpisode => - String(eachEpisode.season) === String(season) && - String(eachEpisode.episode) === String(episode) - ) - .map(eachEpisode => eachEpisode.torrents); - - return filterTorrents.length > 0 ? filterTorrents[0] : []; - } - - static formatEpisode({ season, episode, torrents }) { - return { - season, - episode, - torrents: this.formatEpisodeTorrents(torrents) - }; - } - - static formatMovieTorrent(torrent) { - return { - quality: torrent.quality, - magnet: torrent.url, - seeders: torrent.seed || torrent.seeds, - leechers: 0, - metadata: String(torrent.url), - _provider: 'pct' - }; - } - - static formatEpisodeTorrents(torrents) { - return Object.keys(torrents).map(videoQuality => ({ - quality: videoQuality === '0' ? '0p' : videoQuality, - magnet: torrents[videoQuality] && torrents[videoQuality].url, - metadata: String(torrents[videoQuality] && torrents[videoQuality].url), - seeders: - (torrents[videoQuality] && torrents[videoQuality].seeds) || - (torrents[videoQuality] && torrents[videoQuality].seed) || - (torrents[videoQuality] && torrents[videoQuality].seeders) || - 0, - leechers: - (torrents[videoQuality] && torrents[videoQuality].peers) || - (torrents[videoQuality] && torrents[videoQuality].peer), - _provider: 'pct' - })); - } - - static getStatus() { - return fetch(resolvedEndpoint) - .then(res => res.ok) - .catch(() => false); - } - - static provide(itemId: string, type: string, extendedDetails: Object = {}) { - switch (type) { - case 'movies': - return this.fetch(itemId, type, extendedDetails).catch(error => { - handleProviderError(error); - return []; - }); - case 'shows': - return this.fetch(itemId, type, extendedDetails).catch(error => { - handleProviderError(error); - return []; - }); - default: - return Promise.resolve([]); - } - } -} diff --git a/app/api/torrents/PctTorrentProvider.ts b/app/api/torrents/PctTorrentProvider.ts new file mode 100644 index 00000000..74c50199 --- /dev/null +++ b/app/api/torrents/PctTorrentProvider.ts @@ -0,0 +1,151 @@ +import fetch from "node-fetch"; +import { + handleProviderError, + timeout, + resolveEndpoint, +} from "./BaseTorrentProvider"; +import { + TorrentProviderInterface, + ProviderTorrent, + ExtendedDetails, + ShowDetail, +} from "./TorrentProviderInterface"; +import { ItemKind } from "../metadata/MetadataProviderInterface"; + +const endpoint = "https://tv-v2.api-fetch.website"; +const providerId = "PCT"; +const resolvedEndpoint = resolveEndpoint(endpoint, providerId); + +type RawTorrent = { + quality: string; + url: string; + seed?: number; + seeds?: number; + seeders?: number; + peer?: number; + season?: number; + episode?: number; +}; + +type RawEpisode = { + season: number; + episode: number; + torrents: RawTorrent[]; +}; + +type RawMovieTorrent = { + torrents: { + en: { + "1080p": RawTorrent; + "720p": RawTorrent; + }; + }; +}; + +type RawEpisodeTorrent = { + episodes: RawEpisode[]; +}; + +export default class PctTorrentProvider extends TorrentProviderInterface { + static providerName = "PopcornTime"; + + static async fetch( + itemId: string, + type: ItemKind, + extendedDetails: ExtendedDetails = {} + ): Promise { + const urlTypeParam = type === ItemKind.Movie ? "movie" : "show"; + const request = timeout( + fetch(`${resolvedEndpoint}/${urlTypeParam}/${itemId}`).then((res) => + res.json() + ) + ); + + switch (type) { + case ItemKind.Movie: { + const movieTorrent = (await request) as RawMovieTorrent; + return [ + { ...movieTorrent.torrents.en["1080p"], quality: "1080p" }, + { ...movieTorrent.torrents.en["720p"], quality: "720p" }, + ].map((torrent) => this.formatMovieTorrent(torrent)); + } + case ItemKind.Show: { + const { season, episode } = extendedDetails as ShowDetail; + try { + const show = (await request) as RawEpisodeTorrent; + const episodes = show.episodes + .map((eachEpisode) => this.formatEpisodeTorrent(eachEpisode)) + .filter( + (eachEpisode) => + String(eachEpisode.season) === String(season) && + String(eachEpisode.episode) === String(episode) + ) + .map((eachEpisode) => eachEpisode.torrents); + return episodes.length > 0 ? episodes[0] : []; + } catch (error) { + handleProviderError(error); + return []; + } + } + default: + return []; + } + } + + static formatEpisodeTorrent({ season, episode, torrents }: RawEpisode) { + return { + season, + episode, + torrents: this.formatEpisodeTorrents(torrents), + }; + } + + static formatMovieTorrent(torrent: RawTorrent): ProviderTorrent { + return { + quality: torrent.quality, + magnet: torrent.url, + seeders: torrent.seed || torrent.seeds || 0, + leechers: torrent.peer || 0, + metadata: String(torrent.url), + _provider: "pct", + }; + } + + static formatEpisodeTorrents(torrents: RawTorrent[]): ProviderTorrent[] { + return Object.entries(torrents).map(([videoQuality, video]) => ({ + quality: videoQuality === "0" ? "0p" : videoQuality, + magnet: video?.url, + metadata: String(video?.url), + seeders: video?.seeds || video?.seed || video?.seeders || 0, + leechers: video?.peer || 0, + _provider: "pct", + })); + } + + static getStatus() { + return fetch(resolvedEndpoint) + .then((res) => res.ok) + .catch(() => false); + } + + static provide( + itemId: string, + type: ItemKind, + extendedDetails: ShowDetail + ): Promise { + switch (type) { + case ItemKind.Movie: + return this.fetch(itemId, type, extendedDetails).catch((error) => { + handleProviderError(error); + return []; + }); + case ItemKind.Show: + return this.fetch(itemId, type, extendedDetails).catch((error) => { + handleProviderError(error); + return []; + }); + default: + return Promise.resolve([]); + } + } +} diff --git a/app/api/torrents/RarbgTorrentProvider.js b/app/api/torrents/RarbgTorrentProvider.js deleted file mode 100644 index e69de29b..00000000 diff --git a/app/actions/itemPageActions.js b/app/api/torrents/RarbgTorrentProvider.ts similarity index 100% rename from app/actions/itemPageActions.js rename to app/api/torrents/RarbgTorrentProvider.ts diff --git a/app/api/Torrent.js b/app/api/torrents/Torrent.ts similarity index 54% rename from app/api/Torrent.js rename to app/api/torrents/Torrent.ts index 7c25b894..5f36b4ea 100644 --- a/app/api/Torrent.js +++ b/app/api/torrents/Torrent.ts @@ -1,17 +1,17 @@ /** * Torrents controller, responsible for playing, stoping, etc - * @flow */ -import os from 'os'; -import WebTorrent from 'webtorrent'; +import os from "os"; +import WebTorrent, { TorrentFile } from "webtorrent"; // 'get-port' lib doesn't work here for some reason. Not sure why -import findFreePort from 'find-free-port'; -import { isExactEpisode } from './torrents/BaseTorrentProvider'; - -type metadataType = { - season: number, - episode: number, - activeMode: string +import findFreePort from "find-free-port"; +import { isExactEpisode } from "./BaseTorrentProvider"; +import { TorrentKind } from "./TorrentProviderInterface"; + +type Metadata = { + season: number; + episode: number; + kind: string; }; /** @@ -19,16 +19,16 @@ type metadataType = { */ export function selectSubtitleFile( files: Array<{ name: string }> = [], - activeMode: string, - metadata: { season: number, episode: number } + kind: TorrentKind, + metadata: { season: number; episode: number } ): { name: string } | boolean { return ( - files.find(file => { - const formatIsSupported = file.name.includes('.srt'); + files.find((file) => { + const formatIsSupported = file.name.includes(".srt"); - switch (activeMode) { + switch (kind) { // Check if the current file is the exact episode we're looking for - case 'season_complete': { + case "season_complete": { const { season, episode } = metadata; return ( formatIsSupported && isExactEpisode(file.name, season, episode) @@ -44,42 +44,40 @@ export function selectSubtitleFile( } export default class Torrent { - inProgress: boolean = false; + inProgress = false; - finished: boolean = false; + finished = false; - checkDownloadInterval: number; + checkDownloadInterval?: NodeJS.Timeout; - engine: WebTorrent; + engine?: WebTorrent.Instance; - magnetURI: string; + magnetURI?: string; - server: - | {} - | { - close: () => void, - listen: (port: number) => void - }; + server?: { + close: () => void; + listen: (port: number) => void; + }; async start( magnetURI: string, - metadata: metadataType, + metadata: Metadata, supportedFormats: Array, cb: ( servingUrl: string, file: { name: string }, - files: string, - torrent: string, - subtitle: { name: string } | boolean + files: TorrentFile[], + torrent: WebTorrent.Torrent, + subtitle: WebTorrent.Torrent ) => void ) { if (this.inProgress) { - console.log('Torrent already in progress'); + console.log("Torrent already in progress"); return; } const [port] = await findFreePort(9090); - const { season, episode, activeMode } = metadata; + const { season, episode, kind: torrentKind } = metadata; const maxConns = process.env.CONFIG_MAX_CONNECTIONS ? parseInt(process.env.CONFIG_MAX_CONNECTIONS, 10) : 20; @@ -89,23 +87,23 @@ export default class Torrent { this.magnetURI = magnetURI; const cacheLocation = - process.env.CONFIG_PERSIST_DOWNLOADS === 'true' - ? process.env.CONFIG_DOWNLOAD_LOCATION || '/tmp/popcorn-time-desktop' + process.env.CONFIG_PERSIST_DOWNLOADS === "true" + ? process.env.CONFIG_DOWNLOAD_LOCATION || "/tmp/popcorn-time-desktop" : os.tmpdir(); - this.engine.add(magnetURI, { path: cacheLocation }, torrent => { + this.engine.add(magnetURI, { path: cacheLocation }, (torrent) => { const server = torrent.createServer(); this.server = server.listen(port); const { file, torrentIndex } = torrent.files.reduce( (previous, current, index) => { - const formatIsSupported = !!supportedFormats.find(format => + const formatIsSupported = !!supportedFormats.find((format) => current.name.includes(format) ); - switch (activeMode) { + switch (torrentKind) { // Check if the current file is the exact episode we're looking for - case 'season_complete': + case "season_complete": if ( formatIsSupported && isExactEpisode(current.name, season, episode) @@ -113,7 +111,7 @@ export default class Torrent { previous.file.deselect(); return { file: current, - torrentIndex: index + torrentIndex: index, }; } @@ -125,7 +123,7 @@ export default class Torrent { previous.file.deselect(); return { file: current, - torrentIndex: index + torrentIndex: index, }; } @@ -135,62 +133,63 @@ export default class Torrent { { file: torrent.files[0], torrentIndex: 0 } ); - if (typeof torrentIndex !== 'number') { - console.warn('File List', torrent.files.map(_file => _file.name)); + if (typeof torrentIndex !== "number") { + console.warn( + "File List", + torrent.files.map((_file) => _file.name) + ); throw new Error( `No torrent could be selected. Torrent Index: ${torrentIndex}` ); } - const buffer = 1 * 1024 * 1024; // 1MB + const buffer = 1 * 1_024 * 1_024; // 1MB const { files } = torrent; file.select(); - torrent.on('done', () => { + torrent.on("done", () => { this.inProgress = false; - this.clearIntervals(); + this.clearInterval(); }); - this.checkDownloadInterval = setInterval(async () => { + this.checkDownloadInterval = setInterval(() => { if (torrent.downloaded > buffer) { - console.log('Ready...'); - if (!this.checkDownloadInterval) { - return; - } - - await cb( + clearInterval(this.checkDownloadInterval as NodeJS.Timeout); + this.clearInterval(); + cb( `http://localhost:${port}/${torrentIndex}`, file, files, torrent, - false - // selectSubtitleFile(files, activeMode, metadata) + torrent ); - - this.clearIntervals(); } - }, 1000); + }, 1_000); }); } - clearIntervals() { - clearInterval(this.checkDownloadInterval); - this.checkDownloadInterval = undefined; + clearInterval() { + if (typeof this.checkDownloadInterval === "number") { + clearInterval(this.checkDownloadInterval); + this.checkDownloadInterval = undefined; + } } destroy() { if (this.inProgress) { if (this.server && this.server.close) { - console.log('Closing the torrent server...'); + console.log("Closing the torrent server..."); this.server.close(); - this.server = {}; + this.server = undefined; } - this.clearIntervals(); + this.clearInterval(); - console.log('Destroying the torrent engine...'); - this.engine.destroy(); + console.log("Destroying the torrent engine..."); + if (this.engine) { + this.engine.destroy(); + } this.engine = undefined; this.inProgress = false; @@ -198,30 +197,28 @@ export default class Torrent { } } -type torrentSpeedsType = { - downloadSpeed: number, - uploadSpeed: number, - progress: number, - numPeers: number, - ratio: number +type TorrentSpeeds = { + downloadSpeed: number; + uploadSpeed: number; + progress: number; + numPeers: number; + ratio: number; }; -export function formatSpeeds( - torrentSpeeds: torrentSpeedsType -): torrentSpeedsType { +export function formatSpeeds(torrentSpeeds: TorrentSpeeds): TorrentSpeeds { const { downloadSpeed, uploadSpeed, progress, numPeers, - ratio + ratio, } = torrentSpeeds; return { - downloadSpeed: downloadSpeed / 1000000, - uploadSpeed: uploadSpeed / 1000000, + downloadSpeed: downloadSpeed / 1_000_000, + uploadSpeed: uploadSpeed / 1_000_000, progress: Math.round(progress * 100) / 100, numPeers, - ratio + ratio, }; } diff --git a/app/api/torrents/TorrentAdapter.js b/app/api/torrents/TorrentAdapter.js deleted file mode 100644 index 09ea1e5a..00000000 --- a/app/api/torrents/TorrentAdapter.js +++ /dev/null @@ -1,187 +0,0 @@ -// @flow -import { - determineQuality, - formatSeasonEpisodeToString, - formatSeasonEpisodeToObject, - convertTmdbToImdb, - sortTorrentsBySeeders, - getHealth, - resolveCache, - setCache, - merge -} from './BaseTorrentProvider'; - -type extendedDetailsType = - | {} - | { - season: number, - episode: number - }; - -/** - * @TODO: Use ES6 dynamic imports here - */ -const providers = [ - import('./YtsTorrentProvider').then(e => e.default || e), - // import('./PbTorrentProvider').then(e => e.default || e), - import('./PctTorrentProvider').then(e => e.default || e) - // import('./KatTorrentProvider').then(e => e.default || e) - // import('./KatShowsTorrentProvider').then(e => e.default || e) -]; - -export function filterShows(show, season: number, episode: number) { - return ( - show.metadata - .toLowerCase() - .includes(formatSeasonEpisodeToString(season, episode)) && - show.seeders !== 0 && - show.magnet - ); -} - -/** - * Select one 720p and 1080p quality movie from torrent list - * By default, sort all torrents by seeders - */ -export function selectTorrents( - torrents, - returnAll: boolean = false, - key: string -) { - const sortedTorrents = sortTorrentsBySeeders( - torrents.filter( - torrent => - torrent.quality !== 'n/a' && torrent.quality !== '' && !!torrent.magnet - ) - ); - - const formattedTorrents = returnAll - ? sortedTorrents - : { - '480p': sortedTorrents.find(torrent => torrent.quality === '480p'), - '720p': sortedTorrents.find(torrent => torrent.quality === '720p'), - '1080p': sortedTorrents.find(torrent => torrent.quality === '1080p') - }; - - setCache(key, formattedTorrents); - - return formattedTorrents; -} - -/** - * Merge results from providers - * - * @param {array} providerResults - * @return {array} - */ -function appendAttributes(providerResults) { - const formattedResults = merge(providerResults).map(result => ({ - ...result, - health: getHealth(result.seeders || 0, result.leechers || 0), - quality: - 'quality' in result - ? result.quality - : determineQuality(result.magnet, result.metadata, result) - })); - - return formattedResults; -} - -export function filterShowsComplete(show, season: number) { - const metadata = show.metadata.toLowerCase(); - - return ( - metadata.includes(`${season} complete`) || - metadata.includes(`${season} [complete]`) || - metadata.includes(`${season} - complete`) || - metadata.includes(`season ${season}`) || - (metadata.includes(`s${formatSeasonEpisodeToObject(season).season}`) && - !metadata.includes('e0') && - show.seeders !== 0 && - show.magnet) - ); -} - -export function getStatuses() { - return Promise.all(providers.map(provider => provider.getStatus())).then( - providerStatuses => - providerStatuses.map((status, index) => ({ - providerName: providers[index].providerName, - online: status - })) - ); -} - -export default async function TorrentAdapter( - _itemId: string, - type: string, - extendedDetails: extendedDetailsType = {}, - returnAll: boolean = false, - method: string = 'all', - cache: boolean = true -) { - const args = JSON.stringify({ extendedDetails, returnAll, method }); - - if (resolveCache(args) && cache) { - return resolveCache(args); - } - - // Temporary hack to convert tmdbIds to imdbIds if necessary - const itemId = !_itemId.includes('tt') - ? await convertTmdbToImdb(_itemId) - : _itemId; - - const torrentPromises = (await Promise.all(providers)).map(provider => - provider.provide(itemId, type, extendedDetails) - ); - - switch (method) { - case 'all': { - const providerResults = await Promise.all(torrentPromises); - const { season, episode } = extendedDetails; - - switch (type) { - case 'movies': - return selectTorrents( - appendAttributes(providerResults).map(result => ({ - ...result, - method: 'movies' - })), - returnAll, - args - ); - case 'shows': - return selectTorrents( - appendAttributes(providerResults) - .filter(show => !!show.metadata) - .filter(show => filterShows(show, season, episode)) - .map(result => ({ - ...result, - method: 'shows' - })), - returnAll, - args - ); - case 'season_complete': - return selectTorrents( - appendAttributes(providerResults) - .filter(show => !!show.metadata) - .filter(show => filterShowsComplete(show, season)) - .map(result => ({ - ...result, - method: 'season_complete' - })), - returnAll, - args - ); - default: - throw new Error('Invalid query method'); - } - } - case 'race': { - return Promise.race(torrentPromises); - } - default: - throw new Error('Invalid query method'); - } -} diff --git a/app/api/torrents/TorrentAdapter.ts b/app/api/torrents/TorrentAdapter.ts new file mode 100644 index 00000000..3ac2db78 --- /dev/null +++ b/app/api/torrents/TorrentAdapter.ts @@ -0,0 +1,176 @@ +/* eslint class-methods-use-this: off */ +import { + determineQuality, + formatSeasonEpisodeToString, + formatSeasonEpisodeToObject, + convertTmdbToImdb, + sortTorrentsBySeeders, + getHealth, +} from "./BaseTorrentProvider"; +import { + Torrent, + TorrentProviderInterface, + TorrentKind, + ProviderTorrent, + ExtendedDetails, + ShowDetail, + TorrentSelection, +} from "./TorrentProviderInterface"; +import { ItemKind } from "../metadata/MetadataProviderInterface"; +import Cache, { PctCache } from "./Cache"; +import cache from "../helpers/cache-decorator"; + +const providers: Promise[] = [ + import("./YtsTorrentProvider").then((e) => e.default || e), + // import('./PbTorrentProvider').then(e => e.default || e), + import("./PctTorrentProvider").then((e) => e.default || e), + // import('./KatTorrentProvider').then(e => e.default || e) +]; + +export function filterShowTorrent( + showTorrent: Torrent, + season: number, + episode: number +): boolean { + return !!( + showTorrent.metadata + .toLowerCase() + .includes(formatSeasonEpisodeToString(season, episode)) && + showTorrent.seeders !== 0 && + showTorrent.magnet + ); +} + +/** + * Select one 720p and 1080p quality movie from torrent list + * By default, sort all torrents by seeders + */ +export function selectTorrents(torrents: Torrent[]): TorrentSelection { + const sortedTorrents = sortTorrentsBySeeders( + torrents.filter((torrent) => !!torrent.magnet) + ); + + const formattedTorrents = { + "1080p": sortedTorrents.find((torrent) => torrent.quality === "1080p"), + "720p": sortedTorrents.find((torrent) => torrent.quality === "720p"), + "480p": sortedTorrents.find((torrent) => torrent.quality === "480p"), + }; + + return formattedTorrents; +} + +/** + * Merge results from providers + */ +function appendAttributes(providerResults: ProviderTorrent[]): Torrent[] { + return providerResults.flat().map((result) => ({ + ...result, + health: getHealth(result.seeders || 0, result.leechers || 0), + quality: + "quality" in result + ? result.quality + : determineQuality(result.magnet, result.metadata), + })); +} + +export function filterShowsComplete(show: Torrent, season: number): boolean { + const metadata = show.metadata.toLowerCase(); + + return !!( + metadata.includes(`${season} complete`) || + metadata.includes(`${season} [complete]`) || + metadata.includes(`${season} - complete`) || + metadata.includes(`season ${season}`) || + (metadata.includes(`s${formatSeasonEpisodeToObject(season).season}`) && + !metadata.includes("e0") && + show.seeders !== 0 && + show.magnet) + ); +} + +export async function getStatuses() { + const resolvedProviders = await Promise.all(providers); + const statuses = await Promise.all( + resolvedProviders.map((provider) => provider.getStatus()) + ); + return statuses.map((status, index) => ({ + providerName: resolvedProviders[index].providerName, + online: status, + })); +} + +export default class TorrentAdapter { + private cache: PctCache; + + constructor(opts: { cache?: PctCache } = {}) { + this.cache = opts.cache || new Cache(); + } + + @cache() + async getTorrent( + itemId: string, + kind: TorrentKind, + extendedDetails: ExtendedDetails = {}, + method = "all" + ): Promise { + const args = JSON.stringify({ extendedDetails, method }); + + // Temporary hack to convert tmdbIds to imdbIds if necessary + const imdbId = !itemId.includes("tt") + ? await convertTmdbToImdb(itemId) + : itemId; + + const torrentPromises = (await Promise.all(providers)).map((provider) => + provider.provide(imdbId, kind) + ); + + switch (method) { + case "all": { + const providerResults = await Promise.all(torrentPromises); + const { season, episode } = extendedDetails as ShowDetail; + + switch (kind) { + case ItemKind.Movie: + return selectTorrents( + appendAttributes(providerResults).map((result) => ({ + ...result, + kind: ItemKind.Movie, + })), + args + ); + case ItemKind.Show: + return selectTorrents( + appendAttributes(providerResults) + .filter((show: Torrent) => !!show.metadata) + .filter((show: Torrent) => + filterShowTorrent(show, season, episode) + ) + .map((result) => ({ + ...result, + kind: ItemKind.Show, + })), + args + ); + case "season_complete": + return selectTorrents( + appendAttributes(providerResults) + .filter((show) => !!show.metadata) + .filter((show) => filterShowsComplete(show, season)) + .map((result) => ({ + ...result, + kind: "season_complete", + })), + args + ); + default: + throw new Error("Invalid query kind"); + } + } + case "race": { + return Promise.race(torrentPromises); + } + default: + throw new Error("Invalid query kind"); + } + } +} diff --git a/app/api/torrents/TorrentProviderInterface.js b/app/api/torrents/TorrentProviderInterface.js deleted file mode 100644 index 5365ff7b..00000000 --- a/app/api/torrents/TorrentProviderInterface.js +++ /dev/null @@ -1,31 +0,0 @@ -// @flow -export type fetchType = { - quality: string, - magnet: string, - seeders: number, - leechers: number, - metadata: string, - _provider: string -}; - -export type healthType = 'poor' | 'decent' | 'healthy'; - -export type torrentMethodType = 'all' | 'race'; - -export type qualityType = '1080p' | '720p' | '480p' | 'default'; - -export type torrentQueryType = 'movies' | 'show' | 'season_complete'; - -export type torrentType = { - ...fetchType, - health: healthType, - quality: qualityType, - method: torrentQueryType -}; - -export interface TorrentProviderInterface { - supportedIdTypes: Array<'tmdb' | 'imdb'>; - getStatus: () => Promise; - fetch: (itemId: string) => Promise>; - provide: (itemId: string, type: torrentType) => Promise>; -} diff --git a/app/api/torrents/TorrentProviderInterface.ts b/app/api/torrents/TorrentProviderInterface.ts new file mode 100644 index 00000000..48f527f1 --- /dev/null +++ b/app/api/torrents/TorrentProviderInterface.ts @@ -0,0 +1,62 @@ +import { ItemKind } from "../metadata/MetadataProviderInterface"; + +enum _TorrentKind { + SeasonComplete = "season_complete", +} + +export type TorrentKind = _TorrentKind | ItemKind; + +export type Health = "poor" | "decent" | "healthy"; + +export type TorrentMethod = "all" | "race"; + +export type Quality = "1080p" | "720p" | "480p"; + +export type TorrentQuery = ItemKind | "season_complete"; + +export type ProviderTorrent = { + quality: string; + magnet: string; + seeders: number; + leechers: number; + metadata: string; + _provider: string; +}; + +export type SearchDetail = { + season: number; + episode: number; +}; + +export type ShowDetail = { + season: number; + episode: number; +}; + +export type ExtendedDetails = ShowDetail | SearchDetail | {}; + +export type Torrent = ProviderTorrent & { + health: Health; + quality: Quality; + kind: TorrentQuery; +}; + +export type TorrentSelection = Record; + +export abstract class TorrentProviderInterface { + static providerName: string; + + static supportedIdTypes: Array<"tmdb" | "imdb">; + + static getStatus: () => Promise; + + static fetch: ( + itemId: string, + extendedDetails?: ExtendedDetails + ) => Promise; + + static provide: ( + itemId: string, + type: ItemKind + ) => Promise>; +} diff --git a/app/api/torrents/YtsTorrentProvider.js b/app/api/torrents/YtsTorrentProvider.js deleted file mode 100644 index fd481ac9..00000000 --- a/app/api/torrents/YtsTorrentProvider.js +++ /dev/null @@ -1,74 +0,0 @@ -// @flow -import fetch from 'isomorphic-fetch'; -import { - determineQuality, - timeout, - resolveEndpoint -} from './BaseTorrentProvider'; -import type { TorrentProviderInterface } from './TorrentProviderInterface'; - -const endpoint = 'https://yts.am'; -const providerId = 'YTS'; -const resolvedEndpoint = resolveEndpoint(endpoint, providerId); - -const trackers = [ - 'udp://glotorrents.pw:6969/announce', - 'udp://tracker.opentrackr.org:1337/announce', - 'udp://torrent.gresille.org:80/announce', - 'udp://tracker.openbittorrent.com:80', - 'udp://tracker.coppersurfer.tk:6969', - 'udp://tracker.leechers-paradise.org:6969', - 'udp://p4p.arenabg.ch:1337', - 'udp://tracker.internetwarriors.net:1337' -]; - -function constructMagnet(hash: string): string { - return `magnet:?xt=urn:btih:${hash}&tr=${trackers.join('&tr=')}`; -} - -export default class YtsTorrentProvider implements TorrentProviderInterface { - static providerName = 'YTS'; - - static fetch(itemId: string) { - return timeout( - fetch( - [ - `${resolvedEndpoint}/api/v2/list_movies.json`, - `?query_term=${itemId}`, - '&order_by=desc&sort_by=seeds&limit=50' - ].join('') - ) - ).then(res => res.json()); - } - - static formatTorrent(torrent) { - return { - quality: determineQuality(torrent.quality), - magnet: constructMagnet(torrent.hash), - seeders: parseInt(torrent.seeds, 10), - leechers: parseInt(torrent.peers, 10), - metadata: - String(torrent.url) + String(torrent.hash) || String(torrent.hash), - _provider: 'yts' - }; - } - - static getStatus(): Promise { - return fetch('https://yts.am/api/v2/list_movies.json') - .then(res => !!res.ok) - .catch(() => false); - } - - static provide(itemId, type) { - switch (type) { - case 'movies': - return this.fetch(itemId).then(results => { - if (!results.data.movie_count) return []; - const { torrents } = results.data.movies[0]; - return torrents.map(this.formatTorrent); - }); - default: - return Promise.resolve([]); - } - } -} diff --git a/app/api/torrents/YtsTorrentProvider.ts b/app/api/torrents/YtsTorrentProvider.ts new file mode 100644 index 00000000..1900cd3c --- /dev/null +++ b/app/api/torrents/YtsTorrentProvider.ts @@ -0,0 +1,93 @@ +import fetch from "node-fetch"; +import { + determineQuality, + timeout, + resolveEndpoint, +} from "./BaseTorrentProvider"; +import { + TorrentProviderInterface, + Quality, + ProviderTorrent, +} from "./TorrentProviderInterface"; +import { ItemKind } from "../metadata/MetadataProviderInterface"; + +const endpoint = "https://yts.am"; +const providerId = "YTS"; +const resolvedEndpoint = resolveEndpoint(endpoint, providerId); + +const trackers: string[] = [ + "udp://glotorrents.pw:6969/announce", + "udp://tracker.opentrackr.org:1337/announce", + "udp://torrent.gresille.org:80/announce", + "udp://tracker.openbittorrent.com:80", + "udp://tracker.coppersurfer.tk:6969", + "udp://tracker.leechers-paradise.org:6969", + "udp://p4p.arenabg.ch:1337", + "udp://tracker.internetwarriors.net:1337", +]; + +function constructMagnet(hash: string): string { + return `magnet:?xt=urn:btih:${hash}&tr=${trackers.join("&tr=")}`; +} + +type RawTorrents = { + data: { + movie_count: number; + movies: [{ torrents: RawTorrent[] }]; + }; +}; + +type RawTorrent = { + quality: Quality; + seeds: string; + peers: string; + hash: string; + url: string; +}; + +export default class YtsTorrentProvider extends TorrentProviderInterface { + static providerName = "YTS"; + + static fetch(itemId: string): Promise { + return timeout( + fetch( + [ + `${resolvedEndpoint}/api/v2/list_movies.json`, + `?query_term=${itemId}`, + "&order_by=desc&sort_by=seeds&limit=50", + ].join("") + ) + ).then((res) => res.json()); + } + + static formatTorrent(torrent: RawTorrent) { + return { + quality: determineQuality(torrent.quality), + magnet: constructMagnet(torrent.hash), + seeders: parseInt(torrent.seeds, 10), + leechers: parseInt(torrent.peers, 10), + metadata: + String(torrent.url) + String(torrent.hash) || String(torrent.hash), + _provider: "yts", + }; + } + + static getStatus(): Promise { + return fetch("https://yts.am/api/v2/list_movies.json") + .then((res) => !!res.ok) + .catch(() => false); + } + + static provide(itemId: string, type: ItemKind): Promise { + switch (type) { + case ItemKind.Movie: + return this.fetch(itemId).then((results) => { + if (!results.data.movie_count) return []; + const { torrents } = results.data.movies[0]; + return torrents.map(this.formatTorrent); + }); + default: + return Promise.resolve([]); + } + } +} diff --git a/app/api/torrents/example.js b/app/api/torrents/example.js deleted file mode 100644 index 850942b3..00000000 --- a/app/api/torrents/example.js +++ /dev/null @@ -1,32 +0,0 @@ -// -// Here is an example of the structure of a TorrentProvider -// - -// Required: -// itemId -// type | The type of torrent: movies or shows - -// Example: -// -// getTorrent(itemId, type, { -// searchQuery: 'harry potter and the half...', -// ...otherCustomOptions -// }) -// -// Return array of availabe torrents -// Preferrably, these should be ordered by best quality. -// -// [ -// { -// quality: , | Optional. If not provided, quality will be determined -// | by metadata using hueristics -// magnet: , -// seeders: , -// leechers: -// metadata: , | A concanenated string of data about a torrent, -// | used for hueristics -// health: , healthy || decent || poor -// _provder: -// }, -// ... -// ] diff --git a/app/app.global.scss b/app/app.global.scss index cfe1d812..f154e1ad 100644 --- a/app/app.global.scss +++ b/app/app.global.scss @@ -1,26 +1,30 @@ +::selection { + background: black; + color: white; +} + // -// Modules +// CSS Variables // @import "./styles/variables.scss"; // -// Bootstrap +// 3rd Party Libraries // Core variables and mixins // @TODO: Import only boootstrap that are used // @import "~bootstrap/scss/bootstrap.scss"; -@import "~ionicons/dist/css/ionicons.min.css"; @import "~plyr/src/sass/plyr.scss"; -@import "~notie/src/notie.scss"; +@import "~react-notifications-component/dist/theme.css"; // // Components // -@import "./styles/components/Movie.scss"; -@import "./styles/components/CardList.scss"; +@import "./styles/components/Description.scss"; +@import "./styles/components/CardsGrid.scss"; @import "./styles/components/Rating.scss"; @import "./styles/components/Loader.scss"; @import "./styles/components/Item.scss"; diff --git a/app/app.html b/app/app.html index 3ec67f51..b58165c5 100644 --- a/app/app.html +++ b/app/app.html @@ -15,7 +15,7 @@ }()); - +