├── .dockerignore ├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── Dockerfile ├── LICENSE.txt ├── README.md ├── backend ├── .eslintrc ├── core │ ├── Log.js │ ├── MediaItemHelper.js │ ├── Settings.js │ ├── Subtitles.js │ ├── database │ │ ├── Database.js │ │ ├── DatabaseSearch.js │ │ └── Migrate.js │ ├── http │ │ ├── IApiHandler.js │ │ ├── IImageHandler.js │ │ ├── RequestHandler.js │ │ ├── coreHandlers │ │ │ ├── DatabaseApiHandler.js │ │ │ ├── DirectoryBrowserHandler.js │ │ │ ├── DocumentationRequestHandler.js │ │ │ ├── FileRequestHandler.js │ │ │ ├── ImageCacheHandler.js │ │ │ ├── ScannerApiHandler.js │ │ │ ├── SettingsApiHandler.js │ │ │ ├── WatchNext.js │ │ │ └── index.js │ │ ├── doc │ │ │ └── scanner.md │ │ └── index.js │ ├── index.js │ ├── scanner │ │ ├── ExtendedInfoQueue.js │ │ ├── ExtrasExtendedInfo.js │ │ ├── FilewatchWorker.js │ │ ├── IExtendedInfo.js │ │ └── MovieScanner.js │ └── tests │ │ ├── Database.spec.js │ │ ├── Filename.spec.js │ │ ├── HttpServer.spec.js │ │ └── filenames.json └── modules │ ├── debug │ ├── apidoc.md │ └── index.js │ ├── error_reporting │ └── index.js │ ├── ffmpeg │ ├── FFMpeg.js │ ├── FFProbe.js │ ├── FFProbeExtendedInfo.js │ ├── FFProbeImageHandler.js │ ├── HLSContainer.js │ ├── MediaContentApiHandler.js │ ├── Mpeg4Container.js │ ├── PlayHandler.js │ ├── index.js │ ├── mediacontent.md │ ├── playhandler.md │ └── profiles │ │ ├── chromecast_ultra.json │ │ ├── default.json │ │ ├── hls_for_offline_playback.json │ │ └── safari.json │ ├── filename │ └── index.js │ ├── guessit │ ├── Guessit.js │ ├── ParseFileNameExtendedInfo.js │ └── index.js │ ├── sharing │ ├── Crypt.js │ ├── DatabaseFetcher.js │ ├── DownloadFileHandler.js │ ├── EDHT.js │ ├── FileProcessor.js │ ├── MediaFetcher.js │ ├── TcpClient.js │ ├── TcpConnection.js │ ├── TcpServer.js │ └── index.js │ ├── socketio │ └── index.js │ ├── ssl │ ├── certificateRequester.js │ └── index.js │ └── tmdb │ ├── TMDBApiHandler.js │ ├── TMDBImageHandler.js │ ├── TheMovieDBExtendedInfo.js │ ├── TheMovieDBSeriesAndSeasonsExtendedInfo.js │ ├── genres.md │ ├── index.js │ └── redirectToIMDBHandler.js ├── doc ├── docker.png ├── linux.png ├── macos.png ├── pi.png ├── screens.png └── windows.png ├── docker-compose.yml ├── frontend ├── .editorconfig ├── .env ├── .gitignore ├── .npmignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── assets │ │ └── img │ │ │ ├── discord.svg │ │ │ ├── github.png │ │ │ ├── imdb.svg │ │ │ ├── logo.png │ │ │ ├── logo_192.png │ │ │ ├── logo_512.png │ │ │ ├── logo_small.png │ │ │ ├── stars-full.png │ │ │ └── stars.png │ ├── favicon.ico │ ├── index.html │ ├── jquery-2.1.1.min.js │ ├── manifest.json │ ├── materialize.min.js │ └── sw.js └── src │ ├── App.js │ ├── components │ ├── FileSize.js │ ├── Filters.js │ ├── LibraryDialog.js │ ├── ReadableDuration.js │ ├── SearchBar.js │ ├── ServerFileBrowser.js │ ├── Time.js │ ├── TopBar.js │ ├── localStorage │ │ ├── DownloadButton.js │ │ ├── HLSDownloader.js │ │ ├── LocalStorageIcon.js │ │ └── LocalStorageProgressForItem.js │ ├── mediaItem │ │ ├── MediaInfo.js │ │ ├── MediaItemRow.js │ │ └── MediaItemTile.js │ └── player │ │ ├── ButtonMenu.js │ │ ├── CastButton.js │ │ ├── NavBar.js │ │ ├── SeekBar.js │ │ ├── Subtitles.js │ │ └── renderer │ │ ├── BaseRenderer.js │ │ ├── ChromeCastRenderer.js │ │ ├── Html5VideoRenderer.js │ │ └── OfflineVideoRenderer.js │ ├── css │ ├── _detail.scss │ ├── _icon.scss │ ├── _video.scss │ ├── icon.woff2 │ └── index.scss │ ├── fonts │ └── roboto │ │ ├── Roboto-Bold.woff │ │ ├── Roboto-Bold.woff2 │ │ ├── Roboto-Light.woff │ │ ├── Roboto-Light.woff2 │ │ ├── Roboto-Medium.woff │ │ ├── Roboto-Medium.woff2 │ │ ├── Roboto-Regular.woff │ │ ├── Roboto-Regular.woff2 │ │ ├── Roboto-Thin.woff │ │ └── Roboto-Thin.woff2 │ ├── helpers │ ├── ChromeCast.js │ ├── LocalStorage.js │ ├── ShortcutHelper.js │ ├── SocketIO.js │ ├── autoPlaySupported.js │ ├── history.js │ └── stores │ │ ├── deserialize.js │ │ ├── playQueue.js │ │ ├── serialize.js │ │ ├── settingsStore.js │ │ └── store.js │ ├── index.js │ └── routes │ ├── About.js │ ├── Api.js │ ├── Detail.js │ ├── Home.js │ ├── Library.js │ ├── LocalStorage.js │ ├── Settings.js │ └── Video.js ├── main.js ├── package-lock.json ├── package.json └── platform_scripts ├── arm └── Dockerfile └── nasos ├── build.sh ├── buildscript.sh ├── package-armv7.json ├── package-extra.json ├── package-x86_64.json ├── package.json ├── resources └── icons │ ├── icon-20.png │ ├── icon-50.png │ └── icon-80.png ├── scripts ├── post-install ├── post-update ├── pre-install ├── pre-remove └── pre-update └── source ├── etc ├── default │ └── remote-mediaserver └── init.d │ └── remote-mediaserver └── rc.local /.dockerignore: -------------------------------------------------------------------------------- 1 | doc 2 | frontend/node_modules 3 | node_modules 4 | tests 5 | frontend/build 6 | dist 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules* 2 | settings.json 3 | db 4 | *.DS_Store 5 | .idea 6 | ffmpeg.exe 7 | ffprobe.exe 8 | *.iml 9 | data/ 10 | *.rbw 11 | *.tgz 12 | dist 13 | platform_scripts/nasos/source/rms 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules* 2 | settings.json 3 | db 4 | .jshintrc 5 | *.DS_Store 6 | .idea 7 | ffprobe 8 | ffmpeg.exe 9 | ffprobe.exe 10 | platform_scripts 11 | *.rbw 12 | *.tgz 13 | dist 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "16" 4 | services: 5 | - docker 6 | before_install: 7 | - rvm install 2.7.7 8 | - nvm --version 9 | - node --version 10 | - npm --version 11 | - gcc --version 12 | - g++ --version 13 | - docker run --rm --privileged multiarch/qemu-user-static:register --reset 14 | install: 15 | - cd frontend && yarn install && yarn run build && cd ../ 16 | - yarn install 17 | script: 18 | - yarn run test 19 | - yarn pack 20 | - mkdir -p dist 21 | - mv remote-mediaserver-*.tgz dist/ 22 | - yarn run build 23 | deploy: 24 | provider: s3 25 | access_key_id: "AKIAIHYANWLHBG5FRLZQ" 26 | secret_access_key: 27 | secure: "emKpIuz1yAXcg4/5vGC5/DllUFu3G0k56lnZS8ThlZfKlrZnwCwSQKzhyaFX5GOB+6ARTgYT0nkdr64bFwJLduFHvVv4kgKY5edR3PC5VDDTY1qdnw8WU7QSkpTZ+BiB0SpBY0R3Q3Ox1JSgYlAWCH80vsqt7LawyOuK6wmOQne3jshaHXIMh5bG+vPxhU+52p8KVmZRTZIb22WfWtySHAKtQyZE/yHzw0R7apxoBZ/Xus9aX2M8CD8z4i7b0BN87eeOYTX4V6j56BYEizDuMgZGvAn34IzNcGBmbM9r94Xbu7i0RH4xDKYN4EyAapFQK6AW0M2B3Nd/uveHiRAVj4ALKVeg16VCVaveNcoCOaczw4trZTh5MTRqWaTIM7JeVni5Kc4+2II5fBg5uVSNiZW/991WKusIr32enAqGOm2SCneG6LAd4qm5mvvOLIuL3u7p8qjHCcSdVkDN839HpqfQ+efZ81ty5r1J8FjUn0gKGQiBRYdbv+wFFCcECe9zBv5XZVJ+8RduGjtqRno681sDbMmE3A5bipNE11vf/bmIRuj/ufQDkOmomHbIjNzvOvun01eOC79tPTwJrhZm1C+9PpjTLQzoULq49nTLK+elZuwusoJiLfQlgep/wWIBuZYf6MTMwl/yYk/ElLVM/qW3Z9USrEozcwRdlCbJByo=" 28 | bucket: "remote-mediaserver" 29 | skip_cleanup: true 30 | acl: public_read 31 | local_dir: dist 32 | region: eu-west-1 33 | upload-dir: ${TRAVIS_BRANCH} 34 | on: 35 | all_branches: true 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 AS build_backend 2 | 3 | WORKDIR /backend 4 | COPY ./package* ./ 5 | RUN npm install --production 6 | 7 | ################################## 8 | FROM node:16 AS build_frontend 9 | 10 | WORKDIR /frontend 11 | COPY ./frontend/package* ./ 12 | 13 | RUN npm install --production 14 | COPY frontend/ ./ 15 | 16 | RUN npm run build 17 | 18 | 19 | ################################## 20 | FROM node:16 21 | 22 | WORKDIR /app 23 | 24 | COPY --from=build_frontend /frontend/build /app/frontend/build 25 | COPY --from=build_backend /backend /app/ 26 | 27 | COPY . backend/ /app/ 28 | 29 | # EXPOSE [P2P] [HTTP] 30 | EXPOSE 8234 8235 31 | 32 | # USER rms 33 | CMD node main.js 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gitter Chat](https://img.shields.io/gitter/room/nwjs/nw.js.svg?logo=gitter)](https://gitter.im/remote-mediaserver/Lobby) 2 | [![Discord Chat](https://img.shields.io/discord/505014801970364436.svg?logo=discord)](https://discord.gg/4H5EMd6) 3 | [![Build Status](https://travis-ci.org/OwenRay/Remote-MediaServer.svg?branch=dev)](https://travis-ci.org/OwenRay/Remote-MediaServer) 4 | [![npmjs](https://img.shields.io/npm/dw/remote-mediaserver.svg)](https://www.npmjs.com/package/remote-mediaserver) 5 | 6 | 7 | ![](doc/screens.png) 8 | 9 | # INSTALL 10 | ### OPTION 1, INSTALL PRECOMPILED 11 | Download and unzip the version appropriate to your os. 12 | 13 | Linux | Windows | MacOS | Raspberry PI | Docker 14 | --- | --- | --- | --- | --- 15 | [![Linux](doc/linux.png)](https://s3-eu-west-1.amazonaws.com/remote-mediaserver/dev/linux.zip) | [![Windows](doc/windows.png)](https://s3-eu-west-1.amazonaws.com/remote-mediaserver/dev/win.zip) | [![MacOS](doc/macos.png)](https://s3-eu-west-1.amazonaws.com/remote-mediaserver/dev/osx.zip) | [![Mac](doc/pi.png)](https://s3-eu-west-1.amazonaws.com/remote-mediaserver/dev/arm.zip) | [![Docker](doc/docker.png)](https://hub.docker.com/r/owenray/remote-mediaserver) 16 | 17 | ### OPTION 2, INSTALL VIA NPM 18 | You'll need: 19 | - NPM 20 | - NodeJS >= 10 21 | 22 | To install run: 23 | `$ npm install -g remote-mediaserver` 24 | 25 | after installation you can run RMS: 26 | `$ remote` 27 | direct your browser to http://localhost:8234 28 | 29 | # DEVELOPMENT 30 | ### Installing dependencies 31 | To setup your development environment run the following commands 32 | ```bash 33 | npm install 34 | npm install --prefix frontend && npm run build --prefix frontend 35 | ``` 36 | 37 | To start the server: 38 | `$ node main.js` 39 | 40 | A settings file (~/.remote/settings.json) will be created at first run 41 | Restart the server after direct modification 42 | 43 | direct your browser to http://localhost:8234 44 | 45 | ### Frontend 46 | The frontend is build on [React](reactjs.org/) 47 | to build and test the frontend first make sure the backend is running 48 | then execute the following: 49 | `$ cd frontend` 50 | `$ npm start` 51 | 52 | The webapplication will now be accessible from http://localhost:3000 53 | -------------------------------------------------------------------------------- /backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "prettier", "plugin:node/recommended"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | // "prettier/prettier": "error", 6 | "spaced-comment": "off", 7 | "no-console": "off", 8 | "consistent-return": "off", 9 | "func-names": "off", 10 | "object-shorthand": "off", 11 | "no-process-exit": "off", 12 | "no-param-reassign": "off", 13 | "no-return-await": "off", 14 | "no-underscore-dangle": "off", 15 | "class-methods-use-this": "off", 16 | "no-undef": "warn", 17 | "prefer-destructuring": ["error", { "object": true, "array": false }], 18 | "no-unused-vars": ["warn", { "argsIgnorePattern": "req|res|next|val" }] 19 | }, 20 | "overrides": [ 21 | { 22 | "files": [ 23 | "**/*.spec.js", 24 | "**/*.spec.jsx" 25 | ], 26 | "env": { 27 | "jest": true 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /backend/core/Log.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const Settings = require('./Settings'); 4 | 5 | const listeners = []; 6 | 7 | class Log { 8 | static debug(...message) { 9 | Log.log(Log.LEVEL.DEBUG, message); 10 | } 11 | 12 | static info(...message) { 13 | Log.log(Log.LEVEL.INFO, message); 14 | } 15 | 16 | static warning(...message) { 17 | Log.log(Log.LEVEL.WARNING, message); 18 | } 19 | 20 | static exception(...message) { 21 | Log.log(Log.LEVEL.EXCEPTION, message); 22 | } 23 | 24 | static notifyUser(event, ...message) { 25 | Log.log(Log.LEVEL.NOTIFY_USER, [event, ...message]); 26 | } 27 | 28 | static addListener(f) { 29 | listeners.push(f); 30 | } 31 | 32 | static log(level, message) { 33 | listeners.forEach((f) => f(level, message)); 34 | message.unshift(new Date()); 35 | if (level >= Settings.getValue('verbosity')) { 36 | switch (level) { 37 | case Log.LEVEL.WARNING: 38 | console.warn(...message); 39 | break; 40 | case Log.LEVEL.EXCEPTION: 41 | console.error(...message); 42 | break; 43 | default: 44 | console.log(...message); 45 | break; 46 | } 47 | } 48 | } 49 | } 50 | 51 | Log.LEVEL = { 52 | DEBUG: 0, INFO: 1, WARNING: 3, EXCEPTION: 4, NOTIFY_USER: 5, 53 | }; 54 | 55 | module.exports = Log; 56 | -------------------------------------------------------------------------------- /backend/core/MediaItemHelper.js: -------------------------------------------------------------------------------- 1 | const Settings = require('./Settings'); 2 | 3 | class MediaItemHelper { 4 | static getFullFilePath(mediaItem) { 5 | return mediaItem.attributes.filepath; 6 | } 7 | 8 | static getLibrary(item) { 9 | return Settings.getValue('libraries') 10 | .find((l) => l.uuid.split('-')[0] === item.attributes.libraryId); 11 | } 12 | } 13 | 14 | module.exports = MediaItemHelper; 15 | -------------------------------------------------------------------------------- /backend/core/Settings.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const uuid = require('node-uuid'); 3 | const crypto = require('crypto'); 4 | 5 | const { env } = process; 6 | 7 | // @todo move default settings to their respective modules 8 | const settingsObj = { 9 | port: 8234, 10 | bind: '0.0.0.0', 11 | name: 'My Media Server', 12 | ffmpeg_binary: `${process.cwd()}/ffmpeg`, 13 | ffprobe_binary: `${process.cwd()}/ffprobe`, 14 | ffmpeg_preset: 'fast', 15 | libraries: [], 16 | tmdb_apikey: '0699a1db883cf76d71187d9b24c8dd8e', 17 | dhtbootstrap: [ 18 | 'theremote.io:8235', 19 | 'whileip.com:8235', 20 | ], 21 | dhtoffset: 0, 22 | dbKey: crypto.randomBytes(24).toString('hex'), 23 | dbNonce: crypto.randomBytes(16).toString('hex'), 24 | shareport: 8235, 25 | sharehost: '', 26 | sharespace: 15, 27 | videoFileTypes: [ 28 | 'mkv', 29 | 'mp4', 30 | '3gp', 31 | 'avi', 32 | 'mov', 33 | 'ts', 34 | 'webm', 35 | 'flv', 36 | 'f4v', 37 | 'vob', 38 | 'ogv', 39 | 'ogg', 40 | 'wmv', 41 | 'qt', 42 | 'rm', 43 | 'mpg', 44 | 'mpeg', 45 | 'm4v', 46 | ], 47 | // 0: debug, 1: info, 2: warning, 3: exception 48 | verbosity: 1, 49 | guessit: { 50 | host: 'guessit.theremote.io', 51 | port: 5000, 52 | }, 53 | startscan: true, 54 | filewatcher: 'native', 55 | scanInterval: 3600, 56 | modules: [ 57 | 'debug', 58 | 'ffmpeg', 59 | 'filename', 60 | 'sharing', 61 | 'tmdb', 62 | 'ssl', 63 | 'socketio', 64 | ], 65 | startopen: true, 66 | ssl: {}, 67 | sslport: 8443, 68 | }; 69 | 70 | const Settings = { 71 | observers: [], 72 | 73 | createIfNotExists() { 74 | if (!fs.existsSync('settings.json')) { 75 | Settings.save(); 76 | } 77 | }, 78 | 79 | getValue(key) { 80 | return settingsObj[key]; 81 | }, 82 | 83 | /** 84 | * returns true if value changed 85 | * @param key 86 | * @param value 87 | * @returns {boolean} 88 | */ 89 | setValue(key, value) { 90 | if (key === 'libraries') { 91 | value.forEach((lib) => { 92 | if (!lib.uuid) { 93 | lib.uuid = uuid.v4(); 94 | } 95 | }); 96 | } 97 | 98 | const originalValue = settingsObj[key]; 99 | settingsObj[key] = value; 100 | 101 | // quick lazy way to do a deep compare 102 | if (JSON.stringify(originalValue) !== JSON.stringify(value)) { 103 | this.triggerObservers(key); 104 | return true; 105 | } 106 | return false; 107 | }, 108 | 109 | getAll() { 110 | return settingsObj; 111 | }, 112 | 113 | load() { 114 | let newSettings = settingsObj; 115 | if (fs.existsSync('settings.json')) { 116 | const contents = fs.readFileSync('settings.json', 'utf8'); 117 | newSettings = JSON.parse(contents); 118 | } 119 | Object.keys(newSettings).forEach((key) => { 120 | let val = env[`RMS_${key.toLocaleUpperCase()}`]; 121 | if (val && val[0] === '{') val = JSON.parse(val); 122 | val = val || newSettings[key]; 123 | if (val === 'false') val = false; 124 | settingsObj[key] = val; 125 | }); 126 | }, 127 | 128 | save() { 129 | fs.writeFileSync('settings.json', JSON.stringify(settingsObj, null, ' ')); 130 | }, 131 | 132 | triggerObservers(variable) { 133 | if (!Settings.observers[variable]) { 134 | return; 135 | } 136 | Settings.observers[variable].forEach((observer) => { observer(variable); }); 137 | }, 138 | 139 | addObserver(variable, callback) { 140 | if (!Settings.observers[variable]) { 141 | Settings.observers[variable] = []; 142 | } 143 | Settings.observers[variable].push(callback); 144 | }, 145 | }; 146 | 147 | Settings.load(); 148 | Settings.createIfNotExists(); 149 | 150 | module.exports = Settings; 151 | -------------------------------------------------------------------------------- /backend/core/Subtitles.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const os = require('os'); 3 | const { spawn } = require('child_process'); 4 | const fs = require('fs'); 5 | const ass2vtt = require('ass-to-vtt'); 6 | const srt2vtt = require('srt-to-vtt'); 7 | const shift = require('vtt-shift'); 8 | const Settings = require('./Settings'); 9 | const Log = require('./Log'); 10 | 11 | const converters = { 12 | '.srt': srt2vtt, 13 | '.subrip': srt2vtt, 14 | '.ass': ass2vtt, 15 | }; 16 | 17 | class Subtitles { 18 | static async getVtt(mediaId, videoFilePath, directory, file) { 19 | let f = await Subtitles.getCachedPath(mediaId, file); 20 | if (f) { 21 | return f; 22 | } 23 | f = await Subtitles.getPath(videoFilePath, directory, file); 24 | f = await Subtitles.convertedPath(f); 25 | if (f !== `${directory}/${file}`) { // no need to cache an existing user's vtt 26 | f = await Subtitles.cacheThis(f, mediaId, file); 27 | } 28 | return f; 29 | } 30 | 31 | static getTimeShiftedTmpFile(f, offsetMs) { 32 | return new Promise((resolve) => { 33 | const tmpFile = `${os.tmpdir()}/${Math.random()}.srt`; 34 | const writeFile = fs.createWriteStream(tmpFile); 35 | 36 | fs.createReadStream(f) 37 | .pipe(shift({ offsetMs })) 38 | .pipe(writeFile); 39 | writeFile.on('close', () => { 40 | resolve(tmpFile); 41 | }); 42 | }); 43 | } 44 | 45 | static getCachedPath(mediaId, file) { 46 | return new Promise((resolve) => { 47 | file = `subs/${mediaId}_${file}.vtt`; 48 | fs.stat(file, (err) => { 49 | resolve(err ? '' : file); 50 | }); 51 | }); 52 | } 53 | 54 | static cacheThis(f, mediaId, file) { 55 | return new Promise((resolve) => { 56 | file = `subs/${mediaId}_${file}.vtt`; 57 | fs.copyFile(f, file, (err) => { 58 | if(err) return f; 59 | fs.unlink(f, () => {}); 60 | resolve(file); 61 | }); 62 | }); 63 | } 64 | 65 | static getPath(videoFilePath, directory, file) { 66 | if (file[0] !== ':') { 67 | return `${directory}/${file}`; 68 | } 69 | return new Promise((resolve) => { 70 | let filename = file.substr(1); 71 | if (filename.endsWith('subrip')) { 72 | filename += '.srt'; 73 | } 74 | const tmpFile = `${os.tmpdir()}/${filename}`; 75 | const args = [ 76 | '-y', 77 | '-i', videoFilePath, 78 | '-map', `0${file.split('.').shift()}`, 79 | tmpFile, 80 | ]; 81 | 82 | const proc = spawn( 83 | Settings.getValue('ffmpeg_binary'), 84 | args, 85 | ); 86 | proc.stdout.on('data', (data) => { 87 | Log.info('ffmpeg result:', `${data}`); 88 | }); 89 | proc.stderr.on('data', (data) => { 90 | Log.info('ffmpeg result:', `${data}`); 91 | }); 92 | proc.on( 93 | 'close', 94 | async () => { 95 | resolve(tmpFile); 96 | }, 97 | ); 98 | }); 99 | } 100 | 101 | static convertedPath(file) { 102 | const extension = path.extname(file); 103 | if (extension === '.vtt') { 104 | return file; 105 | } 106 | if (converters[extension]) { 107 | return new Promise((resolve) => { 108 | const tmpFile = `${os.tmpdir()}/${Math.random()}.vtt`; 109 | const writeFile = fs.createWriteStream(tmpFile); 110 | writeFile.on( 111 | 'close', 112 | () => { 113 | resolve(tmpFile); 114 | }, 115 | ); 116 | fs.createReadStream(file) 117 | .pipe(converters[extension]()) 118 | .pipe(writeFile); 119 | }); 120 | } 121 | return ''; 122 | } 123 | } 124 | 125 | module.exports = Subtitles; 126 | -------------------------------------------------------------------------------- /backend/core/database/DatabaseSearch.js: -------------------------------------------------------------------------------- 1 | const Database = require('./Database'); 2 | // / 3 | class DatabaseSearch { 4 | static query( 5 | singularType, 6 | { 7 | where, sort, distinct, join, relationConditions = [], limit, offset, 8 | }, 9 | ) { 10 | let data; 11 | 12 | if (where && Object.keys(where).length > 0) { 13 | // find items with given filters 14 | data = Database.findByMatchFilters(singularType, where); 15 | } else { 16 | // get all items 17 | data = Database.getAll(singularType); 18 | } 19 | 20 | // parse sort params, example params: key:ASC,key2:DESC 21 | let sortArray = []; 22 | if (sort) { 23 | sortArray = sort.split(',').map((i) => i.split(':')); 24 | } 25 | const sortFunction = (a, b) => { 26 | // eslint-disable-next-line guard-for-in,no-restricted-syntax 27 | for (const key in sortArray) { 28 | const sortItem = sortArray[key][0]; 29 | let direction = sortArray[key].length > 1 ? sortArray[key][1] : 'ASC'; 30 | direction = direction === 'ASC' ? 1 : -1; 31 | if (a.attributes[sortItem] === undefined || a.attributes[sortItem] === null) { 32 | return 1; 33 | } 34 | if (b.attributes[sortItem] === undefined || b.attributes[sortItem] === null) { 35 | return -1; 36 | } 37 | if (a.attributes[sortItem].localeCompare) { 38 | if (a.attributes[sortItem].localeCompare(b.attributes[sortItem]) !== 0) { 39 | return a.attributes[sortItem].localeCompare(b.attributes[sortItem]) * direction; 40 | } 41 | } 42 | if (a.attributes[sortItem] - b.attributes[sortItem] !== 0) { 43 | return (a.attributes[sortItem] - b.attributes[sortItem] > 0 ? 1 : -1) * direction; 44 | } 45 | } 46 | return 0; 47 | }; 48 | 49 | // add relationships 50 | if (join) { 51 | for (let key = 0; key < data.length; key += 1) { 52 | let meetsConditions = true; 53 | let relObject; 54 | const rel = data[key].relationships ? data[key].relationships[join] : null; 55 | if (rel) { 56 | relObject = Database.getById(join, rel.data.id); 57 | } 58 | 59 | if (relationConditions[join] !== undefined) { 60 | meetsConditions = Object.keys(relationConditions[join]).every((conditionKey) => { 61 | const what = relationConditions[join][conditionKey]; 62 | if (!relObject) { 63 | return what !== 'true'; 64 | } 65 | return `${relObject.attributes[conditionKey]}` === what; 66 | }); 67 | } 68 | 69 | if (!meetsConditions) { 70 | data.splice(key, 1); 71 | key -= 1; 72 | } 73 | } 74 | } 75 | 76 | if (sort) { 77 | data = data.sort(sortFunction); 78 | } 79 | 80 | // make sure all the items have a unique "distinct" value 81 | const got = []; 82 | if (distinct) { 83 | data = data.filter((item) => { 84 | const distinctVal = item.attributes[distinct]; 85 | if (got[distinctVal]) { 86 | return false; 87 | } 88 | got[distinctVal] = true; 89 | return true; 90 | }); 91 | } 92 | const metadata = {}; 93 | if (offset || limit) { 94 | metadata.totalPages = Math.ceil(data.length / limit); 95 | metadata.totalItems = data.length; 96 | data = data.splice(offset, limit); 97 | } 98 | 99 | const included = join ? DatabaseSearch.getRelationShips(join, data) : []; 100 | 101 | return { data, included, metadata }; 102 | } 103 | 104 | static getRelationShips(join, items) { 105 | // get relationships 106 | const rels = {}; 107 | 108 | for (let c = 0; c < items.length; c += 1) { 109 | const relation = items[c].relationships ? items[c].relationships[join] : null; 110 | if (relation) { 111 | if (!rels[relation.data.id]) { 112 | rels[relation.data.id] = Database.getById(join, relation.data.id); 113 | } 114 | } 115 | } 116 | 117 | return Object.values(rels); 118 | } 119 | } 120 | 121 | module.exports = DatabaseSearch; 122 | -------------------------------------------------------------------------------- /backend/core/database/Migrate.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Database = require('./Database'); 3 | const Log = require('../Log'); 4 | const Settings = require('../Settings'); 5 | 6 | class Migrate { 7 | /** 8 | * Migration is started from the core (../) 9 | */ 10 | static run() { 11 | while (this[`version${Database.version}`]) { 12 | Log.info('running migration', Database.version); 13 | this[`version${Database.version}`](); 14 | Database.version += 1; 15 | Database.doSave('version'); 16 | } 17 | } 18 | 19 | static version0() { 20 | const items = Database.getAll('media-item', true); 21 | items.forEach((item) => { 22 | const p = item.relationships && item.relationships['play-position']; 23 | if (p) { 24 | const i = Database.getById('play-position', p.data.id); 25 | i.attributes.watched = i.attributes.position > item.attributes.fileduration * 0.97; 26 | } 27 | }); 28 | } 29 | 30 | /** 31 | * Migrates the database to multiple files, saves a lot of disk writes 32 | */ 33 | static version1() { 34 | const items = JSON.parse(fs.readFileSync('db', 'utf8')); 35 | Object.keys(items).forEach((key) => { Database[key] = items[key]; }); 36 | Database.writeTimeout = {}; 37 | Object.keys(items.tables).forEach((item) => { 38 | Database.doSave(item); 39 | }); 40 | Database.doSave('ids'); 41 | } 42 | 43 | /** 44 | * enable modules that are new since the previous release 45 | */ 46 | static version2() { 47 | const modules = Settings.getValue('modules'); 48 | if (modules.indexOf('ssl') === -1) modules.push('ssl'); 49 | if (modules.indexOf('socketio') === -1) modules.push('socketio'); 50 | Settings.setValue('modules', modules); 51 | Settings.save(); 52 | } 53 | 54 | /** 55 | * enable the filename module and disable guessit 56 | */ 57 | static version3() { 58 | const modules = Settings.getValue('modules'); 59 | const guess = modules.indexOf('guessit'); 60 | if (guess !== -1) modules.splice(guess, 1); 61 | if (modules.indexOf('filename') === -1) modules.push('filename'); 62 | Settings.setValue('modules', modules); 63 | Settings.save(); 64 | } 65 | } 66 | 67 | module.exports = Migrate; 68 | -------------------------------------------------------------------------------- /backend/core/http/IApiHandler.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | /** 3 | * Created by owen on 14-4-2016. 4 | */ 5 | 6 | class IApiHandler { 7 | /** 8 | * 9 | * @param {IncomingMessage} request 10 | * @param {ServerResponse} response 11 | * @param {{pathname:string,query:string}} url 12 | * @returns {boolean} handled or not? 13 | */ 14 | handle() { 15 | return false; 16 | } 17 | } 18 | 19 | module.exports = IApiHandler; 20 | -------------------------------------------------------------------------------- /backend/core/http/IImageHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 7/9/16. 3 | */ 4 | const RequestHandler = require('./RequestHandler'); 5 | const Database = require('../database/Database'); 6 | 7 | class IImageHandler extends RequestHandler { 8 | /** 9 | * 10 | * @param method 11 | * @param path 12 | * @param context 13 | */ 14 | constructor(context, method, path) { 15 | super(context, method, path); 16 | 17 | if (!this.context) { 18 | return; 19 | } 20 | const item = context.params.image.split('_'); 21 | context.set('Cache-control', 'max-age=86400'); 22 | 23 | [, this.type] = item; 24 | this.item = Database.getById('media-item', item[0]); 25 | this.response.header['Content-Type'] = 'image/jpeg'; 26 | } 27 | 28 | static getDescription() { 29 | return ':image should be [id]_[type] \n' 30 | + 'where type is one of \n' 31 | + '- backdrop \n' 32 | + '- poster \n' 33 | + '- posterlarge \n' 34 | + '- postersmall'; 35 | } 36 | } 37 | 38 | IImageHandler.TYPE_BACKDROP = 'backdrop'; 39 | IImageHandler.TYPE_POSTER = 'poster'; 40 | IImageHandler.TYPE_POSTER_LARGE = 'posterlarge'; 41 | IImageHandler.TYPE_POSTER_SMALL = 'postersmall'; 42 | 43 | module.exports = IImageHandler; 44 | -------------------------------------------------------------------------------- /backend/core/http/RequestHandler.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | /** 3 | * Created by owenray on 08-04-16. 4 | */ 5 | 6 | class RequestHandler { 7 | /** 8 | * 9 | * @param method 10 | * @param path 11 | * @param context 12 | */ 13 | constructor(context, method, path) { 14 | this.method = method; 15 | this.path = path; 16 | this.context = context; 17 | if (this.context) { 18 | this.request = this.context.request; 19 | this.response = this.context.response; 20 | } 21 | this.path = path; 22 | } 23 | 24 | /** 25 | * 26 | * @returns {boolean|Promise} did you consume the request? 27 | */ 28 | handleRequest() { 29 | return false; 30 | } 31 | 32 | static getDescription() { 33 | return ''; 34 | } 35 | } 36 | 37 | module.exports = RequestHandler; 38 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/DatabaseApiHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Owen on 14-4-2016. 3 | */ 4 | 5 | const pluralize = require('pluralize'); 6 | const Database = require('../../database/Database'); 7 | const RequestHandler = require('../RequestHandler'); 8 | const httpServer = require('..'); 9 | const DatabaseSearch = require('../../database/DatabaseSearch'); 10 | 11 | class DatabaseApiHandler extends RequestHandler { 12 | handleRequest() { 13 | const urlParts = this.path.split('/'); 14 | const type = urlParts[2]; 15 | const singularType = pluralize.singular(type); 16 | 17 | switch (this.request.method) { 18 | case 'PATCH': 19 | case 'POST': 20 | case 'PUT': 21 | this.handlePost(singularType); 22 | break; 23 | default: 24 | this.handleGet(this.context.query, singularType, this.context.params.id); 25 | break; 26 | } 27 | return true; 28 | } 29 | 30 | handlePost(singularType) { 31 | const i = this.context.request.body.data; 32 | const item = Database.getById(singularType, i.id); 33 | 34 | if (item) { 35 | item.attributes = Object.assign(item.attributes, i.attributes); 36 | item.relationships = i.relationships; 37 | this.respond(Database.update(singularType, item)); 38 | return; 39 | } 40 | 41 | this.respond(Database.setObject(singularType, i.attributes)); 42 | } 43 | 44 | handleGet(query, singularType, itemId) { 45 | this.response.header['Content-Type'] = 'text/json'; 46 | const { sort, distinct, join } = query; 47 | let offset; 48 | let limit; 49 | let filterValues; 50 | const relationConditions = []; 51 | 52 | // parse all the query items 53 | if (query['page[limit]']) { 54 | limit = parseInt(query['page[limit]'], 10); 55 | delete query['page[limit]']; 56 | } 57 | if (query['page[offset]']) { 58 | offset = parseInt(query['page[offset]'], 10); 59 | delete query['page[offset]']; 60 | } 61 | delete query.sort; 62 | delete query.distinct; 63 | delete query.join; 64 | 65 | // all the query items left become "where conditions" 66 | Object.keys(query) 67 | .forEach((key) => { 68 | const item = query[key]; 69 | if (key.indexOf('.') !== -1) { 70 | const s = key.split('.'); 71 | if (!relationConditions[s[0]]) { 72 | relationConditions[s[0]] = {}; 73 | } 74 | relationConditions[s[0]][s[1]] = query[key]; 75 | delete query[key]; 76 | } 77 | if (!item) { 78 | delete query[key]; 79 | } 80 | }); 81 | 82 | if (query.filterValues) { 83 | filterValues = query.filterValues.split(','); 84 | delete query.filterValues; 85 | } 86 | 87 | let data; 88 | let included = []; 89 | if (itemId) { 90 | data = Database.getById(singularType, itemId); 91 | } else { 92 | ({ data, included } = DatabaseSearch.query(singularType, { 93 | where: query, 94 | sort, 95 | distinct, 96 | join, 97 | relationConditions, 98 | })); 99 | } 100 | 101 | // build the possible filter values. 102 | if (filterValues) { 103 | const values = {}; 104 | filterValues.forEach((a) => { 105 | const items = {}; 106 | data.forEach((i) => { 107 | i = i.attributes[a]; 108 | if (Array.isArray(i)) { 109 | i.forEach((entry) => { items[entry] = true; }); 110 | return; 111 | } 112 | items[i] = true; 113 | }); 114 | values[a] = Object.keys(items).sort(); 115 | }); 116 | filterValues = values; 117 | } 118 | 119 | let metadata = {}; 120 | if (offset || limit) { 121 | metadata.totalPages = Math.ceil(data.length / limit); 122 | metadata.totalItems = data.length; 123 | data = data.splice(offset, limit); 124 | } 125 | 126 | // build return data 127 | metadata = { filterValues, ...metadata }; 128 | 129 | this.respond(data, metadata, included); 130 | return true; 131 | } 132 | 133 | respond(data, metadata, included) { 134 | const obj = {}; 135 | obj.data = data; 136 | obj.meta = metadata; 137 | obj.included = included; 138 | this.context.body = obj; 139 | if (this.resolve) { 140 | this.resolve(); 141 | } 142 | } 143 | } 144 | 145 | httpServer.registerRoute('all', '/api/media-items', DatabaseApiHandler); 146 | httpServer.registerRoute('all', '/api/media-items/:id', DatabaseApiHandler); 147 | httpServer.registerRoute('all', '/api/media-item/:id', DatabaseApiHandler); 148 | httpServer.registerRoute('all', '/api/play-positions', DatabaseApiHandler); 149 | httpServer.registerRoute('all', '/api/play-positions/:id', DatabaseApiHandler); 150 | 151 | module.exports = DatabaseApiHandler; 152 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/DirectoryBrowserHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 18-4-2016. 3 | */ 4 | 5 | const fs = require('fs'); 6 | const RequestHandler = require('../RequestHandler'); 7 | const httpServer = require('..'); 8 | 9 | class DirectoryBrowserHandler extends RequestHandler { 10 | handleRequest() { 11 | const { query } = this.context; 12 | if (!query.directory) { 13 | query.directory = '/'; 14 | } 15 | if (query.directory[query.directory.length - 1] !== '/') { 16 | query.directory += '/'; 17 | } 18 | this.dir = query.directory; 19 | 20 | fs.readdir( 21 | query.directory, 22 | this.onDirectoryList.bind(this), 23 | ); 24 | return new Promise((resolve) => { 25 | this.resolve = resolve; 26 | }); 27 | } 28 | 29 | onDirectoryList(err, result) { 30 | if (err) { 31 | this.context.body = { error: err }; 32 | return this.resolve(); 33 | } 34 | 35 | this.pos = 0; 36 | this.result = result; 37 | this.stat(); 38 | return null; 39 | } 40 | 41 | /** 42 | * function to loop over files to see if they are directories 43 | * @param err 44 | * @param res 45 | */ 46 | stat(err, res) { 47 | if (res || err) { 48 | if (res && res.isDirectory()) { // is the file a directory? move along 49 | this.pos += 1; 50 | } else { // file is not a directory remove from the results 51 | this.result.splice(this.pos, 1); 52 | } 53 | 54 | if (this.pos === this.result.length) { // all files processed, return result 55 | this.context.body = { result: this.result }; 56 | this.resolve(); 57 | return; 58 | } 59 | } 60 | fs.stat(this.dir + this.result[this.pos], this.stat.bind(this)); 61 | } 62 | } 63 | 64 | httpServer.registerRoute('get', '/api/browse', DirectoryBrowserHandler); 65 | 66 | module.exports = DirectoryBrowserHandler; 67 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/DocumentationRequestHandler.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const RequestHandler = require('../RequestHandler'); 3 | const httpServer = require('..'); 4 | 5 | class DocumentationRequestHandler extends RequestHandler { 6 | async handleRequest() { 7 | const routes = httpServer.getRoutes(); 8 | this.context.body = { 9 | apiDoc: Object.keys(routes) 10 | .reduce( 11 | (acc, route) => acc.concat(DocumentationRequestHandler.parseRoutes(route, routes[route])), 12 | [], 13 | ), 14 | }; 15 | } 16 | 17 | static parseRoutes(name, routes) { 18 | return Object.keys(routes).map((priority) => { 19 | const [method, url] = name.split('@'); 20 | const r = routes[priority]; 21 | return { 22 | method, 23 | url, 24 | priority, 25 | classname: r.name, 26 | description: DocumentationRequestHandler.parseDescription(r.getDescription(method, url)), 27 | }; 28 | }); 29 | } 30 | 31 | static parseDescription(description) { 32 | if (description.substr(-3) !== '.md') return description || 'No description available'; 33 | return `${fs.readFileSync(description)}`; 34 | } 35 | } 36 | 37 | httpServer.registerRoute('get', '/api/documentation', DocumentationRequestHandler); 38 | 39 | module.exports = DocumentationRequestHandler; 40 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/FileRequestHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 08-04-16. 3 | */ 4 | 5 | const fs = require('fs'); 6 | const mime = require('mime'); 7 | const { promisify } = require('util'); 8 | 9 | const readFile = promisify(fs.readFile); 10 | const unlink = promisify(fs.unlink); 11 | 12 | const RequestHandler = require('../RequestHandler'); 13 | 14 | class FileRequestHandler extends RequestHandler { 15 | handleRequest() { 16 | const { url } = this.context; 17 | const dir = `${__dirname}/../../../frontend/dist/`; 18 | return this.serveFile(dir + url, false); 19 | } 20 | 21 | serveFile(filename, andDelete) { 22 | this.response.header['Content-Type'] = mime.lookup(filename); 23 | return readFile(filename) 24 | .then((data) => { 25 | this.context.body = data; 26 | if (andDelete) unlink(filename); 27 | return data; 28 | }); 29 | } 30 | } 31 | 32 | module.exports = FileRequestHandler; 33 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/ImageCacheHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 7/9/16. 3 | */ 4 | const fs = require('fs'); 5 | const IImageHandler = require('../IImageHandler'); 6 | const httpServer = require('..'); 7 | 8 | class ImageCacheHandler extends IImageHandler { 9 | handleRequest() { 10 | if (!this.item) { 11 | return null; 12 | } 13 | const id = this.item.attributes['external-id'] 14 | ? `tmdb_${this.item.attributes['external-id']}` 15 | : this.item.id; 16 | const filename = `cache/thumb_${id}_${this.type}`; 17 | try { 18 | fs.statSync(filename); 19 | } catch (e) { 20 | return null; 21 | } 22 | 23 | return new Promise((resolve) => { 24 | fs.readFile(filename, (err, data) => { 25 | this.context.body = data; 26 | resolve(data); 27 | }); 28 | }); 29 | } 30 | 31 | put(data) { 32 | const id = this.item.attributes['external-id'] 33 | ? `tmdb_${this.item.attributes['external-id']}` 34 | : this.item.id; 35 | const filename = `cache/thumb_${id}_${this.type}`; 36 | if (data) fs.writeFile(filename, data, () => {}); 37 | } 38 | 39 | static getDescription() { 40 | return `Will automatically cache any lower priority request \n${IImageHandler.getDescription()}`; 41 | } 42 | } 43 | 44 | httpServer.registerRoute('get', '/img/:image.jpg', ImageCacheHandler, 0, 10); 45 | 46 | module.exports = ImageCacheHandler; 47 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/ScannerApiHandler.js: -------------------------------------------------------------------------------- 1 | const RequestHandler = require('../RequestHandler'); 2 | const httpServer = require('..'); 3 | const Database = require('../../database/Database'); 4 | const MovieScanner = require('../../scanner/MovieScanner'); 5 | const ExtendedInfoQueue = require('../../scanner/ExtendedInfoQueue'); 6 | 7 | class ScannerApiHandler extends RequestHandler { 8 | async handleRequest() { 9 | MovieScanner.scan(); 10 | const items = Database.getAll('media-item', true); 11 | if (this.context.query.ffprobe) { 12 | items.forEach(({ attributes }) => { attributes.gotfileinfo = false; }); 13 | } 14 | if (this.context.query.reshare) { 15 | items.forEach(({ attributes }) => { attributes.shared = false; }); 16 | } 17 | if (this.context.query.tmdb) { 18 | items.forEach(({ attributes }) => { 19 | attributes.gotExtendedInfo = 0; 20 | attributes.gotSeriesAndSeasonInfo = 0; 21 | }); 22 | } 23 | ExtendedInfoQueue.concat(items); 24 | this.context.body = { status: 'ok' }; 25 | } 26 | 27 | static getDescription() { 28 | return `${__dirname}/../doc/scanner.md`; 29 | } 30 | } 31 | 32 | httpServer.registerRoute('get', '/api/rescan', ScannerApiHandler); 33 | 34 | module.exports = ScannerApiHandler; 35 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/SettingsApiHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Owen on 14-4-2016. 3 | */ 4 | 5 | // eslint-disable-next-line max-classes-per-file 6 | const fs = require('fs'); 7 | const util = require('util'); 8 | const RequestHandler = require('../RequestHandler'); 9 | const Settings = require('../../Settings'); 10 | 11 | const readdir = util.promisify(fs.readdir); 12 | 13 | class SettingsApiHandler extends RequestHandler { 14 | handleRequest() { 15 | this.response.header['Content-Type'] = 'text/json'; 16 | 17 | if (this.request.method === 'PATCH') { 18 | const data = this.context.request.body; 19 | const attrs = data.data.attributes; 20 | Object.keys(attrs).forEach((key) => Settings.setValue(key, attrs[key])); 21 | Settings.save(); 22 | } 23 | this.respond(); 24 | 25 | return true; 26 | } 27 | 28 | respond() { 29 | this.context.body = { data: { id: 1, type: 'setting', attributes: Settings.getAll() } }; 30 | } 31 | } 32 | 33 | class ModuleApiHandler extends RequestHandler { 34 | async handleRequest() { 35 | this.context.body = await readdir(`${__dirname}/../../../modules`); 36 | } 37 | } 38 | 39 | require('..') 40 | .registerRoute('all', '/api/settings/:unused_id', SettingsApiHandler) 41 | .registerRoute('get', '/api/modules', ModuleApiHandler); 42 | 43 | module.exports = SettingsApiHandler; 44 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/WatchNext.js: -------------------------------------------------------------------------------- 1 | const MovieDB = require('moviedb-api'); 2 | const RequestHandler = require('../RequestHandler'); 3 | const httpServer = require('..'); 4 | const DatabaseSearch = require('../../database/DatabaseSearch'); 5 | const Database = require('../../database/Database'); 6 | const Settings = require('../../Settings'); 7 | 8 | const movieDB = new MovieDB({ 9 | apiKey: Settings.getValue('tmdb_apikey'), 10 | }); 11 | 12 | // @todo let modules add responses here. 13 | 14 | class WatchNext extends RequestHandler { 15 | async handleRequest() { 16 | let continueWatching = DatabaseSearch.query( 17 | 'media-item', 18 | { 19 | join: 'play-position', 20 | where: { type: 'tv', extra: 'false' }, 21 | relationConditions: { 'play-position': { watched: 'true' } }, 22 | sort: 'season:DESC,episode:DESC', 23 | distinct: 'external-id', 24 | }, 25 | ).data; 26 | 27 | WatchNext.sortByRecentWatch(continueWatching); 28 | 29 | continueWatching = continueWatching 30 | // now find the next episode if available 31 | .map(({ attributes }) => { 32 | let { season, episode } = attributes; 33 | const id = attributes['external-id']; 34 | episode += 1; 35 | const [i] = Database.findByMatchFilters('media-item', { season, episode, 'external-id': id }); 36 | season += 1; 37 | episode = 1; 38 | return i || Database.findByMatchFilters('media-item', { season, episode, 'external-id': id }).pop(); 39 | }) 40 | // filter out the one's that are not found 41 | .filter((i) => i); 42 | 43 | const newMovies = DatabaseSearch.query( 44 | 'media-item', 45 | { 46 | sort: 'date_added:DESC', join: 'play-position', where: { type: 'movie' }, limit: 20, 47 | }, 48 | ); 49 | const newTV = DatabaseSearch.query( 50 | 'media-item', 51 | { 52 | sort: 'date_added:DESC', join: 'play-position', where: { type: 'tv' }, limit: 20, 53 | }, 54 | ); 55 | 56 | this.context.body = { 57 | continueWatching: { 58 | data: continueWatching, 59 | included: DatabaseSearch.getRelationShips('play-position', continueWatching), 60 | }, 61 | newMovies, 62 | newTV, 63 | recommended: await WatchNext.getRecommendations(), 64 | }; 65 | } 66 | 67 | static async getRecommendations() { 68 | let { data } = DatabaseSearch.query( 69 | 'media-item', 70 | { 71 | join: 'play-position', 72 | where: { type: 'movie', extra: 'false' }, 73 | relationConditions: { 'play-position': { watched: 'true' } }, 74 | distinct: 'external-id', 75 | }, 76 | ); 77 | 78 | // last 5 79 | WatchNext.sortByRecentWatch(data); 80 | data = data.slice(0, 5).map((i) => i.attributes); 81 | 82 | // fetch rec's, get relevant movies, filter out missing, and count double 83 | const itemsById = {}; 84 | (await Promise.all(data.map((i) => WatchNext.getRecommendation(i['external-id'])))) 85 | .reduce((acc, { results }) => acc.concat(results), []) 86 | .map(({ id }) => DatabaseSearch.query( 87 | 'media-item', 88 | { 89 | join: 'play-position', 90 | where: { 'external-id': id, extra: 'false' }, 91 | limit: 1, 92 | relationConditions: { 'play-position': { watched: 'false' } }, 93 | }, 94 | ).data.pop()) 95 | .filter((i) => i) 96 | .forEach((i) => { 97 | if (itemsById[i.id]) { 98 | itemsById[i.id].count += 1; 99 | return; 100 | } 101 | i.count = 1; 102 | itemsById[i.id] = i; 103 | }); 104 | 105 | // sort by occurence and return 106 | const items = Object.values(itemsById).sort((a, b) => b.count - a.count); 107 | return { data: items, included: DatabaseSearch.getRelationShips('play-position', items) }; 108 | } 109 | 110 | static async getRecommendation(id) { 111 | return new Promise((resolve) => { 112 | movieDB.request( 113 | '/movie/{id}/recommendations', 114 | 'GET', 115 | { id }, 116 | (err, res) => { 117 | resolve(res); 118 | }, 119 | ); 120 | }); 121 | } 122 | 123 | static sortByRecentWatch(items) { 124 | // sort by the most recently watched items 125 | items.sort((a, b) => (Database.getById('play-position', b.relationships['play-position'].data.id).attributes.created || 0) 126 | - (Database.getById('play-position', a.relationships['play-position'].data.id).attributes.created || 0)); 127 | } 128 | } 129 | 130 | httpServer.registerRoute('get', '/api/watchNext', WatchNext); 131 | 132 | module.exports = WatchNext; 133 | -------------------------------------------------------------------------------- /backend/core/http/coreHandlers/index.js: -------------------------------------------------------------------------------- 1 | require('./DatabaseApiHandler'); 2 | require('./DirectoryBrowserHandler'); 3 | require('./FileRequestHandler'); 4 | require('./ImageCacheHandler'); 5 | require('./ScannerApiHandler'); 6 | require('./SettingsApiHandler'); 7 | require('./WatchNext'); 8 | require('./DocumentationRequestHandler'); 9 | -------------------------------------------------------------------------------- /backend/core/http/doc/scanner.md: -------------------------------------------------------------------------------- 1 | Will trigger a file rescan and add all movies to the ExtendedInfoQueue 2 | 3 | | GET params | Description | 4 | | ---------- | ----------------------------------------------- | 5 | | ffprobe | will reset ffprobe information before extending | 6 | | reshare | will reset sharing information before extending | 7 | | tmdb | will reset tmdb information before extending | 8 | -------------------------------------------------------------------------------- /backend/core/index.js: -------------------------------------------------------------------------------- 1 | // to enable modules we'll have to suppress these eslint rules 2 | /* eslint-disable global-require,import/no-dynamic-require */ 3 | const fs = require('fs'); 4 | const http = require('./http'); 5 | const Settings = require('./Settings'); 6 | const Migrate = require('./database/Migrate'); 7 | 8 | const dirs = ['cache', 'subs', 'store']; 9 | const beforeListeners = []; 10 | const afterListeners = []; 11 | const modules = {}; 12 | 13 | /** 14 | * @todo to simplify some things create a restart command 15 | * - Will need a wrapping process 16 | * - On certain exit code restart 17 | */ 18 | class RemoteCore { 19 | static async init() { 20 | dirs.forEach((dir) => { if (!fs.existsSync(dir)) fs.mkdirSync(dir); }); 21 | Migrate.run(); 22 | 23 | Settings.getValue('modules').forEach((m) => { 24 | modules[m] = require(`../modules/${m}`); 25 | }); 26 | 27 | http.preflight(); 28 | await Promise.all(beforeListeners.map((f) => f())); 29 | await http.start(); 30 | afterListeners.forEach((f) => f()); 31 | 32 | require('./scanner/MovieScanner'); 33 | require('./database/Migrate'); 34 | } 35 | 36 | static addBeforeStartListener(func) { 37 | beforeListeners.push(func); 38 | } 39 | 40 | static addAfterStartListener(func) { 41 | afterListeners.push(func); 42 | } 43 | 44 | /** 45 | * returns undefined if module is disabled or inactive 46 | * @param name 47 | * @returns {*} 48 | */ 49 | static getModule(name) { 50 | return modules[name]; 51 | } 52 | } 53 | 54 | module.exports = RemoteCore; 55 | -------------------------------------------------------------------------------- /backend/core/scanner/ExtendedInfoQueue.js: -------------------------------------------------------------------------------- 1 | const Database = require('../database/Database'); 2 | const Log = require('../Log'); 3 | const Settings = require('../Settings'); 4 | const core = require('..'); 5 | require('./ExtrasExtendedInfo'); 6 | 7 | const extendedInfoProviders = []; 8 | const onDrainCallbacks = []; 9 | const queue = []; 10 | const running = []; 11 | let timeout; 12 | 13 | /** 14 | * there's a queue for every extending function, once the first queue (with priority) is empty 15 | * the next queue will start processing 16 | */ 17 | class ExtendedInfoQueue { 18 | static push(item) { 19 | // extras should be processed last 20 | if (item.attributes.extra) { 21 | queue[0].unshift(item); 22 | } else { 23 | queue[0].push(item); 24 | } 25 | 26 | clearTimeout(timeout); 27 | timeout = setTimeout(ExtendedInfoQueue.start.bind(this), 1000); 28 | } 29 | 30 | static concat(items) { 31 | items.forEach((item) => ExtendedInfoQueue.push(item)); 32 | } 33 | 34 | static async start(qNumber = 0, libs = null) { 35 | if (running[qNumber]) return; 36 | 37 | if (!libs) { 38 | libs = {}; 39 | Settings.getValue('libraries') 40 | .forEach((library) => { 41 | libs[library.uuid] = library; 42 | }); 43 | } 44 | 45 | running[qNumber] = true; 46 | Log.debug('starting queue', qNumber); 47 | ExtendedInfoQueue.next(libs, qNumber); 48 | } 49 | 50 | static async next(libs, currentQ) { 51 | const item = queue[currentQ].pop(); 52 | // this queue has emptied out? 53 | if (!item) { 54 | running[currentQ] = false; 55 | // all queues empty? 56 | if (!queue.some((q) => q.length)) { 57 | onDrainCallbacks.forEach((cb) => cb()); 58 | } 59 | return; 60 | } 61 | await extendedInfoProviders[currentQ].extendInfo(item, libs[item.attributes.libraryId]); 62 | // queue for the nex 63 | if (queue[currentQ + 1]) { 64 | queue[currentQ + 1].push(item); 65 | this.start(currentQ + 1, libs); 66 | } 67 | Database.update('media-item', item); 68 | ExtendedInfoQueue.next(libs, currentQ); 69 | } 70 | 71 | /** 72 | * @param IExtendedInfo extendedInfoProvider 73 | */ 74 | static registerExtendedInfoProvider(extendedInfoProvider, highPrio) { 75 | if (highPrio) extendedInfoProviders.unshift(extendedInfoProvider); 76 | else extendedInfoProviders.push(extendedInfoProvider); 77 | queue.push([]); 78 | } 79 | 80 | static setOnDrain(cb) { 81 | onDrainCallbacks.push(cb); 82 | } 83 | 84 | static debugInfo() { 85 | return { 86 | extendedInfoQuelength: queue.map((a) => a.length), 87 | running, 88 | }; 89 | } 90 | } 91 | 92 | core.addAfterStartListener(() => { 93 | const debug = core.getModule('debug'); 94 | if (!debug) return; 95 | debug.registerDebugInfoProvider( 96 | 'scanner', 97 | ExtendedInfoQueue.debugInfo, 98 | ); 99 | }); 100 | 101 | module.exports = ExtendedInfoQueue; 102 | -------------------------------------------------------------------------------- /backend/core/scanner/ExtrasExtendedInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 29-06-16. 3 | */ 4 | const path = require('path'); 5 | const IExtendedInfo = require('./IExtendedInfo'); 6 | const Database = require('../database/Database'); 7 | 8 | class ExtrasExtendedInfo extends IExtendedInfo { 9 | static extendInfo(mediaItem) { 10 | if (!mediaItem.attributes.extra || mediaItem.attributes['external-id']) { 11 | return; 12 | } 13 | // If external id has not been detected yet and items is an extra 14 | let items; 15 | 16 | // find all items with the same path, filtering out the current item 17 | let fileParts = mediaItem.attributes.filepath; 18 | for (let c = 0; c < 2; c += 1) { 19 | fileParts = path.parse(fileParts).dir; 20 | items = Database 21 | .findByMatchFilters('media-item', { filepath: `${fileParts}%` }) 22 | .filter((item) => item.id !== mediaItem.id); 23 | if (items.length) { 24 | break; 25 | } 26 | } 27 | 28 | // have we found an item? give this item the same ids 29 | if (items.length) { 30 | mediaItem.attributes['exernal-id'] = items[0].attributes['external-id']; 31 | mediaItem.attributes['external-episode-id'] = items[0].attributes['external-episode-id']; 32 | } else { 33 | mediaItem.attributes.extra = false; 34 | } 35 | } 36 | } 37 | 38 | module.exports = ExtrasExtendedInfo; 39 | -------------------------------------------------------------------------------- /backend/core/scanner/FilewatchWorker.js: -------------------------------------------------------------------------------- 1 | const chokidar = require('chokidar'); 2 | const Log = require('../Log'); 3 | 4 | const watcherType = process.argv[3]; 5 | const options = { 6 | ignoreInitial: true, 7 | usePolling: watcherType === 'polling', 8 | awaitWriteFinish: { 9 | stabilityThreshold: 6000, 10 | pollInterval: 100, 11 | }, 12 | useFsEvents: watcherType === 'native', 13 | }; 14 | 15 | Log.debug(`started ${process.argv[3]} filewatch worker for:`, process.argv[2]); 16 | chokidar.watch( 17 | process.argv[2], 18 | options, 19 | ).on('all', (type, file) => { 20 | Log.debug('worker found file', file); 21 | process.send(file); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/core/scanner/IExtendedInfo.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | 3 | /** 4 | * Created by owenray on 29-06-16. 5 | */ 6 | 7 | class IExtendedInfo { 8 | /** 9 | * 10 | * @param mediaItem 11 | * @param library 12 | * @returns {Promise} 13 | */ 14 | static async extendInfo() { 15 | return false; 16 | } 17 | } 18 | 19 | module.exports = IExtendedInfo; 20 | -------------------------------------------------------------------------------- /backend/core/tests/Database.spec.js: -------------------------------------------------------------------------------- 1 | jest.useFakeTimers() 2 | 3 | const fs = require('fs'); 4 | const util = require('util'); 5 | const DatabaseSpec = require('../database/Database'); 6 | 7 | const mkdir = util.promisify(fs.mkdir); 8 | 9 | describe('Database', () => { 10 | beforeEach(() => mkdir('store').catch(console.log)); 11 | afterEach(() => { 12 | ['store/ids', 'store/table', 'store/version', 'store/media-item'] 13 | .forEach((f) => { 14 | try { 15 | fs.unlinkSync(f); 16 | // eslint-disable-next-line no-empty 17 | } catch (e) { } 18 | }); 19 | fs.rmdirSync('store'); 20 | }); 21 | 22 | it('inserts', async () => { 23 | const o = DatabaseSpec.setObject('table', { test: 'test' }); 24 | expect(o.id).toEqual(1); 25 | expect(DatabaseSpec.getById('table', o.id)).not.toEqual(null); 26 | }); 27 | 28 | it('finds', () => { 29 | for (let c = 0; c < 10; c += 1) { 30 | DatabaseSpec.setObject('table', { test: `${c}` }); 31 | } 32 | expect(DatabaseSpec.findBy('table', 'test', 5)).toBeTruthy(); 33 | expect(DatabaseSpec.findByMatchFilters('table', { test: '5' })).toHaveLength(1); 34 | }); 35 | 36 | it('writes', async () => { 37 | DatabaseSpec.setObject('media-item', { test: 'test' }); 38 | let resolve; 39 | const promise = new Promise((r) => { resolve = r; }); 40 | DatabaseSpec.doSave('media-item', resolve); 41 | 42 | await promise; 43 | expect(fs.statSync('store/media-item')).toBeTruthy(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /backend/core/tests/Filename.spec.js: -------------------------------------------------------------------------------- 1 | jest.useFakeTimers() 2 | 3 | const fs = require('fs'); 4 | const filenameSpec = require('../../modules/filename'); 5 | 6 | describe('Filenames', () => { 7 | it('parses filenames', () => { 8 | const items = JSON.parse(fs.readFileSync('backend/core/tests/filenames.json')) || []; 9 | Object.keys(items).forEach((filepath) => { 10 | const values = items[filepath]; 11 | values.filepath = filepath; 12 | const result = { attributes: { filepath } }; 13 | filenameSpec.extendInfo( 14 | result, 15 | { type: values.type }, 16 | ); 17 | expect(values).toEqual(result.attributes); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /backend/core/tests/HttpServer.spec.js: -------------------------------------------------------------------------------- 1 | jest.useFakeTimers() 2 | 3 | const http = require('http'); 4 | const fs = require('fs'); 5 | const util = require('util'); 6 | const server = require('../http'); 7 | 8 | const mkdir = util.promisify(fs.mkdir); 9 | 10 | describe('HttpServer', () => { 11 | afterEach(() => { 12 | try { 13 | fs.unlinkSync('cache/httpCache'); 14 | } catch (e) { 15 | console.log('no cache dir'); 16 | } 17 | try { 18 | fs.rmdirSync('cache'); 19 | } catch (e) { 20 | console.log('no cache dir'); 21 | } 22 | }); 23 | 24 | it('starts and connects', async () => { 25 | await mkdir('cache'); 26 | server.preflight(); 27 | await server.start(); 28 | 29 | let resolve; 30 | const promise = new Promise((r) => { resolve = r; }); 31 | http.get('http://localhost:8234/index.html', resolve); 32 | 33 | const res = await promise; 34 | expect(res.statusCode).toEqual(200); 35 | await new Promise(server.stop); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /backend/modules/debug/apidoc.md: -------------------------------------------------------------------------------- 1 | Will return usefull debugging info 2 | 3 | Can be hooked into by any module using `DebugApiHandler.registerDebugInfoProvider` 4 | 5 | ## Example output: 6 | ```json 7 | { 8 | "scanner": { 9 | "currentLibrary": { 10 | "name": "movs", 11 | "folder": "/home/owenray/testmov", 12 | "type": "movie", 13 | "uuid": "95c91708-5004-42ae-8d50-4652f253d0b7", 14 | "shared": "on" 15 | }, 16 | "scanning": -1, 17 | "extendedInfoQuelength": 0 18 | }, 19 | "sharing": { 20 | "announcing": false, 21 | "publishQueueSize": 0, 22 | "publishing": false, 23 | "dht": { 24 | "nodes": [ 25 | { 26 | "host": "1.2.3.4", 27 | "port": 8235 28 | } 29 | ], 30 | "values": { 31 | "50933682d171c9c144d1597538dfbe5cccab5139": { 32 | "v": "69615474486b756255705159314e4e676f5a4736324d5a6e5154536635762b574e49765a365952715332303d3436", 33 | "id": "2532abb040aaebe11f68a5a431ae4c5e9f22f7b3" 34 | }, 35 | "e7a173369d107caecfec6d3c83ab1e618d5f005e": { 36 | "v": "469895332a97095237037473ddfe8619e64df739", 37 | "id": "2532abb040aaebe11f68a5a431ae4c5e9f22f7b3" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | ``` 45 | -------------------------------------------------------------------------------- /backend/modules/debug/index.js: -------------------------------------------------------------------------------- 1 | const RequestHandler = require('../../core/http/RequestHandler'); 2 | const httpServer = require('../../core/http'); 3 | 4 | const providers = []; 5 | 6 | class DebugApiHandler extends RequestHandler { 7 | handleRequest() { 8 | const result = {}; 9 | providers.forEach(({ category, target }) => { 10 | result[category] = { ...target(), ...result[category] }; 11 | }); 12 | this.context.body = result; 13 | } 14 | 15 | static registerDebugInfoProvider(category, target) { 16 | providers.push({ category, target }); 17 | } 18 | 19 | static getDescription() { 20 | return `${__dirname}/apidoc.md`; 21 | } 22 | } 23 | 24 | httpServer.registerRoute('get', '/debug', DebugApiHandler); 25 | 26 | module.exports = DebugApiHandler; 27 | -------------------------------------------------------------------------------- /backend/modules/error_reporting/index.js: -------------------------------------------------------------------------------- 1 | const Sentry = require('@sentry/node'); 2 | 3 | process.removeAllListeners('uncaughtException'); 4 | 5 | Sentry.init({ 6 | dsn: 'https://b74da17a778a4e698bc7f181c792434e@o237120.ingest.sentry.io/4503975825965056', 7 | integrations: [ 8 | new Sentry.Integrations.Http({ tracing: true }), 9 | ], 10 | tracesSampleRate: 1.0, 11 | }); 12 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/FFProbe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Cole on 10-4-2016. 3 | */ 4 | 5 | const { spawn } = require('child_process'); 6 | const Settings = require('../../core/Settings'); 7 | const Log = require('../../core/Log'); 8 | 9 | const FFProbe = { 10 | /** 11 | * @type {{format:{bit_rate:Number, size:Number, duration:Number}}} 12 | */ 13 | fileInfo: {}, 14 | /** 15 | * @type {{pkt_pts_time:Number,pkt_duration_time:Number}} 16 | */ 17 | frameData: {}, 18 | 19 | getInfo(fileName) { 20 | return new Promise((resolve, reject) => { 21 | const proc = spawn( 22 | Settings.getValue('ffprobe_binary'), 23 | [ 24 | '-v', 'quiet', 25 | '-show_format', 26 | '-show_streams', 27 | '-print_format', 'json', 28 | fileName, 29 | ], 30 | ); 31 | let returnData = ''; 32 | proc.stdout.on('data', (data) => { 33 | returnData += `${data}`; 34 | }); 35 | proc.stderr.on('data', (data) => { 36 | reject(new Error(`${data}`)); 37 | Log.info('ffprobe result:', `${data}`); 38 | }); 39 | proc.on('close', () => { 40 | try { 41 | resolve(JSON.parse(returnData)); 42 | } catch (e) { 43 | reject(e); 44 | } 45 | }); 46 | }); 47 | }, 48 | 49 | getNearestKeyFrame(fileName, position) { 50 | return new Promise((resolve, reject) => { 51 | Log.debug(Settings.getValue('ffprobe_binary'), fileName); 52 | const proc = spawn( 53 | Settings.getValue('ffprobe_binary'), 54 | [ 55 | '-v', 'quiet', 56 | '-read_intervals', `${position}%+#1`, 57 | '-show_frames', 58 | '-select_streams', 'v:0', 59 | '-print_format', 'json', 60 | fileName, 61 | ], 62 | ); 63 | let returnData = ''; 64 | proc.stdout.on('data', (data) => { 65 | returnData += `${data}`; 66 | }); 67 | proc.stderr.on('data', (data) => { 68 | reject(new Error(`${data}`)); 69 | Log.debug('got nearest key frame', `${data}`); 70 | }); 71 | proc.on('close', () => { 72 | try { 73 | let data = JSON.parse(returnData); 74 | /** 75 | * @type {FFProbe.frameData} 76 | */ 77 | [data] = data.frames; 78 | resolve(parseFloat(data.pkt_pts_time) - parseFloat(data.pkt_duration_time)); 79 | } catch (e) { 80 | reject(e); 81 | } 82 | }); 83 | }); 84 | }, 85 | }; 86 | 87 | module.exports = FFProbe; 88 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/FFProbeExtendedInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 29-06-16. 3 | */ 4 | const IExtendedInfo = require('../../core/scanner/IExtendedInfo'); 5 | const FFProbe = require('./FFProbe'); 6 | const Log = require('../../core/Log'); 7 | const core = require('../../core'); 8 | const ExtendedInfoQueue = require('../../core/scanner/ExtendedInfoQueue'); 9 | 10 | class FFProbeExtendedInfo extends IExtendedInfo { 11 | static async extendInfo(mediaItem) { 12 | if (mediaItem.attributes.gotfileinfo) { 13 | return; 14 | } 15 | 16 | Log.debug('ffprobe extended info', mediaItem.id); 17 | 18 | const fileData = await FFProbe.getInfo(mediaItem.attributes.filepath); 19 | if (!fileData || !fileData.format) { 20 | return; 21 | } 22 | 23 | const videoStream = fileData.streams.find((s) => s.codec_type === 'video'); 24 | if (videoStream) { 25 | mediaItem.attributes.width = videoStream.width; 26 | mediaItem.attributes.height = videoStream.height; 27 | } 28 | mediaItem.attributes.fileduration = parseFloat(fileData.format.duration); 29 | mediaItem.attributes.filesize = parseInt(fileData.format.size, 10); 30 | mediaItem.attributes.bitrate = fileData.format.bit_rate; 31 | mediaItem.attributes.format = fileData.format; 32 | mediaItem.attributes.streams = fileData.streams; 33 | mediaItem.attributes.gotfileinfo = true; 34 | } 35 | } 36 | 37 | core.addBeforeStartListener(() => ExtendedInfoQueue.registerExtendedInfoProvider(FFProbeExtendedInfo)); 38 | 39 | module.exports = FFProbeExtendedInfo; 40 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/FFProbeImageHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 7/9/16. 3 | */ 4 | const { spawn } = require('child_process'); 5 | const IImageHandler = require('../../core/http/IImageHandler'); 6 | const Settings = require('../../core/Settings'); 7 | const MediaItemHelper = require('../../core/MediaItemHelper'); 8 | const Log = require('../../core/Log'); 9 | const httpServer = require('../../core/http'); 10 | const ImageCacheHandler = require('../../core/http/coreHandlers/ImageCacheHandler'); 11 | 12 | const sizes = {}; 13 | sizes[IImageHandler.TYPE_POSTER] = { w: 300, h: 450 }; 14 | sizes[IImageHandler.TYPE_POSTER_SMALL] = { w: 300, h: 450 }; 15 | sizes[IImageHandler.TYPE_POSTER_LARGE] = { w: 1280, h: 2000 }; 16 | 17 | class FFProbeImageHandler extends IImageHandler { 18 | handleRequest() { 19 | let offset = 0; 20 | if (!this.item || !this.item.attributes.width) { 21 | return false; 22 | } 23 | if (this.item.attributes.fileduration) { 24 | offset = this.item.attributes.fileduration / 2; 25 | } 26 | 27 | let crop = { 28 | x: 0, 29 | y: 0, 30 | width: this.item.attributes.width, 31 | height: this.item.attributes.height, 32 | }; 33 | let size = `${crop.width}x${crop.height}`; 34 | 35 | if (this.type !== IImageHandler.TYPE_BACKDROP) { 36 | const targetSize = sizes[this.type]; 37 | 38 | size = `${targetSize.w}x${targetSize.h}`; 39 | if (targetSize.w / targetSize.h > crop.width / crop.height) { 40 | crop.width = targetSize.w; 41 | crop.height = crop.width * (targetSize.h / targetSize.w); 42 | } else { 43 | crop.height = targetSize.h; 44 | crop.width = crop.height * (targetSize.w / targetSize.h); 45 | } 46 | crop.y += Math.floor((this.item.attributes.height - crop.height) / 2); 47 | crop.x += Math.floor((this.item.attributes.width - crop.width) / 2); 48 | } 49 | crop = `${crop.width}:${crop.height}:${crop.x}:${crop.y}`; 50 | 51 | const file = MediaItemHelper.getFullFilePath(this.item); 52 | const args = [ 53 | '-ss', offset, 54 | '-i', file, 55 | '-frames', '1', 56 | '-filter', `crop=${crop}`, 57 | '-y', 58 | '-s', size, 59 | '-f', 'singlejpeg', 60 | '-', 61 | ]; 62 | const proc = spawn( 63 | Settings.getValue('ffmpeg_binary'), 64 | args, 65 | ); 66 | 67 | let b; 68 | proc.stdout.on('data', (data) => { 69 | if (b) { 70 | b = Buffer.concat([b, data]); 71 | } else { 72 | b = data; 73 | } 74 | }); 75 | 76 | proc.stderr.on('data', (data) => { 77 | Log.debug(`${data}`); 78 | }); 79 | 80 | return new Promise((resolve) => { 81 | proc.on('close', () => { 82 | new ImageCacheHandler(this.context).put(b); 83 | this.context.body = b; 84 | resolve(); 85 | }); 86 | }); 87 | } 88 | 89 | static getDescription() { 90 | return `will get a thumbnail from the middle of the video \n${IImageHandler.getDescription()}`; 91 | } 92 | } 93 | 94 | httpServer.registerRoute('get', '/img/:image.jpg', FFProbeImageHandler, 0, 1); 95 | 96 | module.exports = FFProbeImageHandler; 97 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/MediaContentApiHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 31-3-2017. 3 | */ 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const RequestHandler = require('../../core/http/RequestHandler'); 8 | const db = require('../../core/database/Database'); 9 | const FileRequestHandler = require('../../core/http/coreHandlers/FileRequestHandler'); 10 | const FFProbe = require('./FFProbe'); 11 | const httpServer = require('../../core/http'); 12 | const Subtitles = require('../../core/Subtitles'); 13 | 14 | const supportedSubtitleFormats = ['.srt', '.ass', '.subrip']; 15 | 16 | class SubtitleApiHandler extends RequestHandler { 17 | handleRequest() { 18 | const item = db.getById('media-item', this.context.params.id); 19 | if (!item) { 20 | return null; 21 | } 22 | this.context.set('Access-Control-Allow-Origin', '*'); 23 | this.context.set('Content-Type', 'text/vtt'); 24 | 25 | this.item = item; 26 | this.filePath = item.attributes.filepath; 27 | const directory = path.dirname(this.filePath); 28 | 29 | if (this.context.params.file) { 30 | return this.serveSubs(item.id, this.filePath, directory, this.context.params.file); 31 | } 32 | this.serveList(directory); 33 | 34 | return new Promise((resolve) => { 35 | this.resolve = resolve; 36 | }); 37 | } 38 | 39 | static getDescription(method, url) { 40 | if (url === '/api/mediacontent/subtitle/:id/:file') return 'serves a webvtt caption'; 41 | return `${__dirname}/mediacontent.md`; 42 | } 43 | 44 | serveList(directory) { 45 | fs.readdir(directory, this.onReadDir.bind(this)); 46 | } 47 | 48 | async serveSubs(id, filepath, dir, filename) { 49 | let f = await Subtitles.getVtt(id, filepath, dir, filename); 50 | let deleteAfter = false; 51 | if (this.context.query.offset) { 52 | deleteAfter = true; 53 | f = await Subtitles.getTimeShiftedTmpFile(f, this.context.query.offset); 54 | } 55 | return new FileRequestHandler(this.context).serveFile(f, deleteAfter) 56 | } 57 | 58 | returnEmpty() { 59 | this.response.end('[]'); 60 | } 61 | 62 | async onReadDir(err, result = []) { 63 | const subtitles = result 64 | .filter((file) => supportedSubtitleFormats.indexOf(path.extname(file)) !== -1) 65 | .map((file) => ({ label: file, value: file })); 66 | const response = { subtitles }; 67 | 68 | let { streams } = this.item.attributes; 69 | if (!streams) { 70 | const probe = await FFProbe.getInfo(this.filePath); 71 | ({ streams } = probe); 72 | } 73 | 74 | streams.forEach((str) => { 75 | let name = str.tags ? str.tags.language : str.codec_long_name; 76 | if (!name) { 77 | name = str.tags.title; 78 | } 79 | name = name || str.codec_name; 80 | if (supportedSubtitleFormats.indexOf(`.${str.codec_name}`) !== -1) { 81 | response.subtitles.push({ 82 | label: `Built in: ${name}`, 83 | value: `:${str.index}.${str.codec_name}`, 84 | }); 85 | } else { 86 | if (!response[str.codec_type]) { 87 | response[str.codec_type] = []; 88 | } 89 | response[str.codec_type].push({ 90 | label: name, 91 | value: str.index, 92 | }); 93 | } 94 | this.context.body = response; 95 | this.resolve(); 96 | }); 97 | } 98 | } 99 | 100 | httpServer.registerRoute('get', '/api/mediacontent/:id', SubtitleApiHandler); 101 | httpServer.registerRoute('get', '/api/mediacontent/subtitle/:id/:file', SubtitleApiHandler); 102 | 103 | module.exports = SubtitleApiHandler; 104 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/PlayHandler.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Mpeg4Container = require('./Mpeg4Container'); 3 | const HLSContainer = require('./HLSContainer'); 4 | const httpServer = require('../../core/http'); 5 | const RequestHandler = require('../../core/http/RequestHandler'); 6 | 7 | const containers = { 8 | mpeg4: Mpeg4Container, 9 | hls: HLSContainer, 10 | }; 11 | 12 | // read all the profiles, parse their json and add remember the filename for reference 13 | const profileDir = `${__dirname}/profiles/`; 14 | const profiles = fs.readdirSync(profileDir) 15 | .map((filename) => ({ 16 | name: filename.split('.')[0], 17 | ...(JSON.parse(fs.readFileSync(`${profileDir}${filename}`))), 18 | })) 19 | .sort((a, b) => b.priority - a.priority); 20 | 21 | class PlayHandler extends RequestHandler { 22 | handleRequest() { 23 | const { query } = this.context; 24 | 25 | const ua = this.request.headers['user-agent']; 26 | let profile; 27 | if (query.profile) { 28 | profile = profiles.find((p) => p.name === query.profile); 29 | } 30 | if (!profile) { 31 | profile = profiles.find((p) => p.useragent && ua.match(new RegExp(p.useragent))); 32 | } 33 | 34 | const Container = containers[profile.container]; 35 | const container = new Container(this.context, this.method, this.path); 36 | return container.handleRequest(profile); 37 | } 38 | } 39 | 40 | httpServer.registerRoute('get', '/ply/:id', PlayHandler, false, 0); 41 | httpServer.registerRoute('get', '/ply/:id/:offset', PlayHandler, false, 0); 42 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const os = require('os'); 3 | const http = require('http'); 4 | const unzip = require('node-unzip-2'); 5 | const Settings = require('../../core/Settings'); 6 | 7 | if (!fs.existsSync('ffmpeg') && !fs.existsSync('ffmpeg.exe')) { 8 | http.get(`http://downloadffmpeg.s3-website-eu-west-1.amazonaws.com/ffmpeg_${os.platform()}_${os.arch()}.zip`, (response) => { 9 | const e = unzip.Extract({ path: './' }); 10 | response.pipe(e); 11 | e.on('close', () => { 12 | fs.chmodSync(`ffmpeg${os.platform() === 'win32' ? '.exe' : ''}`, '755'); 13 | fs.chmodSync(`ffprobe${os.platform() === 'win32' ? '.exe' : ''}`, '755'); 14 | }); 15 | }); 16 | } 17 | 18 | if (os.platform() === 'win32') { 19 | const ffmpeg = Settings.getValue('ffmpeg_binary'); 20 | const ffprobe = Settings.getValue('ffprobe_binary'); 21 | if (!ffmpeg.match(/exe$/)) { 22 | Settings.setValue('ffmpeg_binary', `${ffmpeg}.exe`); 23 | } 24 | if (!ffprobe.match(/exe$/)) { 25 | Settings.setValue('ffprobe_binary', `${ffprobe}.exe`); 26 | } 27 | Settings.save(); 28 | } 29 | 30 | require('./FFProbeExtendedInfo'); 31 | require('./FFProbeImageHandler'); 32 | require('./MediaContentApiHandler'); 33 | require('./PlayHandler'); 34 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/mediacontent.md: -------------------------------------------------------------------------------- 1 | will return information about the media item 2 | 3 | ## example output 4 | ```json 5 | { 6 | "subtitles": [ 7 | { 8 | "label": "Built in: eng", 9 | "value": ":2.srt" 10 | }, 11 | { 12 | "label": "Built in: eng", 13 | "value": ":3.srt" 14 | } 15 | ], 16 | "video": [ 17 | { 18 | "label": "eng", 19 | "value": 0 20 | } 21 | ], 22 | "audio": [ 23 | { 24 | "label": "eng", 25 | "value": 1 26 | } 27 | ] 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/playhandler.md: -------------------------------------------------------------------------------- 1 | will serve an mp4 h264 aac 2 | 3 | ## Supported get parameters 4 | 5 | | GET Param | Accepted value | 6 | |---------------|----------------------------------| 7 | | audioChannel | Audio channel offset (default 0) | 8 | | videoChannel | Video channel offset (default 0) | 9 | | format | set to hls to force hls playback | 10 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/profiles/chromecast_ultra.json: -------------------------------------------------------------------------------- 1 | { 2 | "useragent": "^.*aarch64.*Chrome.*CrKey.*$", 3 | "priority": 1, 4 | "container": "mpeg4", 5 | "encoder": { 6 | "audio": "aac", 7 | "video": "libx264" 8 | }, 9 | "demux": { 10 | "video": [ 11 | "h264", 12 | "hevc" 13 | ], 14 | "audio": [ 15 | ] 16 | }, 17 | "ffmpegArguments": { 18 | "output": [ 19 | "-movflags", "empty_moov+omit_tfhd_offset+default_base_moof+frag_keyframe", 20 | "-reset_timestamps", "1" 21 | ], 22 | "input": [ 23 | 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/profiles/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "useragent": "^.*$", 3 | "priority": 0, 4 | "container": "mpeg4", 5 | "encoder": { 6 | "audio": "aac", 7 | "video": "libx264" 8 | }, 9 | "demux": { 10 | "video": [ 11 | "h264" 12 | ], 13 | "audio": [ 14 | ] 15 | }, 16 | "ffmpegArguments": { 17 | "output": [ 18 | "-movflags", "empty_moov+omit_tfhd_offset+default_base_moof+frag_keyframe", 19 | "-reset_timestamps", "1" 20 | ], 21 | "input": [ 22 | 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/profiles/hls_for_offline_playback.json: -------------------------------------------------------------------------------- 1 | { 2 | "priority": 0, 3 | "container": "hls", 4 | "neverPause": true, 5 | "encoder": { 6 | "audio": "aac", 7 | "video": "libx264" 8 | }, 9 | "demux": { 10 | "video": [ 11 | "h264" 12 | ], 13 | "audio": [ 14 | ] 15 | }, 16 | "ffmpegArguments": { 17 | "output": [ 18 | "-hls_time", 30, 19 | "-hls_list_size", 0, 20 | "-bsf:v", "h264_mp4toannexb" 21 | ], 22 | "input": [ 23 | 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/modules/ffmpeg/profiles/safari.json: -------------------------------------------------------------------------------- 1 | { 2 | "useragent": "^((?!Chrome).)*(Safari|AppleCoreMedia).*?$", 3 | "priority": 5, 4 | "container": "hls", 5 | "maxBufferedChunks": 6, 6 | "encoder": { 7 | "audio": "aac", 8 | "video": "libx264" 9 | }, 10 | "demux": { 11 | "video": [ 12 | "h264" 13 | ], 14 | "audio": [ 15 | ] 16 | }, 17 | "ffmpegArguments": { 18 | "output": [ 19 | "-hls_time", 10, 20 | "-hls_list_size", 0 21 | ], 22 | "input": [ 23 | 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/modules/guessit/Guessit.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const qs = require('querystring'); 3 | const Q = require('q'); 4 | const Settings = require('../../core/Settings'); 5 | 6 | class Guessit { 7 | static apiCall(path, query, post) { 8 | const deferred = Q.defer(); 9 | 10 | const isPOST = (post === true); 11 | 12 | query = (query ? qs.stringify(query) : ''); 13 | 14 | if (!isPOST) { 15 | path += (query.length ? `?${query}` : ''); 16 | } 17 | 18 | const options = { 19 | hostname: Settings.getValue('guessit').host, 20 | port: Settings.getValue('guessit').port, 21 | path, 22 | method: isPOST ? 'POST' : 'GET', 23 | }; 24 | 25 | if (isPOST) { 26 | options.headers = { 27 | 'Content-Type': 'application/x-www-form-urlencoded', 28 | 'Content-Length': query.length, 29 | }; 30 | } 31 | 32 | const req = http.request(options, (res) => { 33 | res.setEncoding('utf8'); 34 | 35 | res.on('data', (chunk) => { 36 | deferred.resolve(JSON.parse(chunk)); 37 | }); 38 | }); 39 | 40 | req.on('error', (err) => { 41 | deferred.reject(err); 42 | }); 43 | 44 | if (isPOST) { 45 | req.write(query); 46 | } 47 | 48 | req.end(); 49 | 50 | return deferred.promise; 51 | } 52 | 53 | static getVersion() { 54 | return this.apiCall('/guessit_version'); 55 | } 56 | 57 | static parseName(filename, post) { 58 | return this.apiCall('/', { 59 | filename, 60 | }, post); 61 | } 62 | 63 | static submitBug(filename) { 64 | return this.apiCall('/bugs', { 65 | filename, 66 | }, true); 67 | } 68 | } 69 | module.exports = Guessit; 70 | -------------------------------------------------------------------------------- /backend/modules/guessit/ParseFileNameExtendedInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 29-06-16. 3 | */ 4 | const path = require('path'); 5 | const IExtendedInfo = require('../../core/scanner/IExtendedInfo'); 6 | const Guessit = require('./Guessit'); 7 | const Log = require('../../core/Log'); 8 | const core = require('../../core'); 9 | const ExtendedInfoQueue = require('../../core/scanner/ExtendedInfoQueue'); 10 | 11 | class ParseFileNameExtendedInfo extends IExtendedInfo { 12 | static async extendInfo(mediaItem, library, tryCount = 0) { 13 | if (!tryCount) { 14 | tryCount = 0; 15 | } 16 | 17 | const relativePath = mediaItem.attributes.filepath; 18 | 19 | if (mediaItem.attributes.title) { 20 | return; 21 | } 22 | Log.debug('parse filename', mediaItem.id); 23 | 24 | const filePath = path.parse(relativePath); 25 | const folder = path.parse(filePath.dir); 26 | const extraGuessitOptions = []; 27 | const fileParts = filePath.dir.split(path.sep); 28 | 29 | let season = ''; 30 | let serieName = ''; 31 | if (library.type === 'tv') { 32 | let offset; 33 | for (offset = 0; offset < fileParts.length; offset += 1) { 34 | // the first directory we find containing season info is probably the child directory 35 | // Of the directory containing the season name. 36 | const seasonCheck = fileParts[offset].replace(/^.*?(s|se|season)[^a-zA-Z0-9]?([0-9]+).*?$/i, '$2'); 37 | if (seasonCheck !== fileParts[offset]) { 38 | season = parseInt(seasonCheck, 10); 39 | break; 40 | } 41 | } 42 | if (season && offset > 0) { 43 | serieName = fileParts[offset - 1]; 44 | extraGuessitOptions.push(`-T ${serieName}`); 45 | } 46 | } 47 | 48 | let searchQuery = filePath.base.replace(/ /g, '.'); 49 | 50 | if (tryCount === 1) { 51 | searchQuery = `${folder.base.replace(/ /g, '.')}-${filePath.base.replace(/ /g, '.')}`; 52 | } 53 | 54 | try { 55 | const data = await Guessit.parseName( 56 | searchQuery, 57 | { options: `-t ${library.type} ${extraGuessitOptions.join(' ')}` }, 58 | ); 59 | if (tryCount === 1 && data.title) { 60 | data.title = data.title.replace(`${folder.base}-`, ''); 61 | } 62 | if (data.title) { 63 | if (season) { 64 | data.season = season; 65 | } 66 | if (serieName) { 67 | mediaItem.attributes['episode-title'] = data['episode-title'] ? data['episode-title'] : data.title; 68 | data.title = serieName; 69 | } 70 | mediaItem.attributes.season = data.season; 71 | mediaItem.attributes.episode = data.episode; 72 | mediaItem.attributes.title = data.title; 73 | mediaItem.attributes.type = library.type; 74 | return; 75 | } 76 | if (tryCount >= 1) { 77 | return; 78 | } 79 | await this.extendInfo(mediaItem, library, tryCount + 1); 80 | } catch (e) { 81 | Log.debug(e); 82 | } 83 | } 84 | } 85 | 86 | core.addBeforeStartListener( 87 | () => ExtendedInfoQueue.registerExtendedInfoProvider(ParseFileNameExtendedInfo, true), 88 | ); 89 | 90 | module.exports = ParseFileNameExtendedInfo; 91 | -------------------------------------------------------------------------------- /backend/modules/guessit/index.js: -------------------------------------------------------------------------------- 1 | require('./ParseFileNameExtendedInfo'); 2 | -------------------------------------------------------------------------------- /backend/modules/sharing/Crypt.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const Log = require('../../core/Log'); 3 | 4 | class Crypt { 5 | static encrypt(stream, key, nonce) { 6 | const cypher = crypto.createCipheriv('aes192', key, nonce); 7 | cypher.on('error', (e) => { 8 | Log.debug('cypher emitted error', e); 9 | }); 10 | return stream.pipe(cypher); 11 | } 12 | 13 | static decrypt(key, nonce) { 14 | const cypher = crypto.createDecipheriv('aes192', key, nonce); 15 | 16 | cypher.on('error', (e) => { 17 | Log.debug('cypher emitted error', e); 18 | }); 19 | return cypher; 20 | } 21 | } 22 | 23 | module.exports = Crypt; 24 | -------------------------------------------------------------------------------- /backend/modules/sharing/DatabaseFetcher.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Database = require('../../core/database/Database'); 3 | const EDHT = require('./EDHT'); 4 | const Settings = require('../../core/Settings'); 5 | const Log = require('../../core/Log'); 6 | const TcpClient = require('./TcpClient'); 7 | 8 | // @todo handle library delete 9 | class DatabaseFetcher { 10 | constructor() { 11 | Database.addDataProvider(this.provide.bind(this)); 12 | Database.addUpdateOverwriter(this.overwriteUpdate.bind(this)); 13 | this.cached = { 'media-item': {} }; 14 | try { 15 | this.diffs = JSON.parse(fs.readFileSync('share/diffs')); 16 | } catch (e) { 17 | this.diffs = {}; 18 | Log.debug('no share/diffs'); 19 | } 20 | EDHT.setOnreadyListener(this.onReady.bind(this)); 21 | } 22 | 23 | onReady() { 24 | this.refreshDatabase(); 25 | // refresh every 15 mins 26 | setInterval(this.refreshDatabase.bind(this), 15 * 60 * 1000); 27 | Settings.addObserver('libraries', this.refreshDatabase.bind(this)); 28 | } 29 | 30 | async refreshDatabase() { 31 | if (this.refreshing) return; 32 | this.refreshing = true; 33 | const libs = Settings.getValue('libraries').filter((lib) => lib.type === 'shared'); 34 | try { 35 | await Promise.all(libs.map(this.fetchLib.bind(this))); 36 | } catch (e) { 37 | setTimeout(this.refreshDatabase.bind(this), 10000); 38 | Log.debug(e); 39 | } 40 | this.refreshing = false; 41 | } 42 | 43 | async fetchLib(lib) { 44 | Log.debug('try to fetch!!', lib.uuid); 45 | Log.notifyUser('toast', 'Trying to download external library'); 46 | 47 | const [ref, key, nonce] = lib.uuid.split('-'); 48 | const value = await EDHT.getValue(Buffer.from(ref, 'hex')); 49 | // @todo delete old database if necessary 50 | if (value.v.length !== 20) { 51 | Log.exception('Something is wrong with library', lib); 52 | Log.notifyUser('toast', 'Error loading external library'); 53 | return; 54 | } 55 | const client = new TcpClient(value.v.toString('hex'), key, nonce); 56 | await client.downloadFile(); 57 | let items = JSON.parse(await client.getContents()); 58 | Log.debug('fetched database', lib.uuid); 59 | Log.notifyUser('toast', 'Downloaded external library'); 60 | 61 | items = items.map((item) => { 62 | item.attributes.filepath = `http://127.0.0.1:${Settings.getValue('port')}${item.attributes.filepath}`; 63 | return item; 64 | }); 65 | this.cached['media-item'][ref] = items; 66 | } 67 | 68 | provide(type) { 69 | if (!this.cached[type]) { return []; } 70 | let items = Object.values(this.cached[type]); 71 | if (!items.length) return []; 72 | items = items.reduce((red, arr) => (red ? red.concat(arr) : arr)); 73 | items = items.map((i) => { 74 | const lib = this.diffs[i.attributes.libraryId]; 75 | if (!lib) return i; 76 | return lib[i.id] ? DatabaseFetcher.applyDiff(i, lib[i.id]) : i; 77 | }); 78 | return items; 79 | } 80 | 81 | overwriteUpdate(item) { 82 | const libId = item.attributes.libraryId; 83 | const localLib = this.cached['media-item'][libId]; 84 | if (item.type !== 'media-item' || !localLib) { 85 | return false; 86 | } 87 | 88 | if (!this.diffs[libId]) this.diffs[libId] = {}; 89 | const localItem = localLib.find((i) => i.id === item.id); 90 | this.diffs[libId][item.id] = { 91 | attributes: DatabaseFetcher.diff(localItem.attributes || {}, item.attributes || {}), 92 | relationships: DatabaseFetcher.diff(localItem.relationships || {}, item.relationships || {}), 93 | }; 94 | DatabaseFetcher.applyDiff(localItem, this.diffs[libId][item.id]); 95 | this.saveDiff(); 96 | return true; 97 | } 98 | 99 | saveDiff() { 100 | clearTimeout(this.saveInterval); 101 | this.saveInterval = setTimeout(() => { 102 | fs.writeFile('share/diffs', JSON.stringify(this.diffs), () => { 103 | Log.debug('share/diffs written'); 104 | }); 105 | }, 1000); 106 | } 107 | 108 | static diff(a, b) { 109 | const o = {}; 110 | Object.keys(b).forEach((key) => { 111 | if (a[key] === b[key] || JSON.stringify(a[key]) === JSON.stringify(b[key])) return; 112 | o[key] = b[key]; 113 | }); 114 | return o; 115 | } 116 | 117 | static applyDiff(a, b) { 118 | a.attributes = Object.assign(a.attributes || {}, b.attributes); 119 | a.relationships = Object.assign(a.relationships || {}, b.relationships); 120 | return a; 121 | } 122 | } 123 | 124 | module.exports = new DatabaseFetcher(); 125 | -------------------------------------------------------------------------------- /backend/modules/sharing/DownloadFileHandler.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const mime = require('mime'); 3 | const RequestHandler = require('../../core/http/RequestHandler'); 4 | const httpServer = require('../../core/http'); 5 | const Database = require('../../core/database/Database'); 6 | const MediaFetcher = require('./MediaFetcher'); 7 | const MediaItemHelper = require('../../core/MediaItemHelper'); 8 | 9 | class DownloadFileHandler extends RequestHandler { 10 | async handleRequest() { 11 | const item = Database.getById('media-item', this.context.params.id); 12 | 13 | const range = this.context.get('Range'); 14 | let offset = 0; 15 | if (range) { 16 | offset = parseInt(range.split('=')[1].split('-')[0], 10); 17 | } 18 | this.context.set('Accept-Ranges', 'bytes'); 19 | 20 | this.context.type = mime.lookup(item.attributes.extension); 21 | const lib = MediaItemHelper.getLibrary(item); 22 | if (lib.type === 'shared') { 23 | const s = new MediaFetcher(item); 24 | s.setContextHeaders(this.context, offset); 25 | this.context.body = s.startStream(offset); 26 | return; 27 | } 28 | this.context.set('Content-Length', item.attributes.filesize - offset); 29 | 30 | const { filepath } = item.attributes; 31 | this.context.type = mime.lookup(filepath); 32 | this.context.body = fs.createReadStream(filepath, { offset }); 33 | } 34 | 35 | static getDescription() { 36 | return 'Will serve the original file'; 37 | } 38 | } 39 | 40 | httpServer.registerRoute('get', '/download/:id', DownloadFileHandler); 41 | 42 | module.exports = DownloadFileHandler; 43 | -------------------------------------------------------------------------------- /backend/modules/sharing/MediaFetcher.js: -------------------------------------------------------------------------------- 1 | const { PassThrough } = require('stream'); 2 | const TcpClient = require('./TcpClient'); 3 | const MediaItemHelper = require('../../core/MediaItemHelper'); 4 | const Log = require('../../core/Log'); 5 | 6 | /** 7 | * @todo handle downloads in background 8 | * starting the player costs about 3 requests, 9 | * upon the first request we should try to keep downloading in the background 10 | */ 11 | class MediaFetcher { 12 | constructor(item) { 13 | this.onReadable = this.onReadable.bind(this); 14 | this.item = item; 15 | this.offset = 0; 16 | this.hashes = this.item.attributes.hashes; 17 | const lib = MediaItemHelper.getLibrary(item); 18 | [, this.key] = lib.uuid.split('-'); 19 | } 20 | 21 | startStream(offset) { 22 | this.offset = this.hashes.findIndex((h) => offset <= h.offset + h.size); 23 | Log.debug('starting to download from offset: ', offset, `${this.offset}/${this.hashes.length - 1}`); 24 | this.skipBytes = offset - this.hashes[this.offset].offset; 25 | this.output = new PassThrough(); 26 | this.output.on('close', this.end.bind(this)); 27 | this.downloadNext(); 28 | return this.output; 29 | } 30 | 31 | end() { 32 | if (this.ended) return; 33 | 34 | this.ended = true; 35 | this.tcpClient.stop(); 36 | } 37 | 38 | downloadNext() { 39 | if (this.offset >= this.hashes.length) { 40 | this.ended = true; 41 | this.output.end(); 42 | return; 43 | } 44 | if (this.ended) return; 45 | 46 | this.tcpClient = new TcpClient( 47 | this.hashes[this.offset].hash, 48 | this.key, 49 | this.item.attributes.nonce, 50 | this.hashes[this.offset].size, 51 | ); 52 | this.input = this.tcpClient.streamFile(this.downloadNext.bind(this), this.item.id); 53 | if (this.skipBytes) { 54 | this.input.on('readable', this.onReadable); 55 | } else { 56 | this.input.pipe(this.output); 57 | } 58 | this.input.on('finish', () => { 59 | this.input.unpipe(this.output); 60 | this.downloadNext(); 61 | }); 62 | this.offset += 1; 63 | } 64 | 65 | /** 66 | * used to "throw away" the first x bytes of data (when handling ranged requests) 67 | */ 68 | onReadable() { 69 | const read = this.input.readableLength > this.skipBytes 70 | ? this.skipBytes 71 | : this.input.readableLength; 72 | this.input.read(read); 73 | this.skipBytes -= read; 74 | if (this.skipBytes === 0) { 75 | this.input.off('readable', this.onReadable); 76 | this.input.pipe(this.output); 77 | } 78 | } 79 | 80 | setContextHeaders(context, offset) { 81 | const { filesize } = this.item.attributes; 82 | context.set('Content-Range', `bytes ${offset}-${filesize}/${filesize}`); 83 | context.set('Content-Length', filesize - offset); 84 | context.status = 206; 85 | } 86 | } 87 | 88 | module.exports = MediaFetcher; 89 | -------------------------------------------------------------------------------- /backend/modules/sharing/TcpConnection.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const EDHT = require('./EDHT'); 3 | const Log = require('../../core/Log'); 4 | 5 | class TcpConnection { 6 | constructor({ host, port }) { 7 | Log.debug('try connecting to', host, port, 'to download'); 8 | this.peer = { host, port }; 9 | this.timeOut = this.timeOut.bind(this); 10 | 11 | this.client = net.createConnection(port, host, this.didConnect.bind(this)); 12 | this.client.on('error', (err) => { 13 | this.errored = true; 14 | if (this.timeoutTimer) clearTimeout(this.timeoutTimer); 15 | this.onResult(false); 16 | Log.debug('client connect error', err, host, port); 17 | }); 18 | } 19 | 20 | setOnConnect(onConnect) { 21 | this.onConnect = onConnect; 22 | } 23 | 24 | setOnResult(onResult) { 25 | this.onResultCallback = onResult; 26 | } 27 | 28 | didConnect() { 29 | this.connected = true; 30 | if (this.onConnect) { 31 | this.onConnect(this); 32 | } 33 | } 34 | 35 | end() { 36 | this.client.end(); 37 | } 38 | 39 | onResult(success) { 40 | clearTimeout(this.timeoutTimer); 41 | if (this.resultGiven) { 42 | Log.warning('result already given???'); 43 | return; 44 | } 45 | this.resultGiven = true; 46 | if (this.onResultCallback) this.onResultCallback(this, success); 47 | } 48 | 49 | writeAndStream(reference, extra, writeOut) { 50 | Log.debug('try to download file from', this.peer, reference, extra); 51 | EDHT.announce(reference); 52 | 53 | Log.debug('connected to server!', this.peer); 54 | this.client.write(`${reference}${extra}\n`, () => Log.debug('data written', this.peer)); 55 | 56 | let finished = 0; 57 | const onFinish = () => { 58 | finished += 1; 59 | if (this.errored) { 60 | return; 61 | } 62 | if (finished === writeOut.length) this.onResult(this.client.bytesRead > 0); 63 | }; 64 | 65 | this.client.on('close', () => { 66 | clearTimeout(this.timeoutTimer); 67 | if (this.client.bytesRead > 0) { 68 | writeOut.forEach((s) => s.end()); 69 | } else { 70 | writeOut.forEach((s) => s.off('finish', onFinish)); 71 | this.onResult(false); 72 | } 73 | }); 74 | // timeout the connection when not receiving data for 10 seconds 75 | this.timeoutTimer = setTimeout(this.timeOut, 10000); 76 | 77 | writeOut.forEach((s) => this.client.pipe(s, { end: false })); 78 | writeOut.forEach((s) => s.on('finish', onFinish)); 79 | } 80 | 81 | timeOut() { 82 | Log.debug('bytes read', this.client.bytesRead); 83 | if (this.client.bytesRead > 0) return; 84 | Log.debug('no data received, conn timeout', this.peer); 85 | this.end(); 86 | this.errored = true; 87 | this.onResult(false); 88 | } 89 | } 90 | 91 | module.exports = TcpConnection; 92 | -------------------------------------------------------------------------------- /backend/modules/sharing/TcpServer.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const readline = require('readline'); 3 | const fs = require('fs'); 4 | const Settings = require('../../core/Settings'); 5 | const Crypt = require('./Crypt'); 6 | const Log = require('../../core/Log'); 7 | const FileProcessor = require('./FileProcessor'); 8 | 9 | class TcpServer { 10 | constructor() { 11 | this.server = net.createServer(TcpServer.connected); 12 | this.server.listen(Settings.getValue('shareport')); 13 | this.server.on('error', (error) => { 14 | Log.exception('error in incoming share connection', error); 15 | }); 16 | } 17 | 18 | /** 19 | * @todo doesn't always disconnect?? 20 | * @param socket 21 | */ 22 | static connected(socket) { 23 | Log.debug('sharing, new peer connection'); 24 | socket.on('error', Log.exception); 25 | const timeout = setTimeout(() => { socket.end(); }, 30000); 26 | 27 | readline 28 | .createInterface(socket) 29 | .on('error', Log.exception) 30 | .on('line', async (line) => { 31 | clearTimeout(timeout); 32 | if (line === Settings.getValue('currentSharedDB')) { 33 | Log.debug('serve database'); 34 | const db = fs.createReadStream('share/db'); 35 | const stream = Crypt.encrypt( 36 | db, 37 | Buffer.from(Settings.getValue('dbKey'), 'hex'), 38 | Buffer.from(Settings.getValue('dbNonce'), 'hex'), 39 | ); 40 | stream.pipe(socket); 41 | stream.on('error', Log.exception) 42 | } else { 43 | const [hash, , id] = line.split('-'); 44 | const stream = await FileProcessor.getReadStream(id, hash); 45 | if (!stream) { 46 | socket.end(); 47 | return; 48 | } 49 | Log.debug('piping file to socket'); 50 | stream.pipe(socket); 51 | socket.on('close', () => { 52 | if (typeof stream.end === 'function') stream.end(); 53 | }); 54 | stream.on('error', Log.exception) 55 | } 56 | }); 57 | } 58 | } 59 | 60 | module.exports = new TcpServer(); 61 | -------------------------------------------------------------------------------- /backend/modules/sharing/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | if (!fs.existsSync('share')) { 4 | fs.mkdirSync('share'); 5 | } 6 | 7 | require('./TcpServer'); 8 | require('./FileProcessor'); 9 | require('./DatabaseFetcher'); 10 | require('./EDHT'); 11 | require('./DownloadFileHandler'); 12 | -------------------------------------------------------------------------------- /backend/modules/socketio/index.js: -------------------------------------------------------------------------------- 1 | const io = require('socket.io')(); 2 | 3 | const core = require('../../core'); 4 | const httpServer = require('../../core/http'); 5 | const Log = require('../../core/Log'); 6 | 7 | class SocketIO { 8 | static emit(event, message) { 9 | io.emit(event, message); 10 | } 11 | 12 | static onLog(type, message) { 13 | if (type !== Log.LEVEL.NOTIFY_USER) return; 14 | SocketIO.emit(message[0], message.slice(1).join(' ')); 15 | } 16 | 17 | static init() { 18 | io.attach(httpServer.getHttpServer()); 19 | if (httpServer.getHttpsServer()) io.attach(httpServer.getHttpsServer()); 20 | } 21 | } 22 | 23 | Log.addListener(SocketIO.onLog); 24 | 25 | core.addAfterStartListener(SocketIO.init); 26 | 27 | module.exports = SocketIO; 28 | -------------------------------------------------------------------------------- /backend/modules/ssl/certificateRequester.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const querystring = require('querystring'); 3 | const ip = require('ip'); 4 | const crypto = require('crypto'); 5 | const acme = require('acme-client'); 6 | const dns = require('dns'); 7 | const HttpServer = require('../../core/http'); 8 | const Log = require('../../core/Log'); 9 | const Settings = require('../../core/Settings'); 10 | const core = require('../../core'); 11 | 12 | const get = (url) => new Promise((resolve, reject) => { 13 | https.get(url, (res) => { 14 | if (res.statusCode === 200) resolve(res); 15 | else reject(res); 16 | }); 17 | }); 18 | 19 | async function waitForTxt(domain, txt, tryCount = 0) { 20 | if (tryCount > 10) return false; 21 | 22 | return new Promise((resolve) => { 23 | setTimeout(() => { 24 | dns.resolveTxt(domain, (err, [res]) => { 25 | Log.debug('got txtrecord', res, tryCount); 26 | if (err || !res || txt !== res[0]) { 27 | return waitForTxt(domain, txt, tryCount + 1).then(resolve); 28 | } 29 | return resolve(); 30 | }); 31 | }, tryCount * 2000); 32 | }); 33 | } 34 | 35 | async function setupCerts(changed) { 36 | try { 37 | if (!Settings.getValue('sslemail') 38 | || !Settings.getValue('ssldomain') 39 | || !Settings.getValue('sslport')) { 40 | return; 41 | } 42 | Log.notifyUser('toast', 'requesting certificate'); 43 | 44 | if (changed !== 'ssldomain' 45 | && Settings.getValue('ssl').expire - new Date().getTime() > 0) { 46 | return; 47 | } 48 | Settings.setValue('ssl', {}); 49 | 50 | const client = new acme.Client({ 51 | directoryUrl: acme.directory.letsencrypt.production, 52 | accountKey: await acme.forge.createPrivateKey(), 53 | challengePriority: ['dns-01'], 54 | }); 55 | 56 | try { 57 | client.getAccountUrl(); 58 | } catch (e) { 59 | await client.createAccount({ 60 | termsOfServiceAgreed: true, 61 | contact: [`mailto:${Settings.getValue('sslemail')}`], 62 | }); 63 | } 64 | 65 | const subdomain = Settings.getValue('ssldomain'); 66 | /* Place new order */ 67 | const order = await client.createOrder({ 68 | identifiers: [ 69 | { 70 | type: 'dns', 71 | value: `${subdomain}.theremote.io`, 72 | }, 73 | ], 74 | }); 75 | 76 | /* Get authorizations and select challenges */ 77 | const [authz] = await client.getAuthorizations(order); 78 | 79 | const challenge = authz.challenges.find((o) => o.type === 'dns-01'); 80 | const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); 81 | 82 | if (!Settings.getValue('sslpassword')) { 83 | Settings.setValue('sslpassword', crypto.randomBytes(32) 84 | .toString('hex')); 85 | Settings.save(); 86 | } 87 | 88 | const params = { 89 | name: subdomain, 90 | password: Settings.getValue('sslpassword'), 91 | token: keyAuthorization, 92 | ip: ip.address(), 93 | }; 94 | await get(`https://certification.theremote.io:8335/?${querystring.stringify(params)}`); 95 | 96 | Log.notifyUser('toast', 'waiting for certificate validation'); 97 | await waitForTxt(`_acme-challenge.${params.name}.theremote.io`, keyAuthorization); 98 | 99 | /* Notify ACME provider that challenge is satisfied */ 100 | await client.completeChallenge(challenge); 101 | 102 | /* Wait for ACME provider to respond with valid status */ 103 | await client.waitForValidStatus(challenge); 104 | 105 | const [key, csr] = await acme.forge.createCsr({ 106 | commonName: `${subdomain}.theremote.io`, 107 | }); 108 | 109 | await client.finalizeOrder(order, csr); 110 | const cert = await client.getCertificate(order); 111 | 112 | /* Done */ 113 | Settings.setValue('ssl', { 114 | key: key.toString(), 115 | cert: cert.toString(), 116 | expire: new Date().getTime() + (30 * 24 * 60 * 60 * 1000), 117 | }); 118 | Settings.save(); 119 | 120 | Log.notifyUser('toast', 'certificate successfully request, restarting...'); 121 | 122 | HttpServer.stop(HttpServer.start); 123 | } catch (e) { 124 | Log.notifyUser('toast', 'could not request certificate for given subdomain'); 125 | } 126 | } 127 | 128 | Settings.addObserver('sslport', setupCerts); 129 | Settings.addObserver('ssldomain', setupCerts); 130 | Settings.addObserver('sslredirect', setupCerts); 131 | 132 | setInterval(setupCerts, 24 * 60 * 60 * 1000); 133 | 134 | core.addBeforeStartListener(setupCerts); 135 | -------------------------------------------------------------------------------- /backend/modules/ssl/index.js: -------------------------------------------------------------------------------- 1 | require('./certificateRequester'); 2 | -------------------------------------------------------------------------------- /backend/modules/tmdb/TMDBApiHandler.js: -------------------------------------------------------------------------------- 1 | const MovieDB = require('moviedb-api'); 2 | const RequestHandler = require('../../core/http/RequestHandler'); 3 | const httpServer = require('../../core/http'); 4 | const Settings = require('../../core/Settings'); 5 | 6 | const movieDB = new MovieDB({ 7 | consume: true, 8 | apiKey: Settings.getValue('tmdb_apikey'), 9 | }); 10 | let genreCache; 11 | 12 | class TMDBApiHandler extends RequestHandler { 13 | async handleRequest() { 14 | if (genreCache) { 15 | this.context.body = genreCache; 16 | return; 17 | } 18 | 19 | const res = (await movieDB.genreMovieList()).genres; 20 | const res2 = (await movieDB.genreTvList()).genres; 21 | const haveIds = []; 22 | genreCache = res.map((item) => { 23 | haveIds[item.id] = true; 24 | return item; 25 | }); 26 | genreCache.concat(res2.filter((item) => !haveIds[item.id])); 27 | genreCache.sort((a, b) => a.name.localeCompare(b.name)); 28 | 29 | this.context.body = genreCache; 30 | } 31 | 32 | static getDescription() { 33 | return `${__dirname}/genres.md`; 34 | } 35 | } 36 | 37 | httpServer.registerRoute('get', '/api/tmdb/genres', TMDBApiHandler); 38 | 39 | module.exports = TMDBApiHandler; 40 | -------------------------------------------------------------------------------- /backend/modules/tmdb/TMDBImageHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 7/9/16. 3 | */ 4 | const http = require('http'); 5 | const IImageHandler = require('../../core/http/IImageHandler'); 6 | const httpServer = require('../../core/http'); 7 | const ImageCacheHandler = require('../../core/http/coreHandlers/ImageCacheHandler'); 8 | 9 | class TmdbImageHandler extends IImageHandler { 10 | handleRequest() { 11 | if (!this.item) { 12 | return null; 13 | } 14 | let w = 'w300'; 15 | if (this.type === IImageHandler.TYPE_BACKDROP) { 16 | w = 'w1280'; 17 | } else if (this.type === IImageHandler.TYPE_POSTER_LARGE) { 18 | w = 'w1280'; 19 | this.type = 'poster'; 20 | } else if (this.type === IImageHandler.TYPE_POSTER_SMALL) { 21 | w = 'w154'; 22 | this.type = 'poster'; 23 | } 24 | 25 | if (!this.item.attributes[`${this.type}-path`]) { 26 | return false; 27 | } 28 | 29 | const img = `http://image.tmdb.org/t/p/${w}/${this.item.attributes[`${this.type}-path`]}`; 30 | const p = new Promise((resolve) => { 31 | http.get(img, (response) => { 32 | const bytes = []; 33 | response.on('data', (data) => { 34 | bytes.push(data); 35 | }); 36 | response.on('end', () => { 37 | const b = Buffer.concat(bytes); 38 | new ImageCacheHandler(this.context).put(b); 39 | this.context.body = b; 40 | resolve(); 41 | }); 42 | }); 43 | }); 44 | return p; 45 | } 46 | 47 | static getDescription() { 48 | return `will get an image from TheMovieDB \n${IImageHandler.getDescription()}`; 49 | } 50 | } 51 | 52 | httpServer.registerRoute('get', '/img/:image.jpg', TmdbImageHandler, 0, 2); 53 | 54 | module.exports = TmdbImageHandler; 55 | -------------------------------------------------------------------------------- /backend/modules/tmdb/TheMovieDBExtendedInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 29-06-16. 3 | */ 4 | const path = require('path'); 5 | const MovieDB = require('moviedb-api'); 6 | const IExtendedInfo = require('../../core/scanner/IExtendedInfo'); 7 | const Settings = require('../../core/Settings'); 8 | const Log = require('../../core/Log'); 9 | const core = require('../../core'); 10 | const ExtendedInfoQueue = require('../../core/scanner/ExtendedInfoQueue'); 11 | 12 | const movieDB = new MovieDB({ 13 | consume: true, 14 | apiKey: Settings.getValue('tmdb_apikey'), 15 | }); 16 | 17 | const discardRegex = /\\W|-|_|([0-9]+p)|(LKRG)/g; 18 | 19 | class TheMovieDBExtendedInfo extends IExtendedInfo { 20 | static async extendInfo(mediaItem, library, tryCount = 0) { 21 | if (!tryCount) { 22 | tryCount = 0; 23 | } 24 | 25 | if (mediaItem.attributes.gotExtendedInfo >= 2 26 | || !['tv', 'movie'].includes(mediaItem.attributes.type)) { 27 | return; 28 | } 29 | Log.debug('process tmdb', mediaItem.id); 30 | 31 | // If the movie cannot be found: 32 | // 1. try again without year, 33 | // 2. Then try again with the filename 34 | 35 | let params = { query: mediaItem.attributes.title }; 36 | 37 | if (mediaItem.attributes.episode) { 38 | params.query += ` ${mediaItem.attributes.episode}`; 39 | } 40 | 41 | switch (tryCount) { 42 | case 0: 43 | params.year = mediaItem.attributes.year; 44 | break; 45 | default: 46 | [params.query] = path.parse(mediaItem.attributes.filepath).base.split('.'); 47 | params.query = params.query.replace(discardRegex, ' '); 48 | break; 49 | } 50 | 51 | let searchMethod = movieDB.searchMovie; 52 | if (library.type === 'tv') { 53 | searchMethod = movieDB.tvSeasonEpisode; 54 | if (!mediaItem.attributes['external-id']) { 55 | return; 56 | } 57 | params = { 58 | id: mediaItem.attributes['external-id'], 59 | season_number: mediaItem.attributes.season, 60 | episode_number: mediaItem.attributes.episode, 61 | }; 62 | } 63 | searchMethod = searchMethod.bind(movieDB); 64 | 65 | let res = await searchMethod(params); 66 | if (library.type !== 'tv' && res) { 67 | const match = res.results 68 | .find( 69 | (i) => mediaItem.attributes.title.toLocaleLowerCase() === i.title.toLocaleLowerCase(), 70 | ); 71 | if (match) res = match; 72 | else [res] = res.results; 73 | } 74 | 75 | if (res) { 76 | if (library.type === 'tv') { 77 | res['external-episode-id'] = res.id; 78 | res['episode-title'] = res.name; 79 | delete res.name; 80 | } else { 81 | res['external-id'] = res.id; 82 | } 83 | 84 | if (library.type === 'movie') { 85 | const { crew, date } = await TheMovieDBExtendedInfo.getMoreMovieInfo(res.id); 86 | if (crew && crew.cast) { 87 | mediaItem.attributes.actors = crew.cast.map((actor) => actor.name); 88 | } 89 | if (date) { 90 | mediaItem.attributes.mpaa = date.certification; 91 | } 92 | } 93 | 94 | delete res.id; 95 | Object.keys(res).forEach((key) => { mediaItem.attributes[key.replace(/_/g, '-')] = res[key]; }); 96 | 97 | let releaseDate = res.release_date ? res.release_date : res.first_air_date; 98 | releaseDate = releaseDate || res.air_date; 99 | 100 | mediaItem.attributes['release-date'] = releaseDate; 101 | if (releaseDate) { 102 | [mediaItem.attributes.year] = releaseDate.split('-'); 103 | } 104 | mediaItem.attributes.gotExtendedInfo = 2; 105 | } else if (tryCount < 2) { 106 | await this.extendInfo(mediaItem, library, tryCount + 1); 107 | } 108 | } 109 | 110 | static async getMoreMovieInfo(id) { 111 | const crew = await movieDB.movieCredits({ id }); 112 | let dates = await movieDB.movieRelease_dates({ id }); 113 | 114 | // is there a US release? get it. otherwise whichever's first 115 | dates = dates.results; 116 | let date = dates.find((d) => d.iso_3166_1 === 'US'); 117 | if (!date && dates.length) { 118 | [date] = dates; 119 | } 120 | 121 | if (date) { 122 | date = date.release_dates.pop(); 123 | } 124 | 125 | return { crew, date }; 126 | } 127 | } 128 | 129 | TheMovieDBExtendedInfo.lastRequest = 0; 130 | 131 | core.addBeforeStartListener( 132 | () => ExtendedInfoQueue.registerExtendedInfoProvider(TheMovieDBExtendedInfo), 133 | ); 134 | 135 | module.exports = TheMovieDBExtendedInfo; 136 | -------------------------------------------------------------------------------- /backend/modules/tmdb/TheMovieDBSeriesAndSeasonsExtendedInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 18-09-16. 3 | */ 4 | const NodeCache = require('node-cache'); 5 | const MovieDB = require('moviedb-api'); 6 | const IExtendedInfo = require('../../core/scanner/IExtendedInfo'); 7 | const Settings = require('../../core/Settings'); 8 | const Log = require('../../core/Log'); 9 | const core = require('../../core'); 10 | const ExtendedInfoQueue = require('../../core/scanner/ExtendedInfoQueue'); 11 | 12 | const nodeCache = new NodeCache(); 13 | 14 | const movieDB = new MovieDB({ 15 | consume: true, 16 | apiKey: Settings.getValue('tmdb_apikey'), 17 | }); 18 | 19 | class TheMovieDBSeriesAndSeasonsExtendedInfo extends IExtendedInfo { 20 | static async extendInfo(mediaItem, library) { 21 | if (library.type !== 'tv') { 22 | return; 23 | } 24 | if (mediaItem.attributes.gotSeriesAndSeasonInfo >= 2) { 25 | return; 26 | } 27 | 28 | Log.debug('process serie', mediaItem.id); 29 | 30 | // find series info 31 | const { title } = mediaItem.attributes; 32 | let cache = nodeCache.get(`1:${title}`); 33 | const items = cache || await movieDB.searchTv({ query: title }); 34 | nodeCache.set(`1:${title}`, items); 35 | 36 | let res = items.results.find((i) => i.name.toLocaleLowerCase() === title.toLocaleLowerCase()); 37 | if (!res) [res] = items.results; 38 | 39 | if (res) { 40 | res['external-id'] = res.id; 41 | delete res.id; 42 | Object.keys(res).forEach((key) => { 43 | mediaItem.attributes[key.replace(/_/g, '-')] = res[key]; 44 | }); 45 | 46 | const date = res.release_date ? res.release_date : res.first_air_date; 47 | mediaItem.attributes.year = parseInt(date.split('-')[0], 10); 48 | } 49 | // find season info 50 | cache = nodeCache.get(`2:${title}:${mediaItem.attributes.season}`); 51 | res = cache || await movieDB.tvSeason({ 52 | id: mediaItem.attributes['external-id'], 53 | season_number: mediaItem.attributes.season, 54 | }); 55 | if (res) { 56 | nodeCache.set(`2:${title}:${mediaItem.attributes.season}`, res); 57 | 58 | delete res.episodes; 59 | mediaItem.attributes.seasonInfo = res; 60 | } 61 | 62 | try { 63 | // get credits 64 | cache = nodeCache.get(`3:${title}:${mediaItem.attributes.season}`); 65 | res = cache || await movieDB.tvCredits({ 66 | id: mediaItem.attributes['external-id'], 67 | }); 68 | nodeCache.set(`3:${title}:${mediaItem.attributes.season}`, res); 69 | mediaItem.attributes.actors = res.cast.map((actor) => actor.name); 70 | } catch (e) { 71 | Log.debug(e); 72 | } 73 | 74 | try { 75 | // get credits 76 | cache = nodeCache.get(`4:${title}:${mediaItem.attributes.season}`); 77 | res = cache || await movieDB.tvContent_ratings({ 78 | id: mediaItem.attributes['external-id'], 79 | }); 80 | nodeCache.set(`4:${title}:${mediaItem.attributes.season}`, res); 81 | // is there a US release? get it. otherwise whichever's first 82 | res = res.results; 83 | let r = res.find((d) => d.iso_3166_1 === 'US'); 84 | if (!r && res.length) { 85 | [r] = res; 86 | } 87 | 88 | if (r) { 89 | mediaItem.attributes.mpaa = r.rating; 90 | } 91 | } catch (e) { 92 | Log.debug(e); 93 | } 94 | mediaItem.attributes.gotSeriesAndSeasonInfo = 2; 95 | } 96 | } 97 | 98 | core.addBeforeStartListener( 99 | () => ExtendedInfoQueue.registerExtendedInfoProvider(TheMovieDBSeriesAndSeasonsExtendedInfo), 100 | ); 101 | 102 | module.exports = TheMovieDBSeriesAndSeasonsExtendedInfo; 103 | -------------------------------------------------------------------------------- /backend/modules/tmdb/genres.md: -------------------------------------------------------------------------------- 1 | Returns a list of genres 2 | 3 | ## Example output: 4 | ```json 5 | [ 6 | { 7 | "id": 28, 8 | "name": "Action" 9 | }, 10 | { 11 | "id": 12, 12 | "name": "Adventure" 13 | }, 14 | ... 15 | ] 16 | ``` 17 | -------------------------------------------------------------------------------- /backend/modules/tmdb/index.js: -------------------------------------------------------------------------------- 1 | require('./redirectToIMDBHandler'); 2 | require('./TheMovieDBExtendedInfo'); 3 | require('./TheMovieDBSeriesAndSeasonsExtendedInfo'); 4 | require('./TMDBImageHandler'); 5 | require('./TMDBApiHandler'); 6 | -------------------------------------------------------------------------------- /backend/modules/tmdb/redirectToIMDBHandler.js: -------------------------------------------------------------------------------- 1 | const MovieDB = require('moviedb-api'); 2 | const RequestHandler = require('../../core/http/RequestHandler'); 3 | const httpServer = require('../../core/http'); 4 | const Database = require('../../core/database/Database'); 5 | const Settings = require('../../core/Settings'); 6 | 7 | const movieDB = new MovieDB({ 8 | consume: true, 9 | apiKey: Settings.getValue('tmdb_apikey'), 10 | }); 11 | 12 | class redirectToIMDBHandler extends RequestHandler { 13 | async handleRequest() { 14 | const item = Database.getById('media-item', this.context.params.id); 15 | let m; 16 | 17 | if (item.attributes.mediaType === 'tv') m = await movieDB.tvExternal_ids({ id: item.attributes['external-id'] }); 18 | else m = await movieDB.movie({ id: item.attributes['external-id'] }); 19 | 20 | this.context.redirect(`https://imdb.com/title/${m.imdb_id}`); 21 | return true; 22 | } 23 | 24 | static getDescription() { 25 | return 'will 302 redirect the client to an IMDB link'; 26 | } 27 | } 28 | 29 | httpServer.registerRoute('get', '/api/redirectToIMDB/:id', redirectToIMDBHandler); 30 | 31 | module.exports = redirectToIMDBHandler; 32 | -------------------------------------------------------------------------------- /doc/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/doc/docker.png -------------------------------------------------------------------------------- /doc/linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/doc/linux.png -------------------------------------------------------------------------------- /doc/macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/doc/macos.png -------------------------------------------------------------------------------- /doc/pi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/doc/pi.png -------------------------------------------------------------------------------- /doc/screens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/doc/screens.png -------------------------------------------------------------------------------- /doc/windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/doc/windows.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | ######################## 5 | ## Remote-MediaServer ## 6 | ######################## 7 | app: 8 | image: owenray/remote-mediaserver 9 | build: . 10 | restart: always 11 | ports: 12 | - "8234:8234" 13 | - "8235:8235" 14 | volumes: 15 | - "./data:/root/.remote" 16 | environment: 17 | RMS_BIND: 0.0.0.0 18 | RMS_PORT: 8234 19 | RMS_SHAREPORT: 8235 20 | RMS_STARTSCAN: "true" 21 | RMS_NAME: "Remote MediaServer - Media freedom." 22 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*] 3 | indent_style = space 4 | end_of_line = lf 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | 2 | SKIP_PREFLIGHT_CHECK=true 3 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /bower_components 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | /dist 13 | /tmp 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | src/css/index.css 26 | -------------------------------------------------------------------------------- /frontend/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !build/**/* 3 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.3.1", 4 | "private": true, 5 | "proxy": "http://localhost:8234", 6 | "dependencies": { 7 | "animejs": "^2.2.0", 8 | "bluebird": "^3.5.0", 9 | "history": "~4.9.0", 10 | "hls.js": "0.12.3-canary.4258", 11 | "humps": "^1.1.0", 12 | "libjass": "^0.11.0", 13 | "markdown-it": "^13.0.1", 14 | "materialize-css": "^1.0.0", 15 | "node-sass-chokidar": "^2.0.0", 16 | "pluralize": "^7.0.0", 17 | "prop-types": "^15.6.0", 18 | "rc-slider": "^8.6.0", 19 | "react": "^17.0.0", 20 | "react-body-classname": "^1.2.0", 21 | "react-circular-progressbar": "^2.1.0", 22 | "react-dom": "^17.0.2", 23 | "react-flip-toolkit": "^7.0.17", 24 | "react-materialize": "^3.6.1", 25 | "react-redux": "^7.2.9", 26 | "react-router-dom": "^4.3.1", 27 | "react-scripts": "^5.0.1", 28 | "react-tooltip": "^3.3.0", 29 | "@enykeev/react-virtualized": "^9.22.4-mirror.1", 30 | "redux": "^3.7.2", 31 | "redux-jsonapi": "^1.1.3", 32 | "throttle-debounce": "^2.1.0", 33 | "vtt-live-edit": "^1.0.7" 34 | }, 35 | "devDependencies": { 36 | "npm-run-all": "^4.0.2" 37 | }, 38 | "scripts": { 39 | "build-css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/", 40 | "watch-css": "npm run build-css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive", 41 | "start-js": "react-scripts start ", 42 | "start": "npm-run-all -p watch-css start-js", 43 | "build": "npm run build-css && react-scripts build", 44 | "test": "react-scripts test --env=jsdom", 45 | "eject": "react-scripts eject" 46 | }, 47 | "browserslist": [ 48 | ">0.2%", 49 | "not dead", 50 | "not ie <= 11", 51 | "not op_mini all" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /frontend/public/assets/img/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/assets/img/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/assets/img/github.png -------------------------------------------------------------------------------- /frontend/public/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/assets/img/logo.png -------------------------------------------------------------------------------- /frontend/public/assets/img/logo_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/assets/img/logo_192.png -------------------------------------------------------------------------------- /frontend/public/assets/img/logo_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/assets/img/logo_512.png -------------------------------------------------------------------------------- /frontend/public/assets/img/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/assets/img/logo_small.png -------------------------------------------------------------------------------- /frontend/public/assets/img/stars-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/assets/img/stars-full.png -------------------------------------------------------------------------------- /frontend/public/assets/img/stars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/assets/img/stars.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 28 | Remote MediaServer 29 | 30 | 31 | 34 |
35 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "RMS", 3 | "name": "Remote MediaServer", 4 | "icons": [ 5 | { 6 | "src": "assets/img/logo_192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "assets/img/logo_512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "theme_color": "#1c1d36", 19 | "background_color": "#1c1d36" 20 | } 21 | 22 | -------------------------------------------------------------------------------- /frontend/public/sw.js: -------------------------------------------------------------------------------- 1 | const CACHE = 'cache-update-and-refresh'; 2 | const TO_CACHE = [ 3 | '/static/', 4 | '/api/settings', 5 | 'min.js', 6 | '.woff2', 7 | '/assets/', 8 | '.ico', 9 | '.json', 10 | '/api/watchNext', 11 | '.jpg', 12 | '/api/media-items', 13 | '/api/play-positions', 14 | '/api/tmdb', 15 | ]; 16 | 17 | // On install, cache some resource. 18 | self.addEventListener('install', (evt) => { 19 | // Open a cache and use `addAll()` with an array of assets to add all of them 20 | // to the cache. Ask the service worker to keep installing until the 21 | // returning promise resolves. 22 | evt.waitUntil(caches.open(CACHE).then((cache) => { 23 | cache.addAll([ 24 | '/', 25 | '/api/watchNext', 26 | ]); 27 | })); 28 | }); 29 | 30 | // On fetch, use cache but update the entry with the latest contents 31 | // from the server. 32 | self.addEventListener('fetch', (evt) => { 33 | const { url } = evt.request; 34 | if (url.match('(socket|hot.update)')) return; 35 | 36 | evt.request.originalUrl = url; 37 | let matchesCache = TO_CACHE.find(c => url.indexOf(c) !== -1); 38 | let cacheKey = evt.request; 39 | if (evt.request.method !== 'GET') return; 40 | if (!matchesCache && !url.match(/^.*?:\/\/.+?\/(api|img|ply|static|socket|sockjs|(.+\.))/)) { 41 | cacheKey = '/'; 42 | matchesCache = true; 43 | } 44 | if (!matchesCache) return false; 45 | evt.respondWith(cacheOrFetch(evt.request, cacheKey)); 46 | }); 47 | 48 | function cacheOrFetch(request, cacheKey) { 49 | return caches.open(CACHE) 50 | .then(cache => cache.match(cacheKey)) 51 | .then((r) => { 52 | if (r) { 53 | update(request, cacheKey) 54 | .catch(e => console.log('failed to fetch', e)); 55 | return r; 56 | } 57 | return update(request, cacheKey); 58 | }); 59 | } 60 | 61 | 62 | // Update consists in opening the cache, performing a network request and 63 | // storing the new response data. 64 | function update(request, cacheKey) { 65 | return caches 66 | .open(CACHE) 67 | .then(cache => fetch(request) 68 | .then(response => cache.put(cacheKey, response.clone()) 69 | .then(() => response))); 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/components/FileSize.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const units = ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb']; 4 | 5 | function FileSize(props) { 6 | let n = 0; 7 | let size = props.children; 8 | while (size > 999) { 9 | size /= 1000; 10 | n += 1; 11 | } 12 | if (size < 10) size = Math.round(size * 100) / 100; 13 | else if (size < 100) size = Math.round(size * 10) / 10; 14 | else size = Math.round(size); 15 | return {size}{units[n]}; 16 | } 17 | 18 | export default FileSize; 19 | -------------------------------------------------------------------------------- /frontend/src/components/LibraryDialog.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 6/30/2017. 3 | */ 4 | import React, { Component } from 'react'; 5 | import { Select, Row, Button, Modal, TextInput, Checkbox } from 'react-materialize'; 6 | import PropTypes from 'prop-types'; 7 | import ServerFileBrowser from './ServerFileBrowser'; 8 | 9 | class LibraryDialog extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.onChange = this.onChange.bind(this); 13 | this.onClose = this.onClose.bind(this); 14 | this.onSubmit = this.onSubmit.bind(this); 15 | this.fileBrowserChange = this.fileBrowserChange.bind(this); 16 | this.state = { name: '', folder: '', type: '' }; 17 | } 18 | 19 | /** 20 | * get the state from the passed arguments 21 | */ 22 | componentWillMount() { 23 | this.setState(this.props.editing); 24 | } 25 | 26 | /** 27 | * @param e 28 | * called when user types in field, applies typed value to state 29 | */ 30 | onChange(e) { 31 | const o = {}; 32 | o[e.target.name] = e.target.value; 33 | this.setState(o); 34 | } 35 | 36 | /** 37 | * called when closing the modal 38 | */ 39 | onClose() { 40 | if (this.props.onClose) { 41 | this.props.onClose(); 42 | } 43 | } 44 | 45 | /** 46 | * save settings, called when submit is clicked 47 | */ 48 | onSubmit() { 49 | if (this.props.onSave) { 50 | this.props.onSave(this.state); 51 | } 52 | this.onClose(); 53 | } 54 | 55 | // should never rerender because of a bug in the modal 56 | shouldComponentUpdate() { 57 | return false; 58 | } 59 | 60 | /** 61 | * called when the input changes 62 | * @param val 63 | */ 64 | fileBrowserChange(val) { 65 | this.setState({ folder: val }); 66 | } 67 | 68 | sharedOrOther() { 69 | if (this.state.type === 'shared') { 70 | return ; 71 | } 72 | return ( 73 | 74 | 82 | 83 | 84 | ); 85 | } 86 | 87 | render() { 88 | return ( 89 | close, 95 | , 96 | ]} 97 | options={{ 98 | onCloseEnd: this.props.onClose, 99 | }} 100 | > 101 | 102 | 109 | 110 | {this.sharedOrOther()} 111 | 112 | 113 | ); 114 | } 115 | } 116 | 117 | LibraryDialog.propTypes = { 118 | onSave: PropTypes.func.isRequired, 119 | onClose: PropTypes.func.isRequired, 120 | editing: PropTypes.object.isRequired, 121 | }; 122 | 123 | 124 | export default LibraryDialog; 125 | 126 | -------------------------------------------------------------------------------- /frontend/src/components/ReadableDuration.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function readableDuration(props) { 4 | return {`${Math.round(props.children / 60)}m`}; 5 | } 6 | 7 | export default readableDuration; 8 | -------------------------------------------------------------------------------- /frontend/src/components/SearchBar.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /** 3 | * Created by owenray on 19/07/2017. 4 | */ 5 | 6 | import React, { Component } from 'react'; 7 | import { Button, Icon, Select, Row, TextInput } from 'react-materialize'; 8 | import { deserialize } from 'redux-jsonapi'; 9 | import store from '../helpers/stores/settingsStore'; 10 | 11 | class SearchBar extends Component { 12 | constructor() { 13 | super(); 14 | this.toggleGrouped = this.toggleGrouped.bind(this); 15 | this.state = { filters: { title: '' }, settings: { libraries: [] } }; 16 | } 17 | 18 | componentWillMount() { 19 | this.onChange = this.onChange.bind(this); 20 | store.subscribe(this.onSettingsChange.bind(this)); 21 | this.onSettingsChange(); 22 | } 23 | 24 | /** 25 | * triggered when the settings model changes 26 | */ 27 | onSettingsChange() { 28 | const { api } = store.getState(); 29 | if (!api.setting) { 30 | return; 31 | } 32 | this.setState({ 33 | settings: deserialize(api.setting[1], store), 34 | }); 35 | } 36 | 37 | /** 38 | * @param e 39 | * called when user types in field, applies typed value to state 40 | */ 41 | onChange(e) { 42 | e.stopPropagation(); 43 | e.preventDefault(); 44 | const o = this.state; 45 | o.filters[e.target.name] = e.target.value; 46 | this.setState(o); 47 | 48 | if (this.props.onChange) { 49 | this.props.onChange(o.filters); 50 | } 51 | } 52 | 53 | toggleGrouped() { 54 | const f = this.props.filters; 55 | f.distinct = f.distinct ? '' : 'external-id'; 56 | if (this.props.onChange) { 57 | this.props.onChange(f); 58 | } 59 | } 60 | 61 | render() { 62 | if (this.state === null) { return null; } 63 | const { props } = this; 64 | const { filters } = props; 65 | return ( 66 |
67 | 68 | 77 |
78 | 79 | 80 |
81 | 86 | 94 |
95 |
96 | ); 97 | } 98 | } 99 | 100 | export default SearchBar; 101 | -------------------------------------------------------------------------------- /frontend/src/components/ServerFileBrowser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 7/4/2017. 3 | */ 4 | /* global $ */ 5 | import React, { Component } from 'react'; 6 | import { CollectionItem, Collection, Preloader, Row, TextInput } from 'react-materialize'; 7 | import PropTypes from 'prop-types'; 8 | 9 | class ServerFileBrowser extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { value: props.value ? props.value : '/' }; 13 | this.valueChange = this.valueChange.bind(this); 14 | } 15 | 16 | /** 17 | * set the default value 18 | */ 19 | componentDidMount() { 20 | this.update(this.state.value); 21 | } 22 | 23 | /** 24 | * called when any of the files or dirs are clicked 25 | * @param dir 26 | */ 27 | onClick(dir) { 28 | let val = this.state.value; 29 | if (!val.endsWith('/')) { 30 | val += '/'; 31 | } 32 | if (dir === '..') { 33 | const parts = val.split('/'); 34 | parts.pop(); 35 | parts.pop(); 36 | val = parts.join('/'); 37 | if (!val) { 38 | val = '/'; 39 | } 40 | } else { 41 | val += dir; 42 | } 43 | this.update(val); 44 | } 45 | 46 | /** 47 | * load the contents of the target directory 48 | * @param val 49 | */ 50 | update(val) { 51 | if (this.state.value !== val) { 52 | this.props.onChange(val); 53 | } 54 | 55 | this.setState({ value: val, loading: true }); 56 | $.getJSON( 57 | '/api/browse', 58 | { directory: val }, 59 | ).then( 60 | (data) => { // success 61 | this.setState({ 62 | loading: false, 63 | error: data.error ? 'Could not list directory' : '', 64 | directories: data.result, 65 | }); 66 | }, 67 | () => { // fail 68 | this.setState({ 69 | loading: false, 70 | error: 'Could not list directory', 71 | directories: [], 72 | }); 73 | }, 74 | ); 75 | } 76 | 77 | /** 78 | * helper to go up a directory when clicked 79 | */ 80 | goUp() { 81 | return { this.onClick('..'); }}>Go up; 82 | } 83 | 84 | /** 85 | * called when the input value changes 86 | * @param e 87 | */ 88 | valueChange(e) { 89 | this.update(e.target.value.replace('\\', '/')); 90 | } 91 | 92 | render() { 93 | let contents = ; 94 | 95 | if (!this.state || this.state.loading) { 96 | contents = ; 97 | } else if (this.state.error) { 98 | contents = ( 99 | 100 | {this.goUp()} 101 | {this.state.error} 102 | 103 | ); 104 | } else if (this.state.directories) { 105 | const dirs = this.state.directories.map(dir => ( 106 | { this.onClick(dir); }}>{dir} 107 | )); 108 | contents = ( 109 | 110 | {this.goUp()} 111 | {dirs.length ? dirs : Empty directory} 112 | 113 | ); 114 | } 115 | 116 | return ( 117 |
118 | 119 | { this.input = input; }} 121 | onChange={this.valueChange} 122 | s={12} 123 | label={this.props.label} 124 | value={this.state.value} 125 | /> 126 | 127 |
128 | {contents} 129 |
130 |
131 | ); 132 | } 133 | } 134 | 135 | ServerFileBrowser.propTypes = { 136 | value: PropTypes.string.isRequired, 137 | onChange: PropTypes.func.isRequired, 138 | }; 139 | 140 | export default ServerFileBrowser; 141 | -------------------------------------------------------------------------------- /frontend/src/components/Time.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Time(props) { 4 | let seconds = `${Math.floor(props.children % 60)}`; 5 | let minutes = `${Math.floor((props.children % 3600) / 60)}`; 6 | const hours = Math.floor(props.children / 60 / 60); 7 | if (seconds.length < 2) { 8 | seconds = `0${seconds}`; 9 | } 10 | if (minutes.length < 2) { 11 | minutes = `0${minutes}`; 12 | } 13 | return ({hours}:{minutes}:{seconds}); 14 | } 15 | 16 | export default Time; 17 | -------------------------------------------------------------------------------- /frontend/src/components/TopBar.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | import React, { Component } from 'react'; 3 | import { Icon } from 'react-materialize'; 4 | 5 | class TopBar extends Component { 6 | constructor() { 7 | super(); 8 | this.onBackPressed = this.onBackPressed.bind(this); 9 | } 10 | 11 | onBackPressed() { 12 | window.history.back(); 13 | } 14 | 15 | render() { 16 | return ( 17 |
18 | {this.props.showBackButton ? arrow_back : ''} 19 |
20 | {this.props.children} 21 |
22 |
23 | ); 24 | } 25 | } 26 | 27 | export default TopBar; 28 | -------------------------------------------------------------------------------- /frontend/src/components/localStorage/DownloadButton.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { Button, Icon } from 'react-materialize'; 3 | import LocalStorage from '../../helpers/LocalStorage'; 4 | 5 | class DownloadButton extends PureComponent { 6 | constructor(props) { 7 | super(props); 8 | this.onClick = this.onClick.bind(this); 9 | this.onAvailabilityChange = this.onAvailabilityChange.bind(this); 10 | this.componentWillReceiveProps(props); 11 | } 12 | 13 | componentWillReceiveProps(nextProps) { 14 | if (this.props.item && nextProps.item.id === this.props.item.id) return; 15 | if (this.offListener) this.offListener(); 16 | 17 | this.offListener = LocalStorage.addListener( 18 | nextProps.item.id, 19 | null, 20 | null, 21 | this.onAvailabilityChange, 22 | this.onAvailabilityChange, 23 | ); 24 | } 25 | 26 | componentWillUnmount() { 27 | if (this.offListener) this.offListener(); 28 | } 29 | 30 | onAvailabilityChange() { 31 | this.forceUpdate(); 32 | } 33 | 34 | async onClick() { 35 | if (LocalStorage.isAvailable(this.props.item)) { 36 | LocalStorage.delete(this.props.item); 37 | return; 38 | } 39 | window.M.toast({ html: 'Starting download', displayLength: 3000 }); 40 | if (!await LocalStorage.download(this.props.item)) { 41 | window.M.toast({ html: 'Not enough space, please increase quota', displayLength: 5000 }); 42 | } 43 | } 44 | 45 | render() { 46 | if (!LocalStorage.isSupported) return null; 47 | const available = LocalStorage.isAvailable(this.props.item); 48 | 49 | return ( 50 | 56 | ); 57 | } 58 | } 59 | 60 | export default DownloadButton; 61 | -------------------------------------------------------------------------------- /frontend/src/components/localStorage/LocalStorageIcon.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | import 'react-circular-progressbar/dist/styles.css'; 3 | import { CircularProgressbar } from 'react-circular-progressbar'; 4 | import React, { Component } from 'react'; 5 | import LocalStorage from '../../helpers/LocalStorage'; 6 | 7 | class LocalStorageIcon extends Component { 8 | constructor() { 9 | super(); 10 | this.state = { useRatio: 0 }; 11 | } 12 | 13 | componentWillMount() { 14 | this.refresh(); 15 | } 16 | 17 | async refresh() { 18 | const quota = await LocalStorage.getCurrentQuota(); 19 | const useRatio = Math.round((quota.used / quota.granted) * 100) || 0; 20 | this.setState({ useRatio }); 21 | } 22 | 23 | render() { 24 | return ( 25 | 42 | ); 43 | } 44 | } 45 | 46 | export default LocalStorageIcon; 47 | -------------------------------------------------------------------------------- /frontend/src/components/localStorage/LocalStorageProgressForItem.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | import 'react-circular-progressbar/dist/styles.css'; 3 | import { ProgressBar } from 'react-materialize'; 4 | import React, { Component } from 'react'; 5 | import { throttle } from 'throttle-debounce'; 6 | import LocalStorage from '../../helpers/LocalStorage'; 7 | 8 | class LocalStorageProgressForItem extends Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { progress: 0 }; 12 | this.onFinish = this.onFinish.bind(this); 13 | this.onProgressOriginal = this.onProgress.bind(this); 14 | this.componentWillReceiveProps(props); 15 | } 16 | 17 | componentWillReceiveProps(nextProps) { 18 | if (this.props.item && nextProps.item.id === this.props.item.id) return; 19 | if (this.off) { 20 | this.off(); 21 | this.onProgress.cancel(); 22 | this.setState({ progress: 0 }); 23 | } 24 | if (nextProps.item) { 25 | this.onProgress = throttle(300, this.onProgressOriginal); 26 | this.off = LocalStorage.addListener(nextProps.item.id, this.onProgress, this.onFinish); 27 | } 28 | } 29 | 30 | componentWillUnmount() { 31 | if (this.off) this.off(); 32 | if (this.onProgress.cancel) this.onProgress.cancel(); 33 | } 34 | 35 | onProgress(progress) { 36 | this.setState({ progress: progress * 100 }); 37 | } 38 | 39 | onFinish() { 40 | setTimeout(() => { 41 | this.setState({ progress: 0 }); 42 | }, 350); 43 | } 44 | 45 | render() { 46 | const { progress } = this.state; 47 | if (!progress) return null; 48 | return ; 49 | } 50 | } 51 | 52 | export default LocalStorageProgressForItem; 53 | -------------------------------------------------------------------------------- /frontend/src/components/mediaItem/MediaInfo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Table } from 'react-materialize'; 3 | import ReadableDuration from '../ReadableDuration'; 4 | import FileSize from '../FileSize'; 5 | 6 | function mediaInfo(props) { 7 | const i = props.item; 8 | if (!i) return null; 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
File:{i.filepath}
Media type:{i.mediaType}
Media dimensions:{i.width}x{i.height}
Duration:{i.fileduration}
Bitrate:{i.bitrate}ps
Filesize:{i.filesize}
Date added:{new Date(i.dateAdded).toDateString()}
Release date{i.releaseDate}
Episode title:{i.episodeTitle}
Season{i.season}
Episode{i.episode}
47 | ); 48 | } 49 | 50 | export default mediaInfo; 51 | -------------------------------------------------------------------------------- /frontend/src/components/mediaItem/MediaItemRow.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 19/07/2017. 3 | */ 4 | 5 | import React from 'react'; 6 | import { Icon } from 'react-materialize'; 7 | import { NavLink } from 'react-router-dom'; 8 | import { MediaItemTile } from './MediaItemTile'; 9 | import FileSize from '../FileSize'; 10 | 11 | class MediaItemRow extends MediaItemTile { 12 | title() { 13 | const { state } = this; 14 | if (state.episode) return `${state.episode} - ${state.episodeTitle}`; 15 | return ( 16 | 17 | {state.filesize} {state.height}p 18 | 19 | ); 20 | } 21 | 22 | render() { 23 | if (!this.state) { 24 | return ( 25 |
26 | movie 27 |
28 | ); 29 | } 30 | 31 | return ( 32 | 33 | {this.title()} 34 | {this.playPos()} 35 | 36 | ); 37 | } 38 | } 39 | 40 | export default MediaItemRow; 41 | -------------------------------------------------------------------------------- /frontend/src/components/mediaItem/MediaItemTile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 19/07/2017. 3 | */ 4 | 5 | import React, { Component } from 'react'; 6 | import { Button, Icon } from 'react-materialize'; 7 | import { NavLink } from 'react-router-dom'; 8 | import PropTypes from 'prop-types'; 9 | import { Flipped } from 'react-flip-toolkit'; 10 | import { connect } from 'react-redux'; 11 | import { playQueueActions } from '../../helpers/stores/playQueue'; 12 | 13 | class MediaItemTile extends Component { 14 | constructor() { 15 | super(); 16 | this.play = this.play.bind(this); 17 | } 18 | 19 | componentWillMount() { 20 | this.componentWillReceiveProps(this.props); 21 | } 22 | 23 | componentWillReceiveProps(nextProps) { 24 | if (!nextProps.mediaItem) { 25 | return; 26 | } 27 | if (nextProps.mediaItem.index) { 28 | if (this.waitingForPromise) { 29 | return; 30 | } 31 | this.waitingForPromise = true; 32 | nextProps.requestData(nextProps.mediaItem.index) 33 | .then(this.gotData.bind(this)); 34 | return; 35 | } 36 | this.waitingForPromise = true; 37 | this.gotData(nextProps.mediaItem); 38 | } 39 | 40 | componentWillUnmount() { 41 | this.waitingForPromise = false; 42 | } 43 | 44 | async gotData(data) { 45 | if (this.waitingForPromise) { 46 | this.waitingForPromise = false; 47 | if (data.playPosition) { 48 | data.playPos = (await data.playPosition()).position; 49 | } 50 | this.setState(data); 51 | } 52 | } 53 | 54 | playPos() { 55 | if (this.state.playPosition) { 56 | return ( 57 |
58 |
59 |
60 | ); 61 | } 62 | return ''; 63 | } 64 | 65 | play() { 66 | this.props.insertAtCurrentOffset(this.props.mediaItem); 67 | } 68 | 69 | render() { 70 | if (!this.state || !this.state.id) { 71 | return ( 72 |
73 | movie 74 |
75 | ); 76 | } 77 | 78 | let seasonEpisode; 79 | if (this.state.season) { 80 | let S = `${this.state.season}`; 81 | let E = `${this.state.episode}`; 82 | if (S.length < 2) S = `0${S}`; 83 | if (E.length < 2) E = `0${E}`; 84 | seasonEpisode = s{S}e{E}; 85 | } 86 | 87 | if (!this.props.selected) this.intoView = false; 88 | 89 | return ( 90 |
{ 94 | if (ref && !this.intoView) { 95 | this.intoView = true; 96 | ref.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); 97 | } 98 | } : null} 99 | > 100 | 101 |
102 |
107 |
108 | 109 | 110 |
111 | {this.playPos()} 112 | 120 | {this.state.title} 121 | {this.state.year} 122 | {seasonEpisode} 123 |
124 |
125 | ); 126 | } 127 | } 128 | 129 | MediaItemTile.propTypes = { 130 | mediaItem: PropTypes.oneOfType([ 131 | PropTypes.string, 132 | PropTypes.object, 133 | ]).isRequired, 134 | requestData: PropTypes.func, 135 | style: PropTypes.object, 136 | }; 137 | 138 | 139 | MediaItemTile.defaultProps = { 140 | style: {}, 141 | requestData: null, 142 | }; 143 | 144 | export { MediaItemTile }; 145 | export default connect(null, playQueueActions)(MediaItemTile); 146 | -------------------------------------------------------------------------------- /frontend/src/components/player/ButtonMenu.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Button, Icon } from 'react-materialize'; 3 | 4 | class ButtonMenu extends Component { 5 | shouldComponentUpdate() { 6 | return false; 7 | } 8 | 9 | onSelect(item) { 10 | this.props.onSelect(this.props.type, item.value); 11 | } 12 | 13 | render() { 14 | const { items, type } = this.props; 15 | if (!items || items.length <= 1) { 16 | return (
); 17 | } 18 | return ( 19 | 31 | ); 32 | } 33 | } 34 | 35 | export default ButtonMenu; 36 | -------------------------------------------------------------------------------- /frontend/src/components/player/CastButton.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Icon } from 'react-materialize'; 3 | import Chromecast from '../../helpers/ChromeCast'; 4 | 5 | class CastButton extends Component { 6 | constructor() { 7 | super(); 8 | this.castingChange = this.castingChange.bind(this); 9 | this.onClick = this.onClick.bind(this); 10 | } 11 | 12 | componentWillMount() { 13 | Chromecast.addListener(Chromecast.EVENT_CASTING_CHANGE, this.castingChange); 14 | this.setState({ available: Chromecast.isAvailable(), active: Chromecast.isActive() }); 15 | } 16 | 17 | onClick() { 18 | if (this.state.active) { 19 | Chromecast.stopCasting(); 20 | return; 21 | } 22 | Chromecast.startCasting(); 23 | } 24 | 25 | castingChange(active) { 26 | this.setState({ active }); 27 | } 28 | 29 | render() { 30 | if (!this.state.available) { 31 | return null; 32 | } 33 | return ( 34 | 35 | cast 36 | 37 | ); 38 | } 39 | } 40 | 41 | export default CastButton; 42 | -------------------------------------------------------------------------------- /frontend/src/components/player/NavBar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Button, Icon } from 'react-materialize'; 3 | import { connect } from 'react-redux'; 4 | import ButtonMenu from './ButtonMenu'; 5 | import { playQueueActions } from '../../helpers/stores/playQueue'; 6 | 7 | class NavBar extends Component { 8 | playPauseButton() { 9 | if (this.props.paused) { 10 | return ; 11 | } 12 | return ; 13 | } 14 | 15 | async componentWillReceiveProps(nextProps) { 16 | if (!this.state || !this.state.mediaContent) { 17 | this.setState({ mediaContent: nextProps.mediaContent }); 18 | } 19 | } 20 | 21 | selected(type, item) { 22 | this.props.onSelectContent(type, item.value); 23 | } 24 | 25 | render() { 26 | const { playing, hasNext, hasPrev } = this.props.playQueue; 27 | const { onSelectContent } = this.props; 28 | const { mediaContent } = playing; 29 | if (!playing || !mediaContent) { 30 | return
; 31 | } 32 | 33 | return ( 34 |
35 | poster 36 | 37 | {this.playPauseButton()} 38 | 39 |
40 | 41 | 42 | 43 | 44 |
45 | {this.props.children} 46 |
47 | ); 48 | } 49 | } 50 | 51 | export default connect(({ playQueue }) => ({ playQueue }), playQueueActions)(NavBar); 52 | -------------------------------------------------------------------------------- /frontend/src/components/player/SeekBar.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | import React, { Component } from 'react'; 3 | import Time from '../Time'; 4 | 5 | class SeekBar extends Component { 6 | constructor() { 7 | super(); 8 | this.tracker = null; 9 | this.progress = null; 10 | this.onClick = this.onClick.bind(this); 11 | this.onMove = this.onMove.bind(this); 12 | this.stopSeeking = this.stopSeeking.bind(this); 13 | this.onMove = this.onMove.bind(this); 14 | } 15 | 16 | componentWillMount() { 17 | this.setState({ progress: -1 }); 18 | } 19 | 20 | componentDidMount() { 21 | this.ell.onmousemove = this.moveTime.bind(this); 22 | } 23 | 24 | onClick(e) { 25 | e.preventDefault(); 26 | e.stopPropagation(); 27 | document.onmouseup = this.stopSeeking; 28 | document.onmousemove = this.onMove; 29 | this.onMove(e); 30 | } 31 | 32 | onMove(e) { 33 | let pos = e.pageX - this.tracker.getBoundingClientRect().left; 34 | if (pos < 0) { 35 | pos = 0; 36 | } else if (pos > this.tracker.offsetWidth) { 37 | pos = this.tracker.offsetWidth; 38 | } 39 | this.setState({ progress: (pos / this.tracker.offsetWidth) * this.props.max }); 40 | } 41 | 42 | moveTime(e) { 43 | const left = e.pageX - this.tracker.getBoundingClientRect().left; 44 | this.setState({ mouseX: e.clientX, hint: (left / this.tracker.offsetWidth) * this.props.max }); 45 | } 46 | 47 | stopSeeking() { 48 | document.onmousemove = null; 49 | document.onmouseup = null; 50 | this.props.onSeek(this.state.progress); 51 | this.setState({ progress: -1 }); 52 | } 53 | 54 | render() { 55 | return ( 56 |
{ this.ell = ell; }} id={this.props.id} onMouseDown={this.onClick}> 57 | {this.props.displayTime ? () : ''} 58 |
{ this.tracker = input; }}> 59 | {this.props.displayTime ? [, ] : ''} 60 |
{ this.progress = input; }} 63 | style={{ 64 | width: 65 | `${(this.state.progress === -1 ? 66 | this.props.progress / this.props.max : 67 | this.state.progress / this.props.max) * 100}%`, 68 | }} 69 | > 70 | {this.props.displayTime ? () : ''} 71 |
72 |
73 |
74 |
); 75 | } 76 | } 77 | 78 | export default SeekBar; 79 | -------------------------------------------------------------------------------- /frontend/src/components/player/Subtitles.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/components/player/Subtitles.js -------------------------------------------------------------------------------- /frontend/src/components/player/renderer/BaseRenderer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-unused-prop-types */ 2 | import { Component } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | class BaseRenderer extends Component { 6 | componentDidMount() { 7 | this.componentWillReceiveProps(this.props); 8 | } 9 | 10 | getVideoUrl() { 11 | if (!this.props.mediaItem) return ''; 12 | const params = []; 13 | if (this.props.audioChannel !== undefined) { 14 | params.push(`audioChannel=${this.props.audioChannel}`); 15 | } 16 | if (this.props.videoChannel !== undefined) { 17 | params.push(`videoChannel=${this.props.videoChannel}`); 18 | } 19 | return `/ply/${this.props.mediaItem.id}/${this.props.seek}?${params.join('&')}`; 20 | } 21 | } 22 | 23 | 24 | BaseRenderer.propTypes = { 25 | mediaItem: PropTypes.object.isRequired, 26 | onProgress: PropTypes.func.isRequired, 27 | seek: PropTypes.number, 28 | audioChannel: PropTypes.number, 29 | videoChannel: PropTypes.number, 30 | subtitle: PropTypes.string, 31 | volume: PropTypes.number, 32 | paused: PropTypes.bool, 33 | onVolumeChange: PropTypes.func.isRequired, 34 | }; 35 | 36 | BaseRenderer.defaultProps = { 37 | seek: 0, 38 | volume: 1, 39 | paused: false, 40 | audioChannel: undefined, 41 | videoChannel: undefined, 42 | subtitle: null, 43 | }; 44 | 45 | export default BaseRenderer; 46 | -------------------------------------------------------------------------------- /frontend/src/components/player/renderer/ChromeCastRenderer.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | import React from 'react'; 3 | import BaseRenderer from './BaseRenderer'; 4 | import ChromeCast from '../../../helpers/ChromeCast'; 5 | 6 | 7 | class ChromeCastRenderer extends BaseRenderer { 8 | constructor() { 9 | super(); 10 | this.onPlay = this.onPlay.bind(this); 11 | } 12 | 13 | componentWillReceiveProps(newProps) { 14 | if (newProps.volume !== this.props.volume) { 15 | ChromeCast.setVolume(newProps.volume); 16 | } 17 | 18 | if (newProps.paused !== this.props.paused) { 19 | if (newProps.paused) { 20 | ChromeCast.pause(); 21 | } else { 22 | ChromeCast.play(); 23 | } 24 | } 25 | 26 | this.setState(newProps); 27 | } 28 | 29 | componentDidMount() { 30 | this.interval = setInterval(this.refreshOffset.bind(this), 500); 31 | this.componentWillReceiveProps(this.props); 32 | this.props.onStart(); 33 | ChromeCast.addListener(ChromeCast.EVENT_ONPLAY, this.onPlay); 34 | } 35 | 36 | componentWillUnmount() { 37 | ChromeCast.removeListener(ChromeCast.EVENT_ONPLAY, this.onPlay); 38 | clearInterval(this.interval); 39 | } 40 | 41 | refreshOffset() { 42 | this.props.onProgress(this.state.seek + ChromeCast.getOffset()); 43 | } 44 | 45 | onPlay() { 46 | /* @todo get volume */ 47 | this.props.onVolumeChange(ChromeCast.getVolume()); 48 | } 49 | 50 | backDrop() { 51 | return { backgroundImage: `url(/img/${this.state.mediaItem.id}_backdrop.jpg)` }; 52 | } 53 | 54 | componentDidUpdate(props, prevState) { 55 | const s = this.state; 56 | if (!prevState || 57 | s.mediaItem.id !== prevState.mediaItem.id || 58 | s.seek !== prevState.seek || 59 | s.audioChannel !== prevState.audioChannel || 60 | s.videoChannel !== prevState.videoChannel || 61 | s.subtitles.length !== prevState.subtitles.length) { 62 | const baseUrl = `${document.location.protocol}//${document.location.host}`; 63 | const subtitles = s.subtitles 64 | .filter(sub => sub.value) 65 | .map(sub => ( 66 | `${baseUrl}/api/mediacontent/subtitle/` + 67 | `${this.state.mediaItem.id}/${sub.value}?offset=${s.seek * -1000}` 68 | )); 69 | ChromeCast.setMedia( 70 | `${baseUrl}${this.getVideoUrl()}`, 71 | 'video/mp4', 72 | subtitles, 73 | s.subtitles.findIndex(sub => sub.value === s.subtitle), 74 | ); 75 | return; 76 | } 77 | if (prevState && s.subtitle !== prevState.subtitle) { 78 | ChromeCast.updateSubtitle(s.subtitles.findIndex(sub => sub.value === s.subtitle)); 79 | } 80 | } 81 | 82 | 83 | render() { 84 | if (!this.state) { 85 | return null; 86 | } 87 | return ( 88 |
89 | ); 90 | } 91 | } 92 | 93 | 94 | export default ChromeCastRenderer; 95 | -------------------------------------------------------------------------------- /frontend/src/components/player/renderer/Html5VideoRenderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BaseRenderer from './BaseRenderer'; 3 | 4 | class Html5VideoRenderer extends BaseRenderer { 5 | constructor() { 6 | super(); 7 | this.onProgress = this.onProgress.bind(this); 8 | this.reInit = this.reInit.bind(this); 9 | this.onTrackLoad = this.onTrackLoad.bind(this); 10 | this.onTrackRef = this.onTrackRef.bind(this); 11 | this.onStart = this.onStart.bind(this); 12 | this.onStartLoad = this.onStartLoad.bind(this); 13 | } 14 | 15 | componentDidMount() { 16 | this.componentWillReceiveProps(this.props); 17 | } 18 | 19 | componentWillReceiveProps(newProps) { 20 | if (newProps.volume !== this.props.volume) { 21 | this.vidRef.volume = newProps.volume; 22 | } 23 | 24 | if (newProps.subtitle !== this.props.subtitle) { 25 | if (this.activeTrack) { 26 | this.activeTrack.mode = 'hidden'; 27 | } 28 | const tracks = this.vidRef.textTracks; 29 | this.activeTrack = Object.keys(tracks).find(i => tracks[i].id === newProps.subtitle); 30 | if (this.activeTrack) { 31 | this.activeTrack = tracks[this.activeTrack]; 32 | this.activeTrack.mode = 'showing'; 33 | this.setOffset(newProps.seek); 34 | } 35 | } 36 | 37 | if (this.props.seek !== newProps.seek) { 38 | this.setOffset(newProps.seek); 39 | } 40 | 41 | if (newProps.paused !== this.props.paused) { 42 | if (newProps.paused) { 43 | this.vidRef.pause(); 44 | } else { 45 | this.vidRef.play(); 46 | } 47 | } 48 | } 49 | 50 | componentWillUnmount() { 51 | if (!this.vidRef) return; 52 | 53 | this.vidRef.removeEventListener('timeupdate', this.onProgress); 54 | this.vidRef.removeEventListener('error', this.reInit); 55 | this.vidRef.removeEventListener('ended', this.reInit); 56 | } 57 | 58 | gotVidRef(vidRef) { 59 | if (!vidRef || this.vidRef === vidRef) { return; } 60 | 61 | this.vidRef = vidRef; 62 | vidRef.addEventListener('timeupdate', this.onProgress); 63 | vidRef.addEventListener('error', this.reInit); 64 | vidRef.addEventListener('ended', this.reInit); 65 | vidRef.addEventListener('play', this.onStart); 66 | vidRef.addEventListener('loadstart', this.onStartLoad); 67 | } 68 | 69 | onStart() { 70 | this.props.onStart(); 71 | } 72 | 73 | reInit() { 74 | console.warn('playback error!'); 75 | if (this.state && this.state.progress < this.props.mediaItem.fileduration * 0.99) { 76 | this.props.onSeek(this.state.progress); 77 | } 78 | } 79 | 80 | onTrackLoad() { 81 | this.setOffset(this.props.seek); 82 | } 83 | 84 | onProgress() { 85 | if (this.vidRef.readyState === 0) { 86 | return; 87 | } 88 | const progress = this.props.seek + this.vidRef.currentTime; 89 | this.setState({ progress }); 90 | this.props.onProgress(progress); 91 | } 92 | 93 | onTrackRef(ref) { 94 | if (!ref) return; 95 | ref.addEventListener('load', this.onTrackLoad); 96 | } 97 | 98 | onStartLoad() { 99 | this.props.onLoadStarted(); 100 | } 101 | 102 | setOffset(offset) { 103 | if (!this.activeTrack) return; 104 | if (!this.activeTrack.subtitleOffset) this.activeTrack.subtitleOffset = 0; 105 | 106 | const items = Array.from(this.activeTrack.cues); 107 | if (!items.length) return; 108 | items.forEach((cue) => { 109 | cue.startTime -= offset - this.activeTrack.subtitleOffset; 110 | cue.endTime -= offset - this.activeTrack.subtitleOffset; 111 | }); 112 | this.activeTrack.subtitleOffset = offset; 113 | } 114 | 115 | render() { 116 | return ( 117 |
118 | 138 |
139 | ); 140 | } 141 | } 142 | 143 | 144 | export default Html5VideoRenderer; 145 | -------------------------------------------------------------------------------- /frontend/src/components/player/renderer/OfflineVideoRenderer.js: -------------------------------------------------------------------------------- 1 | import Hls from 'hls.js'; 2 | import Html5VideoRenderer from './Html5VideoRenderer'; 3 | import LocalStorage from '../../../helpers/LocalStorage'; 4 | 5 | class OfflineVideoRenderer extends Html5VideoRenderer { 6 | componentWillUnmount() { 7 | if (this.hls) this.hls.destroy(); 8 | } 9 | 10 | getVideoUrl() { 11 | return this.vidRef ? this.vidRef.src : ''; 12 | } 13 | 14 | onProgress() { 15 | if (this.vidRef.readyState === 0) { 16 | return; 17 | } 18 | this.setState({ progress: this.vidRef.currentTime }); 19 | this.props.onProgress(this.state.progress); 20 | } 21 | 22 | gotVidRef(vidRef) { 23 | if (!this.hls) { 24 | const hls = new Hls(); 25 | this.hls = hls; 26 | hls.attachMedia(vidRef); 27 | hls.on(Hls.Events.MANIFEST_PARSED, () => vidRef.play()); 28 | hls.loadSource(LocalStorage.getMediaUrl(this.props.mediaItem)); 29 | if (this.props.seek) { 30 | vidRef.currentTime = this.props.seek; 31 | } 32 | } 33 | 34 | return super.gotVidRef(vidRef); 35 | } 36 | 37 | shouldComponentUpdate(nextProps) { 38 | if (this.props.seek !== nextProps.seek && this.vidRef) { 39 | this.vidRef.currentTime = nextProps.seek; 40 | } 41 | 42 | // reload the video when it changes 43 | if (this.props.mediaItem && this.props.mediaItem.id !== nextProps.mediaItem.id && this.vidRef) { 44 | this.hls = null; 45 | this.gotVidRef(this.vidRef); 46 | } 47 | return false; 48 | } 49 | 50 | setOffset(offset) { 51 | this.vidRef.currentTime = offset; 52 | } 53 | } 54 | 55 | export default OfflineVideoRenderer; 56 | -------------------------------------------------------------------------------- /frontend/src/css/_icon.scss: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: 'Material Icons'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local('Material Icons'), local('MaterialIcons-Regular'), url(icon.woff2) format('woff2'); 7 | } 8 | 9 | .material-icons { 10 | font-family: 'Material Icons'; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | -webkit-font-feature-settings: 'liga'; 22 | -webkit-font-smoothing: antialiased; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/css/icon.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/css/icon.woff2 -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Bold.woff -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Bold.woff2 -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Light.woff -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Light.woff2 -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Medium.woff -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Medium.woff2 -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Regular.woff -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Regular.woff2 -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Thin.woff -------------------------------------------------------------------------------- /frontend/src/fonts/roboto/Roboto-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/frontend/src/fonts/roboto/Roboto-Thin.woff2 -------------------------------------------------------------------------------- /frontend/src/helpers/ChromeCast.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | /* global chrome,window */ 4 | 5 | class ChromeCast { 6 | constructor() { 7 | this.EVENT_CASTING_INIT = 'CASTING_INIT'; 8 | this.EVENT_CASTING_CHANGE = 'CASTING_CHANGE'; 9 | this.EVENT_ONPLAY = 'EVENT_ONPLAY'; 10 | this.events = {}; 11 | window.__onGCastApiAvailable = this.onApiAvailable.bind(this); 12 | } 13 | 14 | onApiAvailable(available) { 15 | if (!available) { 16 | return; 17 | } 18 | const apiConfig = new chrome.cast.ApiConfig( 19 | new chrome.cast.SessionRequest('07EA9E92'), 20 | this.onSession.bind(this), 21 | this.onReceiver.bind(this), 22 | chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, 23 | ); 24 | 25 | chrome.cast.initialize(apiConfig, this.onInit.bind(this)); 26 | } 27 | 28 | onInit() { 29 | this.trigger(this.EVENT_CASTING_INIT, []); 30 | } 31 | 32 | onSession(session) { 33 | this.session = session; 34 | this.trigger(this.EVENT_CASTING_CHANGE, [true]); 35 | } 36 | 37 | onReceiver(e) { 38 | if (e === 'available') { 39 | this._available = true; 40 | } 41 | } 42 | 43 | isAvailable() { 44 | return this._available; 45 | } 46 | 47 | startCasting() { 48 | chrome.cast.requestSession(this.onRequestSession.bind(this)); 49 | } 50 | 51 | stopCasting() { 52 | if (this.session) { 53 | this.session.leave(); 54 | this.session = null; 55 | this.trigger(this.EVENT_CASTING_CHANGE, [false]); 56 | } 57 | } 58 | 59 | onRequestSession(session) { 60 | this.session = session; 61 | this.trigger(this.EVENT_CASTING_CHANGE, [true]); 62 | } 63 | 64 | trigger(event, args = []) { 65 | if (this.events[event]) { 66 | this.events[event].forEach((e) => { e(...args); }); 67 | } 68 | } 69 | 70 | addListener(event, callback) { 71 | if (!this.events[event]) { 72 | this.events[event] = []; 73 | } 74 | this.events[event].push(callback); 75 | } 76 | 77 | removeListener(event, callback) { 78 | this.events[event] = this.events[event].filter(e => e !== callback); 79 | } 80 | 81 | updateSubtitle(subtitle) { 82 | const activeTracks = [subtitle]; 83 | const tracksInfoRequest = new chrome.cast.media.EditTracksInfoRequest(activeTracks); 84 | this.media.editTracksInfo(tracksInfoRequest, () => console.log('Requested subtitles'), err => console.log(err)); 85 | } 86 | 87 | setMedia(media, contentType, subtitles, activeSubtitle) { 88 | if (!this.session) { 89 | return; 90 | } 91 | const mediaInfo = new chrome.cast.media.MediaInfo(media, contentType); 92 | mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle(); 93 | const request = new chrome.cast.media.LoadRequest(mediaInfo); 94 | if (activeSubtitle >= 0) request.activeTrackIds = [activeSubtitle]; 95 | 96 | mediaInfo.tracks = subtitles.map((sub, i) => { 97 | const track = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT); 98 | track.trackContentId = sub; 99 | track.trackContentType = 'text/vtt'; 100 | track.subtype = chrome.cast.media.TextTrackType.SUBTITLES; 101 | track.name = `sub_${i}`; 102 | track.customData = null; 103 | return track; 104 | }); 105 | 106 | this.session.loadMedia( 107 | request, 108 | (m) => { 109 | this.media = m; 110 | this.trigger(this.EVENT_ONPLAY); 111 | }, 112 | (e) => { 113 | console.log('error loading media', e); 114 | }, 115 | ); 116 | } 117 | 118 | setVolume(volume) { 119 | if (this.media) { 120 | const request = new chrome.cast.media.VolumeRequest(new chrome.cast.Volume(volume)); 121 | this.media.setVolume(request); 122 | } 123 | } 124 | 125 | getVolume() { 126 | return this.media.volume.level; 127 | } 128 | 129 | play() { 130 | if (this.media) { 131 | this.media.play(); 132 | } 133 | } 134 | 135 | pause() { 136 | if (this.media) { 137 | this.media.pause(); 138 | } 139 | } 140 | 141 | isActive() { 142 | return !!this.session; 143 | } 144 | 145 | getOffset() { 146 | if (!this.media) { 147 | return 0; 148 | } 149 | // fix unknown duration 150 | this.media.media.duration = Number.MAX_SAFE_INTEGER; 151 | return this.media.getEstimatedTime(); 152 | } 153 | } 154 | 155 | export default new ChromeCast(); 156 | -------------------------------------------------------------------------------- /frontend/src/helpers/LocalStorage.js: -------------------------------------------------------------------------------- 1 | /* global navigator,localStorage,window */ 2 | import HLSDownloader from '../components/localStorage/HLSDownloader'; 3 | 4 | const eventListeners = { [-1]: [] }; 5 | const { webkitPersistentStorage } = navigator; 6 | const { fetch, webkitRequestFileSystem, PERSISTENT } = window; 7 | const localStorageData = JSON.parse(localStorage.getItem('offline') || '{}'); 8 | const downloading = {}; 9 | 10 | function offChangeListener() { 11 | eventListeners[this[0]].splice(this[1], 1); 12 | } 13 | 14 | class LocalStorage { 15 | static requestStorage(gigaBytes) { 16 | return new Promise((resolve, reject) => { 17 | webkitPersistentStorage.requestQuota( 18 | gigaBytes * (1024 ** 3), 19 | resolve, 20 | reject, 21 | ); 22 | }); 23 | } 24 | 25 | static getCurrentQuota() { 26 | return new Promise((resolve, reject) => { 27 | webkitPersistentStorage.queryUsageAndQuota( 28 | (usedBytes, grantedBytes) => { 29 | resolve({ 30 | used: Math.round((usedBytes / (1024 ** 3)) * 100) / 100, 31 | granted: Math.round((grantedBytes / (1024 ** 3)) * 100) / 100, 32 | }); 33 | }, 34 | reject, 35 | ); 36 | }); 37 | } 38 | 39 | static save() { 40 | localStorage.setItem('offline', JSON.stringify(localStorageData)); 41 | } 42 | 43 | static checkQuota(size) { 44 | return new Promise((resolve, reject) => { 45 | webkitPersistentStorage.queryUsageAndQuota( 46 | (usedBytes, grantedBytes) => { 47 | resolve(grantedBytes - usedBytes > size); 48 | }, 49 | reject, 50 | ); 51 | }); 52 | } 53 | 54 | /** 55 | * @todo fetch subtitles 56 | * @todo fetch video with specific audio/video channel 57 | */ 58 | static async download(item, audioChannel, videoChannel) { 59 | const estimatedSize = (item.bitrate / 8) * item.fileduration; 60 | if (!await this.checkQuota(estimatedSize)) return false; 61 | 62 | // make sure the necessary assets are cache 63 | [ 64 | `/api/media-items/${item.id}`, 65 | `/img/${item.id}_poster.jpg`, 66 | `/img/${item.id}_backdrop.jpg`, 67 | `/img/${item.id}_posterlarge.jpg`, 68 | `/api/mediacontent/${item.id}`, 69 | ].forEach((url) => { 70 | fetch(url); 71 | }); 72 | 73 | // start download of the HLS 74 | const downloader = new HLSDownloader( 75 | `hls_${item.id}`, 76 | `/ply/${item.id}/0?profile=hls_for_offline_playback¬hrottle=true`, 77 | ); 78 | downloading[item.id] = downloader; 79 | downloader.setOnProgress(progress => this.trigger( 80 | item.id, 81 | 'onProgress', 82 | [progress / estimatedSize], 83 | )); 84 | downloader.setOnComplete(() => { 85 | downloading[item.id] = null; 86 | this.trigger(item.id, 'onFinish'); 87 | }); 88 | downloader.start(); 89 | 90 | localStorageData[item.id] = item; 91 | this.trigger(item.id, 'onStart'); 92 | item.localUrl = await downloader.getLocalUrl(); 93 | LocalStorage.save(); 94 | return true; 95 | } 96 | 97 | static async delete({ id }) { 98 | if (downloading[id]) { 99 | downloading[id].cancel(); 100 | } 101 | 102 | return new Promise((resolve) => { 103 | webkitRequestFileSystem(PERSISTENT, 0, (storage) => { 104 | storage.root.getDirectory(`hls_${id}`, { 105 | create: true, 106 | exclusive: false, 107 | }, (dir) => { 108 | delete localStorageData[id]; 109 | dir.removeRecursively(resolve); 110 | LocalStorage.save(); 111 | this.trigger(id, 'onDelete'); 112 | }); 113 | }); 114 | }); 115 | } 116 | 117 | static isAvailable({ id }) { 118 | return LocalStorage.isSupported 119 | && localStorageData[id]; 120 | } 121 | 122 | static trigger(id, event, args = []) { 123 | eventListeners[id] 124 | .slice() 125 | .concat(eventListeners[-1]) 126 | .filter(e => e[event]) 127 | .forEach(e => e[event](...args)); 128 | } 129 | 130 | static addListener(id, onProgress, onFinish, onStart, onDelete) { 131 | if (!eventListeners[id]) eventListeners[id] = []; 132 | eventListeners[id].push({ 133 | onProgress, onFinish, onStart, onDelete, 134 | }); 135 | return offChangeListener.bind([id, eventListeners[id].length - 1]); 136 | } 137 | 138 | static getMediaUrl({ id }) { 139 | return localStorageData[id].localUrl; 140 | } 141 | 142 | static getItems() { 143 | return Object.values(localStorageData); 144 | } 145 | } 146 | 147 | LocalStorage.isSupported = webkitPersistentStorage && window.chrome; 148 | 149 | export default LocalStorage; 150 | -------------------------------------------------------------------------------- /frontend/src/helpers/ShortcutHelper.js: -------------------------------------------------------------------------------- 1 | /* global window,$ */ 2 | const items = {}; 3 | let onSuccessfulShortcut = () => {}; 4 | 5 | class ShortcutArray { 6 | constructor() { 7 | this.shortcuts = []; 8 | 9 | this.id = Math.random(); 10 | items[this.id] = this; 11 | } 12 | 13 | add(keys, func, preventDefault) { 14 | this.shortcuts.push({ keys, func, preventDefault }); 15 | return this; 16 | } 17 | 18 | off() { 19 | delete items[this.id]; 20 | } 21 | } 22 | 23 | ShortcutArray.EVENT = { 24 | PAUSE_PLAY: [32, 80, 75], 25 | FULLSCREEN: [70, 122], 26 | UP: [38], 27 | LEFT: [37, 74], 28 | RIGHT: [39, 76], 29 | DOWN: [40], 30 | SELECT: [32, 13], 31 | MUTE: [77], 32 | HELP: [191], 33 | }; 34 | 35 | window.addEventListener('keydown', (e) => { 36 | if ($('input:focus').length) return; 37 | const triggered = Object.values(items) 38 | .filter(({ shortcuts }) => { 39 | const a = shortcuts.find(({ keys }) => keys.indexOf(e.keyCode) !== -1); 40 | if (!a) return false; 41 | onSuccessfulShortcut(a.func()); 42 | return a.preventDefault; 43 | }); 44 | if (triggered.length) e.preventDefault(); 45 | }); 46 | 47 | ShortcutArray.setOnSuccessfulShortcut = (func) => { 48 | onSuccessfulShortcut = func; 49 | }; 50 | 51 | export default ShortcutArray; 52 | -------------------------------------------------------------------------------- /frontend/src/helpers/SocketIO.js: -------------------------------------------------------------------------------- 1 | /* global io */ 2 | const connection = typeof io !== 'undefined' ? io() : null; 3 | 4 | class SocketIO { 5 | static onMessage(event, func) { 6 | if (!connection) return false; 7 | return connection.on(event, func); 8 | } 9 | } 10 | 11 | export default SocketIO; 12 | -------------------------------------------------------------------------------- /frontend/src/helpers/autoPlaySupported.js: -------------------------------------------------------------------------------- 1 | /* global window,document */ 2 | const { sessionStorage } = window; 3 | 4 | // isAutoplaySupported(callback); 5 | // Test if HTML5 video autoplay is supported 6 | const autoPlaySupported = async () => { 7 | // Check if sessionStorage exist for autoplaySupported, 8 | // if so we don't need to check for support again 9 | const supported = sessionStorage.getItem('autoplaySupported'); 10 | if (supported) { 11 | return supported === 'true'; 12 | } 13 | 14 | let result = true; 15 | const video = document.createElement('video'); 16 | video.onerror = () => { result = false; }; 17 | try { 18 | video.autoplay = true; 19 | video.muted = true; 20 | video.src = 'data:video/mp4;base64,AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAATKbW9vdgAAAGxtdmhkAAAAANLEP5XSxD+VAAB1MAAAdU4AAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAACFpb2RzAAAAABCAgIAQAE////9//w6AgIAEAAAAAQAABDV0cmFrAAAAXHRraGQAAAAH0sQ/ldLEP5UAAAABAAAAAAAAdU4AAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAHVOAAAH0gABAAAAAAOtbWRpYQAAACBtZGhkAAAAANLEP5XSxD+VAAB1MAAAdU5VxAAAAAAANmhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABMLVNNQVNIIFZpZGVvIEhhbmRsZXIAAAADT21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAw9zdGJsAAAAwXN0c2QAAAAAAAAAAQAAALFhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABCkFWQyBDb2RpbmcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAAOGF2Y0MBZAAf/+EAHGdkAB+s2UCgL/lwFqCgoKgAAB9IAAdTAHjBjLABAAVo6+yyLP34+AAAAAATY29scm5jbHgABQAFAAUAAAAAEHBhc3AAAAABAAAAAQAAABhzdHRzAAAAAAAAAAEAAAAeAAAD6QAAAQBjdHRzAAAAAAAAAB4AAAABAAAH0gAAAAEAABONAAAAAQAAB9IAAAABAAAAAAAAAAEAAAPpAAAAAQAAE40AAAABAAAH0gAAAAEAAAAAAAAAAQAAA+kAAAABAAATjQAAAAEAAAfSAAAAAQAAAAAAAAABAAAD6QAAAAEAABONAAAAAQAAB9IAAAABAAAAAAAAAAEAAAPpAAAAAQAAE40AAAABAAAH0gAAAAEAAAAAAAAAAQAAA+kAAAABAAATjQAAAAEAAAfSAAAAAQAAAAAAAAABAAAD6QAAAAEAABONAAAAAQAAB9IAAAABAAAAAAAAAAEAAAPpAAAAAQAAB9IAAAAUc3RzcwAAAAAAAAABAAAAAQAAACpzZHRwAAAAAKaWlpqalpaampaWmpqWlpqalpaampaWmpqWlpqalgAAABxzdHNjAAAAAAAAAAEAAAABAAAAHgAAAAEAAACMc3RzegAAAAAAAAAAAAAAHgAAA5YAAAAVAAAAEwAAABMAAAATAAAAGwAAABUAAAATAAAAEwAAABsAAAAVAAAAEwAAABMAAAAbAAAAFQAAABMAAAATAAAAGwAAABUAAAATAAAAEwAAABsAAAAVAAAAEwAAABMAAAAbAAAAFQAAABMAAAATAAAAGwAAABRzdGNvAAAAAAAAAAEAAAT6AAAAGHNncGQBAAAAcm9sbAAAAAIAAAAAAAAAHHNiZ3AAAAAAcm9sbAAAAAEAAAAeAAAAAAAAAAhmcmVlAAAGC21kYXQAAAMfBgX///8b3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMTEgNzU5OTIxMCAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMTUgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMgbWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz0xMSBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgc3RpdGNoYWJsZT0xIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PWluZmluaXRlIGtleWludF9taW49Mjkgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD00MCByYz0ycGFzcyBtYnRyZWU9MSBiaXRyYXRlPTExMiByYXRldG9sPTEuMCBxY29tcD0wLjYwIHFwbWluPTUgcXBtYXg9NjkgcXBzdGVwPTQgY3BseGJsdXI9MjAuMCBxYmx1cj0wLjUgdmJ2X21heHJhdGU9ODI1IHZidl9idWZzaXplPTkwMCBuYWxfaHJkPW5vbmUgZmlsbGVyPTAgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAG9liIQAFf/+963fgU3DKzVrulc4tMurlDQ9UfaUpni2SAAAAwAAAwAAD/DNvp9RFdeXpgAAAwB+ABHAWYLWHUFwGoHeKCOoUwgBAAADAAADAAADAAADAAAHgvugkks0lyOD2SZ76WaUEkznLgAAFFEAAAARQZokbEFf/rUqgAAAAwAAHVAAAAAPQZ5CeIK/AAADAAADAA6ZAAAADwGeYXRBXwAAAwAAAwAOmAAAAA8BnmNqQV8AAAMAAAMADpkAAAAXQZpoSahBaJlMCCv//rUqgAAAAwAAHVEAAAARQZ6GRREsFf8AAAMAAAMADpkAAAAPAZ6ldEFfAAADAAADAA6ZAAAADwGep2pBXwAAAwAAAwAOmAAAABdBmqxJqEFsmUwIK//+tSqAAAADAAAdUAAAABFBnspFFSwV/wAAAwAAAwAOmQAAAA8Bnul0QV8AAAMAAAMADpgAAAAPAZ7rakFfAAADAAADAA6YAAAAF0Ga8EmoQWyZTAgr//61KoAAAAMAAB1RAAAAEUGfDkUVLBX/AAADAAADAA6ZAAAADwGfLXRBXwAAAwAAAwAOmQAAAA8Bny9qQV8AAAMAAAMADpgAAAAXQZs0SahBbJlMCCv//rUqgAAAAwAAHVAAAAARQZ9SRRUsFf8AAAMAAAMADpkAAAAPAZ9xdEFfAAADAAADAA6YAAAADwGfc2pBXwAAAwAAAwAOmAAAABdBm3hJqEFsmUwIK//+tSqAAAADAAAdUQAAABFBn5ZFFSwV/wAAAwAAAwAOmAAAAA8Bn7V0QV8AAAMAAAMADpkAAAAPAZ+3akFfAAADAAADAA6ZAAAAF0GbvEmoQWyZTAgr//61KoAAAAMAAB1QAAAAEUGf2kUVLBX/AAADAAADAA6ZAAAADwGf+XRBXwAAAwAAAwAOmAAAAA8Bn/tqQV8AAAMAAAMADpkAAAAXQZv9SahBbJlMCCv//rUqgAAAAwAAHVE='; 21 | video.load(); 22 | video.style.display = 'none'; 23 | video.playing = false; 24 | await video.play(); 25 | } catch (e) { 26 | result = false; 27 | } 28 | sessionStorage.setItem('autoplaySupported', result); 29 | return result; 30 | }; 31 | // call it right away to make sure it's in the cache when we need it 32 | autoPlaySupported(); 33 | 34 | export default autoPlaySupported; 35 | -------------------------------------------------------------------------------- /frontend/src/helpers/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export default createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /frontend/src/helpers/stores/deserialize.js: -------------------------------------------------------------------------------- 1 | import { camelize } from 'humps'; 2 | import pluralize from 'pluralize'; 3 | import { apiActions } from 'redux-jsonapi'; 4 | 5 | function deserializeRelationships(resources = [], store) { 6 | return resources 7 | .map((resource) => deserializeRelationship(resource, store)) 8 | .filter((resource) => !!resource); 9 | } 10 | 11 | async function deserializeRelationship(resource = {}, store) { 12 | const type = pluralize.singular(resource.type); 13 | const {api} = store.getState(); 14 | if (api[camelize(type)] && api[camelize(type)][resource.id]) { 15 | return deserialize({ ...api[camelize(type)][resource.id], meta: { loaded: true } }, api); 16 | } 17 | 18 | const req = (await store.dispatch( 19 | apiActions.read( 20 | { 21 | id:resource.id, 22 | _type:pluralize.plural(resource.type) 23 | } 24 | ) 25 | )); 26 | return deserialize( 27 | req.resources[0], 28 | store 29 | ); 30 | //return deserialize({ ...resource, meta: { loaded: false } }, api); 31 | } 32 | 33 | function deserialize({ id, type, attributes, relationships, meta }, store) { 34 | let resource = { _type: type, _meta: meta }; 35 | 36 | if (id) resource = { ...resource, id }; 37 | 38 | if (attributes) { 39 | resource = Object.keys(attributes).reduce((resource, key) => { 40 | return { ...resource, [camelize(key)]: attributes[key] }; 41 | }, resource); 42 | } 43 | 44 | if (relationships) { 45 | resource = Object.keys(relationships).reduce((resource, key) => { 46 | return { 47 | ...resource, 48 | [camelize(key)]: () => { 49 | if (Array.isArray(relationships[key].data)) { 50 | return deserializeRelationships(relationships[key].data, store); 51 | } else { 52 | return deserializeRelationship(relationships[key].data, store) 53 | } 54 | }, 55 | }; 56 | }, resource); 57 | } 58 | 59 | return resource; 60 | } 61 | 62 | require('redux-jsonapi/dist/').deserialize = deserialize ; 63 | 64 | export default deserialize; 65 | -------------------------------------------------------------------------------- /frontend/src/helpers/stores/playQueue.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | import { apiActions, deserialize } from 'redux-jsonapi'; 3 | import store from './store'; 4 | import history from '../../helpers/history'; 5 | 6 | export const ACTION_ENQUEUE = 'enqueue'; 7 | export const ACTION_CLEAR = 'clear'; 8 | export const ACTION_OVERWRITE = 'overwrite'; 9 | export const ACTION_INSERT = 'insert'; 10 | export const ACTION_SKIP = 'skip'; 11 | export const HIDE_PLAYER = 'hide'; 12 | export const LOADING = 'loading'; 13 | 14 | const defaultValue = { 15 | offset: 0, 16 | items: [], 17 | playerVisible: false, 18 | }; 19 | 20 | 21 | const playQueue = (state = defaultValue, { type, data }) => { 22 | switch (type) { 23 | case ACTION_ENQUEUE: 24 | state.push(data); 25 | state.loading = false; 26 | break; 27 | case ACTION_CLEAR: 28 | state = []; 29 | state.playerVisible = false; 30 | state.loading = false; 31 | break; 32 | case ACTION_OVERWRITE: 33 | state = { ...state, ...data }; 34 | state.loading = false; 35 | break; 36 | case ACTION_SKIP: 37 | state.offset += data; 38 | if (state.offset < 0) state.offset = 0; 39 | if (state.offset >= state.items.length) state.offset = state.offset.length - 1; 40 | break; 41 | case HIDE_PLAYER: 42 | state.playerVisible = false; 43 | state.loading = false; 44 | break; 45 | case LOADING: 46 | state.loading = data; 47 | state.playerVisible = true; 48 | break; 49 | case ACTION_INSERT: 50 | state.items.splice(state.offset, 0, data); 51 | state.playerVisible = true; 52 | state.loading = false; 53 | break; 54 | default: return state; 55 | } 56 | 57 | state.playing = state.offset <= state.items.length ? state.items[state.offset] : null; 58 | state.hasNext = state.offset < state.items.length - 1; 59 | state.hasPrev = state.offset > 0; 60 | 61 | if (state.playing && state.playerVisible) { 62 | const url = `/item/play/${state.playing.id}`; 63 | if (window.location.pathname !== url) { 64 | if(window.location.pathname.indexOf('/item/play/')===0) 65 | history.replace(url) 66 | else 67 | history.push(url); 68 | } 69 | } 70 | 71 | return { ...state }; 72 | }; 73 | 74 | 75 | async function prepareForPlayback(item) { 76 | if (item.attributes) item = deserialize(item, store); 77 | 78 | if (item.playPosition) item._fetchedPlayPosition = await item.playPosition(); 79 | const mediaContent = await $.getJSON(`/api/mediacontent/${item.id}`); 80 | if (mediaContent.subtitles.length) { 81 | mediaContent.subtitles.push({ value: '', label: 'Disable' }); 82 | } 83 | item.mediaContent = mediaContent; 84 | return item; 85 | } 86 | 87 | async function populateQueue(item, dispatch) { 88 | if (item.mediaType !== 'tv') return; 89 | if (!item.externalId) return; 90 | 91 | // fetch and queue other episodes if available 92 | const readAction = apiActions.read( 93 | { _type: 'media-items' }, 94 | { 95 | params: 96 | { 97 | 'external-id': item.externalId, 98 | sort: 'season:ASC,episode:ASC', 99 | join: 'play-position', 100 | extra: 'false', 101 | }, 102 | }, 103 | ); 104 | const episodes = await store.dispatch(readAction); 105 | const offset = episodes.resources.findIndex(e => e.id === item.id); 106 | const items = await Promise.all(episodes.resources 107 | .filter(e => e.type === 'media-item') 108 | .map(e => prepareForPlayback(e))); 109 | dispatch({ type: ACTION_OVERWRITE, data: { offset, items } }); 110 | } 111 | 112 | const playQueueActions = dispatch => ({ 113 | skip: (offset) => { 114 | dispatch({ type: ACTION_SKIP, data: offset }); 115 | }, 116 | 117 | insertAtCurrentOffset: async (item) => { 118 | dispatch({ type: LOADING, data: item.id }); 119 | item = await prepareForPlayback(item); 120 | dispatch({ type: ACTION_INSERT, data: item }); 121 | populateQueue(item, dispatch); 122 | return item; 123 | }, 124 | 125 | hidePlayer: () => { 126 | dispatch({ type: HIDE_PLAYER }); 127 | }, 128 | 129 | insertAtCurrentOffsetById: async (id) => { 130 | dispatch({ type: LOADING, data: id }); 131 | const request = await store.dispatch(apiActions.read({ _type: 'media-items', id })); 132 | const item = deserialize(request.resources[0], store); 133 | await prepareForPlayback(item); 134 | dispatch({ type: ACTION_INSERT, data: item }); 135 | populateQueue(item, dispatch); 136 | }, 137 | }); 138 | 139 | export { playQueue, playQueueActions }; 140 | -------------------------------------------------------------------------------- /frontend/src/helpers/stores/serialize.js: -------------------------------------------------------------------------------- 1 | const humps = require('humps'); 2 | 3 | const { decamelize } = humps; 4 | humps.decamelize = (a, b = {}) => { 5 | b.separator = '-'; 6 | return decamelize(a, b); 7 | }; 8 | const o = { separator: '-' }; 9 | 10 | 11 | function serializeRelationship({ id, _type } = {}) { 12 | return { id, type: _type }; 13 | } 14 | 15 | function serializeRelationships(resources = []) { 16 | return resources.map(resource => serializeRelationship(resource)); 17 | } 18 | 19 | function serialize({ 20 | id, _type, _meta, ...otherAttributes 21 | }) { 22 | let resrc = {}; 23 | 24 | if (id) resrc = { ...resrc, id }; 25 | resrc = { ...resrc, type: _type }; 26 | 27 | resrc = Object.keys(otherAttributes).reduce((resource, key) => { 28 | if (typeof otherAttributes[key] === 'function') { 29 | const data = otherAttributes[key].call(); 30 | 31 | if (Array.isArray(data)) { 32 | return { 33 | ...resource, 34 | relationships: { 35 | ...resource.relationships, 36 | [decamelize(key, o)]: { 37 | data: serializeRelationships(data), 38 | }, 39 | }, 40 | }; 41 | } 42 | 43 | return { 44 | ...resource, 45 | relationships: { 46 | ...resource.relationships, 47 | [decamelize(key, o)]: { 48 | data: data && serializeRelationship(data), 49 | }, 50 | }, 51 | }; 52 | } 53 | 54 | if (key[0] === '_') return resource; 55 | 56 | return { 57 | ...resource, 58 | attributes: { 59 | ...resource.attributes, 60 | [decamelize(key, o)]: otherAttributes[key], 61 | }, 62 | }; 63 | }, resrc); 64 | 65 | if (_meta) resrc = { ...resrc, meta: _meta }; 66 | return resrc; 67 | } 68 | 69 | require('redux-jsonapi/dist/serializers').serialize = serialize; 70 | 71 | export default serialize; 72 | -------------------------------------------------------------------------------- /frontend/src/helpers/stores/settingsStore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 7/2/2017. 3 | * This store is specifically for settings, so settings changes can be subscribed to 4 | */ 5 | 6 | import { combineReducers } from 'redux'; 7 | import { apiReducer as api } from 'redux-jsonapi'; 8 | import {createStore, applyMiddleware} from 'redux'; 9 | import { createApiMiddleware } from 'redux-jsonapi'; 10 | 11 | const reducers = combineReducers({ 12 | api 13 | }); 14 | 15 | const apiMiddleware = createApiMiddleware('/api'); 16 | 17 | export default createStore(reducers, applyMiddleware(apiMiddleware)); 18 | -------------------------------------------------------------------------------- /frontend/src/helpers/stores/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 7/2/2017. 3 | * this store should be used for any api calls except for settings 4 | */ 5 | 6 | import { combineReducers } from 'redux'; 7 | import { apiReducer as api } from 'redux-jsonapi'; 8 | import {createStore, applyMiddleware} from 'redux'; 9 | import { createApiMiddleware } from 'redux-jsonapi'; 10 | import { playQueue } from './playQueue'; 11 | 12 | const reducers = combineReducers({ 13 | api, 14 | playQueue, 15 | }); 16 | 17 | const apiMiddleware = createApiMiddleware('/api'); 18 | 19 | export default createStore(reducers, applyMiddleware(apiMiddleware)); 20 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | /* global document,navigator,window */ 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import history from './helpers/history'; 5 | import { Router, Switch } from 'react-router-dom'; 6 | // these two lines overwrite the serialization of redux-jsonapi 7 | // eslint-disable-next-line 8 | import serializer from "./helpers/stores/serialize"; 9 | // eslint-disable-next-line 10 | import deserializer from "./helpers/stores/deserialize"; 11 | 12 | import App from './App'; 13 | 14 | require('./css/index.css'); 15 | 16 | 17 | if ('serviceWorker' in navigator) { 18 | window.addEventListener('load', () => { 19 | navigator.serviceWorker.register('/sw.js', { scope: '/' }).then((registration) => { 20 | registration.update(); 21 | }); 22 | }); 23 | } 24 | 25 | ReactDOM.render( 26 | 27 | 28 | 29 | 30 | , 31 | document.getElementById('root'), 32 | ); 33 | // registerServiceWorker(); 34 | -------------------------------------------------------------------------------- /frontend/src/routes/About.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 6/30/2017. 3 | */ 4 | 5 | import React from 'react'; 6 | import { Flipped } from 'react-flip-toolkit'; 7 | import { Card, Icon } from 'react-materialize'; 8 | import { Link } from 'react-router-dom'; 9 | 10 | function About() { 11 | return ( 12 | 13 |
14 | 15 |

Remote MediaServer

16 | A full open source media server that can index your videos, match them against 17 | movies and tv shows and allow your to watch them straight from your browser.
18 | 19 | 20 | Github 21 | Github 22 | 23 | 24 | Discord 25 | Discord 26 | 27 | 28 | code 29 | API documentation 30 | 31 | 32 | report_problem 33 | Report a problem 34 | 35 |
36 |
37 |
38 | ); 39 | } 40 | 41 | export default About; 42 | -------------------------------------------------------------------------------- /frontend/src/routes/Api.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | /** 3 | * Created by owenray on 6/30/2017. 4 | */ 5 | 6 | import React, { PureComponent } from 'react'; 7 | import { Flipped } from 'react-flip-toolkit'; 8 | import { Card, Chip } from 'react-materialize'; 9 | import MarkdownIt from 'markdown-it'; 10 | 11 | class Api extends PureComponent { 12 | constructor(props) { 13 | super(props); 14 | this.state = { data: [] }; 15 | } 16 | 17 | async componentWillMount() { 18 | const data = await $.get('/api/documentation'); 19 | this.setState({ data: data.apiDoc }); 20 | } 21 | 22 | toggle(index) { 23 | const data = this.state.data.slice(0); 24 | data[index].open = !data[index].open; 25 | this.setState({ data }); 26 | } 27 | 28 | render() { 29 | const md = new MarkdownIt(); 30 | 31 | return ( 32 | 33 |
34 | 35 |

API Documentation

36 |
    37 |
  • 38 | A number behind any route points at a priority, 39 | if a higher priority route doesn't 40 | handle the call the call will be passed onto a higher priority route. 41 |
  • 42 |
  • 43 | Any backend module can implement getDescription 44 | and return a string or the path to an md file. 45 |
  • 46 |
  • Click any route for details.
  • 47 |
48 |
49 | {this.state.data.map((o, index) => ( 50 | 54 |
{ this.toggle(index); }}> 55 | {o.method.toUpperCase()} 56 | {o.url} 57 | {o.priority !== '0' ? `(${o.priority})` : ''} 58 | {o.classname} 59 |
60 |

61 | 62 | ))} 63 |

64 |
65 | ); 66 | } 67 | } 68 | 69 | export default Api; 70 | -------------------------------------------------------------------------------- /frontend/src/routes/Home.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 6/30/2017. 3 | */ 4 | /* global $ */ 5 | 6 | import React, { PureComponent } from 'react'; 7 | import { Card, ProgressBar } from 'react-materialize'; 8 | import { Flipped } from 'react-flip-toolkit'; 9 | import { deserialize } from 'redux-jsonapi'; 10 | import MediaItemTile from '../components/mediaItem/MediaItemTile'; 11 | import store from '../helpers/stores/store'; 12 | 13 | class Home extends PureComponent { 14 | static deserializeAll(items) { 15 | items.included.forEach(i => deserialize(i, store)); 16 | return items.data.map(i => deserialize(i, store)); 17 | } 18 | 19 | async componentWillMount() { 20 | const data = await $.get('/api/watchNext'); 21 | this.setState({ 22 | items: [ 23 | Home.deserializeAll(data.continueWatching), 24 | Home.deserializeAll(data.recommended), 25 | Home.deserializeAll(data.newMovies), 26 | Home.deserializeAll(data.newTV), 27 | ], 28 | }); 29 | } 30 | 31 | renderList() { 32 | if (!this.state) { 33 | return ; 34 | } 35 | 36 | const titles = ['Continue watching', 'Recommended Movies', 'New Movies', 'New Episodes']; 37 | 38 | return this.state.items.map((items, index) => ( 39 | 40 |
41 | {items.length ? 42 | items.map(i => ) : 43 | 'Nothing to see here yet' 44 | } 45 |
46 |
47 | )); 48 | } 49 | 50 | render() { 51 | return ( 52 | 53 |
54 | {this.renderList()} 55 |
56 | Remote 57 |
58 |
59 |
60 | ); 61 | } 62 | } 63 | 64 | export default Home; 65 | -------------------------------------------------------------------------------- /frontend/src/routes/LocalStorage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by owenray on 6/30/2017. 3 | */ 4 | 5 | import React, { PureComponent } from 'react'; 6 | import { Flipped } from 'react-flip-toolkit'; 7 | import { Card, Row, Col, Button, TextInput } from 'react-materialize'; 8 | import Slider from 'rc-slider'; 9 | import LocalStorage from '../helpers/LocalStorage'; 10 | import { throttle } from 'throttle-debounce'; 11 | import MediaItemTile from '../components/mediaItem/MediaItemTile'; 12 | 13 | class LocalStorageRoute extends PureComponent { 14 | constructor() { 15 | super(); 16 | this.state = { 17 | desiredQuota: 0, 18 | quota: {}, 19 | }; 20 | 21 | this.onChangeSlider = this.onChangeSlider.bind(this); 22 | this.onChangeInput = this.onChangeInput.bind(this); 23 | this.refreshQuota = this.refreshQuota.bind(this); 24 | this.save = this.save.bind(this); 25 | } 26 | 27 | componentWillMount() { 28 | this.refreshQuota(); 29 | LocalStorage.addListener(-1, throttle(2000, this.refreshQuota)); 30 | } 31 | 32 | async refreshQuota() { 33 | const quota = await LocalStorage.getCurrentQuota(); 34 | this.setState({ quota }); 35 | if (this.state.desiredQuota === 0) { 36 | this.setState({ desiredQuota: quota.granted }); 37 | } 38 | } 39 | 40 | onChangeSlider(desiredQuota) { 41 | this.setState({ desiredQuota }); 42 | } 43 | 44 | onChangeInput(e) { 45 | this.setState({ desiredQuota: parseInt(e.target.value, 10) }); 46 | } 47 | 48 | async save() { 49 | await LocalStorage.requestStorage(this.state.desiredQuota); 50 | this.refreshQuota(); 51 | } 52 | 53 | render() { 54 | return ( 55 | 56 |
57 | Save]} 60 | > 61 |

Offline Storage Quota

62 |
63 | 64 | 70 | 71 | 78 | 79 | 80 |
81 |
82 | 83 |

Available Offline

84 |
85 | {LocalStorage.getItems().length ? 86 | LocalStorage.getItems().map(i => ) : 87 | 'No offline items yet' 88 | } 89 |
90 |
91 |
92 |
93 | ); 94 | } 95 | } 96 | 97 | export default LocalStorageRoute; 98 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs'); 3 | 4 | const dir = `${process.env.HOME || process.env.USERPROFILE}/.remote`; 5 | if (!fs.existsSync(dir)) { 6 | fs.mkdirSync(dir); 7 | } 8 | 9 | // make sure all settings files are in the right directory 10 | process.chdir(dir); 11 | 12 | require('./backend/core').init(); 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remote-mediaserver", 3 | "description": "Manage and watch your movies and tv series.", 4 | "version": "0.5.0", 5 | "main": "main.js", 6 | "nodeGypRebuild": true, 7 | "engines": { 8 | "node": ">=10" 9 | }, 10 | "bin": { 11 | "remote": "./main.js" 12 | }, 13 | "scripts": { 14 | "start": "nodemon --inspect --watch backend main.js", 15 | "test": "npm-run-all test:unit test:lint", 16 | "test:lint": "eslint --config backend/.eslintrc backend/", 17 | "test:unit": "jest backend --forceExit", 18 | "package": "cp -r node_modules/moviedb-api/", 19 | "build": "pkg package.json && npm-run-all build:mac build:win build:linux build:arm", 20 | "build:arm": "docker run --rm -v $(pwd):/src owenray/rms-buildenv sh -c \"npm install && npm run build:arm:indocker\" && zip file.zip remote-mediaserver && mv file.zip dist/arm.zip", 21 | "build:mac": "zip file.zip remote-mediaserver-*macos && mv file.zip dist/osx.zip", 22 | "build:win": "zip file.zip remote-mediaserver-*win.exe && mv file.zip dist/win.zip", 23 | "build:linux": "zip file.zip remote-mediaserver-*linux && mv file.zip dist/linux.zip", 24 | "build:arm:indocker": "pkg --targets node16-linux-armv7 package.json", 25 | "build:nasos:arm": "platform_scripts/nasos/buildscript.sh armv7", 26 | "build:nasos:x86": "platform_scripts/nasos/buildscript.sh x86_64" 27 | }, 28 | "files": [ 29 | "backend", 30 | "frontend/build" 31 | ], 32 | "pkg": { 33 | "scripts": [ 34 | "backend/**/*.js", 35 | "node_modules/moviedb-api/apib/endpoints.json" 36 | ], 37 | "assets": [ 38 | "frontend/build/**", 39 | "backend/**/*.json" 40 | ], 41 | "targets": [ 42 | "node16-linux-x64", 43 | "node16-win-x64", 44 | "node16-macos-x64" 45 | ] 46 | }, 47 | "dependencies": { 48 | "@sentry/node": "^7.15.0", 49 | "acme-client": "^4.2.5", 50 | "ass-to-vtt": "^1.1.1", 51 | "bencode": "^2.0.0", 52 | "bittorrent-dht": "^9.0.0", 53 | "chokidar": "^3.0.1", 54 | "ip": "^1.1.5", 55 | "koa": "^2.3.0", 56 | "koa-bodyparser": "^4.2.0", 57 | "koa-router": "^7.1.1", 58 | "koa-static": "^3.0.0", 59 | "mime": "^1.3.4", 60 | "moviedb-api": "git+https://github.com/OwenRay/moviedb-api.git", 61 | "node-cache": "^5.1.2", 62 | "node-file-cache": "^1.0.2", 63 | "node-unzip-2": "^0.2.8", 64 | "node-uuid": "^1.4.7", 65 | "npm-run-all": "^4.1.5", 66 | "opn": "^5.4.0", 67 | "pluralize": "^1.2.1", 68 | "q": "^1.5.1", 69 | "server-destroy": "^1.0.1", 70 | "socket.io": "^2.5.0", 71 | "srt-to-vtt": "^1.1.1", 72 | "supercop.js": "^2.0.1", 73 | "vtt-shift": "^0.1.0" 74 | }, 75 | "devDependencies": { 76 | "ajv": "^6.12.6", 77 | "eslint": "^7.32.0", 78 | "eslint-config-airbnb": "^19.0.4", 79 | "eslint-config-prettier": "^8.8.0", 80 | "eslint-plugin-import": "^2.27.5", 81 | "eslint-plugin-jsx-a11y": "^6.7.1", 82 | "eslint-plugin-node": "^11.1.0", 83 | "eslint-plugin-prettier": "^4.2.1", 84 | "eslint-plugin-react": "^7.32.2", 85 | "jest": "^29.5.0", 86 | "nodemon": "^2.0.20", 87 | "pkg": "^5.8.1", 88 | "prettier": "^2.8.7" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /platform_scripts/arm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM multiarch/ubuntu-core:armhf-focal 2 | 3 | CMD ["/bin/bash"] 4 | 5 | RUN apt update 6 | RUN apt install -y curl patch build-essential 7 | RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - 8 | RUN apt install -y nodejs 9 | 10 | WORKDIR /src 11 | RUN npm init -y 12 | 13 | RUN npm install -g pkg 14 | RUN > index.js 15 | RUN npx pkg --targets node16-linux-armv7 index.js 16 | RUN chmod -R 777 /root 17 | -------------------------------------------------------------------------------- /platform_scripts/nasos/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p /root/rms 4 | arch="armv7l" 5 | node=8.16.1 6 | if [ $RAINBOW_ARCHITECTURE == "x86_64" ]; then 7 | arch="x64" 8 | fi 9 | #sed -i 's/ftp.us/archive/g' /etc/apt/sources.list 10 | 11 | apt-get update 12 | apt-get install -y rsync git wget 13 | wget "https://nodejs.org/dist/v$node/node-v$node-linux-$arch.tar.gz" 14 | ls -al *.tar.gz 15 | tar xfzv *.tar.gz 16 | rsync -a node*/* /usr/local/ 17 | rm -r node* 18 | 19 | rsync -a /home/source/rms/package/* /rms 20 | 21 | install -m 755 /home/source/rc.local /etc 22 | install -m 755 /home/source/etc/default/remote-mediaserver /etc/default/ 23 | install -m 755 /home/source/etc/init.d/remote-mediaserver /etc/init.d/ 24 | mkdir -p /var/run/remote-mediaserver/ 25 | mkdir -p /usr/local/var/run 26 | 27 | cd /rms 28 | export PATH=$PATH:/usr/local/bin 29 | npx nodeunit backend/core/tests/ 30 | -------------------------------------------------------------------------------- /platform_scripts/nasos/buildscript.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npx yarn pack 4 | mkdir -p platform_scripts/nasos/source/rms 5 | tar -xvzf remote-mediaserver-v*.tgz -C platform_scripts/nasos/source/rms 6 | cd platform_scripts/nasos/source/rms/package 7 | 8 | npx yarn install 9 | cd ../../../ 10 | rainbow --arch $1 --build . 11 | rainbow --arch $1 --pack . 12 | -------------------------------------------------------------------------------- /platform_scripts/nasos/package-armv7.json: -------------------------------------------------------------------------------- 1 | { 2 | "architecture": "armv7" 3 | } -------------------------------------------------------------------------------- /platform_scripts/nasos/package-extra.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": { 3 | "ru": "Remote MediaServer", 4 | "fr": "Remote MediaServer", 5 | "en": "Remote MediaServer", 6 | "nl": "Remote MediaServer", 7 | "pt": "Remote MediaServer", 8 | "ko": "Remote MediaServer", 9 | "de": "Remote MediaServer", 10 | "tr": "Remote MediaServer", 11 | "it": "Remote MediaServer", 12 | "zh-cn": "Remote MediaServer", 13 | "zh-tw": "Remote MediaServer", 14 | "ja": "Remote MediaServer", 15 | "es": "Remote MediaServer" 16 | }, 17 | "description_short": { 18 | "en": "Remote MediaServer, Index and watch your Movies & TV Series." 19 | }, 20 | "visibility": "always", 21 | "publisher_contact": "lateam@internet.com", 22 | "description_long": { 23 | "en": "A NodeJS based media server: Manage, watch and share your movies and tv series." 24 | }, 25 | "categories": [ 26 | "video" 27 | ], 28 | "publication_date": "2013-8-9", 29 | "privacy_url": "http://www.theremote.io", 30 | "publisher_name": "Automattic", 31 | "supported_languages": [ 32 | "en" 33 | ], 34 | "website_url": "http://www.theremote.io", 35 | "disclaimer": "Use at own risk" 36 | } 37 | -------------------------------------------------------------------------------- /platform_scripts/nasos/package-x86_64.json: -------------------------------------------------------------------------------- 1 | { 2 | "architecture": "x86_64" 3 | } -------------------------------------------------------------------------------- /platform_scripts/nasos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "id": "io.theremote.mediaserver", 4 | "version": "1.0", 5 | "depends": [ 6 | "org.debian.wheezy-lamp-1.2" 7 | ], 8 | "network_ports": { 9 | "WEB_UI": 8234, 10 | "SHARING": 8235, 11 | "SSL": 8443 12 | }, 13 | "redirect_mode": "custom" 14 | } 15 | -------------------------------------------------------------------------------- /platform_scripts/nasos/resources/icons/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/platform_scripts/nasos/resources/icons/icon-20.png -------------------------------------------------------------------------------- /platform_scripts/nasos/resources/icons/icon-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/platform_scripts/nasos/resources/icons/icon-50.png -------------------------------------------------------------------------------- /platform_scripts/nasos/resources/icons/icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwenRay/Remote-MediaServer/c060fb2ddf267336913588098a9d1313f34e9da3/platform_scripts/nasos/resources/icons/icon-80.png -------------------------------------------------------------------------------- /platform_scripts/nasos/scripts/post-install: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 0 4 | -------------------------------------------------------------------------------- /platform_scripts/nasos/scripts/post-update: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 0 4 | -------------------------------------------------------------------------------- /platform_scripts/nasos/scripts/pre-install: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 0 4 | -------------------------------------------------------------------------------- /platform_scripts/nasos/scripts/pre-remove: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 0 4 | -------------------------------------------------------------------------------- /platform_scripts/nasos/scripts/pre-update: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 1 4 | -------------------------------------------------------------------------------- /platform_scripts/nasos/source/etc/default/remote-mediaserver: -------------------------------------------------------------------------------- 1 | # defaults for remote-mediaserver 2 | # sourced by /etc/init.d/remote-mediaserver 3 | 4 | # Change to 0 to disable daemon 5 | ENABLE_DAEMON=1 6 | 7 | # This directory stores some runtime information, like torrent files 8 | # and links to the config file, which itself can be found in 9 | # /etc/transmission-daemon/settings.json 10 | # CONFIG_DIR="/var/lib/transmission-daemon/info" 11 | 12 | # Default options foe daemon, see transmission-daemon(1) for more options 13 | PIDFILE="/var/run/remote-mediaserver/remote-mediaserver.pid" 14 | # OPTIONS="--config-dir $CONFIG_DIR --pid-file $PIDFILE" 15 | 16 | # (optional) extra options to start-stop-daemon 17 | #S TART_STOP_OPTIONS="--iosched idle --nicelevel 10" 18 | -------------------------------------------------------------------------------- /platform_scripts/nasos/source/rc.local: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /etc/init.d/remote-mediaserver $1 4 | 5 | exit 0 6 | --------------------------------------------------------------------------------