├── .browserslistrc ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── test.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.cuda ├── README.md ├── Shared ├── Constants.ts ├── Types.ts └── Util.ts ├── VSCODE_DEV.md ├── backend ├── Src │ ├── AccessGuard.ts │ ├── AppFacade.ts │ ├── BootstrapConfiguration.ts │ ├── ClientIO │ │ ├── Broadcaster.ts │ │ ├── RpcRequestHandler.ts │ │ ├── Unicaster.ts │ │ └── index.ts │ ├── Common │ │ ├── Event │ │ │ └── index.ts │ │ ├── ExecutionFence.ts │ │ ├── FFmpeg │ │ │ ├── ClipMaker.ts │ │ │ ├── Exceptions.ts │ │ │ ├── MediaInfo.ts │ │ │ ├── ThumbnailGenerator.ts │ │ │ └── index.ts │ │ ├── LimitedCache.ts │ │ ├── LinkCounter.ts │ │ ├── Logger │ │ │ ├── ConsoleWriter.ts │ │ │ ├── SqliteWriter.ts │ │ │ └── index.ts │ │ ├── RecurringTask.ts │ │ ├── Types.ts │ │ └── Util.ts │ ├── Constants.ts │ ├── HttpClient │ │ ├── HttpClient.ts │ │ └── RemoteSeleniumHttpClient.ts │ ├── Kick │ │ └── KickApi.ts │ ├── PluginManagerListener.ts │ ├── Plugins │ │ ├── Bongacams │ │ │ ├── BongacamsDirectLocator.ts │ │ │ ├── BongacamsExtractor.ts │ │ │ ├── BongacamsLocator.ts │ │ │ └── index.ts │ │ ├── Camsoda │ │ │ ├── CamsodaExtractor.ts │ │ │ ├── CamsodaLocator.ts │ │ │ └── index.ts │ │ ├── Chaturbate │ │ │ ├── ChaturbateDirectLocator.ts │ │ │ ├── ChaturbateExtractor.ts │ │ │ └── index.ts │ │ ├── Dummy │ │ │ ├── DummyExtractor.ts │ │ │ ├── DummyLocator.ts │ │ │ └── index.ts │ │ ├── ExtractorWithCache.ts │ │ ├── KickClip │ │ │ ├── KickClipExtractor.ts │ │ │ ├── KickClipLocator.ts │ │ │ └── index.ts │ │ └── Plugin.ts │ ├── RecorderListener.ts │ ├── Route │ │ └── HttpRequestHandler.ts │ ├── Services │ │ ├── NotificationCenter.ts │ │ ├── ObservableValidator │ │ │ ├── Service.ts │ │ │ └── index.ts │ │ ├── PluginManager.ts │ │ ├── PluginManager │ │ │ ├── PluginManagerController.ts │ │ │ └── Quotas │ │ │ │ ├── Constants.ts │ │ │ │ ├── DownloadSpeedQuota.ts │ │ │ │ ├── InstanceQuota.ts │ │ │ │ └── StorageQuota.ts │ │ ├── RecordingService.ts │ │ ├── SizeQuotaNotifier.ts │ │ ├── SqliteAdapter.ts │ │ └── SystemResourcesMonitor │ │ │ ├── DefaultSystemResourceMonitor.ts │ │ │ ├── DockerSystemResourcesMonitor.ts │ │ │ ├── SystemResourcesMonitor.ts │ │ │ ├── SystemResourcesMonitorFactory.ts │ │ │ └── index.ts │ ├── Settings.ts │ ├── SocketSameOrigin.ts │ ├── StreamDispatcher.ts │ ├── WebServerFactory.ts │ └── main.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── plugins └── OfflineMode.js ├── public ├── env.js ├── favicon.png ├── index.html └── service-worker.js ├── src ├── App.vue ├── Common │ ├── BoolFilter.ts │ ├── BoolFilterTemplates │ │ ├── ArchiveValueNode.ts │ │ ├── ObservableValueNode.ts │ │ └── PlaylistValueNode.ts │ ├── Decorators │ │ └── AppComponent.ts │ ├── FileUploader.ts │ ├── IsPortrait.ts │ ├── ParticalSum.ts │ ├── RpcClient.ts │ ├── ThumbnailFromFilename.ts │ ├── Util.ts │ ├── VideoFromFilename.ts │ ├── index.ts │ └── interfaces │ │ └── Chartjs.ts ├── Components │ ├── Analytics │ │ ├── ArchivePopulation.vue │ │ └── SpaceUsage.vue │ ├── Archive │ │ ├── Action.ts │ │ ├── ActionMenu.vue │ │ ├── ClipProgressCard.vue │ │ ├── FilterInfo.vue │ │ ├── PlaylistCard.vue │ │ ├── PlaylistPreviewActionMenu.vue │ │ ├── RecordCard.vue │ │ ├── RecordsView.vue │ │ ├── RowWrapper.vue │ │ └── TagManager.vue │ ├── BackBtn.vue │ ├── Charts │ │ ├── HorizontalBar.ts │ │ └── LineChart.ts │ ├── ClipProgressPopup.vue │ ├── CountdownTimer.vue │ ├── GetAccessDialog.vue │ ├── GroupList │ │ ├── GraphBuilder.ts │ │ ├── GroupList.vue │ │ └── Types.ts │ ├── InfoDialog.vue │ ├── InputWithChips.vue │ ├── LongPressButton.vue │ ├── NoConnectionIcon.vue │ ├── Observables │ │ ├── AddObservableDialog.vue │ │ ├── EditObservableDialog.vue │ │ ├── FilterInfo.vue │ │ ├── ObservableItem.vue │ │ └── index.ts │ ├── Playlist │ │ ├── AddRecordDlg.vue │ │ ├── EditPlaylist.vue │ │ ├── FragmentPreview.vue │ │ ├── LoopMenu.vue │ │ ├── NewPlaylist.vue │ │ └── RecordCard.vue │ ├── PluginIcon.vue │ ├── SearchFilter │ │ ├── SaveFilterDialog.vue │ │ └── SearchFilter.vue │ ├── SegmentedColorLine.vue │ ├── System │ │ ├── PluginManager.vue │ │ ├── ProxySettings.vue │ │ ├── Quotas.vue │ │ ├── WebPushSettings.vue │ │ └── index.ts │ ├── Tile.vue │ ├── Tiles │ │ ├── Archive.vue │ │ ├── Observables.vue │ │ ├── Recorder.vue │ │ ├── System.vue │ │ └── index.ts │ ├── TimeAgo.vue │ ├── UploadVideo │ │ ├── Archive.vue │ │ ├── EditDialog.vue │ │ ├── LocalFile.vue │ │ └── Url.vue │ └── VideoPlayer.vue ├── Directives │ ├── index.ts │ └── visible.ts ├── Env.ts ├── ErrorHandler.ts ├── EventBusTypes.ts ├── MetaInfo │ ├── AppThemeColor.ts │ └── index.ts ├── Mixins │ ├── EventBus.ts │ ├── Initialized.ts │ ├── MountedEmitter.ts │ ├── OrientationChange.ts │ ├── RefsForwarding.ts │ └── Services.ts ├── Pages │ ├── 404.vue │ ├── Analytics.vue │ ├── Archive.vue │ ├── Clip.vue │ ├── Dashboard.vue │ ├── Live.vue │ ├── Log.vue │ ├── Observables.vue │ ├── Player.vue │ ├── Playlist.vue │ ├── PlaylistPlayer.vue │ ├── Recorder.vue │ ├── System.vue │ └── UploadVideo.vue ├── Plugins │ ├── ConfirmDlg │ │ ├── View.vue │ │ ├── index.ts │ │ └── vue.d.ts │ ├── Notifications │ │ ├── Types.ts │ │ ├── View.vue │ │ ├── index.ts │ │ └── vue.d.ts │ └── Rpc │ │ ├── RpcClientPlugin.ts │ │ ├── index.ts │ │ └── vue.d.ts ├── Router │ ├── Playlist.ts │ ├── UploadVideo.ts │ ├── analytics.ts │ └── index.ts ├── RpcInstance.ts ├── ServiceWorkerResponder.ts ├── Store │ ├── Access.ts │ ├── App.ts │ ├── ClipSettings.ts │ ├── Settings.ts │ ├── SystemResources.ts │ └── index.ts ├── Theme.ts ├── assets │ ├── logo.png │ └── plugins │ │ ├── bongacams.png │ │ ├── bongacams_direct.png │ │ ├── camsoda.png │ │ ├── chaturbate.png │ │ ├── dummy.png │ │ └── kick_clip.png ├── index.css ├── main.ts ├── services │ ├── PushService.ts │ ├── ServiceWorker.ts │ └── index.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts └── types.ts ├── test ├── backend │ ├── RecordingService.ts │ ├── SqliteAdapter.ts │ └── Util.ts └── frontend │ └── BoolFilter.ts ├── tsconfig.json ├── tslint.json ├── types ├── vue-socket.io.d.ts ├── vue-virtual-scroller.d.ts └── vuedraggable.d.ts └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version: 16, 14, 12 4 | ARG VARIANT="16-buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | RUN apt update && \ 8 | DEBIAN_FRONTEND=noninteractive apt install -y ffmpeg 9 | 10 | # [Optional] Uncomment this section to install additional OS packages. 11 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 12 | # && apt-get -y install --no-install-recommends 13 | 14 | # [Optional] Uncomment if you want to install an additional version of node using nvm 15 | # ARG EXTRA_NODE_VERSION=10 16 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 17 | 18 | # [Optional] Uncomment if you want to install more global node modules 19 | # RUN su node -c "npm install -g " 20 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 12, 14, 16 8 | "args": { "VARIANT": "16" } 9 | }, 10 | "forwardPorts": [3000], 11 | 12 | // Set *default* container specific settings.json values on container create. 13 | "settings": {}, 14 | 15 | // Add the IDs of extensions you want installed when the container is created. 16 | "extensions": [ 17 | "dbaeumer.vscode-eslint" 18 | ], 19 | 20 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 21 | // "forwardPorts": [], 22 | 23 | // Use 'postCreateCommand' to run commands after the container is created. 24 | // "postCreateCommand": "yarn install", 25 | 26 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 27 | "remoteUser": "node" 28 | } 29 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .release 3 | node_modules 4 | data -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/standard', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 17 | "semi": ["error", "always"], 18 | "space-before-function-paren": ["error", {"anonymous": "always", "named": "never", "asyncArrow": "always"}], 19 | "operator-linebreak": ["error", "after"], 20 | "indent": "off", 21 | '@typescript-eslint/indent': [ 22 | 'error', 23 | 2 24 | ], 25 | "no-unused-vars": "off", 26 | "@typescript-eslint/no-unused-vars": "off", 27 | "no-empty-function": "off", 28 | "@typescript-eslint/no-empty-function": "off", 29 | "no-useless-constructor": "off", 30 | "no-unused-expressions": "off", 31 | "promise/param-names": "off", 32 | "@typescript-eslint/no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }], 33 | "prefer-promise-reject-errors": ["error", {"allowEmptyReject": true}], 34 | "@typescript-eslint/no-non-null-assertion": "off", 35 | "no-async-promise-executor": "off" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - uses: actions/setup-node@v1 9 | - run: npm i 10 | - run: npm test 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | deploy.sh 5 | /.release 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | /data 26 | /backend/out/ 27 | /src/**/*.js 28 | /src/**/*.js.map 29 | /client 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:current-alpine as deps 2 | WORKDIR /app 3 | 4 | RUN apk add python3 g++ make 5 | 6 | COPY package.json . 7 | COPY package-lock.json . 8 | 9 | RUN npm i \ 10 | && mv ./node_modules/@types/jsonstream ./node_modules/@types/JSONStream 11 | 12 | 13 | FROM deps as frontend 14 | WORKDIR /app 15 | 16 | COPY public ./public 17 | COPY src ./src 18 | COPY plugins ./plugins 19 | COPY types ./types 20 | COPY Shared ./Shared 21 | COPY .browserslistrc tsconfig.json tslint.json vue.config.js ./ 22 | COPY .eslintrc.js ./ 23 | 24 | RUN npm run build-frontend 25 | 26 | 27 | FROM deps as backend 28 | WORKDIR /app 29 | 30 | COPY backend ./backend 31 | COPY Shared ./Shared 32 | 33 | RUN npm run build-backend 34 | 35 | 36 | 37 | FROM node:current-alpine 38 | WORKDIR /app 39 | 40 | RUN apk add git ffmpeg 41 | 42 | COPY --from=deps /app/node_modules node_modules 43 | COPY --from=deps /app/package.json . 44 | COPY --from=frontend /app/client client 45 | COPY --from=backend /app/backend/build . 46 | COPY --from=backend /app/backend/tsconfig.json ./backend 47 | COPY .git ./.git 48 | 49 | RUN echo "window.build=\"$(git rev-parse HEAD | cut -c 1-7)_$(date +'%d.%m.%Y')_$(date +"%T")\";" > /app/client/env.js \ 50 | && rm -rf .git 51 | 52 | EXPOSE 80 443 53 | ENTRYPOINT [ "npm", "start" ] -------------------------------------------------------------------------------- /Dockerfile.cuda: -------------------------------------------------------------------------------- 1 | FROM nvidia/cuda:11.4.2-base-ubuntu20.04 as deps 2 | WORKDIR /app 3 | 4 | ARG DEBIAN_FRONTEND=noninteractive 5 | RUN apt update && apt install -y curl && \ 6 | curl -fsSL https://deb.nodesource.com/setup_17.x | bash - && \ 7 | apt update && \ 8 | apt install -y nodejs python3 g++ make 9 | 10 | COPY package.json . 11 | COPY package-lock.json . 12 | 13 | RUN npm i \ 14 | && mv ./node_modules/@types/jsonstream ./node_modules/@types/JSONStream 15 | 16 | 17 | FROM deps as frontend 18 | WORKDIR /app 19 | 20 | COPY public ./public 21 | COPY src ./src 22 | COPY plugins ./plugins 23 | COPY types ./types 24 | COPY Shared ./Shared 25 | COPY .browserslistrc tsconfig.json tslint.json vue.config.js ./ 26 | COPY .eslintrc.js ./ 27 | 28 | RUN npm run build-frontend 29 | 30 | 31 | FROM deps as backend 32 | WORKDIR /app 33 | 34 | COPY backend ./backend 35 | COPY Shared ./Shared 36 | 37 | RUN npm run build-backend 38 | 39 | 40 | 41 | FROM nvidia/cuda:11.4.2-base-ubuntu20.04 42 | WORKDIR /app 43 | 44 | ARG DEBIAN_FRONTEND=noninteractive 45 | RUN apt update && apt install -y curl && \ 46 | curl -fsSL https://deb.nodesource.com/setup_17.x | bash - && \ 47 | apt update && \ 48 | apt install -y nodejs git 49 | 50 | RUN curl -L -o ffmpeg-master-latest-linux64-lgpl.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-lgpl.tar.xz" && \ 51 | tar xf ffmpeg-master-latest-linux64-lgpl.tar.xz && \ 52 | mv ffmpeg-master-latest-linux64-lgpl ffmpeg 53 | 54 | ENV PATH "$PATH:/app/ffmpeg/bin" 55 | 56 | COPY --from=deps /app/node_modules node_modules 57 | COPY --from=deps /app/package.json . 58 | COPY --from=frontend /app/client client 59 | COPY --from=backend /app/backend/build . 60 | COPY --from=backend /app/backend/tsconfig.json ./backend 61 | COPY .git ./.git 62 | 63 | RUN echo "window.build=\"$(git rev-parse HEAD | cut -c 1-7)_$(date +'%d.%m.%Y')_$(date +"%T")\";" > /app/client/env.js \ 64 | && rm -rf .git 65 | 66 | EXPOSE 80 443 67 | ENTRYPOINT [ "npm", "start" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JoyBox 2 | ## Features 3 | + Stream recording 4 | + Built-in player 5 | + Push notifications 6 | + Web interface 7 | ## Providers 8 | + bongacams 9 | + chaturbate 10 | + camsoda 11 | + kick.com (category clips only) 12 | ## How to run 13 | 14 | ### Alpine 15 | ``` 16 | docker build -t joybox . 17 | ``` 18 | 19 | ``` 20 | docker run --name joybox --rm -d -p 8080:80 -p 8081:443 -v $(pwd):/app/data joybox 21 | ``` 22 | 23 | 24 | ### Ubuntu with CUDA 25 | 26 | ``` 27 | docker build -t joybox-cuda -f Dockerfile.cuda . 28 | ``` 29 | 30 | ``` 31 | docker run --name joybox --rm -d -e REENCODING_MODE=hardware -e NVIDIA_DRIVER_CAPABILITIES=all --gpus=all -p 8080:80 -p 8081:443 -v $(pwd):/app/data joybox-cuda 32 | ``` 33 | 34 |
35 | 36 | Open `http://host:8080` or `https://host:8081` 37 | 38 | ## Installation on Raspberry Pi's 39 | Due to a bug in Alpine 3.13 you need to use Alpine 3.12 - Replace 'node:current-alpine' with 'alpine:3.12' line 1 and 38 in the Dockerfile; 40 | This bug is mentioned here: https://github.com/alpinelinux/docker-alpine/issues/135 41 | 42 | ## Kick.com 43 | 44 | kick.com only works in tandem with Selenium 45 | 46 | ## Selenium 47 | 48 | ``` 49 | docker run -d -p 4444:4444 -p 7900:7900 --shm-size="2g" selenium/standalone-firefox:4.11.0-20230801 50 | ``` 51 | 52 | Set remote selenium url to 53 | 54 | ``` 55 | http://[container_host]:4444/wd/hub 56 | ``` 57 | 58 | ## Settings 59 | All files should be in the mounted folder 60 | ### SSL 61 | 62 | `server.cer` & `server.key` 63 | 64 | ### Push notifications 65 | 66 | `vapid.json` 67 | ```json 68 | { 69 | "subject": "mailto:service@example.com", 70 | "privateKey": "", 71 | "publicKey": "" 72 | } 73 | ``` -------------------------------------------------------------------------------- /Shared/Constants.ts: -------------------------------------------------------------------------------- 1 | export const UPLOAD_VIDEO_PATH = '/upload_video'; 2 | -------------------------------------------------------------------------------- /Shared/Util.ts: -------------------------------------------------------------------------------- 1 | export enum SizeToByteMetric { BYTE = 'B', KB = 'k', MB = 'M', GB = 'G', TB = 'T' } 2 | /** 3 | * Parse size string like 300kB to bytes 4 | * @param size string contain size 5 | * @returns Rounded number of bytes or NaN 6 | */ 7 | export function SizeStrToByte(size: string, defaultMetric: SizeToByteMetric = SizeToByteMetric.BYTE): number { 8 | if (typeof size !== 'string') { 9 | return NaN; 10 | } 11 | 12 | const MetricMult = (m: string): number => { 13 | return Math.pow(1000, [ 14 | SizeToByteMetric.BYTE, 15 | SizeToByteMetric.KB, 16 | SizeToByteMetric.MB, 17 | SizeToByteMetric.GB, 18 | SizeToByteMetric.TB].findIndex(x => x === m)); 19 | }; 20 | 21 | return Math.ceil(size.endsWith('bit') ? 22 | Number.parseFloat(size) * MetricMult(size[size.length - 4]) / 8 : 23 | size.endsWith('B') ? 24 | Number.parseFloat(size) * MetricMult(size[size.length - 2]) : 25 | Number.parseFloat(size) * MetricMult(defaultMetric)); 26 | } 27 | 28 | export function Clamp(x: number, lo: number, hi: number): number { 29 | return Math.min(hi, Math.max(x, lo)); 30 | } 31 | 32 | export function Merge(comparator: (l: T, r: T) => boolean, ...arrays: T[][]): T[] { 33 | const it: number[] = new Array(arrays.length).fill(0, 0, arrays.length); 34 | let minIdx = 0; 35 | const ret: T[] = []; 36 | while (minIdx !== -1) { 37 | minIdx = arrays.findIndex((x, i) => it[i] < x.length); 38 | 39 | for (let n = 0; n < arrays.length; ++n) { 40 | if (it[n] < arrays[n].length && comparator(arrays[n][it[n]], arrays[minIdx][it[minIdx]])) { 41 | minIdx = n; 42 | } 43 | } 44 | 45 | if (minIdx !== -1) { 46 | ret.push(arrays[minIdx][it[minIdx]]); 47 | ++it[minIdx]; 48 | } 49 | } 50 | 51 | return ret; 52 | } 53 | -------------------------------------------------------------------------------- /VSCODE_DEV.md: -------------------------------------------------------------------------------- 1 | `npm run serve` 2 | 3 | `launch.json` 4 | ```json 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "type": "node", 10 | "request": "launch", 11 | "name": "Backend", 12 | "runtimeArgs": [ 13 | "-r", 14 | "ts-node/register", 15 | "-r", 16 | "tsconfig-paths/register" 17 | ], 18 | "args": [ 19 | "${workspaceFolder}/backend/Src/main.ts" 20 | ], 21 | "env": { 22 | "env": "dev", 23 | "default-access": "FULL_ACCESS", 24 | "passphrase": "12345", 25 | "hostname": "dev.lan", 26 | "TS_NODE_PROJECT": "backend/tsconfig.json", 27 | "PORT": "3000" 28 | } 29 | }, 30 | { 31 | "type": "chrome", 32 | "request": "launch", 33 | "name": "Frontend", 34 | "url": "https://dev.lan:8080", 35 | "webRoot": "${workspaceFolder}/src", 36 | "breakOnLoad": true, 37 | "sourceMapPathOverrides": { 38 | "webpack:///./src/*": "${webRoot}/*", 39 | "webpack:///src/*": "${webRoot}/*", 40 | "webpack:///*": "*", 41 | "webpack:///./~/*": "${webRoot}/node_modules/*" 42 | } 43 | } 44 | ] 45 | } 46 | ``` -------------------------------------------------------------------------------- /backend/Src/AccessGuard.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import * as jwt from 'jsonwebtoken'; 3 | class ProcessingError { 4 | public constructor(public reponseCode: number) { } 5 | } 6 | export class AccessGuard { 7 | public constructor(private secret: string) { } 8 | public Handler(req: Request, res: Response, next: NextFunction) { 9 | try { 10 | const token = req.header('Authorization'); 11 | 12 | if (!token) 13 | throw new ProcessingError(400); 14 | 15 | try { 16 | jwt.verify(token, this.secret); 17 | } catch (e) { 18 | throw new ProcessingError(401); 19 | } 20 | next(); 21 | } catch (e) { 22 | if (e instanceof ProcessingError) 23 | res.status((e as ProcessingError).reponseCode).end(); 24 | } 25 | } 26 | public get Middleware() { return this.Handler.bind(this); } 27 | } 28 | -------------------------------------------------------------------------------- /backend/Src/ClientIO/Unicaster.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Socket } from 'socket.io'; 3 | 4 | import { Exact, AppStateSnapshot } from '@Shared/Types'; 5 | 6 | export class Unicaster { 7 | public constructor(private client: Socket) { } 8 | public Snapshot(snapshot: Exact) { 9 | this.client.emit('Snapshot', snapshot); 10 | } 11 | public ProlongateSession(token: string) { 12 | this.client.emit('ProlongateSession', token); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/Src/ClientIO/index.ts: -------------------------------------------------------------------------------- 1 | export { Broadcaster } from './Broadcaster'; 2 | export { RpcMethod, RpcRequestHandler, RpcRequestHandlerImpl } from './RpcRequestHandler'; 3 | export { Unicaster } from './Unicaster'; 4 | -------------------------------------------------------------------------------- /backend/Src/Common/Event/index.ts: -------------------------------------------------------------------------------- 1 | export interface Observable { 2 | On(cb: (e: T) => void): void; 3 | Off(cb: (e: T) => void): void; 4 | } 5 | 6 | export class Event implements Observable { 7 | protected cbs: Set<(e: T) => void> = new Set(); 8 | public On(cb: (e: T) => void): void { this.cbs.add(cb); } 9 | public Off(cb: (e: T) => void): void { this.cbs.delete(cb); } 10 | public Emit(e: T): void { this.cbs.forEach(x => x(e)); } 11 | } 12 | -------------------------------------------------------------------------------- /backend/Src/Common/ExecutionFence.ts: -------------------------------------------------------------------------------- 1 | export class ExecutionFence { 2 | private fence: Promise | null = null; 3 | 4 | private resolve: (() => void) | null = null; 5 | 6 | // Pause execution in place where ExecutionFence() invoked 7 | public Pause(): void { 8 | this.fence = new Promise(r => (this.resolve = r)); 9 | } 10 | 11 | // Resume execution after Pause() 12 | public Resume(): void { 13 | if (this.resolve) { 14 | this.resolve(); 15 | 16 | this.resolve = null; 17 | this.fence = null; 18 | } 19 | } 20 | 21 | // Pause execution if Pause() was invoked 22 | public async ExecutionFence(): Promise { 23 | if (this.fence) { await this.fence; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/Src/Common/FFmpeg/ClipMaker.ts: -------------------------------------------------------------------------------- 1 | import { FFMpegProgress } from 'ffmpeg-progress-wrapper'; 2 | 3 | import { Event } from '../Event'; 4 | import { FFMpegProgressInfo } from './'; 5 | 6 | export enum ReencodingMode { Copy, Software, Hardware } 7 | 8 | export class ClipMaker { 9 | private progress: Event = new Event(); 10 | private complete: Event = new Event(); 11 | public get Progress(): Event { return this.progress; } 12 | public get Complete(): Event { return this.complete; } 13 | public constructor( 14 | private source: string, 15 | private begin: number, 16 | private end: number, 17 | private dest: string, 18 | private reencode: ReencodingMode) { 19 | const ffmpeg = new FFMpegProgress(this.BuildOptions()); 20 | ffmpeg.on('progress', x => this.progress.Emit(x)); 21 | ffmpeg.once('end', code => this.complete.Emit(code === 0)); 22 | } 23 | 24 | private BuildOptions() { 25 | return ['-ss', this.begin.toString(), 26 | '-i', this.source, 27 | '-t', (this.end - this.begin).toString(), 28 | ...this.ReencodeOptions, 29 | this.dest]; 30 | } 31 | 32 | private get ReencodeOptions(): string[] { 33 | switch (this.reencode) { 34 | case ReencodingMode.Copy: 35 | return ['-acodec', 'copy', '-vcodec', 'copy']; 36 | case ReencodingMode.Software: 37 | return []; 38 | case ReencodingMode.Hardware: 39 | return ['-vcodec', 'h264_nvenc']; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/Src/Common/FFmpeg/Exceptions.ts: -------------------------------------------------------------------------------- 1 | export class FileNotFoundException extends Error { } 2 | -------------------------------------------------------------------------------- /backend/Src/Common/FFmpeg/MediaInfo.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { spawn } from 'child_process'; 3 | 4 | import * as JSONStream from 'JSONStream'; 5 | import { Transform, TransformCallback } from 'stream'; 6 | 7 | import { Exists } from '../../Common/Util'; 8 | import { FileNotFoundException } from './Exceptions'; 9 | 10 | export interface Disposition { 11 | default: number; 12 | dub: number; 13 | original: number; 14 | comment: number; 15 | lyrics: number; 16 | karaoke: number; 17 | forced: number; 18 | hearing_impaired: number; 19 | visual_impaired: number; 20 | clean_effects: number; 21 | attached_pic: number; 22 | timed_thumbnails: number; 23 | } 24 | 25 | export interface Tags { 26 | language: string; 27 | handler_name: string; 28 | } 29 | 30 | export interface StreamInfo { 31 | index: number; 32 | codec_name: string; 33 | codec_long_name: string; 34 | profile: string; 35 | codec_type: string; 36 | codec_time_base: string; 37 | codec_tag_string: string; 38 | codec_tag: string; 39 | width: number; 40 | height: number; 41 | coded_width: number; 42 | coded_height: number; 43 | has_b_frames: number; 44 | pix_fmt: string; 45 | level: number; 46 | chroma_location: string; 47 | refs: number; 48 | is_avc: string; 49 | nal_length_size: string; 50 | r_frame_rate: string; 51 | avg_frame_rate: string; 52 | time_base: string; 53 | start_pts: number; 54 | start_time: string; 55 | duration_ts: number; 56 | duration: string; 57 | bit_rate: string; 58 | bits_per_raw_sample: string; 59 | nb_frames: string; 60 | disposition: Disposition; 61 | tags: Tags; 62 | } 63 | 64 | class TransformToString extends Transform { 65 | constructor() { 66 | super({ objectMode: true }); 67 | } 68 | 69 | public _transform(chunk: Buffer, encoding: string, callback: TransformCallback) { 70 | callback(null, chunk.toString()); 71 | } 72 | } 73 | export class MediaInfo { 74 | private toString: TransformToString = new TransformToString(); 75 | 76 | /** 77 | * Extract media info from first v-stream 78 | * @param filename path to media file. 79 | */ 80 | public Info(filename: string): Promise { 81 | return new Promise(async (resolve, reject) => { 82 | // Костиль из-за ERR_STREAM_WRITE_AFTER_END в ffprobe 83 | if (!await Exists(filename)) { 84 | reject(new FileNotFoundException(`File ${filename} not found.`)); 85 | return; 86 | } 87 | 88 | const opts = ['-loglevel', 'error', 89 | '-show_streams', '-select_streams', 90 | 'v:0', '-print_format', 91 | 'json=compact=1', filename]; 92 | const ffprobe = spawn('ffprobe', opts, { detached: true }); 93 | ffprobe.stdout 94 | .pipe(JSONStream.parse('*')) 95 | .once('data', (data: StreamInfo[]) => resolve(data[0])); 96 | ffprobe.stderr 97 | .pipe(this.toString) 98 | .once('data', (data: string) => reject(new Error(data))); 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /backend/Src/Common/FFmpeg/ThumbnailGenerator.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | 3 | /** 4 | * @param source path to source 5 | * @param position position from start where thumbnail will be taken, in seconds 6 | * @param destination path to save thumbnail 7 | */ 8 | export class ThumbnailGenerator { 9 | public Generate(source: string, position: number, destination: string): Promise { 10 | return new Promise((resolve, reject) => { 11 | const ffmpeg = spawn('ffmpeg', 12 | ['-loglevel', 'error', 13 | '-ss', position.toString(), 14 | '-i', source, 15 | '-vframes', '1', `${destination}.jpg`]); 16 | ffmpeg.once('error', () => reject(false)); 17 | ffmpeg.once('close', () => resolve(true)); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/Src/Common/FFmpeg/index.ts: -------------------------------------------------------------------------------- 1 | export interface FFMpegProgressInfo { 2 | bitrate: string; // 3626.3kbits/s 3 | eta: any; 4 | fps: number; 5 | frame: number; 6 | progress: any; 7 | q: number; 8 | size: string; // 21248kB 9 | Lsize: string; 10 | speed: number; 11 | time: number; // milliseconds 12 | } 13 | export { ClipMaker, ReencodingMode } from './ClipMaker'; 14 | export { FileNotFoundException } from './Exceptions'; 15 | export { MediaInfo } from './MediaInfo'; 16 | export { ThumbnailGenerator } from './ThumbnailGenerator'; 17 | -------------------------------------------------------------------------------- /backend/Src/Common/LimitedCache.ts: -------------------------------------------------------------------------------- 1 | export class LimitedCache { 2 | private data = new Map(); 3 | public constructor(private capacity: number) { } 4 | public Set(key: K, value: V) { 5 | if (this.data.size >= this.capacity) 6 | this.data.delete(this.data.keys().next().value); 7 | 8 | this.data.set(key, value); 9 | } 10 | public Get(key: K) { 11 | return this.data.get(key) ?? null; 12 | } 13 | public Has(key: K) { 14 | return this.data.has(key); 15 | } 16 | public get Values() { return this.data.values(); } 17 | } 18 | -------------------------------------------------------------------------------- /backend/Src/Common/LinkCounter.ts: -------------------------------------------------------------------------------- 1 | export class LinkCounter { 2 | private usage = new Map(); 3 | public Capture(label: T) { 4 | const found = this.usage.get(label) || 0; 5 | this.usage.set(label, found + 1); 6 | } 7 | public Release(label: T) { 8 | const found = this.usage.get(label) || 0; 9 | this.usage.set(label, found - 1); 10 | } 11 | public Captured(label: T) { return !!this.usage.get(label); } 12 | } 13 | -------------------------------------------------------------------------------- /backend/Src/Common/Logger/ConsoleWriter.ts: -------------------------------------------------------------------------------- 1 | import { LogWriter } from './'; 2 | 3 | export class ConsoleWriter implements LogWriter { 4 | public Write(text: string): void { console.log(text); } 5 | } 6 | -------------------------------------------------------------------------------- /backend/Src/Common/Logger/SqliteWriter.ts: -------------------------------------------------------------------------------- 1 | import { LogWriter } from './'; 2 | import { SqliteAdapter } from './../../Services/SqliteAdapter'; 3 | 4 | export class SqliteWriter implements LogWriter { 5 | public constructor(private storage: SqliteAdapter) { } 6 | public Write(text: string): void { this.storage.Log(text); } 7 | } 8 | -------------------------------------------------------------------------------- /backend/Src/Common/Logger/index.ts: -------------------------------------------------------------------------------- 1 | export { ConsoleWriter } from './ConsoleWriter'; 2 | export { SqliteWriter } from './SqliteWriter'; 3 | 4 | export interface LogWriter { 5 | Write(text: string): void; 6 | } 7 | export class Logger { 8 | private static instance: Logger | null = null; 9 | private writers: Set = new Set(); 10 | private constructor() { } 11 | public static get Get() { return this.instance ? this.instance : this.instance = new Logger(); } 12 | public AddWriter(writer: LogWriter) { this.writers.add(writer); } 13 | public RemoveWriter(writer: LogWriter) { this.writers.delete(writer); } 14 | public Log(msg: string) { this.writers.forEach(x => x.Write(msg)); } 15 | } 16 | -------------------------------------------------------------------------------- /backend/Src/Common/RecurringTask.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a recurring task. 3 | * Rescheduling will be canceled, if exceptions throws. 4 | */ 5 | export abstract class RecurringTask { 6 | private isRunning = false; 7 | private isTaskRunning = false; 8 | private timer!: NodeJS.Timeout; 9 | private resolveCb: (() => void) | null = null; 10 | public get IsRunning(): boolean { return this.isRunning; } 11 | public constructor(protected period: number) { } 12 | public async Start(): Promise { 13 | if (!this.isRunning) { 14 | this.isRunning = true; 15 | await this.ScheduleNext(); 16 | } 17 | } 18 | 19 | public async Stop(): Promise { 20 | if (this.isRunning) { 21 | this.isRunning = false; 22 | clearTimeout(this.timer); 23 | } 24 | return this.isTaskRunning ? new Promise(r => (this.resolveCb = r)) : Promise.resolve(); 25 | } 26 | 27 | public abstract Task(): Promise; 28 | 29 | public abstract OnAbort(e: Error): void; 30 | 31 | private async ScheduleNext() { 32 | this.isTaskRunning = true; 33 | try { 34 | await this.Task(); 35 | 36 | this.isTaskRunning = false; 37 | 38 | if (this.isRunning) { this.timer = setTimeout(() => this.ScheduleNext(), this.period); } 39 | } catch (e) { 40 | this.isTaskRunning = false; 41 | this.isRunning = false; 42 | this.OnAbort(e as Error); 43 | } 44 | 45 | if (this.resolveCb !== null) { 46 | this.resolveCb(); 47 | this.resolveCb = null; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/Src/Common/Types.ts: -------------------------------------------------------------------------------- 1 | import { NotificationType } from '@Shared/Types'; 2 | import { PushSubscription } from 'web-push'; 3 | import { LocatorService } from '../Plugins/Plugin'; 4 | export interface Plugin { 5 | id: number; 6 | name: string; 7 | enabled: boolean; 8 | service: LocatorService; 9 | } 10 | export interface ObservableStream { 11 | // User-friendly stream url 12 | url: string; 13 | lastSeen: number; 14 | download: number; 15 | valid: boolean; 16 | // Plugins that can handle stream, arranged in priority order 17 | plugins: Plugin[]; 18 | } 19 | 20 | export type TrackedStreamCollection = Map; 21 | 22 | export interface ClientSubscription { 23 | push: PushSubscription; 24 | notifications: NotificationType[] 25 | } 26 | -------------------------------------------------------------------------------- /backend/Src/Constants.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | export const DATA_FOLDER = 'data'; 4 | export const INCOMPLETE_FOLDER = path.join(DATA_FOLDER, 'incomplete'); 5 | export const ARCHIVE_FOLDER = path.join(DATA_FOLDER, 'archive'); 6 | export const THUMBNAIL_FOLDER = path.join(ARCHIVE_FOLDER, 'thumbnail'); 7 | export const DB_FILENAME = 'db.sqlite3'; 8 | export const DB_LOCATION = path.join(DATA_FOLDER, DB_FILENAME); 9 | export const TLS_PRIVATE_KEY = path.join(DATA_FOLDER, 'server.key'); 10 | export const TLS_CERTIFICATE = path.join(DATA_FOLDER, 'server.cer'); 11 | export const VAPID_CONFIG = path.join(DATA_FOLDER, 'vapid.json'); 12 | export const JWT_TTL = 3600; 13 | export const JWT_PROLONGATION_TTL = 900; 14 | -------------------------------------------------------------------------------- /backend/Src/HttpClient/HttpClient.ts: -------------------------------------------------------------------------------- 1 | export interface HttpClient { 2 | Get(url: string): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /backend/Src/HttpClient/RemoteSeleniumHttpClient.ts: -------------------------------------------------------------------------------- 1 | import { Builder, By, WebDriver } from 'selenium-webdriver'; 2 | 3 | import { HttpClient } from './HttpClient'; 4 | 5 | export class RemoteSeleniumHttpClient implements HttpClient { 6 | private browser!: WebDriver; 7 | 8 | static async Create(url: string, browser: string): Promise { 9 | const instance = new RemoteSeleniumHttpClient(); 10 | 11 | instance.browser = await new Builder() 12 | .forBrowser(browser) 13 | .usingServer(url) 14 | .build(); 15 | 16 | return instance; 17 | } 18 | 19 | private constructor() { } 20 | 21 | async Get(url: string): Promise { 22 | await this.browser.get(url); 23 | 24 | return this.browser.findElement(By.css('body')).getText(); 25 | } 26 | 27 | async Dispose(): Promise { 28 | await this.browser.quit(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/Src/Kick/KickApi.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { HttpClient } from '../HttpClient/HttpClient'; 3 | 4 | interface ClipCategory { 5 | banner: string; 6 | id: number; 7 | name: string; 8 | parent_category: string; 9 | responsive: string; 10 | slug: string; 11 | } 12 | 13 | interface ClipChannel { 14 | id: number; 15 | profile_picture: string; 16 | slug: string; 17 | username: string; 18 | } 19 | 20 | interface ClipCreator { 21 | id: number; 22 | profile_picture: string; 23 | slug: string; 24 | username: string; 25 | } 26 | 27 | type ClipPrivacy = 'CLIP_PRIVACY_PUBLIC'; 28 | 29 | export interface Clip { 30 | category: ClipCategory; 31 | category_id: string; 32 | channel: ClipChannel; 33 | channel_id: number; 34 | clip_url: string; 35 | created_at: string; 36 | creator: ClipCreator; 37 | duration: number; 38 | id: string; 39 | is_mature: boolean; 40 | liked: boolean; 41 | likes: number; 42 | likes_count: number; 43 | livestream_id: string; 44 | privacy: ClipPrivacy; 45 | started_at: string; 46 | thumbnail_url: string; 47 | title: string; 48 | user_id: number; 49 | video_url: string; 50 | view_count: number; 51 | views: number; 52 | } 53 | 54 | interface ClipApiResponse { 55 | clips: Clip[]; 56 | nextCursor?: string; 57 | } 58 | 59 | type ClipSortOrder = 'view' | 'date' | 'like'; 60 | 61 | type ClipTimeframe = 'day' | 'week' | 'month' | 'all'; 62 | 63 | export class KickApi { 64 | private httpClient!: HttpClient 65 | 66 | constructor(private entry: string) { } 67 | 68 | set HttpClient(httpClient: HttpClient) { 69 | this.httpClient = httpClient; 70 | } 71 | 72 | async Clips(category: string, sort: ClipSortOrder, time: ClipTimeframe): Promise { 73 | const clips: Map = new Map(); 74 | 75 | let response = await this.FetchClips(category, sort, time, '0'); 76 | response.clips.forEach(x => clips.set(x.id, x)); 77 | 78 | while (response.nextCursor) { 79 | response = await this.FetchClips(category, sort, time, response.nextCursor); 80 | response.clips.forEach(x => clips.set(x.id, x)); 81 | } 82 | 83 | return [...clips.values()]; 84 | } 85 | 86 | private async FetchClips(category: string, sort: ClipSortOrder, time: ClipTimeframe, cursor: string): Promise { 87 | const response = await this.httpClient.Get(`${this.entry}/categories/${category}/clips?cursor=${cursor}&sort=${sort}&time=${time}`); 88 | 89 | return JSON.parse(response); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /backend/Src/PluginManagerListener.ts: -------------------------------------------------------------------------------- 1 | import * as Path from 'path'; 2 | 3 | import { AppFacade } from './AppFacade'; 4 | import { GenFilename, Timestamp } from './Common/Util'; 5 | import { INCOMPLETE_FOLDER } from './Constants'; 6 | import { LiveStream } from './Plugins/Plugin'; 7 | 8 | export type Interceptor = (e: LiveStream) => boolean; 9 | export class PluginManagerListener { 10 | private interceptorList = new Set(); 11 | public constructor(private app: AppFacade) { 12 | this.app.PluginManager.LiveStreamEvent.On(e => this.InterceptStage(e)); 13 | } 14 | 15 | public AddInterceptor(fn: Interceptor): void { 16 | this.interceptorList.add(fn); 17 | } 18 | 19 | public RemoveInterceptor(fn: Interceptor): void { 20 | this.interceptorList.delete(fn); 21 | } 22 | 23 | private Onlive(e: LiveStream) { 24 | this.app.Recorder.StartRecording(e.url, e.streamUrl, Path.join(INCOMPLETE_FOLDER, GenFilename(e.url))); 25 | this.app.LinkedStreams.Remove(e.url); 26 | this.UpdateLastSeen(e.url); 27 | this.app.Broadcaster.NewRecording({ label: e.url, streamUrl: e.streamUrl }); 28 | } 29 | 30 | private InterceptStage(e: LiveStream) { 31 | if ([...this.interceptorList.values()].every(x => x(e))) { this.Onlive(e); } 32 | } 33 | 34 | private UpdateLastSeen(url: string) { 35 | const now = Timestamp(); 36 | const target = this.app.Observables.get(url); 37 | if (!target) return; 38 | target.lastSeen = now; 39 | this.app.Storage.UpdateLastSeen(url, now); 40 | this.app.Broadcaster.UpdateLastSeen({ url, lastSeen: now }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Bongacams/BongacamsDirectLocator.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as Rx from 'rxjs'; 3 | import * as Rop from 'rxjs/operators'; 4 | 5 | import { LocatorService } from '../Plugin'; 6 | import { Logger } from './../../Common/Logger'; 7 | 8 | export class BongacamsDirectLocator extends LocatorService { 9 | private readonly EMPTY_PLAYLIST_SIZE = 25; 10 | public async Start(): Promise { 11 | const status = this.IsRunning; 12 | await super.Start(); 13 | if (status !== this.IsRunning) { Logger.Get.Log('BongacamsDirectLocator::Start()'); } 14 | } 15 | 16 | public async Stop(): Promise { 17 | await super.Stop(); 18 | if (!this.IsRunning) { Logger.Get.Log('BongacamsDirectLocator::Stop()'); } 19 | } 20 | 21 | public async Task(): Promise { 22 | if (this.observables.size === 0) { 23 | return; 24 | } 25 | 26 | try { 27 | await this.pauseFence.ExecutionFence(); 28 | 29 | Rx.from([...this.observables]) 30 | .pipe( 31 | Rop.flatMap(async (x) => { 32 | await this.pauseFence.ExecutionFence(); 33 | 34 | return { url: x, streamUrl: await this.extractor.Extract(x) }; 35 | }), 36 | Rop.catchError(x => Rx.of(({ url: x, streamUrl: '' }))), 37 | Rop.flatMap(async (x) => ({ ...x, online: await this.IsOnline(x.streamUrl) })), 38 | Rop.filter(x => x.online) 39 | ).subscribe(x => this.Notify({ url: x.url, streamUrl: x.streamUrl as string })); 40 | } catch (e) { 41 | Logger.Get.Log(e as string); 42 | } 43 | } 44 | 45 | public OnAbort(e: Error): void { 46 | Logger.Get.Log('BongacamsDirectLocator::Stop() with ' + e); 47 | } 48 | 49 | private async IsOnline(source: string | null): Promise { 50 | if (source === null) { 51 | return false; 52 | } 53 | 54 | try { 55 | const response = await axios.get(source); 56 | const playlistContent = response.data; 57 | return playlistContent.length !== this.EMPTY_PLAYLIST_SIZE; 58 | } catch (e) { 59 | return false; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Bongacams/BongacamsExtractor.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios'; 3 | import { URL } from 'url'; 4 | import { UsernameFromUrl } from '../../Common/Util'; 5 | import { StreamExtractor } from '../Plugin'; 6 | 7 | export interface UserData { 8 | username: string; 9 | displayName: string; 10 | location: string; 11 | chathost: string; 12 | isRu: boolean; 13 | } 14 | 15 | export interface LocalData { 16 | dataKey: string; 17 | NC_AccessSalt: string; 18 | NC_AccessKey: string; 19 | vsid: string; 20 | videoServerUrl: string; 21 | } 22 | 23 | export interface MessageStyle { 24 | color: string; 25 | fontFamily: string; 26 | fontSize: string; 27 | } 28 | 29 | export interface DmcaSecurity { 30 | image: string; 31 | width: number; 32 | height: number; 33 | marginTop: number; 34 | marginLeft: number; 35 | } 36 | 37 | export interface PerformerData { 38 | userId: number; 39 | username: string; 40 | displayName: string; 41 | friendRequestSettings: string; 42 | messageStyle: MessageStyle; 43 | hasProfile: boolean; 44 | sexType: string; 45 | showType: string; 46 | gender: string; 47 | avatarUrl: string; 48 | isRu: boolean; 49 | videoQuality: string; 50 | loversCount: number; 51 | dmcaSecurity: DmcaSecurity; 52 | sessionTs: number; 53 | } 54 | 55 | export interface RoomInfo { 56 | status: string; 57 | userData: UserData; 58 | localData: LocalData; 59 | performerData: PerformerData; 60 | } 61 | 62 | export class BongacamsExtractor implements StreamExtractor { 63 | public async Extract(uri: string): Promise { 64 | try { 65 | return await this.ExtractPlaylist(uri); 66 | } catch (e) { 67 | return null; 68 | } 69 | } 70 | 71 | public CanParse(uri: string): boolean { 72 | const hostname = new URL(uri).hostname; 73 | 74 | if (typeof hostname !== 'string') { 75 | return false; 76 | } 77 | 78 | return hostname.toLowerCase().endsWith('bongacams.com'); 79 | } 80 | 81 | private async ExtractPlaylist(uri: string): Promise { 82 | const username = UsernameFromUrl(uri); 83 | const body = `method=getRoomData&args[]=${username}&args[]=true`; 84 | 85 | const headers = { 86 | 'x-requested-with': 'XMLHttpRequest', 87 | 'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; rv:20.0) Gecko/20121202 Firefox/20.0' 88 | }; 89 | 90 | const response = await axios.post('https://bongacams.com/tools/amf.php', body, { headers }); 91 | const info = response.data; 92 | 93 | if (info.status === 'error') { 94 | throw new Error('Server respond an error'); 95 | } 96 | 97 | return `https:${info.localData.videoServerUrl}/hls/stream_${info.performerData.username}/playlist.m3u8`; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Bongacams/BongacamsLocator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios, { AxiosError } from 'axios'; 3 | 4 | import { isAxiosError, UsernameFromUrl } from '../../Common/Util'; 5 | import { LocatorService } from '../Plugin'; 6 | 7 | import { Logger } from './../../Common/Logger'; 8 | 9 | interface ProfileImage { 10 | profile_image: string; 11 | thumbnail_image_small: string; 12 | thumbnail_image_medium: string; 13 | thumbnail_image_big: string; 14 | thumbnail_image_small_live: string; 15 | thumbnail_image_medium_live: string; 16 | thumbnail_image_big_live: string; 17 | } 18 | 19 | interface Streamer { 20 | username: string; 21 | display_name: string; 22 | display_age: string; 23 | profile_images: ProfileImage; 24 | chat_url: string; 25 | random_chat_url: string; 26 | popular_chat_url: string; 27 | chat_url_on_home_page: string; 28 | direct_chat_url: string; 29 | profile_page_url: string; 30 | online_time: number; 31 | chat_status: string; 32 | marker: string; 33 | status: boolean; 34 | hometown: string; 35 | turns_on: string; 36 | turns_off: string; 37 | signup_date: string; 38 | last_update_date: string; 39 | members_count: number; 40 | vibratoy: boolean; 41 | hd_cam: boolean; 42 | primary_language_key: string; 43 | secondary_language_key: string; 44 | gender: string; 45 | height: string; 46 | weight: string; 47 | ethnicity: string; 48 | hair_color: string; 49 | eye_color: string; 50 | bust_penis_size: string; 51 | pubic_hair: string; 52 | primary_language: string; 53 | secondary_language: string; 54 | embed_chat_url: string; 55 | is_geo: boolean; 56 | } 57 | export class BongacamsLocator extends LocatorService { 58 | private readonly ONLINE_ENDPOINT = 'http://tools.bongacams.com/promo.php?c=3511&type=api&api_type=json'; 59 | public async Start(): Promise { 60 | const status = this.IsRunning; 61 | await super.Start(); 62 | if (status !== this.IsRunning) { Logger.Get.Log('BongacamsLocator::Start()'); } 63 | } 64 | 65 | public async Stop(): Promise { 66 | await super.Stop(); 67 | if (!this.IsRunning) { Logger.Get.Log('BongacamsLocator::Stop()'); } 68 | } 69 | 70 | public async Task(): Promise { 71 | if (this.observables.size === 0) { 72 | return; 73 | } 74 | 75 | try { 76 | await this.pauseFence.ExecutionFence(); 77 | 78 | const response = await axios.get(this.ONLINE_ENDPOINT); 79 | const streamersIndex = new Set(response.data.map(x => x.username.toLowerCase())); 80 | 81 | [...this.observables] 82 | .filter(x => streamersIndex.has(UsernameFromUrl(x).toLowerCase())) 83 | .forEach(async (x: string) => { 84 | await this.pauseFence.ExecutionFence(); 85 | 86 | const streamUrl = await this.extractor.Extract(x); 87 | if (streamUrl !== '' && streamUrl !== null) { 88 | this.Notify({ url: x, streamUrl }); 89 | } 90 | }); 91 | } catch (e) { 92 | if (isAxiosError(e)) { Logger.Get.Log(e.message); } 93 | } 94 | } 95 | 96 | public OnAbort(e: Error): void { 97 | Logger.Get.Log('BongacamsLocator::Stop() with ' + e); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Bongacams/index.ts: -------------------------------------------------------------------------------- 1 | export { BongacamsExtractor } from './BongacamsExtractor'; 2 | export { BongacamsLocator } from './BongacamsLocator'; 3 | export { BongacamsDirectLocator } from './BongacamsDirectLocator'; 4 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Camsoda/CamsodaExtractor.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as Url from 'url'; 3 | 4 | import { UsernameFromUrl } from '../../Common/Util'; 5 | import { StreamExtractor } from '../Plugin'; 6 | 7 | export interface VTokenInfo { 8 | status: number; 9 | token: string; 10 | app: string; 11 | edge_servers: string[]; 12 | stream_name: string; 13 | private_servers: string[]; 14 | aspect_ratio: string; 15 | c2c_server: string; 16 | } 17 | 18 | export class CamsodaExtractor implements StreamExtractor { 19 | private readonly VTOKEN_RESOURCE = 'https://www.camsoda.com/api/v1/video/vtoken/'; 20 | public async Extract(url: string): Promise { 21 | try { 22 | return await this.ExtractPlaylist(url); 23 | } catch (e) { 24 | return null; 25 | } 26 | } 27 | 28 | public CanParse(uri: string): boolean { 29 | const hostname = Url.parse(uri).hostname; 30 | 31 | if (typeof hostname !== 'string') { 32 | return false; 33 | } 34 | 35 | return hostname.toLowerCase().endsWith('camsoda.com'); 36 | } 37 | 38 | private async ExtractPlaylist(url: string): Promise { 39 | const username = UsernameFromUrl(url); 40 | const vt = await (await axios.get(this.VTOKEN_RESOURCE + username)).data; 41 | 42 | if (vt.status === 0) { 43 | throw new Error('Server respond an error'); 44 | } 45 | 46 | if (vt.edge_servers.length === 0) { return ''; } 47 | 48 | const host = vt.edge_servers[0]; 49 | return `https://${host}/${vt.stream_name}_v1/index.m3u8?token=${vt.token}`; 50 | } 51 | 52 | private Prefix(streamName: string) { 53 | return streamName.includes('/') ? 54 | streamName : 55 | '/cam/mp4:' + streamName; 56 | } 57 | 58 | private Quality(streamName: string) { 59 | return streamName.includes('/') ? 60 | '720p' : 61 | '480p'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Camsoda/CamsodaLocator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios'; 3 | 4 | import { Logger } from '../../Common/Logger'; 5 | import { isAxiosError, UsernameFromUrl } from '../../Common/Util'; 6 | import { LocatorService } from '../Plugin'; 7 | 8 | export interface Result { 9 | bitrate: number; 10 | new: boolean; 11 | offline_picture: string; 12 | tpl: any[]; 13 | status: string; 14 | spy_allowed?: number; 15 | control_her?: number; 16 | top_pvt?: number; 17 | aspect?: number; 18 | app_id?: number; 19 | voyeur?: boolean; 20 | slug: string; 21 | accepts_tokens?: number; 22 | aspect_ratio: string; 23 | } 24 | 25 | export interface OnlineInfo { 26 | cb: number; 27 | count_total: number; 28 | results: Result[]; 29 | status: boolean; 30 | template: string[]; 31 | } 32 | 33 | export class CamsodaLocator extends LocatorService { 34 | private readonly ONLINE_RESOURCE = 'https://www.camsoda.com/api/v1/browse/online'; 35 | public async Start() { 36 | const status = this.IsRunning; 37 | await super.Start(); 38 | if (status !== this.IsRunning) { Logger.Get.Log('CamsodaLocator::Start()'); } 39 | } 40 | 41 | public async Stop() { 42 | await super.Stop(); 43 | if (!this.IsRunning) { Logger.Get.Log('CamsodaLocator::Stop()'); } 44 | } 45 | 46 | public async Task() { 47 | if (this.observables.size === 0) { 48 | return; 49 | } 50 | 51 | try { 52 | await this.pauseFence.ExecutionFence(); 53 | 54 | const response = await axios.get(this.ONLINE_RESOURCE); 55 | const streamersIndex = new Set(response.data.results.map(x => x.tpl[1])); 56 | 57 | [...this.observables] 58 | .filter(x => streamersIndex.has(UsernameFromUrl(x))) 59 | .forEach(async (x: string) => { 60 | await this.pauseFence.ExecutionFence(); 61 | 62 | const streamUrl = await this.extractor.Extract(x); 63 | if (streamUrl !== '' && streamUrl !== null) { 64 | this.Notify({ url: x, streamUrl }); 65 | } 66 | }); 67 | } catch (e) { 68 | if (isAxiosError(e)) { Logger.Get.Log(e.message); } 69 | } 70 | } 71 | 72 | public OnAbort(e: Error) { 73 | Logger.Get.Log('CamsodaLocator::Stop() with ' + e); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Camsoda/index.ts: -------------------------------------------------------------------------------- 1 | export { CamsodaExtractor } from './CamsodaExtractor'; 2 | export { CamsodaLocator } from './CamsodaLocator'; 3 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Chaturbate/ChaturbateDirectLocator.ts: -------------------------------------------------------------------------------- 1 | import { LocatorService } from '../Plugin'; 2 | import { Logger } from './../../Common/Logger'; 3 | 4 | export class ChaturbateDirectLocator extends LocatorService { 5 | public async Start(): Promise { 6 | const status = this.IsRunning; 7 | await super.Start(); 8 | if (status !== this.IsRunning) { Logger.Get.Log('ChaturbateDirectLocator::Start()'); } 9 | } 10 | 11 | public async Stop(): Promise { 12 | await super.Stop(); 13 | if (!this.IsRunning) { Logger.Get.Log('ChaturbateDirectLocator::Stop()'); } 14 | } 15 | 16 | public async Task(): Promise { 17 | if (this.observables.size === 0) { 18 | return; 19 | } 20 | 21 | const sourceList = await Promise 22 | .all([...this.observables].map(async (x) => { 23 | await this.pauseFence.ExecutionFence(); 24 | 25 | return { url: x, streamUrl: await this.extractor.Extract(x) }; 26 | })); 27 | 28 | sourceList 29 | .filter(x => x.streamUrl) 30 | .forEach(x => this.Notify({ url: x.url, streamUrl: x.streamUrl as string })); 31 | } 32 | 33 | public OnAbort(e: Error): void { 34 | Logger.Get.Log('ChaturbateDirectLocator::Stop() with ' + e); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Chaturbate/index.ts: -------------------------------------------------------------------------------- 1 | export { ChaturbateExtractor } from './ChaturbateExtractor'; 2 | export { ChaturbateDirectLocator } from './ChaturbateDirectLocator'; 3 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Dummy/DummyExtractor.ts: -------------------------------------------------------------------------------- 1 | import { StreamExtractor } from '../Plugin'; 2 | 3 | export class DummyExtractor implements StreamExtractor { 4 | public Extract(uri: string): Promise { 5 | return new Promise((resolve, reject) => { 6 | resolve('dummy://path/to/stream'); 7 | }); 8 | } 9 | 10 | public CanParse(uri: string): boolean { 11 | return uri.length > 2; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Dummy/DummyLocator.ts: -------------------------------------------------------------------------------- 1 | import { LocatorService } from '../Plugin'; 2 | import { Logger } from './../../Common/Logger'; 3 | export class DummyLocator extends LocatorService { 4 | public async Start() { 5 | const status = this.IsRunning; 6 | await super.Start(); 7 | if (status !== this.IsRunning) 8 | Logger.Get.Log('DummyLocator::Start()'); 9 | } 10 | public async Stop() { 11 | await super.Stop(); 12 | if (!this.IsRunning) 13 | Logger.Get.Log('DummyLocator::Stop()'); 14 | } 15 | public async Task() { await this.pauseFence.ExecutionFence(); } 16 | public OnAbort(e: Error) { 17 | Logger.Get.Log('DummyLocator::Stop() with ' + e); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Dummy/index.ts: -------------------------------------------------------------------------------- 1 | export { DummyExtractor } from './DummyExtractor'; 2 | export { DummyLocator } from './DummyLocator'; 3 | -------------------------------------------------------------------------------- /backend/Src/Plugins/ExtractorWithCache.ts: -------------------------------------------------------------------------------- 1 | import * as LruCache from 'lru-cache'; 2 | 3 | import { StreamExtractor } from './Plugin'; 4 | 5 | export class ExtractorWithCache implements StreamExtractor { 6 | private cache = new LruCache({ max: 50, maxAge: 10 * 1000 }); 7 | private nextExtractor: StreamExtractor | null= null; 8 | 9 | public constructor(private extractor: StreamExtractor) {} 10 | 11 | public Next(extractor: ExtractorWithCache): ExtractorWithCache { 12 | this.nextExtractor = extractor; 13 | return extractor; 14 | } 15 | 16 | public CanParse(uri: string): boolean { 17 | return this.extractor.CanParse(uri); 18 | } 19 | 20 | public async Extract(uri: string): Promise { 21 | if (this.CanParse(uri)) { 22 | const playlistUrl = this.cache.get(uri); 23 | if (playlistUrl) { 24 | return playlistUrl; 25 | } 26 | 27 | const reqPlaylistUrl = await this.extractor.Extract(uri); 28 | 29 | if (reqPlaylistUrl === null) { 30 | return null; 31 | } 32 | 33 | this.cache.set(uri, reqPlaylistUrl); 34 | 35 | return reqPlaylistUrl; 36 | } else if (this.nextExtractor) { 37 | return this.nextExtractor.Extract(uri); 38 | } else { 39 | return ''; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/Src/Plugins/KickClip/KickClipExtractor.ts: -------------------------------------------------------------------------------- 1 | import { StreamExtractor } from '../Plugin'; 2 | 3 | export class KickClipExtractor implements StreamExtractor { 4 | public async Extract(url: string): Promise { 5 | return ''; 6 | } 7 | 8 | public CanParse(uri: string): boolean { 9 | return uri.startsWith('https://kick.com/categories'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/Src/Plugins/KickClip/index.ts: -------------------------------------------------------------------------------- 1 | export { KickClipExtractor } from './KickClipExtractor'; 2 | export { KickClipLocator } from './KickClipLocator'; 3 | -------------------------------------------------------------------------------- /backend/Src/Plugins/Plugin.ts: -------------------------------------------------------------------------------- 1 | import { Event, Observable } from '../Common/Event'; 2 | import { RecurringTask } from '../Common/RecurringTask'; 3 | import { ExecutionFence } from '../Common/ExecutionFence'; 4 | 5 | export interface LiveStream { 6 | url: string; 7 | streamUrl: string; 8 | } 9 | 10 | export interface StreamExtractor { 11 | CanParse(uri: string): boolean; 12 | // Returns ffmpeg-ready stream url 13 | Extract(uri: string): Promise; 14 | } 15 | 16 | export type OnLiveCb = (info: LiveStream) => void; 17 | 18 | export abstract class LocatorService extends RecurringTask { 19 | protected observables: Set = new Set(); 20 | private liveStreamEvent: Event = new Event(); 21 | protected pauseFence = new ExecutionFence(); 22 | constructor(protected extractor: StreamExtractor, updatePeriod: number) { super(updatePeriod); } 23 | public get LiveStreamEvent(): Observable { 24 | return this.liveStreamEvent; 25 | } 26 | 27 | public SetObservables(observables: Set): void { 28 | this.observables = observables; 29 | } 30 | 31 | public CanParse(uri: string): boolean { 32 | return this.extractor.CanParse(uri); 33 | } 34 | 35 | public Pause(): void { 36 | this.pauseFence.Pause(); 37 | } 38 | 39 | public Resume(): void { 40 | this.pauseFence.Resume(); 41 | } 42 | 43 | /** 44 | * Notify listeners about onlive stream 45 | */ 46 | protected Notify(info: LiveStream): void { 47 | this.liveStreamEvent.Emit(info); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/Src/Services/ObservableValidator/Service.ts: -------------------------------------------------------------------------------- 1 | import { of, from, merge } from 'rxjs'; 2 | import { concatMap, filter, delay } from 'rxjs/operators'; 3 | 4 | import { AppFacade } from '@/AppFacade'; 5 | import { RecurringTask } from '@/Common/RecurringTask'; 6 | import { Logger } from './../../Common/Logger'; 7 | import { ObservableStream } from '@/Common/Types'; 8 | 9 | export class Service extends RecurringTask { 10 | private readonly REQUEST_INTERVAL = 5000; 11 | public constructor(private readonly TASK_INTERVAL: number, private api: AppFacade) { 12 | super(TASK_INTERVAL); 13 | } 14 | 15 | public async Task(): Promise { 16 | from(this.api.Observables.values()) 17 | .pipe( 18 | delay(this.REQUEST_INTERVAL), 19 | concatMap(async (x: ObservableStream) => ({ observable: x, valid: await this.api.PlaylistExtractor.Extract(x.url) !== null }))) 20 | .subscribe(x => { 21 | if (x.valid !== x.observable.valid) { 22 | x.observable.valid = x.valid; 23 | 24 | this.api.Storage.UpdateValidity(x.observable.url, x.observable.valid); 25 | this.api.Broadcaster.UpdateObservableValidity(x.observable.url, x.observable.valid); 26 | } 27 | }); 28 | } 29 | 30 | public OnAbort(e: Error): void { 31 | Logger.Get.Log('ObservableValidatorService::Stop() with ' + e); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/Src/Services/ObservableValidator/index.ts: -------------------------------------------------------------------------------- 1 | export { Service as ObservableValidatorService } from './Service'; 2 | -------------------------------------------------------------------------------- /backend/Src/Services/PluginManager.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../Common/Logger'; 2 | import { Event } from '../Common/Event'; 3 | import { Plugin } from '../Common/Types'; 4 | import { LiveStream, LocatorService } from '../Plugins/Plugin'; 5 | 6 | export class PluginManager { 7 | private plugins: Plugin[] = []; 8 | private liveStreamEvent: Event = new Event(); 9 | private isRunning = false; 10 | public get IsRunning(): boolean { return this.isRunning; } 11 | get Plugins(): Plugin[] { 12 | return this.plugins; 13 | } 14 | 15 | public get LiveStreamEvent(): Event { 16 | return this.liveStreamEvent; 17 | } 18 | 19 | public Register(name: string, service: LocatorService): Plugin { 20 | service.LiveStreamEvent.On((e: LiveStream) => this.liveStreamEvent.Emit(e)); 21 | return this.plugins[this.plugins.push({ id: -1, enabled: true, name, service }) - 1]; 22 | } 23 | 24 | public FindPlugin(name: string): Plugin | null { 25 | return this.plugins.find(x => x.name === name) || null; 26 | } 27 | 28 | public FindPluginById(id: number): Plugin | null { 29 | return this.plugins.find(x => x.id === id) || null; 30 | } 31 | 32 | public FindCompatiblePlugin(uri: string): Plugin[] { 33 | return this.plugins.filter(x => x.service.CanParse(uri)); 34 | } 35 | 36 | public ReorderPlugin(index: number, newIndex: number): void { 37 | this.plugins.splice(newIndex, 0, this.plugins.splice(index, 1)[0]); 38 | } 39 | 40 | public EnablePlugin(id: number, enabled: boolean) : void{ 41 | const plugin = this.FindPluginById(id); 42 | 43 | if (plugin === null || plugin.enabled === enabled) { 44 | return; 45 | } 46 | 47 | plugin.enabled = enabled; 48 | 49 | enabled ? 50 | plugin.service.Start() : 51 | plugin.service.Stop(); 52 | } 53 | 54 | /** 55 | * @param orderMap contain plugins ids in new order 56 | */ 57 | public ReorderPlugins(newOrder: number[]): void { 58 | const orderMap = new Map(newOrder.map((x, p) => [x, p])); 59 | const priv = (p: Plugin) => orderMap.get(p.id) || -1; 60 | this.plugins.sort((a, b) => priv(a) - priv(b)); 61 | } 62 | 63 | public Start(): void { 64 | if (!this.isRunning) { 65 | this.isRunning = true; 66 | this.plugins.filter(x => x.enabled).forEach(x => x.service.Start()); 67 | 68 | Logger.Get.Log('PluginManager::Start()'); 69 | } 70 | } 71 | 72 | public Stop(): void { 73 | if (this.isRunning) { 74 | this.isRunning = false; 75 | this.plugins.forEach(x => x.service.Stop()); 76 | 77 | Logger.Get.Log('PluginManager::Stop()'); 78 | } 79 | } 80 | 81 | public Pause(): void { 82 | if (this.IsRunning) { 83 | this.plugins.forEach(x => x.service.Pause()); 84 | 85 | Logger.Get.Log('PluginManager::Pause()'); 86 | } 87 | } 88 | 89 | public Resume(): void { 90 | if (this.IsRunning) { 91 | this.plugins.forEach(x => x.service.Resume()); 92 | 93 | Logger.Get.Log('PluginManager::Resume()'); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /backend/Src/Services/PluginManager/PluginManagerController.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from '../../Common/Event'; 2 | import { PluginManager } from '../PluginManager'; 3 | 4 | export interface Disposable { 5 | Dispose(): void; 6 | } 7 | export interface Arbiter extends Disposable { 8 | readonly VoteEvent: Observable; 9 | // False if plugin manager should be stopped, otherwise true; 10 | Decision(): boolean; 11 | } 12 | export class PluginManagerController { 13 | private arbiters = new Map(); 14 | 15 | public constructor(private manager: PluginManager) { } 16 | 17 | public AddArbiter(arbiter: Arbiter, id: string): void { 18 | this.arbiters.set(id, arbiter); 19 | arbiter.VoteEvent.On(this.voteFn); 20 | } 21 | 22 | public RemoveArbiter(id: string): void { 23 | const remove = this.arbiters.get(id); 24 | if (remove !== undefined) { 25 | remove.VoteEvent.Off(this.voteFn); 26 | remove.Dispose(); 27 | 28 | this.arbiters.delete(id); 29 | 30 | this.Vote(); 31 | } 32 | } 33 | 34 | public Find(id: string): Arbiter | null { return this.arbiters.get(id) ?? null; } 35 | 36 | private voteFn = () => this.Vote(); 37 | 38 | private Vote() { 39 | if ([...this.arbiters.values()].every(x => x.Decision())) { this.manager.Resume(); } else { this.manager.Pause(); } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/Src/Services/PluginManager/Quotas/Constants.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_QUOTA_ID = 'q_storage'; 2 | export const INSTANCE_QUOTA_ID = 'q_instance'; 3 | export const DOWNLOAD_SPEED_QUOTA = 'q_download_speed'; 4 | -------------------------------------------------------------------------------- /backend/Src/Services/PluginManager/Quotas/InstanceQuota.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '../../../Common/Event'; 2 | import { LiveStream } from '../../../Plugins/Plugin'; 3 | import { PluginManager } from '../../../Services/PluginManager'; 4 | import { CompleteInfo, RecordingService } from '../../../Services/RecordingService'; 5 | import { Arbiter, Disposable } from '../PluginManagerController'; 6 | 7 | export class InstanceQuota implements Arbiter, Disposable { 8 | public readonly VoteEvent = new Event(); 9 | private quota = 0; 10 | private decision = true; 11 | 12 | public constructor( 13 | private pluginManager: PluginManager, 14 | private recorder: RecordingService) { 15 | this.pluginManager.LiveStreamEvent.On(this.liveStreamFn); 16 | this.recorder.CompleteEvent.On(this.finishRecordingFn); 17 | } 18 | 19 | public Dispose() { 20 | this.pluginManager.LiveStreamEvent.Off(this.liveStreamFn); 21 | } 22 | 23 | public get Quota() { return this.quota; } 24 | 25 | public set Quota(quota: number) { 26 | if (this.quota !== quota) { 27 | this.quota = quota; 28 | this.OnUpdate(); 29 | } 30 | } 31 | 32 | public Decision() { 33 | return this.decision; 34 | } 35 | 36 | private OnLive(stream: LiveStream) { 37 | this.OnUpdate(); 38 | } 39 | 40 | private FinisgRecording(e: CompleteInfo) { 41 | this.OnUpdate(); 42 | } 43 | 44 | private liveStreamFn = (s: LiveStream) => this.OnLive(s); 45 | 46 | private finishRecordingFn = (e: CompleteInfo) => this.FinisgRecording(e); 47 | 48 | private OnUpdate() { 49 | const newDecision = this.recorder.Records.length < this.quota; 50 | 51 | // Initiate overall voting if decision has been changed 52 | if (this.decision !== newDecision) { 53 | this.decision = newDecision; 54 | this.VoteEvent.Emit(); 55 | } 56 | 57 | if (this.decision === false && this.quota < this.recorder.Records.length) { 58 | this.recorder.Records 59 | .slice(this.quota - this.recorder.Records.length) 60 | .forEach(x => this.recorder.StopRecording(x.label)); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /backend/Src/Services/PluginManager/Quotas/StorageQuota.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '../../../Common/Event'; 2 | import { Logger } from '../../../Common/Logger'; 3 | import { SystemInfo } from '@Shared/Types'; 4 | import { RecordingService } from '../../RecordingService'; 5 | import { SystemResourcesMonitor } from '../../SystemResourcesMonitor'; 6 | import { Arbiter } from '../PluginManagerController'; 7 | 8 | export class StorageQuota implements Arbiter { 9 | public readonly VoteEvent = new Event(); 10 | private quota = 0; 11 | private size = 0; 12 | private decision = true; 13 | public constructor( 14 | private recorder: RecordingService, 15 | private resourceMonitor: SystemResourcesMonitor) { 16 | resourceMonitor.OnUpdate.On(this.systemMonitorTickFn); 17 | } 18 | 19 | public Dispose(): void { 20 | this.resourceMonitor.OnUpdate.Off(this.systemMonitorTickFn); 21 | } 22 | 23 | public get Quota(): number { return this.quota; } 24 | 25 | public set Quota(quota: number) { 26 | if (this.quota !== quota) { 27 | this.quota = quota; 28 | this.OnUpdate(); 29 | } 30 | } 31 | 32 | public Decision(): boolean { 33 | return this.decision; 34 | } 35 | 36 | private systemMonitorTickFn = (info: SystemInfo) => this.SystemMonitorTick(info.hdd); 37 | 38 | private OnUpdate() { 39 | const newDecision = this.size <= this.quota; 40 | 41 | if (this.decision !== newDecision) { 42 | this.decision = newDecision; 43 | this.VoteEvent.Emit(); 44 | } 45 | 46 | if (this.decision === false) { 47 | Logger.Get.Log('Plugin manager has been stopped due storage quota'); 48 | this.recorder.Records.forEach(x => this.recorder.StopRecording(x.label)); 49 | } 50 | } 51 | 52 | private SystemMonitorTick(hdd: number) { 53 | if (this.size !== hdd) { 54 | this.size = hdd; 55 | this.OnUpdate(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/Src/Services/SizeQuotaNotifier.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@/Common/Logger'; 2 | import { RecurringTask } from '@/Common/RecurringTask'; 3 | import { GetFolderSize } from '@/Common/Util'; 4 | import { RecordingService } from './RecordingService'; 5 | import { ARCHIVE_FOLDER } from './../Constants'; 6 | import { NotificationCenter } from './NotificationCenter'; 7 | import { NotificationType } from '@Shared/Types'; 8 | 9 | export class SizeQuotaNotifier extends RecurringTask { 10 | private quota = 0; 11 | private notificationCenter: NotificationCenter | null = null; 12 | private cachedSize = 0; 13 | public constructor(private recorder: RecordingService, period = 300000) { super(period); } 14 | 15 | public set NotificationCenter(notificationCenter: NotificationCenter) { this.notificationCenter = notificationCenter; } 16 | 17 | public async Task(): Promise { 18 | const timeToFill = 10 * 60; 19 | const size = await GetFolderSize(ARCHIVE_FOLDER); 20 | const downloadSpeed = this.recorder.Records.reduce((acc, x) => acc + x.bitrate, 0); 21 | 22 | if (downloadSpeed * timeToFill >= this.quota - size && this.cachedSize !== size) { 23 | this.Notify(); 24 | } 25 | this.cachedSize = size; 26 | } 27 | 28 | public OnAbort(e: Error): void { 29 | Logger.Get.Log('SizeQuotaNotifier::Stop() with ' + e); 30 | } 31 | 32 | public async Start(): Promise { 33 | const status = this.IsRunning; 34 | await super.Start(); 35 | if (status !== this.IsRunning) { 36 | Logger.Get.Log('SizeQuotaNotifier::Start()'); 37 | } 38 | } 39 | 40 | public async Stop(): Promise { 41 | await super.Stop(); 42 | if (!this.IsRunning) { 43 | Logger.Get.Log('SizeQuotaNotifier::Stop()'); 44 | } 45 | } 46 | 47 | public UpdateQuota(quota: number): void { 48 | this.quota = quota; 49 | this.Task(); 50 | } 51 | 52 | private Notify() { 53 | this.notificationCenter?.NotifyAllByType({ 54 | title: 'The free space is almost out', 55 | data: { url: '/archive' } 56 | }, NotificationType.SizeQuota); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/Src/Services/SystemResourcesMonitor/DefaultSystemResourceMonitor.ts: -------------------------------------------------------------------------------- 1 | import { Broadcaster } from '../../ClientIO'; 2 | import { Logger } from '../../Common/Logger'; 3 | import { GetFolderSize } from '../../Common/Util'; 4 | import { SystemResourcesMonitor } from './SystemResourcesMonitor'; 5 | 6 | export class DefaultSystemResourceMonitor extends SystemResourcesMonitor { 7 | constructor(private path: string, updatePeriod: number) { 8 | super(updatePeriod); 9 | } 10 | public async Collect() { 11 | try { 12 | this.Info.hdd = await GetFolderSize(this.path); 13 | } catch (e) { 14 | Logger.Get.Log('FIXME: Deleting archive record while GetFolderSize execution.'); 15 | } 16 | } 17 | public OnAbort(e: Error) { 18 | Logger.Get.Log('DefaultSystemResourceMonitor::Stop() with ' + e); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/Src/Services/SystemResourcesMonitor/DockerSystemResourcesMonitor.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as readline from 'readline'; 4 | 5 | import { Logger } from '../../Common/Logger'; 6 | import { Exists, GetFolderSize } from '../../Common/Util'; 7 | import { SystemResourcesMonitor } from './SystemResourcesMonitor'; 8 | 9 | class PropertyReader { 10 | public constructor(private source: string, private prop: string) { } 11 | public async Read(): Promise { 12 | return new Promise((ok, fail) => { 13 | const reader = readline.createInterface({ 14 | input: fs.createReadStream(this.source) 15 | }); 16 | 17 | reader.on('line', (line: string) => { 18 | if (line.startsWith(this.prop)) { 19 | reader.removeAllListeners(); 20 | ok(parseFloat(line.slice(this.prop.length).trim())); 21 | } 22 | }); 23 | 24 | reader.once('close', () => fail(new Error(`Propery ${this.prop} not found in ${this.source}`))); 25 | }); 26 | } 27 | } 28 | 29 | interface CpuInfoSnapshot { 30 | timestamp: number; 31 | time: number; 32 | } 33 | 34 | export class DockerSystemResourcesMonitor extends SystemResourcesMonitor { 35 | public static async CgroupAvailable(): Promise { 36 | return await Exists(DockerSystemResourcesMonitor.DOCKER_CPU_STAT) && 37 | await Exists(DockerSystemResourcesMonitor.DOCKER_MEM_STAT); 38 | } 39 | 40 | private static readonly DOCKER_CPU_STAT = '/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage'; 41 | private static readonly DOCKER_MEM_STAT = '/sys/fs/cgroup/memory/memory.stat'; 42 | 43 | private cpuLoadReader = new PropertyReader(DockerSystemResourcesMonitor.DOCKER_CPU_STAT, ''); 44 | private memoryReader = new PropertyReader(DockerSystemResourcesMonitor.DOCKER_MEM_STAT, 'rss'); 45 | private readonly cpuCores = os.cpus().length; 46 | 47 | private cpuInfoPrev: CpuInfoSnapshot = { timestamp: Date.now() - 1, time: 0 }; 48 | constructor(private archiveFolder: string, updatePeriod: number) { 49 | super(updatePeriod); 50 | } 51 | 52 | public async Collect(): Promise { 53 | try { 54 | this.Info.hdd = await GetFolderSize(this.archiveFolder); 55 | } catch (e) { 56 | Logger.Get.Log('FIXME: Deleting archive record while GetFolderSize execution.'); 57 | } 58 | 59 | const timestamp = Date.now(); 60 | const time = await this.cpuLoadReader.Read(); 61 | this.Info.cpu = Math.round((time - this.cpuInfoPrev.time) / this.cpuCores / (timestamp - this.cpuInfoPrev.timestamp) / 10000); 62 | this.cpuInfoPrev = { timestamp, time }; 63 | 64 | this.Info.rss = await this.memoryReader.Read(); 65 | } 66 | 67 | public OnAbort(e: Error): void { 68 | Logger.Get.Log('DockerSystemResourcesMonitor::Stop() with ' + e); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /backend/Src/Services/SystemResourcesMonitor/SystemResourcesMonitor.ts: -------------------------------------------------------------------------------- 1 | import { SystemInfo } from '@Shared/Types'; 2 | import { Event } from '../../Common/Event'; 3 | import { RecurringTask } from './../../Common/RecurringTask'; 4 | 5 | export abstract class SystemResourcesMonitor extends RecurringTask { 6 | public readonly Info: SystemInfo = { cpu: 0, rss: 0, hdd: 0 }; 7 | public readonly OnUpdate = new Event(); 8 | public constructor(updatePeriod: number) { super(updatePeriod); } 9 | public abstract Collect(): Promise; 10 | 11 | public async Task(): Promise { 12 | await this.Collect(); 13 | this.OnUpdate.Emit(this.Info); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/Src/Services/SystemResourcesMonitor/SystemResourcesMonitorFactory.ts: -------------------------------------------------------------------------------- 1 | import { Broadcaster } from '../../ClientIO'; 2 | import { DefaultSystemResourceMonitor } from './DefaultSystemResourceMonitor'; 3 | import { DockerSystemResourcesMonitor } from './DockerSystemResourcesMonitor'; 4 | import { SystemResourcesMonitor } from './SystemResourcesMonitor'; 5 | 6 | export class SystemResourcesMonitorFactory { 7 | private readonly DOCKER_PLATFORM = 'DOCKER'; 8 | 9 | public constructor(private archiveFolder: string, private updatePeriod: number) { } 10 | 11 | public async Create(): Promise { 12 | 13 | return process.env.PLATFORM === this.DOCKER_PLATFORM && await DockerSystemResourcesMonitor.CgroupAvailable() ? 14 | new DockerSystemResourcesMonitor(this.archiveFolder, this.updatePeriod) : 15 | new DefaultSystemResourceMonitor(this.archiveFolder, this.updatePeriod); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/Src/Services/SystemResourcesMonitor/index.ts: -------------------------------------------------------------------------------- 1 | export { DefaultSystemResourceMonitor } from './DefaultSystemResourceMonitor'; 2 | export { DockerSystemResourcesMonitor } from './DockerSystemResourcesMonitor'; 3 | export { SystemResourcesMonitor } from './SystemResourcesMonitor'; 4 | export { SystemResourcesMonitorFactory } from './SystemResourcesMonitorFactory'; 5 | -------------------------------------------------------------------------------- /backend/Src/Settings.ts: -------------------------------------------------------------------------------- 1 | import { SqliteAdapter } from './Services/SqliteAdapter'; 2 | 3 | export class Settings { 4 | public static readonly STORAGE_QUOTA_PROP = 'storageQuota'; 5 | private static readonly INSTANCE_QUOTA_PROP = 'instanceQuota'; 6 | private static readonly DOWNLOAD_SPEED_QUOTA_PROP = 'downloadSpeedQuota'; 7 | private static readonly REMOTE_SELENIUM_URL_PROP = 'remoteSeleniumUrl'; 8 | private static readonly PropList = 9 | [Settings.STORAGE_QUOTA_PROP, Settings.INSTANCE_QUOTA_PROP, Settings.DOWNLOAD_SPEED_QUOTA_PROP, Settings.REMOTE_SELENIUM_URL_PROP] as const; 10 | 11 | private storageQuota!: number; 12 | private instanceQuota!: number; 13 | private downloadSpeedQuota!: number; 14 | private remoteSeleniumUrl!: string; 15 | public constructor(private storage: SqliteAdapter) { 16 | this.Load(); 17 | } 18 | 19 | public get StorageQuota() { return this.storageQuota; } 20 | public set StorageQuota(quota: number) { 21 | this.UpdateProperty(Settings.STORAGE_QUOTA_PROP, quota); 22 | } 23 | 24 | public get InstanceQuota() { return this.instanceQuota; } 25 | public set InstanceQuota(quota: number) { 26 | this.UpdateProperty(Settings.INSTANCE_QUOTA_PROP, quota); 27 | } 28 | 29 | public get DownloadSpeedQuota() { return this.downloadSpeedQuota; } 30 | public set DownloadSpeedQuota(quota: number) { 31 | this.UpdateProperty(Settings.DOWNLOAD_SPEED_QUOTA_PROP, quota); 32 | } 33 | 34 | public get RemoteSeleniumUrl() { return this.remoteSeleniumUrl; } 35 | public set RemoteSeleniumUrl(url: string) { 36 | this.UpdateProperty(Settings.REMOTE_SELENIUM_URL_PROP, url); 37 | } 38 | 39 | private Load() { 40 | const settings = this.storage.FetchSettings(); 41 | this.storageQuota = settings.storageQuota; 42 | this.instanceQuota = settings.instanceQuota; 43 | this.downloadSpeedQuota = settings.downloadSpeedQuota; 44 | this.remoteSeleniumUrl = settings.remoteSeleniumUrl; 45 | } 46 | 47 | private UpdateProperty(prop: typeof Settings.PropList[number], value: string | number) { 48 | if (value !== this.GetLocal(prop)) { 49 | this.SetLocal(prop, value); 50 | this.storage.UpdateSettingProperty(prop, value); 51 | } 52 | } 53 | 54 | private GetLocal(prop: typeof Settings.PropList[number]): string | number { 55 | switch (prop) { 56 | case Settings.STORAGE_QUOTA_PROP: 57 | return this.storageQuota; 58 | case Settings.INSTANCE_QUOTA_PROP: 59 | return this.instanceQuota; 60 | case Settings.DOWNLOAD_SPEED_QUOTA_PROP: 61 | return this.downloadSpeedQuota; 62 | case Settings.REMOTE_SELENIUM_URL_PROP: 63 | return this.remoteSeleniumUrl; 64 | } 65 | } 66 | 67 | private SetLocal(prop: typeof Settings.PropList[number], value: number | string) { 68 | switch (prop) { 69 | case Settings.STORAGE_QUOTA_PROP: 70 | this.storageQuota = value as number; 71 | break; 72 | case Settings.INSTANCE_QUOTA_PROP: 73 | this.instanceQuota = value as number; 74 | break; 75 | case Settings.DOWNLOAD_SPEED_QUOTA_PROP: 76 | this.downloadSpeedQuota = value as number; 77 | break; 78 | case Settings.REMOTE_SELENIUM_URL_PROP: 79 | this.remoteSeleniumUrl = value as string; 80 | break; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /backend/Src/SocketSameOrigin.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | import { Config } from './BootstrapConfiguration'; 4 | 5 | export const SocketSameOrigin = (origin: string, cb: (error: string | null, success: boolean) => void) => { 6 | const o = new URL(origin); 7 | const oPort = parseInt(o.port, 10); 8 | 9 | if (o.protocol === (Config.IsSecure ? 'https:' : 'http:') && 10 | o.hostname === Config.Hostname && 11 | (isNaN(oPort) ? 12 | Config.IsSecure ? 13 | Config.Port === 443 : 14 | Config.Port === 80 : 15 | Config.IsDev || oPort === Config.Port)) 16 | return cb(null, true); 17 | 18 | cb('origin not allowed', false); 19 | }; -------------------------------------------------------------------------------- /backend/Src/WebServerFactory.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { Server as HTTPServer } from 'http'; 3 | import { Server as HTTPSServer } from 'https'; 4 | 5 | import { Config } from './BootstrapConfiguration'; 6 | import { TLS_CERTIFICATE, TLS_PRIVATE_KEY } from './Constants'; 7 | 8 | export class WebServerFactory { 9 | public Create() { 10 | return Config.IsSecure ? new HTTPSServer(this.TLSOptions()) : new HTTPServer(); 11 | } 12 | 13 | private TLSOptions() { 14 | return { 15 | key: fs.readFileSync(TLS_PRIVATE_KEY, 'utf8'), 16 | cert: fs.readFileSync(TLS_CERTIFICATE, 'utf8') 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "Src/main.ts" 4 | ], 5 | "include": [ 6 | "../Shared/**/*" 7 | ], 8 | "compilerOptions": { 9 | "target": "es6", 10 | "module": "commonjs", 11 | "outDir": "build", 12 | "strict": true, 13 | "sourceMap": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "downlevelIteration": true, 17 | "skipLibCheck": true, 18 | "lib": [ 19 | "esnext" 20 | ], 21 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": [ 24 | "Src/*" 25 | ], 26 | "@Shared/*": [ 27 | "../Shared/*" 28 | ] 29 | } 30 | }, 31 | } -------------------------------------------------------------------------------- /plugins/OfflineMode.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const util = require('util'); 3 | const path = require('path'); 4 | const crypto = require('crypto'); 5 | 6 | module.exports = class OfflineMode { 7 | swFilename = ''; 8 | swFullPath = ''; 9 | assetsPattern = 'const cachedAssets = [];'; 10 | cacheNamePattern = 'const cacheName = \'\';'; 11 | 12 | constructor(swFilename) { 13 | this.swFilename = swFilename; 14 | } 15 | 16 | apply(compiler) { 17 | compiler.hooks.afterEmit.tap('OfflineMode', 18 | compilation => { 19 | this.swFullPath = path.join(compilation.options.output.path, this.swFilename); 20 | this.populateSw(Object.keys(compilation.assets).filter(x => x !== this.swFilename)) 21 | } 22 | ); 23 | } 24 | 25 | async populateSw(assets) { 26 | const readFile = util.promisify(fs.readFile); 27 | const writeFile = util.promisify(fs.writeFile); 28 | 29 | 30 | const content = (await readFile(this.swFullPath)).toString() 31 | .replace(this.assetsPattern, `const cachedAssets = ${JSON.stringify(assets)};`) 32 | .replace(this.cacheNamePattern, `const cacheName = '${crypto.randomBytes(16).toString('hex')}';`); 33 | 34 | writeFile(this.swFullPath, content); 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /public/env.js: -------------------------------------------------------------------------------- 1 | // This file will be generated while docker build 2 | window.build="hash_date_time"; -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | joybox 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | const cachedAssets = []; // populated by webpack while building 2 | const cacheName = ''; // populated by webpack while building 3 | 4 | self.addEventListener('install', e => { 5 | e.waitUntil( 6 | caches.open(cacheName).then(cache => cache.addAll(cachedAssets) 7 | .then(() => self.skipWaiting())) 8 | ); 9 | }); 10 | 11 | self.addEventListener('activate', e => { 12 | e.waitUntil( 13 | caches.keys().then(keys => Promise.all( 14 | keys.map(key => { 15 | if (key != cacheName) { 16 | return caches.delete(key); 17 | } 18 | }) 19 | )) 20 | ); 21 | }); 22 | 23 | self.addEventListener('push', e => { 24 | const data = e.data.json(); 25 | e.waitUntil(self.registration.showNotification(data.title, data)); 26 | }); 27 | 28 | self.addEventListener('notificationclick', async e => { 29 | e.waitUntil(ShowPlayerTab(e)); 30 | }) 31 | 32 | async function ShowPlayerTab(e) { 33 | const tabs = await clients.matchAll({ type: 'window' }); 34 | const openedPlayer = tabs.find(x => x.url == self.origin + e.notification.data.url); 35 | 36 | e.notification.close(); 37 | return openedPlayer ? openedPlayer.focus() : clients.openWindow(e.notification.data.url); 38 | } 39 | 40 | self.addEventListener('fetch', e => { 41 | if (e.request.method === 'POST' && e.request.url.endsWith('/upload_video')) 42 | return; 43 | 44 | e.respondWith((async () => { 45 | const url = new URL(e.request.url); 46 | 47 | if (e.request.method === 'GET' && url.origin == location.origin) { 48 | const cache = await caches.open(cacheName); 49 | const response = await cache.match(e.request); 50 | 51 | if (response) 52 | return response; 53 | 54 | if (!url.pathname.includes('.')) { 55 | const entry = await cache.match('index.html'); 56 | 57 | if (entry) 58 | return entry; 59 | } 60 | 61 | } 62 | 63 | if (self.auth_free || !(e.request.url.startsWith(self.location.origin + '/archive/') && e.request.url.endsWith('.mp4'))) { 64 | return fetch(e.request); 65 | } 66 | 67 | if (!self.accessToken) { 68 | const msgWaiter = new Promise(ok => self.tokenReceived = ok); 69 | const client = await clients.get(e.clientId); 70 | client.postMessage('auth_req'); 71 | await msgWaiter; 72 | } 73 | 74 | const headers = new Headers(e.request.headers); 75 | headers.append('Authorization', self.accessToken); 76 | 77 | return fetch(e.request.url, { headers }); 78 | })()); 79 | }); 80 | 81 | self.addEventListener('message', e => { 82 | switch (e.data.type) { 83 | case 'auth_req': 84 | self.auth_free = false; 85 | self.accessToken = e.data.token; 86 | self.tokenReceived && self.tokenReceived(); 87 | break; 88 | case 'auth_free': 89 | self.auth_free = true; 90 | break; 91 | case 'invalidate_token': 92 | delete self.accessToken; 93 | break; 94 | } 95 | }); -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | 78 | -------------------------------------------------------------------------------- /src/Common/BoolFilterTemplates/ObservableValueNode.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from '../../types'; 2 | import { ValueNode } from '../BoolFilter'; 3 | 4 | export class ObservableValueNode extends ValueNode { 5 | public DefaultComparator(input: Stream, filter: string): boolean { 6 | return input.uri 7 | .toLowerCase() 8 | .includes(filter.toLowerCase()) || 9 | input.uri 10 | .toLowerCase() 11 | .split('') 12 | .filter(x => x >= 'a' && x <= 'z') 13 | .join('') 14 | .includes(filter.toLowerCase()); 15 | } 16 | public PropComparator(input: Stream, prop: string, filter: string): boolean { 17 | if (prop === 'url') return this.UrlTest(input, filter); 18 | else if (prop === 'seen') return this.LastSeenTest(input, filter); 19 | else if (prop === 'plugin') return this.PluginTest(input, filter); 20 | return false; 21 | } 22 | // TODO: Good place for reflection 23 | private UrlTest(input: Stream, filter: string) { return this.DefaultComparator(input, filter); } 24 | private TimeDiv(metric: string | undefined): number { 25 | if (metric === undefined || metric === 'd') 26 | return 86400000; 27 | else if (metric === 'm') 28 | return 2592000000; 29 | else if (metric === 'h') 30 | return 3600000; 31 | 32 | throw new Error('Unknown metric'); 33 | } 34 | /*** 35 | * Filter syntax: [< or >][days] or never 36 | * Examples: 37 | * `seen:10` - last seen exactly 10 days ago 38 | * `seen:>15h` - last seen more than 20 hours ago 39 | * `seen:>20d` - last seen more than 20 days ago 40 | * `seen:<30m` - last seen less than 30 months ago 41 | * `seen:never` - never seen 42 | * */ 43 | private LastSeenTest(input: Stream, filter: string) { 44 | if (!filter.length) 45 | return false; 46 | 47 | if (input.lastSeen === -1) 48 | return filter === 'never'; 49 | 50 | const disassembled = /^([<>])?(\d+)(h|d|m)?$/.exec(filter); 51 | 52 | if (!disassembled) 53 | return false; 54 | 55 | const elapsed = Math.round((Date.now() - input.lastSeen * 1000) / this.TimeDiv(disassembled[3])); 56 | const value = Number.parseInt(disassembled[2], 10); 57 | if (disassembled[1] === '<') 58 | return elapsed < value; 59 | else if (disassembled[1] === '>') 60 | return elapsed > value; 61 | else 62 | return value === elapsed; 63 | } 64 | private PluginTest(input: Stream, filter: string) { 65 | const activePlugin = input.plugins.find(x => x.enabled); 66 | return !!activePlugin && activePlugin.name.toLowerCase() === filter.toLowerCase(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Common/BoolFilterTemplates/PlaylistValueNode.ts: -------------------------------------------------------------------------------- 1 | import { Playlist } from '@Shared/Types'; 2 | import { ValueNode } from '../BoolFilter'; 3 | 4 | export class PlaylistValueNode extends ValueNode { 5 | public DefaultComparator(input: Playlist, filter: string): boolean { 6 | return input.title 7 | .toLowerCase() 8 | .includes(filter.toLowerCase()); 9 | } 10 | 11 | // TODO: Implement me 12 | public PropComparator(input: Playlist, prop: string, filter: string): boolean { 13 | return false; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Common/Decorators/AppComponent.ts: -------------------------------------------------------------------------------- 1 | import Vue, { ComponentOptions } from 'vue'; 2 | import { Component } from 'vue-property-decorator'; 3 | 4 | import { AppThemeColor } from '../../MetaInfo'; 5 | 6 | export function AppComponent(options: ComponentOptions & ThisType) { 7 | return Component({ 8 | metaInfo() { 9 | return { 10 | meta: [ 11 | AppThemeColor(this.$vuetify) 12 | ] 13 | }; 14 | }, 15 | ...options 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/Common/FileUploader.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { UPLOAD_VIDEO_PATH } from '@Shared/Constants'; 5 | 6 | export default class FileUploader { 7 | public Upload(title: string, source: string, file: File, token: string): Observable { 8 | const formData = new FormData(); 9 | formData.append('title', title); 10 | formData.append('source', source); 11 | formData.append('file', file); 12 | 13 | const headers: { [name: string]: string; } = { 'Content-Type': 'multipart/form-data' }; 14 | if (token.length) { 15 | headers.Authorization = token; 16 | } 17 | 18 | return new Observable(o => { 19 | axios.post(UPLOAD_VIDEO_PATH, formData, { 20 | headers, 21 | onUploadProgress: (p: ProgressEvent) => o.next(p) 22 | }) 23 | .then(() => o.complete()) 24 | .catch(err => o.error((err.response && err.response.status) || 401)); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Common/IsPortrait.ts: -------------------------------------------------------------------------------- 1 | export const IsPortrait = (): boolean => window.screen.width < window.screen.height; 2 | -------------------------------------------------------------------------------- /src/Common/ParticalSum.ts: -------------------------------------------------------------------------------- 1 | export function* ParticalSum(iterable: number[]) { 2 | let s = 0; 3 | 4 | for (const x of iterable) { 5 | s += x; 6 | yield s; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Common/RpcClient.ts: -------------------------------------------------------------------------------- 1 | interface Request { 2 | callId: number; 3 | method: string; 4 | args: any[]; 5 | } 6 | 7 | export interface Response { 8 | callId: number; 9 | result: {}; 10 | } 11 | 12 | interface PendingRequest { 13 | callId: number; 14 | timeoutTimer: number; 15 | fn: (result: any) => void; 16 | } 17 | 18 | export class RpcClient { 19 | private readonly RPC = 'rpc'; 20 | private callId: number = 0; 21 | private pendingRequests: PendingRequest[] = []; 22 | constructor(private socket: SocketIOClient.Socket, private responseTimeout: number = 3000) { } 23 | 24 | public OnRpcResponse(res: Response): void { 25 | const found = this.pendingRequests.findIndex(x => x.callId === res.callId); 26 | 27 | if (found === -1) 28 | return; 29 | 30 | const requestor = this.pendingRequests[found]; 31 | 32 | clearTimeout(requestor.timeoutTimer); 33 | this.pendingRequests.splice(found, 1); 34 | 35 | requestor.fn(res.result); 36 | } 37 | 38 | protected GetCallId(): number { 39 | return ++this.callId ^ Date.now(); 40 | } 41 | 42 | protected RemovePendingRequest(callId: number) { 43 | const found = this.pendingRequests.findIndex(x => x.callId === callId); 44 | if (found === -1) 45 | return; 46 | 47 | this.pendingRequests.splice(found, 1); 48 | } 49 | 50 | /** 51 | * Call remote function. throw 52 | * @param method method name 53 | * @param args arguments passed to method 54 | */ 55 | protected async Call(method: string, ...args: Args): Promise { 56 | return new Promise((resolve, reject) => { 57 | const req: Request = { method, callId: this.GetCallId(), args }; 58 | 59 | this.socket.emit(this.RPC, req); 60 | 61 | const timeoutTimer = setTimeout(() => { 62 | this.RemovePendingRequest(req.callId); 63 | reject(new Error(`Method call '${method}' timed out.`)); 64 | }, this.responseTimeout); 65 | 66 | this.pendingRequests.push({ callId: req.callId, timeoutTimer, fn: resolve }); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Common/ThumbnailFromFilename.ts: -------------------------------------------------------------------------------- 1 | import { env as Env } from '../Env'; 2 | 3 | export const ThumbnailFromFilename = (filename: string): string => { 4 | return `${Env.Origin}/archive/thumbnail/${filename.slice(0, filename.lastIndexOf('.'))}.jpg`; 5 | }; 6 | -------------------------------------------------------------------------------- /src/Common/Util.ts: -------------------------------------------------------------------------------- 1 | export function ExtractSourceFromUrl(sourceUrl: string): string { 2 | const protoWithSource = sourceUrl.split(':'); 3 | return protoWithSource.length === 2 ? protoWithSource[1].slice(2) : sourceUrl; 4 | } 5 | 6 | export function UrlBase64ToUint8Array(base64String: string): Uint8Array { 7 | const padding = '='.repeat((4 - base64String.length % 4) % 4); 8 | const base64 = (base64String + padding) 9 | .replace(/-/g, '+') 10 | .replace(/_/g, '/'); 11 | 12 | const rawData = atob(base64); 13 | return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); 14 | } 15 | -------------------------------------------------------------------------------- /src/Common/VideoFromFilename.ts: -------------------------------------------------------------------------------- 1 | import { env as Env } from '../Env'; 2 | 3 | export const VideoFromFilename = (filename: string): string => { 4 | return `${Env.Origin}/archive/${filename}`; 5 | }; 6 | -------------------------------------------------------------------------------- /src/Common/index.ts: -------------------------------------------------------------------------------- 1 | export { BoolFilter, ValidationError, ValueNode } from './BoolFilter'; 2 | export { Response, RpcClient } from './RpcClient'; 3 | -------------------------------------------------------------------------------- /src/Common/interfaces/Chartjs.ts: -------------------------------------------------------------------------------- 1 | export interface Dataset { 2 | label: string; 3 | backgroundColor?: string; 4 | data: T[]; 5 | 6 | [i: string]: any; 7 | } 8 | 9 | export interface ChartData { 10 | labels?: L[]; 11 | datasets: Array>; 12 | } 13 | -------------------------------------------------------------------------------- /src/Components/Analytics/ArchivePopulation.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/Components/Analytics/SpaceUsage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /src/Components/Archive/Action.ts: -------------------------------------------------------------------------------- 1 | export enum Action { OPEN_TAG_MANAGER } 2 | -------------------------------------------------------------------------------- /src/Components/Archive/ClipProgressCard.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 25 | 26 | -------------------------------------------------------------------------------- /src/Components/Archive/FilterInfo.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 41 | 42 | -------------------------------------------------------------------------------- /src/Components/Archive/PlaylistCard.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 44 | 45 | 79 | -------------------------------------------------------------------------------- /src/Components/Archive/PlaylistPreviewActionMenu.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 34 | 35 | 56 | -------------------------------------------------------------------------------- /src/Components/Archive/RowWrapper.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 29 | -------------------------------------------------------------------------------- /src/Components/Archive/TagManager.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 45 | 46 | -------------------------------------------------------------------------------- /src/Components/BackBtn.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /src/Components/Charts/HorizontalBar.ts: -------------------------------------------------------------------------------- 1 | import { HorizontalBar as ChartJsHorizontalBar, mixins } from 'vue-chartjs'; 2 | import { Component, Mixins, Prop } from 'vue-property-decorator'; 3 | 4 | @Component 5 | export default class HorizontalBar extends Mixins(ChartJsHorizontalBar, mixins.reactiveProp) { 6 | @Prop({ required: false }) 7 | public readonly options: any; 8 | public mounted() { 9 | this.renderChart(this.chartData, this.options); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Components/Charts/LineChart.ts: -------------------------------------------------------------------------------- 1 | import { Line, mixins } from 'vue-chartjs'; 2 | import { Component, Mixins, Prop } from 'vue-property-decorator'; 3 | 4 | @Component 5 | export default class LineChart extends Mixins(Line, mixins.reactiveProp) { 6 | @Prop({ required: false }) 7 | public readonly options: any; 8 | public mounted() { 9 | this.renderChart(this.chartData, this.options); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Components/ClipProgressPopup.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 61 | 62 | 110 | -------------------------------------------------------------------------------- /src/Components/CountdownTimer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 72 | -------------------------------------------------------------------------------- /src/Components/GetAccessDialog.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /src/Components/GroupList/GraphBuilder.ts: -------------------------------------------------------------------------------- 1 | import { gt } from 'binary-search-bounds'; 2 | import IntervalTree from 'node-interval-tree'; 3 | 4 | import { GroupListItem } from './Types'; 5 | 6 | type GroupId = number; 7 | interface Range { 8 | start: number; 9 | stop: number; 10 | } 11 | interface RangeNode extends Range { 12 | group: number; 13 | track: number; 14 | } 15 | export class GraphBuilder { 16 | private static RangesByStartComp(l: Range, r: Range) { 17 | return l.start - r.start; 18 | } 19 | private readonly rangesMap = new Map(); 20 | private trackCount: number = 0; 21 | private lookupTable: RangeNode[] = []; 22 | private repository = new IntervalTree(); 23 | public constructor(private items: Array>) { 24 | this.BuildRanges(); 25 | this.BuildTracks(); 26 | this.PopulateRepository(); 27 | } 28 | public get TrackCount() { 29 | return this.trackCount; 30 | } 31 | public Query(idx: number, track: number) { 32 | return this.repository 33 | .search(idx, idx) 34 | .find(x => x.track === track) || null; 35 | } 36 | private BuildRanges() { 37 | this.items.forEach((x, i) => { 38 | const range = this.rangesMap.get(x.group); 39 | if (range) { 40 | range.stop = i; 41 | } else { 42 | const newRange: Range = { start: i, stop: i }; 43 | this.rangesMap.set(x.group, newRange); 44 | } 45 | }); 46 | } 47 | private BuildTracks() { 48 | this.lookupTable = [...this.rangesMap.entries()].map(x => ({ ...x[1], group: x[0], track: -1 })); 49 | this.lookupTable.sort(GraphBuilder.RangesByStartComp); 50 | 51 | const localLookup = [...this.lookupTable]; 52 | 53 | for (let i = 0; i < localLookup.length; ++i, ++this.trackCount) { 54 | localLookup[i].track = this.trackCount; 55 | 56 | const needle: Range = { start: localLookup[i].stop, stop: -1 }; 57 | while (true) { 58 | const foundIdx: number = gt(localLookup, needle, GraphBuilder.RangesByStartComp); 59 | 60 | if (foundIdx === -1 || foundIdx >= localLookup.length) break; 61 | 62 | localLookup[foundIdx].track = this.trackCount; 63 | needle.start = localLookup[foundIdx].stop; 64 | 65 | localLookup.splice(foundIdx, 1); 66 | } 67 | 68 | } 69 | } 70 | private PopulateRepository() { 71 | this.lookupTable.forEach(x => this.repository.insert(x.start, x.stop, x)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Components/GroupList/Types.ts: -------------------------------------------------------------------------------- 1 | export interface GroupListItem { 2 | id: number; 3 | group: number; 4 | data: T; 5 | } 6 | -------------------------------------------------------------------------------- /src/Components/InfoDialog.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /src/Components/InputWithChips.vue: -------------------------------------------------------------------------------- 1 | 4 | 26 | 27 | 60 | 61 | -------------------------------------------------------------------------------- /src/Components/LongPressButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Components/NoConnectionIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | 18 | -------------------------------------------------------------------------------- /src/Components/Observables/AddObservableDialog.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | -------------------------------------------------------------------------------- /src/Components/Observables/EditObservableDialog.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 27 | 28 | 70 | -------------------------------------------------------------------------------- /src/Components/Observables/FilterInfo.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 45 | 46 | -------------------------------------------------------------------------------- /src/Components/Observables/ObservableItem.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 50 | 51 | 95 | -------------------------------------------------------------------------------- /src/Components/Observables/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ObservableItem } from './ObservableItem.vue'; 2 | export { default as AddObservableDialog } from './AddObservableDialog.vue'; 3 | export { default as EditObservableDialog } from './EditObservableDialog.vue'; 4 | -------------------------------------------------------------------------------- /src/Components/Playlist/AddRecordDlg.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | 24 | 47 | -------------------------------------------------------------------------------- /src/Components/Playlist/EditPlaylist.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /src/Components/Playlist/FragmentPreview.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 33 | 34 | 78 | -------------------------------------------------------------------------------- /src/Components/Playlist/LoopMenu.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 20 | 21 | 48 | -------------------------------------------------------------------------------- /src/Components/Playlist/NewPlaylist.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | 10 | 45 | -------------------------------------------------------------------------------- /src/Components/Playlist/RecordCard.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 49 | 50 | 107 | -------------------------------------------------------------------------------- /src/Components/PluginIcon.vue: -------------------------------------------------------------------------------- 1 | 5 | 10 | -------------------------------------------------------------------------------- /src/Components/SearchFilter/SaveFilterDialog.vue: -------------------------------------------------------------------------------- 1 | < 16 | 17 | 19 | 20 | -------------------------------------------------------------------------------- /src/Components/SegmentedColorLine.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /src/Components/System/PluginManager.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /src/Components/System/ProxySettings.vue: -------------------------------------------------------------------------------- 1 | 14 | 16 | 46 | -------------------------------------------------------------------------------- /src/Components/System/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PluginManager } from './PluginManager.vue'; 2 | export { default as WebPushSettings } from './WebPushSettings.vue'; 3 | -------------------------------------------------------------------------------- /src/Components/Tile.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /src/Components/Tiles/Archive.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 42 | 43 | -------------------------------------------------------------------------------- /src/Components/Tiles/Observables.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /src/Components/Tiles/Recorder.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /src/Components/Tiles/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Recorder } from './Recorder.vue'; 2 | export { default as Observables } from './Observables.vue'; 3 | export { default as System } from './System.vue'; 4 | export { default as Archive } from './Archive.vue'; 5 | -------------------------------------------------------------------------------- /src/Components/TimeAgo.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/Components/UploadVideo/Archive.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 23 | 24 | -------------------------------------------------------------------------------- /src/Components/UploadVideo/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 23 | 24 | -------------------------------------------------------------------------------- /src/Components/UploadVideo/LocalFile.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | 20 | -------------------------------------------------------------------------------- /src/Components/UploadVideo/Url.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /src/Directives/index.ts: -------------------------------------------------------------------------------- 1 | export { default as visible } from './visible'; 2 | -------------------------------------------------------------------------------- /src/Directives/visible.ts: -------------------------------------------------------------------------------- 1 | import { DirectiveOptions } from 'vue'; 2 | 3 | const visible: DirectiveOptions = { 4 | inserted(el, binding) { 5 | el.style.visibility = binding.value ? 'visible' : 'hidden'; 6 | }, 7 | update(el, binding) { 8 | el.style.visibility = binding.value ? 'visible' : 'hidden'; 9 | } 10 | }; 11 | 12 | export default visible; 13 | -------------------------------------------------------------------------------- /src/Env.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | readonly Host: string; 3 | readonly Protocol: string; 4 | readonly Origin: string; 5 | readonly Secure: boolean; 6 | } 7 | 8 | const Host = location.host; 9 | const Protocol = location.protocol; 10 | const Origin = `${Protocol}//${Host}`; 11 | const IsSecure = () => Protocol === 'https:'; 12 | 13 | export const env: Env = { Host, Protocol, Origin, Secure: IsSecure() }; 14 | -------------------------------------------------------------------------------- /src/ErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Vue } from 'vue/types/vue'; 2 | 3 | export function ErrorHandler(error: Error, vm: Vue, info: string) { 4 | console.error(error); 5 | } 6 | -------------------------------------------------------------------------------- /src/EventBusTypes.ts: -------------------------------------------------------------------------------- 1 | import { createEventDefinition } from 'ts-bus'; 2 | 3 | export const filterBySource = createEventDefinition<{ source: string }>()('filter_by_source'); 4 | export const playlistAddRecordDlgPlay = createEventDefinition<{ filename: string }>()('playlist_addrecorddlg_play'); 5 | -------------------------------------------------------------------------------- /src/MetaInfo/AppThemeColor.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Framework } from 'vuetify'; 3 | 4 | export default ($vuetify: Framework) => 5 | ({ name: 'theme-color', content: ($vuetify.theme as any).currentTheme.primary as string }); 6 | -------------------------------------------------------------------------------- /src/MetaInfo/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppThemeColor } from './AppThemeColor'; 2 | -------------------------------------------------------------------------------- /src/Mixins/EventBus.ts: -------------------------------------------------------------------------------- 1 | import { EventBus as TsEventBus } from 'ts-bus'; 2 | import { Component, Vue } from 'vue-property-decorator'; 3 | 4 | const bus = new TsEventBus(); 5 | 6 | @Component 7 | export default class EventBus extends Vue { 8 | public get EventBus() { return bus; } 9 | } 10 | -------------------------------------------------------------------------------- /src/Mixins/Initialized.ts: -------------------------------------------------------------------------------- 1 | import { Component, Vue, Watch } from 'vue-property-decorator'; 2 | import { getModule } from 'vuex-module-decorators'; 3 | 4 | import store, { App } from '../Store'; 5 | 6 | const app = getModule(App, store); 7 | 8 | @Component 9 | export default class Initialized extends Vue { 10 | private called = false; 11 | public Initialized() { } 12 | public mounted() { 13 | this.CallOnce(); 14 | } 15 | @Watch('App.initialized') 16 | private async OnInitialize(val: boolean, old: boolean) { 17 | this.CallOnce(); 18 | } 19 | private CallOnce() { 20 | if (app.initialized && !this.called) { 21 | this.Initialized(); 22 | this.called = true; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Mixins/MountedEmitter.ts: -------------------------------------------------------------------------------- 1 | import { Component, Emit, Vue } from 'vue-property-decorator'; 2 | 3 | @Component 4 | export default class MountedEmitter extends Vue { 5 | @Emit('mounted') public Mounted() { } 6 | public mounted() { this.Mounted(); } 7 | } 8 | -------------------------------------------------------------------------------- /src/Mixins/OrientationChange.ts: -------------------------------------------------------------------------------- 1 | import { fromEvent, interval, Subject, Subscription } from 'rxjs'; 2 | import { throttle } from 'rxjs/operators'; 3 | import { Component, Emit, Vue } from 'vue-property-decorator'; 4 | 5 | import { IsPortrait } from '../Common/IsPortrait'; 6 | 7 | export enum Orientation { Portrait, Landscape} 8 | 9 | @Component 10 | export default class OrientationChange extends Vue { 11 | private unsub!: Subscription; 12 | 13 | public OrientationChange(e: Orientation): void { } 14 | 15 | public mounted(): void { 16 | this.unsub = fromEvent(window, 'resize') 17 | .pipe(throttle(() => interval(100))) 18 | .subscribe(e => this.OrientationChange(this.Orientation())); 19 | 20 | this.OrientationChange(this.Orientation()); 21 | } 22 | 23 | public destroyed(): void { 24 | this.unsub.unsubscribe(); 25 | } 26 | 27 | private Orientation() { 28 | return IsPortrait() ? Orientation.Portrait : Orientation.Landscape; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Mixins/RefsForwarding.ts: -------------------------------------------------------------------------------- 1 | import ClipSettings from '@/Store/ClipSettings'; 2 | import { Component, Vue } from 'vue-property-decorator'; 3 | import { getModule } from 'vuex-module-decorators'; 4 | 5 | import { Env, env } from '../Env'; 6 | import store, { Access, App, Settings, SystemResources } from '../Store'; 7 | 8 | const app = getModule(App, store); 9 | const access = getModule(Access, store); 10 | const systemResources = getModule(SystemResources, store); 11 | const settings = getModule(Settings, store); 12 | const clipSettings = getModule(ClipSettings, store); 13 | 14 | @Component 15 | export default class RefsForwarding extends Vue { 16 | public get App(): App { return app; } 17 | public get Access(): Access { return access; } 18 | public get SystemResources(): SystemResources { return systemResources; } 19 | public get Settings(): Settings { return settings; } 20 | public get Env(): Env { return env; } 21 | public get ClipSettings(): ClipSettings { return clipSettings; } 22 | } 23 | -------------------------------------------------------------------------------- /src/Mixins/Services.ts: -------------------------------------------------------------------------------- 1 | import { Component, Vue } from 'vue-property-decorator'; 2 | 3 | import Services from '../services'; 4 | 5 | @Component 6 | export default class RefsForwarding extends Vue { 7 | public get Services() { return Services; } 8 | } 9 | -------------------------------------------------------------------------------- /src/Pages/404.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /src/Pages/Analytics.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 21 | 22 | -------------------------------------------------------------------------------- /src/Pages/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | -------------------------------------------------------------------------------- /src/Pages/Live.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 30 | 31 | -------------------------------------------------------------------------------- /src/Pages/Player.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | 21 | 50 | -------------------------------------------------------------------------------- /src/Pages/Recorder.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 52 | 53 | 101 | -------------------------------------------------------------------------------- /src/Pages/UploadVideo.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 20 | 21 | 37 | -------------------------------------------------------------------------------- /src/Plugins/ConfirmDlg/View.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /src/Plugins/ConfirmDlg/index.ts: -------------------------------------------------------------------------------- 1 | import _Vue from 'vue'; 2 | import Vuetify from 'vuetify'; 3 | 4 | import Dlg from './View.vue'; 5 | 6 | interface ConfirmDlgOptions { 7 | vuetify: typeof Vuetify; 8 | el(): HTMLElement; 9 | } 10 | export class ConfirmDlgApi { 11 | public constructor(private options: ConfirmDlgOptions) { } 12 | public Show(message: string): Promise { 13 | return new Promise(ok => { 14 | const component: Dlg = new Dlg({ 15 | propsData: { message }, 16 | vuetify: this.options.vuetify, 17 | destroyed: () => this.Done(component, ok) 18 | }); 19 | 20 | this.options.el().appendChild(component.$mount().$el); 21 | }); 22 | } 23 | 24 | private Done(cmp: Dlg, cb: (x: boolean) => void) { 25 | this.options.el().removeChild(cmp.$el); 26 | cb(((cmp as unknown) as { value: boolean } & Dlg).value); 27 | } 28 | } 29 | export function ConfirmDlgPlugin(Vue: typeof _Vue, options: ConfirmDlgOptions): void { 30 | Vue.prototype.$confirm = new ConfirmDlgApi(options); 31 | } 32 | -------------------------------------------------------------------------------- /src/Plugins/ConfirmDlg/vue.d.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { ConfirmDlgApi } from './index'; 3 | declare module 'vue/types/vue' { 4 | interface Vue { 5 | $confirm: ConfirmDlgApi; 6 | } 7 | } 8 | declare module "vue/types/options" { 9 | interface ComponentOptions { 10 | confirm?: ConfirmDlgApi; 11 | } 12 | } -------------------------------------------------------------------------------- /src/Plugins/Notifications/Types.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationType { 2 | INFO, WARN, ERR 3 | } 4 | -------------------------------------------------------------------------------- /src/Plugins/Notifications/View.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | 17 | 52 | -------------------------------------------------------------------------------- /src/Plugins/Notifications/index.ts: -------------------------------------------------------------------------------- 1 | import _Vue from 'vue'; 2 | import Vuetify from 'vuetify'; 3 | 4 | import Notification from './View.vue'; 5 | 6 | import { NotificationType } from './Types'; 7 | 8 | interface NotificationsOptions { 9 | vuetify: typeof Vuetify; 10 | el(): HTMLElement; 11 | } 12 | export class NotificationApi { 13 | public constructor(private options: NotificationsOptions) { } 14 | public Show(message: string, type: NotificationType = NotificationType.INFO): void { 15 | const component: Notification = new Notification({ 16 | propsData: { message, type }, 17 | vuetify: this.options.vuetify, 18 | destroyed: () => this.Destroyed(component) 19 | }); 20 | 21 | this.options.el().appendChild(component.$mount().$el); 22 | } 23 | 24 | private Destroyed(cmp: Notification) { 25 | this.options.el().removeChild(cmp.$el); 26 | } 27 | } 28 | export function NotificationsPlugin(Vue: typeof _Vue, options: NotificationsOptions): void { 29 | Vue.prototype.$notification = new NotificationApi(options); 30 | } 31 | -------------------------------------------------------------------------------- /src/Plugins/Notifications/vue.d.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { NotificationApi } from './index'; 3 | declare module 'vue/types/vue' { 4 | interface Vue { 5 | $notification: NotificationApi; 6 | } 7 | } 8 | declare module "vue/types/options" { 9 | interface ComponentOptions { 10 | notification?: NotificationApi; 11 | } 12 | } -------------------------------------------------------------------------------- /src/Plugins/Rpc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RpcClientPlugin'; 2 | -------------------------------------------------------------------------------- /src/Plugins/Rpc/vue.d.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { RpcClientPlugin } from './index'; 3 | declare module 'vue/types/vue' { 4 | interface Vue { 5 | $rpc: RpcClientPlugin; 6 | } 7 | } 8 | declare module "vue/types/options" { 9 | interface ComponentOptions { 10 | rpc?: RpcClientPlugin; 11 | } 12 | } -------------------------------------------------------------------------------- /src/Router/Playlist.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | import { AppAccessType } from '@Shared/Types'; 3 | 4 | export default [ 5 | { 6 | path: 'new', 7 | name: 'playlist.new', 8 | props: true, 9 | component: () => import('@/Components/Playlist/NewPlaylist.vue'), 10 | meta: { access: AppAccessType.FULL_ACCESS } 11 | } 12 | ]; 13 | -------------------------------------------------------------------------------- /src/Router/UploadVideo.ts: -------------------------------------------------------------------------------- 1 | import { AppAccessType } from '@Shared/Types'; 2 | 3 | export default [ 4 | { 5 | path: 'file', component: () => import('@/Components/UploadVideo/LocalFile.vue'), 6 | meta: { access: AppAccessType.FULL_ACCESS } 7 | }, 8 | { 9 | path: 'url', component: () => import('@/Components/UploadVideo/Url.vue'), 10 | meta: { access: AppAccessType.FULL_ACCESS } 11 | }, 12 | { 13 | path: 'archive', component: () => import('@/Components/UploadVideo/Archive.vue'), 14 | meta: { access: AppAccessType.FULL_ACCESS } 15 | } 16 | ]; 17 | -------------------------------------------------------------------------------- /src/Router/analytics.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | path: 'space_usage', component: () => import('@/Components/Analytics/SpaceUsage.vue') 4 | }, 5 | { 6 | path: 'archive_population', component: () => import('@/Components/Analytics/ArchivePopulation.vue') 7 | } 8 | ]; 9 | -------------------------------------------------------------------------------- /src/RpcInstance.ts: -------------------------------------------------------------------------------- 1 | import { RpcClientPlugin } from './Plugins/Rpc'; 2 | export const Rpc: { instance: RpcClientPlugin | null } = { instance: null }; 3 | -------------------------------------------------------------------------------- /src/ServiceWorkerResponder.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | 3 | import { AppAccessType } from '@Shared/Types'; 4 | import Services from './services'; 5 | import { StoreType } from './Store'; 6 | 7 | export default class ServiecWorkerResponder { 8 | public constructor(private store: Store) { 9 | navigator.serviceWorker.addEventListener('message', this.accessTokenRqFn); 10 | this.store 11 | .watch(() => this.store.state.access.token, (val: string, old: string) => this.TokenChanged(val, old)); 12 | } 13 | public Dispose() { 14 | navigator.serviceWorker.removeEventListener('message', this.accessTokenRqFn); 15 | } 16 | private accessTokenRqFn = (e: MessageEvent) => this.OnAccessTokenRequest(e); 17 | private TokenChanged(val: string, old: string) { 18 | const msg = val === '' ? 19 | { type: 'invalidate_token' } : 20 | { type: 'auth_req', token: val }; 21 | Services.serviceWorker.Instacnce?.active?.postMessage(msg); 22 | } 23 | private OnAccessTokenRequest(e: MessageEvent) { 24 | if (e.data === 'auth_req') { 25 | if (this.store.state.access.defaultAccess === AppAccessType.NO_ACCESS) 26 | (e.source as MessagePort).postMessage({ type: 'auth_req', token: this.store.state.access.token }); 27 | else 28 | (e.source as MessagePort).postMessage({ type: 'auth_free' }); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Store/Access.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { first } from 'rxjs/operators'; 3 | import { 4 | Action, 5 | Module, 6 | Mutation, 7 | VuexModule, 8 | } from 'vuex-module-decorators'; 9 | 10 | import { AppAccessType, AppStateSnapshot } from '@Shared/Types'; 11 | import { Rpc } from '../RpcInstance'; 12 | 13 | @Module({ namespaced: true, name: 'access' }) 14 | export default class Access extends VuexModule { 15 | public dialogShown = false; 16 | public defaultAccess = AppAccessType.NO_ACCESS; 17 | public access = AppAccessType.NO_ACCESS; 18 | public token = ''; 19 | private OnGrantAcces = new Subject(); 20 | @Mutation 21 | public SOCKET_disconnect() { 22 | this.access = AppAccessType.NO_ACCESS; 23 | } 24 | @Mutation 25 | public SOCKET_Snapshot(snapshot: AppStateSnapshot) { 26 | this.defaultAccess = snapshot.defaultAccess; 27 | this.access = snapshot.defaultAccess; 28 | } 29 | @Mutation 30 | public ShowDlg() { 31 | this.dialogShown = true; 32 | } 33 | @Mutation 34 | public CloseDlg() { 35 | this.dialogShown = false; 36 | this.OnGrantAcces.next(this.access > this.defaultAccess); 37 | } 38 | @Mutation 39 | public SOCKET_ProlongateSession(token: string) { 40 | this.token = token; 41 | } 42 | @Mutation 43 | public GrantFullAccess() { 44 | this.access = AppAccessType.FULL_ACCESS; 45 | this.OnGrantAcces.next(this.access > this.defaultAccess); 46 | } 47 | @Mutation 48 | public InvalidateToken() { this.token = ''; } 49 | @Mutation 50 | public SetAccessToken(token: string) { this.token = token; } 51 | @Action 52 | public async TryUpgrade() { 53 | if (this.HasAccessToken) { 54 | await Rpc.instance?.UpgradeAccess(this.token) ? 55 | this.GrantFullAccess() : 56 | this.InvalidateToken(); 57 | } 58 | } 59 | @Action 60 | public async RequestPassphrase() { 61 | this.ShowDlg(); 62 | return new Promise(ret => { 63 | this.OnGrantAcces 64 | .pipe(first()) 65 | .subscribe(x => ret(x)); 66 | }); 67 | } 68 | @Action 69 | public async AwaitAccessUpgrade(timeout: number) { 70 | return this.FullAccess || !this.HasAccessToken ? Promise.resolve() : 71 | new Promise(ret => { 72 | const tid = setTimeout(() => ret(), timeout); 73 | this.OnGrantAcces 74 | .pipe(first()) 75 | .subscribe(x => (clearTimeout(tid), ret())); 76 | }); 77 | } 78 | public get FullAccess() { return this.access === AppAccessType.FULL_ACCESS; } 79 | public get NoAccess() { return this.access === AppAccessType.NO_ACCESS; } 80 | public get NonFullAccess() { return this.access !== AppAccessType.FULL_ACCESS; } 81 | public get HasAccessToken() { return this.token.length > 0; } 82 | } 83 | -------------------------------------------------------------------------------- /src/Store/ClipSettings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | Module, 4 | Mutation, 5 | VuexModule 6 | } from 'vuex-module-decorators'; 7 | 8 | import store from './index'; 9 | 10 | export interface SettingsBackup { 11 | filename: string; 12 | begin: number; 13 | end: number; 14 | } 15 | 16 | @Module({ namespaced: true, name: 'ClipSettings', dynamic: true, store }) 17 | export default class ClipSettings extends VuexModule { 18 | public settings: SettingsBackup | null = null; 19 | 20 | @Action 21 | public HasSettings(filename: string): boolean { 22 | return filename === this.settings?.filename; 23 | } 24 | 25 | @Mutation 26 | public Reset(): void { 27 | this.settings = null; 28 | } 29 | 30 | @Mutation 31 | public Backup(settings: SettingsBackup): void { 32 | this.settings = settings; 33 | } 34 | 35 | public get Settings(): SettingsBackup { 36 | return this.settings as SettingsBackup; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Store/Settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Module, 3 | Mutation, 4 | VuexModule 5 | } from 'vuex-module-decorators'; 6 | 7 | import { AppStateSnapshot } from '@Shared/Types'; 8 | 9 | @Module({ namespaced: true, name: 'settings' }) 10 | export default class Settings extends VuexModule { 11 | public webPushAvailable = false; 12 | public webPushEnabled = false; 13 | public newRecordNotification = false; 14 | public sizeQuotaNotification = false; 15 | public storageQuota = 0; 16 | public instanceQuota = 0; 17 | public downloadSpeedQuota = 0; 18 | public remoteSeleniumUrl = ''; 19 | @Mutation 20 | public SOCKET_Snapshot(snapshot: AppStateSnapshot) { 21 | this.storageQuota = snapshot.storageQuota; 22 | this.instanceQuota = snapshot.instanceQuota; 23 | this.downloadSpeedQuota = snapshot.downloadSpeedQuota; 24 | this.remoteSeleniumUrl = snapshot.remoteSeleniumUrl; 25 | } 26 | 27 | @Mutation 28 | public SOCKET_UpdateStorageQuota(quota: number) { 29 | this.storageQuota = quota; 30 | } 31 | 32 | @Mutation 33 | public SOCKET_UpdateInstanceQuota(quota: number) { 34 | this.instanceQuota = quota; 35 | } 36 | 37 | @Mutation 38 | public SOCKET_UpdateDownloadSpeedQuota(quota: number) { 39 | this.downloadSpeedQuota = quota; 40 | } 41 | 42 | @Mutation 43 | public SOCKET_UpdateRemoteSeleniumUrl(url: string) { 44 | this.remoteSeleniumUrl = url; 45 | } 46 | 47 | @Mutation 48 | public WebPushAvailable(val: boolean) { 49 | this.webPushAvailable = val; 50 | } 51 | 52 | @Mutation 53 | public WebPushEnabled(val: boolean) { 54 | this.webPushEnabled = val; 55 | } 56 | 57 | @Mutation 58 | public NewRecordNotification(val: boolean): void { 59 | this.newRecordNotification = val; 60 | } 61 | 62 | @Mutation 63 | public SizeQuotaNotification(val: boolean) { 64 | this.sizeQuotaNotification = val; 65 | } 66 | 67 | public get HasEnabledNotification() { 68 | return this.sizeQuotaNotification || this.newRecordNotification; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Store/SystemResources.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Module, 3 | Mutation, 4 | VuexModule, 5 | } from 'vuex-module-decorators'; 6 | 7 | import { AppStateSnapshot } from '@Shared/Types'; 8 | 9 | interface SystemResourcesInfo { 10 | cpu: number; 11 | rss: number; 12 | hdd: number; 13 | } 14 | @Module({ namespaced: true, name: 'systemResources' }) 15 | export default class SystemResources extends VuexModule { 16 | public cpu = 0; 17 | public rss = 0; 18 | public hdd = 0; 19 | @Mutation 20 | public SOCKET_Snapshot(snapshot: AppStateSnapshot) { 21 | this.cpu = snapshot.systemResources.cpu; 22 | this.rss = snapshot.systemResources.rss; 23 | this.hdd = snapshot.systemResources.hdd; 24 | } 25 | @Mutation 26 | public SOCKET_SystemMonitorUpdate(info: SystemResourcesInfo) { 27 | this.cpu = info.cpu; 28 | this.rss = info.rss; 29 | this.hdd = info.hdd; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import createPersistedState from 'vuex-persistedstate'; 4 | 5 | import Access from './Access'; 6 | import App from './App'; 7 | import ClipSettings from './ClipSettings'; 8 | import Settings from './Settings'; 9 | import SystemResources from './SystemResources'; 10 | 11 | export { default as Access } from './Access'; 12 | export { default as App } from './App'; 13 | export { default as SystemResources } from './SystemResources'; 14 | export { default as Settings } from './Settings'; 15 | 16 | Vue.use(Vuex); 17 | export interface StoreType { 18 | app: App; 19 | access: Access; 20 | systemResources: SystemResources; 21 | settings: Settings; 22 | clipSettings: ClipSettings; 23 | } 24 | export default new Vuex.Store({ 25 | modules: { 26 | app: App, 27 | access: Access, 28 | systemResources: SystemResources, 29 | settings: Settings 30 | }, 31 | plugins: [createPersistedState({ paths: ['app.lastTimeArchiveVisit', 'access.token'] })] 32 | }); 33 | -------------------------------------------------------------------------------- /src/Theme.ts: -------------------------------------------------------------------------------- 1 | export const Theme = { 2 | themes: { 3 | light: { 4 | primary: '#0091ea', 5 | secondary: '#424242', 6 | accent: '#82B1FF', 7 | error: '#FF5252', 8 | info: '#2196F3', 9 | success: '#4CAF50', 10 | warning: '#FFC107' 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/plugins/bongacams.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/src/assets/plugins/bongacams.png -------------------------------------------------------------------------------- /src/assets/plugins/bongacams_direct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/src/assets/plugins/bongacams_direct.png -------------------------------------------------------------------------------- /src/assets/plugins/camsoda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/src/assets/plugins/camsoda.png -------------------------------------------------------------------------------- /src/assets/plugins/chaturbate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/src/assets/plugins/chaturbate.png -------------------------------------------------------------------------------- /src/assets/plugins/dummy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/src/assets/plugins/dummy.png -------------------------------------------------------------------------------- /src/assets/plugins/kick_clip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PleasureTools/joyBox/2faaaa888b8325c1d94a36edece655a732b650ca/src/assets/plugins/kick_clip.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background-color: #fafafa; 4 | height: 100%; 5 | overflow: auto; 6 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRx from 'vue-rx'; 3 | 4 | import '@mdi/font/css/materialdesignicons.min.css'; 5 | import 'material-design-icons-iconfont/dist/material-design-icons.css'; 6 | import 'roboto-fontface/css/roboto/roboto-fontface.css'; 7 | 8 | import Vuetify from 'vuetify'; 9 | import 'vuetify/dist/vuetify.min.css'; 10 | 11 | import { RecycleScroller } from 'vue-virtual-scroller'; 12 | import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; 13 | 14 | import Component from 'vue-class-component'; 15 | 16 | import VueMeta from 'vue-meta'; 17 | 18 | import './index.css'; 19 | 20 | import App from './App.vue'; 21 | import router from './Router'; 22 | import store from './Store'; 23 | 24 | import SocketIO from 'socket.io-client'; 25 | import VueSocketIO from 'vue-socket.io'; 26 | 27 | import { Response as RpcResponse } from './Common'; 28 | import { RpcClientPlugin } from './Plugins/Rpc'; 29 | 30 | import { env } from './Env'; 31 | import { Theme } from './Theme'; 32 | 33 | import { ErrorHandler } from './ErrorHandler'; 34 | 35 | import { Rpc } from './RpcInstance'; 36 | 37 | import ServiceWorkerResponder from './ServiceWorkerResponder'; 38 | 39 | import { ConfirmDlgPlugin } from './Plugins/ConfirmDlg'; 40 | import { NotificationsPlugin } from './Plugins/Notifications'; 41 | 42 | const MOUNT_POINT = '#app'; 43 | 44 | Vue.config.errorHandler = ErrorHandler; 45 | 46 | const socket = SocketIO(env.Host); 47 | const rpc = new RpcClientPlugin(socket); 48 | 49 | Rpc.instance = rpc; 50 | 51 | Component.registerHooks([ 52 | 'beforeRouteEnter', 53 | 'beforeRouteLeave', 54 | 'beforeRouteUpdate' // for vue-router 2.2+ 55 | ]); 56 | 57 | Vue.mixin({ 58 | beforeCreate() { 59 | const options = this.$options; 60 | if (options.rpc) 61 | this.$rpc = options.rpc; 62 | else if (options.parent && options.parent.$rpc) 63 | this.$rpc = options.parent.$rpc; 64 | } 65 | }); 66 | 67 | Vue.use(VueRx); 68 | 69 | Vue.use(Vuetify); 70 | const vuetify = new Vuetify({ theme: Theme }); 71 | const MountPoint = () => document.querySelector(MOUNT_POINT); 72 | 73 | Vue.use(ConfirmDlgPlugin, { el: MountPoint, vuetify }); 74 | Vue.use(NotificationsPlugin, { el: MountPoint, vuetify }); 75 | 76 | Vue.component('RecycleScroller', RecycleScroller); 77 | 78 | Vue.use(new VueSocketIO({ 79 | debug: process.env.NODE_ENV === 'development', 80 | connection: socket, 81 | vuex: { 82 | store, 83 | actionPrefix: 'SOCKET_', 84 | mutationPrefix: 'SOCKET_' 85 | } 86 | })); 87 | 88 | Vue.use(VueMeta); 89 | 90 | if (env.Secure) 91 | new ServiceWorkerResponder(store); 92 | 93 | new Vue({ 94 | router, 95 | rpc, 96 | store, 97 | vuetify, 98 | sockets: { 99 | // https://www.npmjs.com/package/vue-socket.io#-component-level-usage 100 | rpc: (res: RpcResponse) => rpc.OnRpcResponse(res) // Handle SC data (all 'rpc' events) 101 | }, 102 | render: h => h(App) 103 | }).$mount(MOUNT_POINT); 104 | -------------------------------------------------------------------------------- /src/services/PushService.ts: -------------------------------------------------------------------------------- 1 | import { UrlBase64ToUint8Array } from './../Common/Util'; 2 | 3 | export class PushService { 4 | private instance: ServiceWorkerRegistration | null = null; 5 | public async GetInstance(): Promise { 6 | if (!this.instance) { this.instance = await navigator.serviceWorker.getRegistration() || null; } 7 | } 8 | 9 | public async Subscribe(VAPIDKey: string): Promise { 10 | await this.GetInstance(); 11 | const sub = await this.instance?.pushManager.getSubscription(); 12 | if (sub) { return sub; } 13 | 14 | if (!VAPIDKey) { return null; } 15 | 16 | try { 17 | return await this.instance?.pushManager 18 | .subscribe({ userVisibleOnly: true, applicationServerKey: UrlBase64ToUint8Array(VAPIDKey) }) || null; 19 | } catch (e) { 20 | return null; 21 | } 22 | } 23 | 24 | public async Unsubscribe(): Promise { 25 | await this.GetInstance(); 26 | if (!this.instance) { return; } 27 | 28 | const subscription = await this.instance.pushManager.getSubscription(); 29 | 30 | if (!subscription) { return; } 31 | 32 | await subscription.unsubscribe(); 33 | } 34 | 35 | public async RetrieveEndpoint(): Promise { 36 | await this.GetInstance(); 37 | if (!this.instance) { return ''; } 38 | 39 | const subscription = await this.instance.pushManager.getSubscription(); 40 | 41 | if (!subscription) { return ''; } 42 | 43 | return subscription.endpoint; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/services/ServiceWorker.ts: -------------------------------------------------------------------------------- 1 | export class ServiceWorker { 2 | private instance: ServiceWorkerRegistration | null = null; 3 | public async Start() { 4 | this.instance = await navigator.serviceWorker.getRegistration() || null; 5 | if (!this.instance) 6 | this.instance = await navigator.serviceWorker.register('/service-worker.js'); 7 | } 8 | public get Instacnce() { return this.instance; } 9 | } 10 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from '../Env'; 2 | import { PushService } from './PushService'; 3 | import { ServiceWorker } from './ServiceWorker'; 4 | 5 | const push = new PushService(); 6 | 7 | const serviceWorker = new ServiceWorker(); 8 | 9 | if (env.Secure) 10 | serviceWorker.Start(); 11 | 12 | export default { push, serviceWorker }; 13 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Plugin { 2 | id: number; 3 | name: string; 4 | enabled: boolean; 5 | } 6 | 7 | export interface Stream { 8 | uri: string; 9 | lastSeen: number; 10 | download: number; 11 | plugins: Plugin[]; 12 | } 13 | 14 | export interface SnapshotStream { 15 | uri: string; 16 | lastSeen: number; 17 | plugins: string[]; 18 | } 19 | export interface SystemMonitorInfo { 20 | cpu: number; 21 | rss: number; 22 | hdd: number; 23 | } 24 | export interface InputEventSubject { 25 | event: { name: string, msg: M }; 26 | data: D; 27 | } 28 | 29 | export interface TouchWrapper { 30 | touchstartX: number; 31 | touchstartY: number; 32 | touchmoveX: number; 33 | touchmoveY: number; 34 | touchendX: number; 35 | touchendY: number; 36 | offsetX: number; 37 | offsetY: number; 38 | } 39 | 40 | export interface UntrackedVideo { 41 | filename: string; 42 | size: number; 43 | } 44 | -------------------------------------------------------------------------------- /test/backend/RecordingService.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as mocha from 'mocha'; 3 | 4 | describe('RecordingService', () => { 5 | describe('String test', () => { 6 | it('should be a string', () => { 7 | chai.expect('Some text').to.be.a('string'); 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "downlevelIteration": true, 13 | "sourceMap": true, 14 | "skipLibCheck": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env" 18 | ], 19 | "paths": { 20 | "@Shared/*": [ 21 | "Shared/*" 22 | ], 23 | "*": [ 24 | "types/*" 25 | ], 26 | "@/*": [ 27 | "src/*" 28 | ] 29 | }, 30 | "lib": [ 31 | "esnext", 32 | "dom", 33 | "dom.iterable", 34 | "scripthost" 35 | ] 36 | }, 37 | "include": [ 38 | "src/**/*.ts", 39 | "src/**/*.tsx", 40 | "src/**/*.vue", 41 | "tests/**/*.ts", 42 | "tests/**/*.tsx" 43 | ], 44 | "exclude": [ 45 | "node_modules" 46 | ] 47 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "rules": { 7 | "quotemark": [ 8 | true, 9 | "single" 10 | ], 11 | "no-console": false, 12 | "max-classes-per-file": false, 13 | "interface-name": false, 14 | "arrow-parens": false, 15 | "no-unused-expression": false, 16 | "trailing-comma": false, 17 | "curly": false, 18 | "no-bitwise": false, 19 | "object-literal-sort-keys": false, 20 | "no-empty": false, 21 | "member-ordering": false 22 | }, 23 | "linterOptions": { 24 | "exclude": [ 25 | "node_modules/**/*.*" 26 | ] 27 | } 28 | } -------------------------------------------------------------------------------- /types/vue-socket.io.d.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import * as SocketIOClient from 'socket.io-client'; 3 | 4 | interface VueSocketIOConstructor { 5 | new(ctor: { connection: any, vuex?: any, debug?: any, options?: any }): VueSocketIO; 6 | } 7 | 8 | interface VueSocketIO { 9 | io: any; 10 | emitter: any; 11 | listener: any; 12 | 13 | install(vue: any): void; 14 | connect(connection: any, options: any): void; 15 | } 16 | 17 | declare module 'vue/types/vue' { 18 | interface Vue { 19 | $socket: SocketIOClient.Socket 20 | sockets: any; 21 | } 22 | } 23 | 24 | declare module "vue/types/options" { 25 | interface ComponentOptions { 26 | sockets?: any; 27 | } 28 | } 29 | 30 | declare const VueSocketIO: VueSocketIOConstructor; 31 | export = VueSocketIO; -------------------------------------------------------------------------------- /types/vue-virtual-scroller.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vue-virtual-scroller'; -------------------------------------------------------------------------------- /types/vuedraggable.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vuedraggable' { 2 | import Vue, { ComponentOptions } from 'vue'; 3 | 4 | export interface DraggedContext { 5 | index: number; 6 | futureIndex: number; 7 | element: T; 8 | } 9 | 10 | export interface DropContext { 11 | index: number; 12 | component: Vue; 13 | element: T; 14 | } 15 | 16 | export interface Rectangle { 17 | top: number; 18 | right: number; 19 | bottom: number; 20 | left: number; 21 | width: number; 22 | height: number; 23 | } 24 | 25 | export interface MoveEvent { 26 | originalEvent: DragEvent; 27 | dragged: Element; 28 | draggedContext: DraggedContext; 29 | draggedRect: Rectangle; 30 | related: Element; 31 | relatedContext: DropContext; 32 | relatedRect: Rectangle; 33 | from: Element; 34 | to: Element; 35 | oldIndex: number; 36 | newIndex: number; 37 | willInsertAfter: boolean; 38 | isTrusted: boolean; 39 | } 40 | export type UpdateEvent = MoveEvent; 41 | 42 | const draggableComponent: ComponentOptions; 43 | 44 | export default draggableComponent; 45 | } -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const fs = require('fs'); 3 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 4 | const OfflineMode = require('./plugins/OfflineMode'); 5 | 6 | const IsProduction = () => process.env.NODE_ENV === 'production'; 7 | 8 | const devServerConfig = () => IsProduction() 9 | ? {} 10 | : { 11 | host: 'dev.lan', 12 | https: { 13 | key: fs.readFileSync('./data/server.key'), 14 | cert: fs.readFileSync('./data/server.cer') 15 | }, 16 | proxy: { 17 | '^/socket.io': { 18 | target: 'https://dev.lan:3000', 19 | ws: true 20 | }, 21 | '^/archive/+.': { 22 | target: 'https://dev.lan:3000' 23 | }, 24 | '^/upload_video': { 25 | target: 'https://dev.lan:3000', 26 | // Have no idea why bypass handled any requests 27 | bypass: req => req.url.startsWith('/upload_video') && req.method === 'POST' ? null : '/' 28 | } 29 | } 30 | }; 31 | 32 | module.exports = { 33 | productionSourceMap: false, 34 | configureWebpack: { 35 | devtool: 'source-map' 36 | }, 37 | devServer: devServerConfig(), 38 | chainWebpack: config => { 39 | config.module 40 | .rule('vue') 41 | .use('vue-loader') 42 | .loader('vue-loader') 43 | .tap(() => ({ transformAssetUrls: { 'v-img': 'src' } })); 44 | 45 | config.resolve 46 | .plugin('tscpp') 47 | .use(TsconfigPathsPlugin); 48 | 49 | if (IsProduction()) { 50 | config 51 | .plugin('offlineMode') 52 | .use(new OfflineMode('service-worker.js')); 53 | } 54 | } 55 | }; 56 | --------------------------------------------------------------------------------