From cfa9e6f3567b64aca7f67b7d5a0af955a1272fa0 Mon Sep 17 00:00:00 2001 From: jwbuiter Date: Mon, 1 Feb 2021 22:11:54 +0000 Subject: [PATCH 1/4] implement chromecast support --- .../src/javascript/actions/TorrentActions.ts | 8 + client/src/javascript/app.tsx | 25 +- .../javascript/components/modals/Modals.tsx | 3 + .../chromecast-modal/ChromecastModal.tsx | 223 ++++++++++++++++++ .../torrent-list/TorrentListContextMenu.tsx | 11 + .../constants/TorrentContextMenuActions.ts | 1 + client/src/javascript/i18n/strings/en.json | 11 + client/src/javascript/stores/UIStore.ts | 2 +- .../javascript/ui/components/FormRowItem.tsx | 3 +- client/src/javascript/ui/icons/Pause.tsx | 18 ++ client/src/javascript/ui/icons/index.tsx | 1 + client/src/javascript/util/chromecastUtil.ts | 16 ++ client/src/sass/components/_modals.scss | 30 +++ client/src/sass/ui/components/form.scss | 4 + package-lock.json | 60 ++++- package.json | 1 + server/routes/api/index.ts | 73 +++--- server/routes/api/torrents.ts | 142 +++++++++++ shared/constants/chromecastableExtensions.ts | 19 ++ shared/constants/defaultFloodSettings.ts | 1 + 20 files changed, 610 insertions(+), 42 deletions(-) create mode 100644 client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx create mode 100644 client/src/javascript/ui/icons/Pause.tsx create mode 100644 client/src/javascript/util/chromecastUtil.ts create mode 100644 shared/constants/chromecastableExtensions.ts diff --git a/client/src/javascript/actions/TorrentActions.ts b/client/src/javascript/actions/TorrentActions.ts index 1f7167d71..8e3074114 100644 --- a/client/src/javascript/actions/TorrentActions.ts +++ b/client/src/javascript/actions/TorrentActions.ts @@ -174,6 +174,14 @@ const TorrentActions = { }api/torrents/${hash}/contents/${indices.join(',')}/data?token=${res.data}`, ), + getTorrentContentsSubtitlePermalink: (hash: TorrentProperties['hash'], index: number) => + axios + .get(`${ConfigStore.baseURI}api/torrents/${hash}/contents/${index}/token`) + .then( + (res) => + `${window.location.protocol}//${window.location.host}${ConfigStore.baseURI}api/torrents/${hash}/contents/${index}/subtitles?token=${res.data}`, + ), + moveTorrents: (options: MoveTorrentsOptions) => axios .post(`${baseURI}api/torrents/move`, options) diff --git a/client/src/javascript/app.tsx b/client/src/javascript/app.tsx index a4e8e15d7..19b8de26c 100644 --- a/client/src/javascript/app.tsx +++ b/client/src/javascript/app.tsx @@ -5,6 +5,7 @@ import {Route, Switch} from 'react-router'; import {Router} from 'react-router-dom'; import {useMedia} from 'react-use'; import {QueryParamProvider} from 'use-query-params'; +import {CastProvider} from 'react-cast-sender'; import AuthActions from './actions/AuthActions'; import AppWrapper from './components/AppWrapper'; @@ -82,17 +83,19 @@ const FloodApp: FC = observer(() => { return ( }> - - - - - - - - - - - + + + + + + + + + + + + + ); diff --git a/client/src/javascript/components/modals/Modals.tsx b/client/src/javascript/components/modals/Modals.tsx index 1425477ef..e060f80bf 100644 --- a/client/src/javascript/components/modals/Modals.tsx +++ b/client/src/javascript/components/modals/Modals.tsx @@ -4,6 +4,7 @@ import {observer} from 'mobx-react'; import {useKeyPressEvent} from 'react-use'; import AddTorrentsModal from './add-torrents-modal/AddTorrentsModal'; +import ChromecastModal from './chromecast-modal/ChromecastModal'; import ConfirmModal from './confirm-modal/ConfirmModal'; import FeedsModal from './feeds-modal/FeedsModal'; import GenerateMagnetModal from './generate-magnet-modal/GenerateMagnetModal'; @@ -22,6 +23,8 @@ const createModal = (id: Modal['id']): React.ReactNode => { switch (id) { case 'add-torrents': return ; + case 'chromecast': + return ; case 'confirm': return ; case 'feeds': diff --git a/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx new file mode 100644 index 000000000..66f14a7da --- /dev/null +++ b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx @@ -0,0 +1,223 @@ +import {FC, useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {CastButton, useCast, useCastPlayer} from 'react-cast-sender'; +import classNames from 'classnames'; + +import type {TorrentContent} from '@shared/types/TorrentContent'; + +import {Form, FormRow, Select, SelectItem, FormRowItem} from '../../../ui'; +import ProgressBar from '../../general/ProgressBar'; +import Tooltip from '../../general/Tooltip'; +import {Start, Stop, Pause} from '../../../ui/icons'; +import Modal from '../Modal'; +import TorrentActions from '../../../actions/TorrentActions'; +import UIStore from '../../../stores/UIStore'; +import {getChromecastContentType, isFileChromecastable, isFileSubtitles} from '../../../util/chromecastUtil'; + +type Subtitles = number | 'none'; + +const GenerateMagnetModal: FC = () => { + const intl = useIntl(); + + const {connected, initialized} = useCast(); + const {loadMedia, currentTime, duration, isPaused, isMediaLoaded, togglePlay} = useCastPlayer(); + + const [contents, setContents] = useState([]); + const [selectedFileIndex, setSelectedFileIndex] = useState(0); + const [selectedSubtitles, setSelectedSubtitles] = useState('none'); + + useEffect(() => { + if (UIStore.activeModal?.id === 'chromecast') { + TorrentActions.fetchTorrentContents(UIStore.activeModal?.hash).then((fetchedContents) => { + if (fetchedContents != null) { + setContents(fetchedContents); + } + }); + } + }, []); + + if (!initialized) + return ( + {intl.formatMessage({id: 'chromecast.modal.notSupported'})} + } + actions={[ + { + clickHandler: null, + content: intl.formatMessage({ + id: 'button.close', + }), + triggerDismiss: true, + type: 'tertiary', + }, + ]} + /> + ); + + const mediaFiles = contents.filter((file) => isFileChromecastable(file.filename)); + const selectedFileName = (contents[selectedFileIndex]?.filename || '').replace(/\.\w+$/, ''); + const subtitleSources: Subtitles[] = [ + 'none', + ...contents + .filter((file) => file.filename.startsWith(selectedFileName) && isFileSubtitles(file.filename)) + .map((file) => file.index), + ]; + + const beginCasting = async () => { + if (!UIStore.activeModal?.hash || !connected) return; + + const hash = UIStore.activeModal?.hash; + const {filename} = contents[selectedFileIndex]; + const contentType = getChromecastContentType(filename); + if (!contentType) return; + + const mediaInfo = new window.chrome.cast.media.MediaInfo( + await TorrentActions.getTorrentContentsDataPermalink(hash, [selectedFileIndex]), + contentType, + ); + + const metadata = new chrome.cast.media.GenericMediaMetadata(); + metadata.title = contents[selectedFileIndex].filename; + + mediaInfo.metadata = metadata; + + const request = new window.chrome.cast.media.LoadRequest(mediaInfo); + if (selectedSubtitles !== 'none') { + mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle(); + mediaInfo.textTrackStyle.backgroundColor = '#00000000'; + mediaInfo.textTrackStyle.edgeColor = '#000000FF'; + mediaInfo.textTrackStyle.edgeType = 'DROP_SHADOW'; + mediaInfo.textTrackStyle.fontFamily = 'SANS_SERIF'; + mediaInfo.textTrackStyle.fontScale = 1.0; + mediaInfo.textTrackStyle.foregroundColor = '#FFFFFF'; + + const track = new chrome.cast.media.Track(0, 'TEXT'); + track.name = 'Text'; + track.subtype = 'CAPTIONS'; + track.trackContentId = await TorrentActions.getTorrentContentsSubtitlePermalink(hash, selectedSubtitles); + track.trackContentType = 'text/vtt'; + + mediaInfo.tracks = [track]; + request.activeTrackIds = [0]; + } + + loadMedia(request); + }; + + const stopCasting = () => { + const castSession = window.cast.framework.CastContext.getInstance().getCurrentSession(); + if (!castSession) return; + + const media = castSession.getMediaSession(); + if (!media) return; + + media.stop(new chrome.cast.media.StopRequest()); + }; + + return ( + +
+ + + + + + + + + + + + + + {isMediaLoaded ? : } + + + + {isMediaLoaded && ( + + + {isPaused ? : } + + + )} + + + + + +
+ + } + actions={[ + { + clickHandler: null, + content: intl.formatMessage({ + id: 'button.close', + }), + triggerDismiss: true, + type: 'tertiary', + }, + ]} + /> + ); +}; + +export default GenerateMagnetModal; diff --git a/client/src/javascript/components/torrent-list/TorrentListContextMenu.tsx b/client/src/javascript/components/torrent-list/TorrentListContextMenu.tsx index a8b7c5632..d64096860 100644 --- a/client/src/javascript/components/torrent-list/TorrentListContextMenu.tsx +++ b/client/src/javascript/components/torrent-list/TorrentListContextMenu.tsx @@ -161,6 +161,17 @@ const getContextMenuItems = (torrent: TorrentProperties): Array document.body.removeChild(link); }, }, + { + type: 'action', + action: 'chromecast', + label: TorrentContextMenuActions.chromecast, + clickHandler: () => { + UIActions.displayModal({ + id: 'chromecast', + hash: getLastSelectedTorrent(), + }); + }, + }, { type: 'action', action: 'generateMagnet', diff --git a/client/src/javascript/constants/TorrentContextMenuActions.ts b/client/src/javascript/constants/TorrentContextMenuActions.ts index 28be027e4..110e489f3 100644 --- a/client/src/javascript/constants/TorrentContextMenuActions.ts +++ b/client/src/javascript/constants/TorrentContextMenuActions.ts @@ -10,6 +10,7 @@ const TorrentContextMenuActions = { torrentDetails: 'torrents.list.context.details', downloadContents: 'torrents.list.context.download.contents', downloadMetainfo: 'torrents.list.context.download.metainfo', + chromecast: 'torrents.list.context.chromecast', generateMagnet: 'torrents.list.context.generate.magnet', setInitialSeeding: 'torrents.list.context.initial.seeding', setSequential: 'torrents.list.context.sequential', diff --git a/client/src/javascript/i18n/strings/en.json b/client/src/javascript/i18n/strings/en.json index a92c8903f..7a6bb36d6 100644 --- a/client/src/javascript/i18n/strings/en.json +++ b/client/src/javascript/i18n/strings/en.json @@ -39,6 +39,16 @@ "button.save.feed": "Save", "button.state.adding": "Adding...", "button.yes": "Yes", + "chromecast.modal.title": "Chromecast", + "chromecast.modal.file": "Media file", + "chromecast.modal.subtitle": "Subtitle source", + "chromecast.modal.subtitle.none": "None", + "chromecast.modal.start": "Start", + "chromecast.modal.stop": "Stop", + "chromecast.modal.play": "Play", + "chromecast.modal.pause": "Pause", + "chromecast.modal.not.supported": "Chromecasting is not supported on this browser", + "connection-interruption.action.selection.retry": "Retry with current client connection settings", "connection-interruption.action.selection.config": "Update client connection settings", "connection-interruption.action.selection.retry": "Retry with current client connection settings", "connection-interruption.heading": "Cannot connect to the client", @@ -318,6 +328,7 @@ "torrents.list.context.check.hash": "Check Hash", "torrents.list.context.details": "Torrent Details", "torrents.list.context.download.contents": "Download Contents", + "torrents.list.context.chromecast": "Chromecast", "torrents.list.context.download.metainfo": "Download .torrent", "torrents.list.context.generate.magnet": "Generate Magnet Link", "torrents.list.context.initial.seeding": "Initial Seeding", diff --git a/client/src/javascript/stores/UIStore.ts b/client/src/javascript/stores/UIStore.ts index 922b25740..fa6f70bf9 100644 --- a/client/src/javascript/stores/UIStore.ts +++ b/client/src/javascript/stores/UIStore.ts @@ -77,7 +77,7 @@ export type Modal = actions: Array; } | { - id: 'torrent-details'; + id: 'chromecast' | 'torrent-details'; hash: string; }; diff --git a/client/src/javascript/ui/components/FormRowItem.tsx b/client/src/javascript/ui/components/FormRowItem.tsx index 34151496c..7ad1d9791 100644 --- a/client/src/javascript/ui/components/FormRowItem.tsx +++ b/client/src/javascript/ui/components/FormRowItem.tsx @@ -17,7 +17,8 @@ export interface FormRowItemProps { | 'one-half' | 'five-eighths' | 'three-quarters' - | 'seven-eighths'; + | 'seven-eighths' + | 'one-sixteenth'; } const FormRowItem = forwardRef( diff --git a/client/src/javascript/ui/icons/Pause.tsx b/client/src/javascript/ui/icons/Pause.tsx new file mode 100644 index 000000000..d0cab79e8 --- /dev/null +++ b/client/src/javascript/ui/icons/Pause.tsx @@ -0,0 +1,18 @@ +import classnames from 'classnames'; +import {FC, memo} from 'react'; + +interface StartProps { + className?: string; +} + +const Start: FC = memo(({className}: StartProps) => ( + + + +)); + +Start.defaultProps = { + className: undefined, +}; + +export default Start; diff --git a/client/src/javascript/ui/icons/index.tsx b/client/src/javascript/ui/icons/index.tsx index 2f02c95c1..e9bca7970 100644 --- a/client/src/javascript/ui/icons/index.tsx +++ b/client/src/javascript/ui/icons/index.tsx @@ -47,6 +47,7 @@ export {default as Lock} from './Lock'; export {default as Logout} from './Logout'; export {default as Menu} from './Menu'; export {default as Notification} from './Notification'; +export {default as Pause} from './Pause'; export {default as Peers} from './Peers'; export {default as Radar} from './Radar'; export {default as RadioDot} from './RadioDot'; diff --git a/client/src/javascript/util/chromecastUtil.ts b/client/src/javascript/util/chromecastUtil.ts new file mode 100644 index 000000000..c8ff317c9 --- /dev/null +++ b/client/src/javascript/util/chromecastUtil.ts @@ -0,0 +1,16 @@ +import {chromecastableExtensions, subtitleExtensions} from '../../../../shared/constants/chromecastableExtensions'; + +export function isFileChromecastable(filename: string): boolean { + const fileExtension = filename.split('.').pop(); + return fileExtension in chromecastableExtensions; +} + +export function getChromecastContentType(filename: string): string | undefined { + const fileExtension = filename.split('.').pop(); + return fileExtension ? chromecastableExtensions[fileExtension] : undefined; +} + +export function isFileSubtitles(filename: string): boolean { + const fileExtension = filename.split('.').pop(); + return subtitleExtensions.includes(fileExtension); +} diff --git a/client/src/sass/components/_modals.scss b/client/src/sass/components/_modals.scss index c247f774f..162ef7e14 100644 --- a/client/src/sass/components/_modals.scss +++ b/client/src/sass/components/_modals.scss @@ -388,4 +388,34 @@ $modal--tabs--padding--vertical--left: $modal--padding--horizontal; } } } + + &__icon-button { + @include theme('color', 'sidebar--icon-button--foreground'); + display: block; + font-size: 0.8em; + line-height: 1; + position: relative; + transition: color 0.25s; + + &--interactive { + cursor: pointer; + + &:hover { + @include theme('color', 'sidebar--icon-button--foreground--hover'); + + .icon { + @include theme('fill', 'sidebar--icon-button--fill--hover'); + } + } + } + + .icon { + @include theme('fill', 'sidebar--icon-button--fill'); + height: 30px; + transition: fill 0.25s; + position: relative; + vertical-align: middle; + width: 30px; + } + } } diff --git a/client/src/sass/ui/components/form.scss b/client/src/sass/ui/components/form.scss index 761ec76fa..054d7db72 100644 --- a/client/src/sass/ui/components/form.scss +++ b/client/src/sass/ui/components/form.scss @@ -110,6 +110,10 @@ width: 87.5%; } + &--one-sixteenth { + width: 6.25%; + } + .button, .checkbox, .form__element__wrapper, diff --git a/package-lock.json b/package-lock.json index 61b82e652..f4e815542 100644 --- a/package-lock.json +++ b/package-lock.json @@ -143,6 +143,7 @@ "js-file-download": "^0.4.12", "jsonwebtoken": "^8.5.1", "lodash": "^4.17.21", + "matroska-subtitles": "^3.3.1", "mini-css-extract-plugin": "^1.3.9", "mobx": "^6.1.8", "mobx-react": "^7.1.0", @@ -2032,6 +2033,7 @@ "jest-resolve": "^26.6.2", "jest-util": "^26.6.2", "jest-worker": "^26.6.2", + "node-notifier": "^8.0.0", "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^4.0.1", @@ -5199,6 +5201,7 @@ "dependencies": { "anymatch": "~3.1.1", "braces": "~3.0.2", + "fsevents": "~2.3.1", "glob-parent": "~5.1.0", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", @@ -7587,6 +7590,15 @@ "xtend": "^4.0.0" } }, + "node_modules/ebml-stream": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ebml-stream/-/ebml-stream-1.0.3.tgz", + "integrity": "sha512-A+jCBY5NNAH/CQlcjLWN9txgv3uNiz+UAmMqDHPxFxoMNnuerV0RLhkE0YI9aNhdS3JVfcEQ9jFWq39fAR2W5g==", + "dev": true, + "engines": { + "node": ">= 10.10.0" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -7829,7 +7841,8 @@ "esprima": "^4.0.1", "estraverse": "^4.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1" + "optionator": "^0.8.1", + "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", @@ -11727,6 +11740,7 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", + "fsevents": "^2.1.2", "graceful-fs": "^4.2.4", "jest-regex-util": "^26.0.0", "jest-serializer": "^26.6.2", @@ -12367,6 +12381,7 @@ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dev": true, "dependencies": { + "graceful-fs": "^4.1.6", "universalify": "^2.0.0" }, "optionalDependencies": { @@ -12938,6 +12953,23 @@ "markdown-it": "bin/markdown-it.js" } }, + "node_modules/matroska-subtitles": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/matroska-subtitles/-/matroska-subtitles-3.3.1.tgz", + "integrity": "sha512-H1KbwFtGMhZeLofzf2y3Cruihgvm1gY7KvO1bd4Reyzj2QSlNNpktv94V6+VXt40Uh/5BAbWoFjcG5TLeTaqdw==", + "dev": true, + "dependencies": { + "ebml-stream": "^1.0.3", + "pako": "^2.0.2", + "readable-stream": "^3.6.0" + } + }, + "node_modules/matroska-subtitles/node_modules/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", + "dev": true + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -26077,6 +26109,7 @@ "@babel/types": "^7.11.5", "@lingui/babel-plugin-extract-messages": "^3.7.0", "@lingui/conf": "^3.7.0", + "babel-plugin-macros": "^2.8.0", "bcp-47": "^1.0.7", "chalk": "^4.1.0", "chokidar": "3.5.1", @@ -30547,6 +30580,12 @@ "xtend": "^4.0.0" } }, + "ebml-stream": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ebml-stream/-/ebml-stream-1.0.3.tgz", + "integrity": "sha512-A+jCBY5NNAH/CQlcjLWN9txgv3uNiz+UAmMqDHPxFxoMNnuerV0RLhkE0YI9aNhdS3JVfcEQ9jFWq39fAR2W5g==", + "dev": true + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -34767,6 +34806,25 @@ "uc.micro": "^1.0.5" } }, + "matroska-subtitles": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/matroska-subtitles/-/matroska-subtitles-3.3.1.tgz", + "integrity": "sha512-H1KbwFtGMhZeLofzf2y3Cruihgvm1gY7KvO1bd4Reyzj2QSlNNpktv94V6+VXt40Uh/5BAbWoFjcG5TLeTaqdw==", + "dev": true, + "requires": { + "ebml-stream": "^1.0.3", + "pako": "^2.0.2", + "readable-stream": "^3.6.0" + }, + "dependencies": { + "pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", + "dev": true + } + } + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", diff --git a/package.json b/package.json index 12f641de1..8632d3df1 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "js-file-download": "^0.4.12", "jsonwebtoken": "^8.5.1", "lodash": "^4.17.21", + "matroska-subtitles": "^3.3.1", "mini-css-extract-plugin": "^1.3.9", "mobx": "^6.1.8", "mobx-react": "^7.1.0", diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 9c1bddf25..496098a43 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -27,6 +27,36 @@ router.use('/auth', authRoutes); // Special routes that may bypass authentication when conditions matched +const authenticateContentRequest = async (req, _res, next) => { + const {token} = req.query; + + if (typeof token === 'string' && token !== '') { + const payload = await verifyToken(token).catch(() => undefined); + + if (payload != null) { + const parsedResult = contentTokenSchema.safeParse(payload); + + if (parsedResult.success) { + const {username, hash: authorizedHash, indices: authorizedIndices, iat} = parsedResult.data; + + if ( + typeof username === 'string' && + typeof authorizedHash === 'string' && + typeof authorizedIndices === 'string' + ) { + const {hash: requestedHash, indices: requestedIndices} = req.params; + + if (requestedHash === authorizedHash && requestedIndices === authorizedIndices) { + req.cookies = {jwt: getAuthToken(username, iat)}; + } + } + } + } + } + + next(); +}; + /** * GET /api/torrents/{hash}/contents/{indices}/data * @summary Gets downloaded data of contents of a torrent. Allows unauthenticated @@ -39,35 +69,22 @@ router.get<{hash: string; indices: string}, unknown, unknown, {token: string}>( windowMs: 5 * 60 * 1000, max: 100, }), - async (req, _res, next) => { - const {token} = req.query; - - if (typeof token === 'string' && token !== '') { - const payload = await verifyToken(token).catch(() => undefined); - - if (payload != null) { - const parsedResult = contentTokenSchema.safeParse(payload); - - if (parsedResult.success) { - const {username, hash: authorizedHash, indices: authorizedIndices, iat} = parsedResult.data; - - if ( - typeof username === 'string' && - typeof authorizedHash === 'string' && - typeof authorizedIndices === 'string' - ) { - const {hash: requestedHash, indices: requestedIndices} = req.params; - - if (requestedHash === authorizedHash && requestedIndices === authorizedIndices) { - req.cookies = {jwt: getAuthToken(username, iat)}; - } - } - } - } - } + authenticateContentRequest, +); - next(); - }, +/** + * GET /api/torrents/{hash}/contents/{index}/subtitles + * @summary Extracts subtitle data from the downloaded contents of a torrent. Allows unauthenticated + * access if a valid content token is found in the query. + * @see torrents.ts + */ +router.get<{hash: string; index: string}, unknown, unknown, {token: string}>( + '/torrents/:hash/contents/:indices/subtitles', + rateLimit({ + windowMs: 5 * 60 * 1000, + max: 100, + }), + authenticateContentRequest, ); // All subsequent routes need authentication diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index 2939991ae..549b933ba 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -7,6 +7,7 @@ import path from 'path'; import rateLimit from 'express-rate-limit'; import sanitize from 'sanitize-filename'; import tar, {Pack} from 'tar-fs'; +import {SubtitleParser} from 'matroska-subtitles'; import type { AddTorrentByFileOptions, @@ -38,6 +39,7 @@ import { import {accessDeniedError, fileNotFoundError, isAllowedPath, sanitizePath} from '../../util/fileUtil'; import {getTempPath} from '../../models/TemporaryStorage'; import {getToken} from '../../util/authUtil'; +import {subtitleExtensions} from '../../../shared/constants/chromecastableExtensions'; const getDestination = async ( services: Express.Request['services'], @@ -822,6 +824,9 @@ router.get<{hash: string; indices: string}, unknown, unknown, {token: string}>( // This is useful for texts, videos and audios. Users can still download them if needed. res.setHeader('content-disposition', contentDisposition(fileName, {type: 'inline'})); + // Chromecast requires that media and subtitle enable CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.sendFile(file); return res; @@ -874,6 +879,143 @@ router.get<{hash: string; indices: string}, unknown, unknown, {token: string}>( }, ); +function convertTimeStamp(millis: number): string { + const hours = String(Math.floor(millis / (3600 * 1000))).padStart(2, '0'); + const minutes = String(Math.floor(millis / (60 * 1000)) % 60).padStart(2, '0'); + const seconds = String(Math.floor(millis / 1000) % 60).padStart(2, '0'); + const mil = String(millis % 1000).padStart(3, '0'); + return `${hours}:${minutes}:${seconds}.${mil}`; +} + +function convertText(text: string): string { + return text.replace(/\\N/g, '\n'); +} + +const subtitleCache: Record = {}; + +/** + * GET /api/torrents/{hash}/contents/{index}/data + * @summary Extracts subtitle data from the downloaded contents of a torrent. + * @tags Torrent + * @security User + * @param {string} hash.path + * @param {string} index.path - index of selected content + * @return {object} 200 - subtitles in vtt - format text/vtt + */ +router.get( + '/:hash/contents/:index/subtitles', + // This operation is resource-intensive + // Limit each IP to 50 requests every 5 minutes + rateLimit({ + windowMs: 5 * 60 * 1000, + max: 50, + }), + (req, res) => { + const {hash, index: stringIndex} = req.params; + + const selectedTorrent = req.services?.torrentService.getTorrent(hash); + if (!selectedTorrent) { + res.status(404).json({error: 'Torrent not found.'}); + return; + } + + req.services?.clientGatewayService + ?.getTorrentContents(hash) + .then(async (contents) => { + if (!contents || contents.length < 1) { + res.status(404).json({error: 'Torrent contents not found'}); + return; + } + + let index = stringIndex ? Number(stringIndex) : contents[0].index; + + let subtitleSourceContent = contents.find((content) => index == content.index); + + if (!subtitleSourceContent) { + res.status(404).json({error: 'Torrent contents not found'}); + return; + } + + let subtitleSourcePath = sanitizePath(path.join(selectedTorrent.directory, subtitleSourceContent.path)); + + if (!isAllowedPath(subtitleSourcePath)) { + const {code, message} = accessDeniedError(); + res.status(403).json({code, message}); + return; + } + + if (!fs.existsSync(subtitleSourcePath)) { + const {code, message} = fileNotFoundError(); + res.status(404).json({code, message}); + return; + } + + const fileExt = path.extname(subtitleSourcePath).substring(1); + if (!fileExt || !subtitleExtensions.includes(fileExt)) { + res.status(415).json({error: 'Cannot extract subtitles for this file.'}); + return; + } + + res.type('text/vtt'); + + // Chromecast requires that media and subtitle enale CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + + if (subtitleSourcePath in subtitleCache) { + res.send(subtitleCache[subtitleSourcePath]); + return; + } + + let subtitles = 'WEBVTT\n\n'; + + switch (fileExt) { + case 'mkv': + await new Promise((resolve) => { + let line = 1; + let trackNum = 0; + + const parser = new SubtitleParser(); + + parser.once('tracks', (tracks) => { + trackNum = tracks[0].number; + }); + + parser.on('subtitle', (subtitle: {text: string; time: number; duration: number}, trackNumber: number) => { + if (trackNumber != trackNum) return; + subtitles += `${line} +${convertTimeStamp(subtitle.time)} --> ${convertTimeStamp(subtitle.time + subtitle.duration)} +${convertText(subtitle.text)}\n\n`; + line++; + }); + + parser.on('finish', () => { + resolve(); + }); + + fs.createReadStream(subtitleSourcePath).pipe(parser); + }); + + break; + case 'srt': + subtitles += String(fs.readFileSync(subtitleSourcePath)).replace( + /([0-9])+:([0-9]+):([0-9]+)[,\.]([0-9]+)/g, + '$1:$2:$3.$4', + ); + break; + case 'vtt': + return res.sendFile(subtitleSourcePath); + } + + subtitleCache[subtitleSourcePath] = subtitles; + + res.send(subtitles); + }) + .catch((err) => { + res.status(500).json(err); + }); + }, +); + /** * GET /api/torrents/{hash}/details * @summary Gets details of a torrent. diff --git a/shared/constants/chromecastableExtensions.ts b/shared/constants/chromecastableExtensions.ts new file mode 100644 index 000000000..716ac8daa --- /dev/null +++ b/shared/constants/chromecastableExtensions.ts @@ -0,0 +1,19 @@ +export const chromecastableExtensions: Record = { + apng: 'image/apng', + bmp: 'image/bmp', + gif: 'image/gif', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + mp2t: 'video/mp2t', + mp3: 'audio/mpeg3', + mp4: 'video/mp4', + ogg: 'application/ogg', + wav: 'audio/wav', + webm: 'video/webm', + mkv: 'video/webm', + flac: 'audio/flac', + aac: 'audio/aac', +}; + +export const subtitleExtensions: string[] = ['mkv', 'vtt', 'srt']; diff --git a/shared/constants/defaultFloodSettings.ts b/shared/constants/defaultFloodSettings.ts index e5c936962..35b0f4f30 100644 --- a/shared/constants/defaultFloodSettings.ts +++ b/shared/constants/defaultFloodSettings.ts @@ -60,6 +60,7 @@ const defaultFloodSettings: Readonly = { {id: 'torrentDetails', visible: true}, {id: 'downloadContents', visible: true}, {id: 'downloadMetainfo', visible: false}, + {id: 'chromecast', visible: false}, {id: 'generateMagnet', visible: false}, {id: 'setInitialSeeding', visible: false}, {id: 'setSequential', visible: false}, From edb58ae50761e865d81cbda26513aa6421353170 Mon Sep 17 00:00:00 2001 From: jwbuiter Date: Mon, 1 Feb 2021 22:30:16 +0000 Subject: [PATCH 2/4] show no progress if no media is loaded --- .../components/modals/chromecast-modal/ChromecastModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx index 66f14a7da..75df83657 100644 --- a/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx +++ b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx @@ -200,7 +200,7 @@ const GenerateMagnetModal: FC = () => { )} - + From a04f7fa4d68948d8a25782d0dfe605547e4f15b4 Mon Sep 17 00:00:00 2001 From: jwbuiter Date: Mon, 1 Feb 2021 23:07:45 +0000 Subject: [PATCH 3/4] chromecast: replace intl with lingui --- .../chromecast-modal/ChromecastModal.tsx | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx index 75df83657..a26cc50ce 100644 --- a/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx +++ b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx @@ -1,5 +1,5 @@ import {FC, useEffect, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {useLingui} from '@lingui/react'; import {CastButton, useCast, useCastPlayer} from 'react-cast-sender'; import classNames from 'classnames'; @@ -17,7 +17,7 @@ import {getChromecastContentType, isFileChromecastable, isFileSubtitles} from '. type Subtitles = number | 'none'; const GenerateMagnetModal: FC = () => { - const intl = useIntl(); + const {i18n} = useLingui(); const {connected, initialized} = useCast(); const {loadMedia, currentTime, duration, isPaused, isMediaLoaded, togglePlay} = useCastPlayer(); @@ -39,12 +39,8 @@ const GenerateMagnetModal: FC = () => { if (!initialized) return ( {intl.formatMessage({id: 'chromecast.modal.notSupported'})} - } + heading={i18n._('chromecast.modal.title')} + content={
{i18n._('chromecast.modal.notSupported')}
} actions={[ { clickHandler: null, @@ -171,7 +167,7 @@ const GenerateMagnetModal: FC = () => { { {isMediaLoaded && ( { actions={[ { clickHandler: null, - content: intl.formatMessage({ - id: 'button.close', - }), + content: i18n._('button.close'), triggerDismiss: true, type: 'tertiary', }, From 4d686bfc252681cad838bbdd5669692ab0200aec Mon Sep 17 00:00:00 2001 From: jwbuiter Date: Sun, 7 Mar 2021 13:15:47 +0000 Subject: [PATCH 4/4] fix linting issues --- .../chromecast-modal/ChromecastModal.tsx | 42 +++++++++---------- client/src/javascript/util/chromecastUtil.ts | 2 + server/routes/api/index.ts | 3 +- server/routes/api/torrents.ts | 6 +-- server/tsconfig.json | 2 +- 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx index a26cc50ce..48a3b8161 100644 --- a/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx +++ b/client/src/javascript/components/modals/chromecast-modal/ChromecastModal.tsx @@ -36,6 +36,10 @@ const GenerateMagnetModal: FC = () => { } }, []); + if (UIStore.activeModal?.id !== 'chromecast') { + return null; + } + if (!initialized) return ( { actions={[ { clickHandler: null, - content: intl.formatMessage({ - id: 'button.close', - }), + content: i18n._('button.close'), triggerDismiss: true, type: 'tertiary', }, @@ -54,6 +56,7 @@ const GenerateMagnetModal: FC = () => { /> ); + const hash = UIStore.activeModal?.hash; const mediaFiles = contents.filter((file) => isFileChromecastable(file.filename)); const selectedFileName = (contents[selectedFileIndex]?.filename || '').replace(/\.\w+$/, ''); const subtitleSources: Subtitles[] = [ @@ -64,9 +67,8 @@ const GenerateMagnetModal: FC = () => { ]; const beginCasting = async () => { - if (!UIStore.activeModal?.hash || !connected) return; + if (!connected) return; - const hash = UIStore.activeModal?.hash; const {filename} = contents[selectedFileIndex]; const contentType = getChromecastContentType(filename); if (!contentType) return; @@ -86,14 +88,14 @@ const GenerateMagnetModal: FC = () => { mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle(); mediaInfo.textTrackStyle.backgroundColor = '#00000000'; mediaInfo.textTrackStyle.edgeColor = '#000000FF'; - mediaInfo.textTrackStyle.edgeType = 'DROP_SHADOW'; + mediaInfo.textTrackStyle.edgeType = chrome.cast.media.TextTrackEdgeType.DROP_SHADOW; mediaInfo.textTrackStyle.fontFamily = 'SANS_SERIF'; mediaInfo.textTrackStyle.fontScale = 1.0; mediaInfo.textTrackStyle.foregroundColor = '#FFFFFF'; - const track = new chrome.cast.media.Track(0, 'TEXT'); + const track = new chrome.cast.media.Track(0, chrome.cast.media.TrackType.TEXT); track.name = 'Text'; - track.subtype = 'CAPTIONS'; + track.subtype = chrome.cast.media.TextTrackType.CAPTIONS; track.trackContentId = await TorrentActions.getTorrentContentsSubtitlePermalink(hash, selectedSubtitles); track.trackContentType = 'text/vtt'; @@ -111,23 +113,23 @@ const GenerateMagnetModal: FC = () => { const media = castSession.getMediaSession(); if (!media) return; - media.stop(new chrome.cast.media.StopRequest()); + media.stop( + new chrome.cast.media.StopRequest(), + () => {}, + () => {}, + ); }; return (
{ if (id === 'none') setSelectedSubtitles('none'); else setSelectedSubtitles(Number(id)); }}> {subtitleSources.map((source) => ( - {source === 'none' - ? intl.formatMessage({ - id: 'chromecast.modal.subtitle.none', - }) - : contents[source].filename} + {source === 'none' ? i18n._('chromecast.modal.subtitle.none') : contents[source].filename} ))} diff --git a/client/src/javascript/util/chromecastUtil.ts b/client/src/javascript/util/chromecastUtil.ts index c8ff317c9..4708fe65e 100644 --- a/client/src/javascript/util/chromecastUtil.ts +++ b/client/src/javascript/util/chromecastUtil.ts @@ -2,6 +2,7 @@ import {chromecastableExtensions, subtitleExtensions} from '../../../../shared/c export function isFileChromecastable(filename: string): boolean { const fileExtension = filename.split('.').pop(); + if (!fileExtension) return false; return fileExtension in chromecastableExtensions; } @@ -12,5 +13,6 @@ export function getChromecastContentType(filename: string): string | undefined { export function isFileSubtitles(filename: string): boolean { const fileExtension = filename.split('.').pop(); + if (!fileExtension) return false; return subtitleExtensions.includes(fileExtension); } diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 496098a43..205b31d75 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -10,6 +10,7 @@ import type {FloodSettings} from '@shared/types/FloodSettings'; import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes'; import type {NotificationFetchOptions} from '@shared/types/Notification'; import type {SetFloodSettingsOptions} from '@shared/types/api/index'; +import type {RequestHandler} from 'express'; import {accessDeniedError, isAllowedPath, sanitizePath} from '../../util/fileUtil'; import appendUserServices from '../../middleware/appendUserServices'; @@ -27,7 +28,7 @@ router.use('/auth', authRoutes); // Special routes that may bypass authentication when conditions matched -const authenticateContentRequest = async (req, _res, next) => { +const authenticateContentRequest: RequestHandler = async (req, _res, next) => { const {token} = req.query; if (typeof token === 'string' && token !== '') { diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index 549b933ba..333a2e673 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -927,16 +927,16 @@ router.get( return; } - let index = stringIndex ? Number(stringIndex) : contents[0].index; + const index = stringIndex ? Number(stringIndex) : contents[0].index; - let subtitleSourceContent = contents.find((content) => index == content.index); + const subtitleSourceContent = contents.find((content) => index == content.index); if (!subtitleSourceContent) { res.status(404).json({error: 'Torrent contents not found'}); return; } - let subtitleSourcePath = sanitizePath(path.join(selectedTorrent.directory, subtitleSourceContent.path)); + const subtitleSourcePath = sanitizePath(path.join(selectedTorrent.directory, subtitleSourceContent.path)); if (!isAllowedPath(subtitleSourcePath)) { const {code, message} = accessDeniedError(); diff --git a/server/tsconfig.json b/server/tsconfig.json index a11abf474..dcd14cc67 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -13,7 +13,7 @@ "paths": { "@shared/*": ["shared/*"] }, - "outDir": "../dist" + "outDir": "../dist", }, "include": ["./**/*.ts", "../shared/**/*.ts", "../config.js", "../config.ts"] }