├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── dependabot.yml └── workflows │ ├── docker-publish.yaml │ ├── package-cleanup.yaml │ └── wiki-publish.yaml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── client ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── public │ └── handbrake-icon.png ├── src │ ├── components │ │ ├── base │ │ │ ├── info │ │ │ │ ├── badge-info │ │ │ │ │ ├── badge-info.scss │ │ │ │ │ └── badge-info.tsx │ │ │ │ └── text-info │ │ │ │ │ ├── text-info.scss │ │ │ │ │ └── text-info.tsx │ │ │ ├── inputs │ │ │ │ ├── button-group │ │ │ │ │ ├── button-group.scss │ │ │ │ │ └── button-group.tsx │ │ │ │ ├── button │ │ │ │ │ ├── button-input.scss │ │ │ │ │ └── button-input.tsx │ │ │ │ ├── checkbox │ │ │ │ │ ├── checkbox-input.scss │ │ │ │ │ └── checkbox-input.tsx │ │ │ │ ├── number │ │ │ │ │ ├── number-input.scss │ │ │ │ │ └── number-input.tsx │ │ │ │ ├── path │ │ │ │ │ ├── path-input.scss │ │ │ │ │ └── path-input.tsx │ │ │ │ ├── select │ │ │ │ │ ├── select-input.scss │ │ │ │ │ └── select-input.tsx │ │ │ │ ├── text │ │ │ │ │ ├── text-input.scss │ │ │ │ │ └── text-input.tsx │ │ │ │ └── toggle │ │ │ │ │ ├── toggle-input.scss │ │ │ │ │ └── toggle-input.tsx │ │ │ └── progress │ │ │ │ ├── progress-bar.scss │ │ │ │ └── progress-bar.tsx │ │ ├── cards │ │ │ ├── preset-card │ │ │ │ ├── preset-card.scss │ │ │ │ ├── preset-card.tsx │ │ │ │ └── tabs │ │ │ │ │ ├── audio │ │ │ │ │ ├── preset-card-audio.scss │ │ │ │ │ └── preset-card-audio.tsx │ │ │ │ │ ├── chapters │ │ │ │ │ └── preset-card-chapters.tsx │ │ │ │ │ ├── dimensions │ │ │ │ │ ├── preset-card-dimensions.scss │ │ │ │ │ └── preset-card-dimensions.tsx │ │ │ │ │ ├── filters │ │ │ │ │ ├── preset-card-filters.scss │ │ │ │ │ └── preset-card-filters.tsx │ │ │ │ │ ├── subtitles │ │ │ │ │ ├── preset-card-subtitles.scss │ │ │ │ │ └── preset-card-subtitles.tsx │ │ │ │ │ ├── summary │ │ │ │ │ ├── preset-card-summary.scss │ │ │ │ │ └── preset-card-summary.tsx │ │ │ │ │ └── video │ │ │ │ │ ├── preset-card-video.scss │ │ │ │ │ └── preset-card-video.tsx │ │ │ ├── queue-card │ │ │ │ ├── components │ │ │ │ │ └── queue-card-section.tsx │ │ │ │ ├── queue-card.scss │ │ │ │ └── queue-card.tsx │ │ │ └── watcher-card │ │ │ │ ├── components │ │ │ │ └── watcher-card-rule.tsx │ │ │ │ ├── watcher-card.scss │ │ │ │ └── watcher-card.tsx │ │ ├── modules │ │ │ ├── file-browser │ │ │ │ ├── components │ │ │ │ │ ├── file-browser-add-directory.tsx │ │ │ │ │ └── file-browser-body.tsx │ │ │ │ ├── file-browser.scss │ │ │ │ └── file-browser.tsx │ │ │ ├── side-bar │ │ │ │ ├── side-bar.scss │ │ │ │ └── side-bar.tsx │ │ │ └── version-info │ │ │ │ ├── version-info.scss │ │ │ │ └── version-info.tsx │ │ ├── overlays │ │ │ ├── create-job │ │ │ │ ├── create-job-funcs.ts │ │ │ │ ├── create-job.scss │ │ │ │ └── create-job.tsx │ │ │ ├── register-watcher │ │ │ │ ├── register-watcher.scss │ │ │ │ └── register-watcher.tsx │ │ │ └── upload-preset │ │ │ │ ├── upload-preset.scss │ │ │ │ └── upload-preset.tsx │ │ └── section │ │ │ ├── section-context.ts │ │ │ ├── section-overlay.scss │ │ │ ├── section-overlay.tsx │ │ │ ├── section.scss │ │ │ ├── section.tsx │ │ │ ├── sub-section.scss │ │ │ └── sub-section.tsx │ ├── index.scss │ ├── index.tsx │ ├── pages │ │ └── primary │ │ │ ├── primary-context.ts │ │ │ ├── primary.scss │ │ │ └── primary.tsx │ ├── sections │ │ ├── dashboard │ │ │ ├── dashboard.scss │ │ │ ├── dashboard.tsx │ │ │ └── sub-sections │ │ │ │ ├── dashboard-presets.scss │ │ │ │ ├── dashboard-presets.tsx │ │ │ │ ├── dashboard-queue.scss │ │ │ │ ├── dashboard-queue.tsx │ │ │ │ ├── dashboard-summary.scss │ │ │ │ ├── dashboard-summary.tsx │ │ │ │ ├── dashboard-watchers.scss │ │ │ │ ├── dashboard-watchers.tsx │ │ │ │ ├── dashboard-workers.scss │ │ │ │ └── dashboard-workers.tsx │ │ ├── error │ │ │ └── error.tsx │ │ ├── no-connection │ │ │ └── no-connection.tsx │ │ ├── presets │ │ │ ├── presets.scss │ │ │ ├── presets.tsx │ │ │ └── sub-sections │ │ │ │ ├── presets-buttons.tsx │ │ │ │ ├── presets-list-category.tsx │ │ │ │ ├── presets-list.d.ts │ │ │ │ └── presets-list.tsx │ │ ├── queue │ │ │ ├── queue.scss │ │ │ ├── queue.tsx │ │ │ └── sub-sections │ │ │ │ ├── queue-job-preview.tsx │ │ │ │ ├── queue-jobs-category.tsx │ │ │ │ ├── queue-jobs.tsx │ │ │ │ └── queue-status.tsx │ │ ├── settings │ │ │ ├── settings.scss │ │ │ ├── settings.tsx │ │ │ └── sub-sections │ │ │ │ ├── settings-application.tsx │ │ │ │ ├── settings-paths.tsx │ │ │ │ └── settings-presets.tsx │ │ ├── watchers │ │ │ ├── watchers.scss │ │ │ └── watchers.tsx │ │ └── workers │ │ │ ├── sub-sections │ │ │ ├── workers-status.scss │ │ │ ├── workers-status.tsx │ │ │ ├── workers-summary.scss │ │ │ └── workers-summary.tsx │ │ │ ├── workers.scss │ │ │ └── workers.tsx │ ├── style │ │ └── modules │ │ │ ├── _colors.scss │ │ │ ├── _font.scss │ │ │ ├── _form.scss │ │ │ ├── _general.scss │ │ │ ├── _headings.scss │ │ │ ├── _sizing.scss │ │ │ └── _table.scss │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── docs └── Home.md ├── images ├── readme │ ├── readme-preset-export.png │ ├── readme-preset-save.png │ └── readme-preset-upload.png └── screenshots │ ├── screenshot-dashboard.png │ ├── screenshot-presets.png │ ├── screenshot-queue.png │ ├── screenshot-settings.png │ ├── screenshot-watchers.png │ └── screenshot-workers.png ├── server ├── Dockerfile ├── Dockerfile.dockerignore ├── package-lock.json ├── package.json ├── src │ ├── html │ │ └── development │ │ │ └── index.html │ ├── routes │ │ └── client.ts │ ├── scripts │ │ ├── config.ts │ │ ├── connections.ts │ │ ├── data.ts │ │ ├── database │ │ │ ├── database-queue.ts │ │ │ ├── database-status.ts │ │ │ ├── database-watcher.ts │ │ │ ├── database.ts │ │ │ └── migrations │ │ │ │ ├── database-migration-0.ts │ │ │ │ ├── database-migration-1.ts │ │ │ │ └── database-migrations.ts │ │ ├── files.ts │ │ ├── logging.ts │ │ ├── media.ts │ │ ├── presets.ts │ │ ├── queue.ts │ │ ├── version.ts │ │ └── watcher.ts │ ├── server-shutdown.ts │ ├── server-startup.ts │ ├── server.ts │ ├── socket │ │ ├── client-socket.ts │ │ └── worker-socket.ts │ └── template │ │ ├── config.yaml │ │ └── default-presets.json └── tsconfig.json ├── shared ├── dict │ ├── presets.dict.ts │ └── queue.dict.ts ├── funcs │ ├── locale.funcs.ts │ ├── preset.funcs.ts │ └── string.funcs.ts ├── tsconfig.json └── types │ ├── config.ts │ ├── database.ts │ ├── dict.ts │ ├── directory.ts │ ├── file-browser.ts │ ├── file-extensions.ts │ ├── handbrake.ts │ ├── preset.ts │ ├── queue.ts │ ├── socket.ts │ ├── transcode.ts │ ├── version.ts │ └── watcher.ts └── worker ├── Dockerfile ├── Dockerfile.dockerignore ├── package-lock.json ├── package.json ├── src ├── scripts │ ├── logging.ts │ └── transcode.ts ├── socket │ └── server-socket.ts ├── worker-shutdown.ts ├── worker-startup.ts └── worker.ts └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/base:bookworm 2 | 3 | # Install dev dependencies 4 | RUN apt update 5 | RUN apt install -y sqlite3 6 | RUN apt install -y handbrake-cli 7 | 8 | ENV NODE_ENV=development -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "handbrake-web", 3 | "build": { "dockerfile": "Dockerfile" }, 4 | "runArgs": ["--name", "handbrake-web-dev"], 5 | "mounts": [ 6 | // Uncomment to map an external media directory 7 | // { 8 | // "source": "/your/media/location", //change this to wherever you store your desired media 9 | // "target": "/video", 10 | // "type": "bind" 11 | // } 12 | ], 13 | "features": { 14 | "ghcr.io/devcontainers/features/node:1": {} 15 | }, 16 | "containerEnv": { 17 | "SERVER_URL": "http://localhost", 18 | "SERVER_PORT": "9999", 19 | "CLIENT_PORT": "5173", 20 | "DATA_PATH": "/workspaces/handbrake-web/data", 21 | "VIDEO_PATH": "/video", 22 | "SERVER_ID": "development-server", 23 | "WORKER_ID": "development-worker" 24 | }, 25 | "customizations": { 26 | "vscode": { 27 | "extensions": [ 28 | "dbaeumer.vscode-eslint", 29 | "christian-kohler.npm-intellisense", 30 | "esbenp.prettier-vscode", 31 | "github.vscode-github-actions" 32 | ] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: thenickoftime 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Submit a report for an issue you are experiencing with HandBrake Web 4 | title: X issue occurs when Y thing happens 5 | labels: request, type/bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | > [!important] 11 | > Have you searched existing issues (both open and closed) for the issue you are having? If not, please consider doing so - your issue may already be fixed or in discussion! 12 | 13 | ## Bug Information 14 | 15 | ### Bug Description 16 | Describe the issue you are having. What is the behavior you are experiencing versus what you were expecting? 17 | 18 | ### Bug Steps 19 | 1. If your issue is reproducible, please provide steps to reproduce the issue. 20 | 21 | ### Additional Information 22 | Any additional information or screenshots related to your issue. 23 | 24 | ## Application Information 25 | 26 | ### HandBrake Web Version 27 | - vX.X.X 28 | 29 | ### Docker Compose 30 | ```yaml 31 | Paste the contents of your docker compose file here... 32 | ``` 33 | 34 | ### Host Machine 35 | - OS: 36 | - CPU: 37 | - GPU: 38 | 39 | ### Log Information 40 | ``` 41 | Provide any relevant sections of your log here 42 | ``` 43 | 44 | > [!tip] 45 | > Please ensure all sections have been filled out with the requested information and the information is properly formatted. Issues with missing/poorly formatted information make giving help more difficult! 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea that you have for HandBrake Web 4 | title: '' 5 | labels: request, type/feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Is your feature request related to a problem that you are experiencing? 11 | If yes, please provide a description of that problem. 12 | 13 | ### Describe your feature request 14 | Describe how your idea works/functions, how it would present itself to a user, etc. 15 | 16 | ### Provide additional context 17 | If you have any additional information you would like to provide, or images you would like to attach, please put them here. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directories: # Location of package manifests 10 | - '/client' 11 | - '/server' 12 | - '/worker' 13 | schedule: 14 | interval: 'weekly' 15 | day: 'monday' 16 | time: '00:00' 17 | timezone: 'America/Los_Angeles' 18 | groups: 19 | security-updates: 20 | applies-to: security-updates 21 | update-types: 22 | - 'patch' 23 | - 'minor' 24 | patch-and-minor-updates: 25 | applies-to: version-updates 26 | update-types: 27 | - 'patch' 28 | - 'minor' 29 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release/** 8 | release: 9 | types: [published] 10 | pull_request: 11 | branches: 12 | - main 13 | - release/** 14 | 15 | env: 16 | REGISTRY: ghcr.io 17 | REPO_OWNER: ${{ github.repository_owner }} 18 | 19 | jobs: 20 | build-and-push-image: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | include: 26 | - image: handbrake-web-server 27 | file: server/Dockerfile 28 | 29 | - image: handbrake-web-worker 30 | file: worker/Dockerfile 31 | 32 | permissions: 33 | contents: read 34 | packages: write 35 | id-token: write 36 | attestations: write 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | 42 | - name: Set up QEMU 43 | uses: docker/setup-qemu-action@v3 44 | 45 | - name: Set up Docker Buildx 46 | uses: docker/setup-buildx-action@v3 47 | 48 | - name: Log in to the Container registry 49 | uses: docker/login-action@v3 50 | with: 51 | registry: ${{ env.REGISTRY }} 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Extract metadata (tags, labels) for Docker 56 | id: meta 57 | uses: docker/metadata-action@v5 58 | with: 59 | images: ${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ matrix.image }} 60 | tags: | 61 | type=ref,event=branch 62 | type=ref,event=pr 63 | type=semver,pattern={{version}} 64 | type=semver,pattern={{major}}.{{minor}} 65 | 66 | - name: Build and push Docker image 67 | id: push 68 | uses: docker/build-push-action@v6 69 | with: 70 | context: . 71 | file: ${{ matrix.file }} 72 | platforms: linux/amd64,linux/arm64 73 | provenance: false 74 | push: true 75 | tags: ${{ steps.meta.outputs.tags }} 76 | labels: ${{ steps.meta.outputs.labels }} 77 | cache-from: type=gha,scope=${{matrix.image}} 78 | cache-to: type=gha,mode=max,scope=${{matrix.image}} 79 | 80 | # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." 81 | - name: Generate artifact attestation 82 | uses: actions/attest-build-provenance@v1 83 | with: 84 | subject-name: ${{ env.REGISTRY }}/${{ env.REPO_OWNER}}/${{ matrix.image }} 85 | subject-digest: ${{ steps.push.outputs.digest }} 86 | push-to-registry: true 87 | -------------------------------------------------------------------------------- /.github/workflows/package-cleanup.yaml: -------------------------------------------------------------------------------- 1 | name: Package Cleanup 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Docker Publish] 6 | types: [completed] 7 | pull_request: 8 | types: [closed] 9 | push: 10 | paths: 11 | - '.github/workflows/package-cleanup.yaml' 12 | 13 | jobs: 14 | cleanup-packages: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | packages: write 18 | steps: 19 | - name: Clean Old PR's 'pr-*' Versions 20 | if: github.event_name == 'workflow_run' || github.event_name == 'push' 21 | uses: snok/container-retention-policy@v3.0.0 22 | with: 23 | account: user 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | image-names: handbrake-web handbrake-web-server handbrake-web-worker 26 | tag-selection: tagged 27 | image-tags: 'pr-*' 28 | cut-off: 1w 29 | - name: Clean This PR's 'pr-*' Versions 30 | if: github.event_name == 'pull_request' 31 | uses: snok/container-retention-policy@v3.0.0 32 | with: 33 | account: user 34 | token: ${{ secrets.GITHUB_TOKEN }} 35 | image-names: handbrake-web handbrake-web-server handbrake-web-worker 36 | tag-selection: tagged 37 | image-tags: 'pr-${{github.event.number}}' 38 | cut-off: 0s 39 | - name: Clean 'sha256-*' Versions 40 | if: github.event_name == 'workflow_run' || github.event_name == 'push' 41 | uses: snok/container-retention-policy@v3.0.0 42 | with: 43 | account: user 44 | token: ${{ secrets.GITHUB_TOKEN }} 45 | image-names: handbrake-web handbrake-web-server handbrake-web-worker 46 | tag-selection: tagged 47 | image-tags: 'sha256-*' 48 | cut-off: 0s 49 | -------------------------------------------------------------------------------- /.github/workflows/wiki-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Wiki Publish 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - docs/** 8 | - .github/workflows/wiki-publish.yml 9 | 10 | concurrency: 11 | group: wiki-publish 12 | cancel-in-progress: true 13 | permissions: 14 | contents: write 15 | jobs: 16 | publish-wiki: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: Andrew-Chen-Wang/github-wiki-action@v4 21 | with: 22 | path: docs/ 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /data/ 2 | /presets/ 3 | /temp/ 4 | /video/ 5 | 6 | build/ 7 | temp/ 8 | 9 | node_modules/ 10 | .pnpm-store 11 | 12 | .env 13 | core 14 | handbrake.db -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": true, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "printWidth": 100, 7 | "overrides": [ 8 | { 9 | "files": ["*.json", "*.yaml", "*.yml"], 10 | "options": { 11 | "tabWidth": 2, 12 | "useTabs": false 13 | } 14 | }, 15 | { 16 | "files": "README.md", 17 | "options": { 18 | "tabWidth": 2, 19 | "useTabs": false 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.detectIndentation": true, 4 | "editor.insertSpaces": false, 5 | "editor.formatOnSave": true, 6 | "editor.rulers": [100], 7 | "editor.tabSize": 4, 8 | "typescript.locale": "en", 9 | "typescript.preferences.useAliasesForRenames": false, 10 | "typescript.validate.enable": true 11 | } 12 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | HandBrake Web 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "handbrake-web-client", 3 | "version": "0.8.0", 4 | "description": "HandBrake Web", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://git.goodforyou.games/ncunningham/handbrake-web.git" 15 | }, 16 | "dependencies": { 17 | "@fontsource/inter": "^5.2.5", 18 | "@fontsource/noto-sans": "^5.2.6", 19 | "bootstrap-icons": "^1.11.3", 20 | "mime": "^4.0.4", 21 | "react": "^19.0.0", 22 | "react-dom": "^19.0.0", 23 | "react-router-dom": "^6.25.1", 24 | "socket.io-client": "^4.7.5" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^22.13.10", 28 | "@types/react": "^19.0.0", 29 | "@types/react-dom": "^19.0.0", 30 | "@typescript-eslint/eslint-plugin": "^8.26.1", 31 | "@typescript-eslint/parser": "^8.26.1", 32 | "@vitejs/plugin-react": "^4.3.4", 33 | "eslint": "^9.22.0", 34 | "eslint-plugin-react-hooks": "^5.2.0", 35 | "eslint-plugin-react-refresh": "^0.4.7", 36 | "sass": "^1.85.1", 37 | "tsconfig-paths": "^4.2.0", 38 | "typescript": "^5.8.2", 39 | "vite": "^6.2.2", 40 | "vite-tsconfig-paths": "^5.1.3" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/public/handbrake-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/client/public/handbrake-icon.png -------------------------------------------------------------------------------- /client/src/components/base/info/badge-info/badge-info.scss: -------------------------------------------------------------------------------- 1 | i.badge-info { 2 | cursor: help; 3 | margin-left: 0.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/components/base/info/badge-info/badge-info.tsx: -------------------------------------------------------------------------------- 1 | import './badge-info.scss'; 2 | 3 | type Params = { 4 | info: string; 5 | }; 6 | 7 | export default function BadgeInfo({ info }: Params) { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/components/base/info/text-info/text-info.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .text-info { 4 | &.vertical > * { 5 | display: block; 6 | } 7 | 8 | &:not(.vertical) { 9 | .text-info-label { 10 | margin-right: 1rem; 11 | } 12 | } 13 | 14 | .text-info-label { 15 | font-size: 1.25rem; 16 | color: colors.$text-color-tertiary; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/base/info/text-info/text-info.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import './text-info.scss'; 3 | 4 | type Params = { 5 | label: string; 6 | vertical?: boolean; 7 | } & PropsWithChildren; 8 | 9 | export default function TextInfo({ label, children, vertical = false }: Params) { 10 | return ( 11 |
12 | {label}: 13 | {children} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/button-group/button-group.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/font'; 3 | 4 | .button-group { 5 | width: 100%; 6 | margin-bottom: 3rem; 7 | overflow: hidden; 8 | display: flex; 9 | 10 | border-radius: 1rem; 11 | border: 0.1rem solid colors.$text-color-primary; 12 | 13 | button { 14 | width: 100%; 15 | padding: 1rem; 16 | 17 | font-size: 1.75rem; 18 | font-family: font.$font-family-primary; 19 | color: colors.$text-color-primary; 20 | 21 | // border: 0.1rem solid $text-color-primary; 22 | border: none; 23 | background-color: transparent; 24 | 25 | &.selected { 26 | background-color: colors.$transparent-light-secondary; 27 | text-decoration: underline; 28 | } 29 | 30 | &:hover { 31 | cursor: pointer; 32 | background-color: colors.$transparent-light-primary; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/button-group/button-group.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import './button-group.scss'; 3 | 4 | export default function ButtonGroup({ children }: PropsWithChildren) { 5 | return
{children}
; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/button/button-input.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/font'; 2 | @use '@style/modules/colors'; 3 | 4 | .controlled-button { 5 | // margin: 1rem; 6 | padding: 0.5rem 1rem; 7 | border: none; 8 | border-radius: 0.75rem; 9 | display: flex; 10 | flex-flow: row nowrap; 11 | align-items: center; 12 | gap: 1rem; 13 | box-shadow: 0 0 1rem black; 14 | 15 | background-color: colors.$handbrake-green; 16 | color: colors.$text-color-primary; 17 | text-shadow: 0 0 1rem colors.$background-primary; 18 | 19 | .button-icon { 20 | font-size: 2.5rem; 21 | } 22 | 23 | .button-label { 24 | font-family: font.$font-family-primary; 25 | font-size: 2rem; 26 | } 27 | } 28 | 29 | .controlled-button:hover:not(:disabled) { 30 | opacity: 80%; 31 | cursor: pointer; 32 | } 33 | 34 | .controlled-button:disabled { 35 | opacity: 50%; 36 | } 37 | 38 | .controlled-button:hover:disabled { 39 | cursor: not-allowed; 40 | } 41 | 42 | .controlled-button.red { 43 | background-color: colors.$handbrake-red; 44 | } 45 | 46 | .controlled-button.orange { 47 | background-color: colors.$handbrake-orange; 48 | } 49 | 50 | .controlled-button.yellow { 51 | background-color: colors.$handbrake-yellow; 52 | } 53 | 54 | .controlled-button.green { 55 | background-color: colors.$handbrake-green; 56 | } 57 | 58 | .controlled-button.blue { 59 | background-color: colors.$handbrake-blue; 60 | } 61 | 62 | .controlled-button.magenta { 63 | background-color: colors.$handbrake-magenta; 64 | } 65 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/button/button-input.tsx: -------------------------------------------------------------------------------- 1 | import './button-input.scss'; 2 | 3 | type Params = { 4 | label?: string; 5 | icon?: string; 6 | color?: string; 7 | title?: string; 8 | disabled?: boolean; 9 | onClick: (event: React.MouseEvent) => void; 10 | }; 11 | 12 | export default function ButtonInput({ 13 | label, 14 | icon, 15 | color, 16 | title, 17 | disabled = false, 18 | onClick, 19 | }: Params) { 20 | return ( 21 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/checkbox/checkbox-input.scss: -------------------------------------------------------------------------------- 1 | .checkbox-input { 2 | label { 3 | margin-right: 1rem; 4 | } 5 | 6 | input { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/checkbox/checkbox-input.tsx: -------------------------------------------------------------------------------- 1 | import './checkbox-input.scss'; 2 | 3 | type Params = { 4 | id: string; 5 | label?: string; 6 | value: boolean; 7 | setValue?: React.Dispatch>; 8 | onChange?: (value: boolean) => void; 9 | }; 10 | 11 | export default function CheckboxInput({ id, label, value, setValue, onChange }: Params) { 12 | const handleChange = (event: React.ChangeEvent) => { 13 | const newValue = event.target.checked; 14 | 15 | if (setValue) { 16 | setValue(newValue); 17 | } 18 | 19 | if (onChange) { 20 | onChange(newValue); 21 | } 22 | }; 23 | 24 | return ( 25 |
26 | {label && ( 27 | 30 | )} 31 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/number/number-input.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/sizing'; 2 | 3 | .number-input { 4 | display: flex; 5 | align-items: center; 6 | flex-flow: row wrap; 7 | row-gap: 0.5rem; 8 | column-gap: 1rem; 9 | 10 | label { 11 | white-space: nowrap; 12 | } 13 | 14 | input[type='number'] { 15 | text-align: center; 16 | flex-grow: 1; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/number/number-input.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { EndWithColon } from 'funcs/string.funcs'; 3 | import './number-input.scss'; 4 | 5 | type Params = { 6 | id: string; 7 | label: string; 8 | value: number; 9 | setValue?: React.Dispatch>; 10 | onChange?: (value: number) => void; 11 | step?: number; 12 | min?: number; 13 | max?: number; 14 | disabled?: boolean; 15 | }; 16 | 17 | export default function NumberInput({ 18 | id, 19 | label, 20 | value, 21 | setValue, 22 | onChange, 23 | step, 24 | min, 25 | max, 26 | disabled, 27 | }: Params) { 28 | const handleChange = (event: React.ChangeEvent) => { 29 | if (setValue) { 30 | setValue(parseInt(event.target.value)); 31 | } 32 | if (onChange) { 33 | onChange(parseInt(event.target.value)); 34 | } 35 | }; 36 | 37 | return ( 38 |
39 | {label && ( 40 | 43 | )} 44 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/path/path-input.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/sizing'; 2 | 3 | .path-input { 4 | display: flex; 5 | flex-direction: column; 6 | gap: 2rem; 7 | 8 | .input-section { 9 | display: flex; 10 | flex-flow: row wrap; 11 | 12 | .inputs { 13 | height: 3rem; 14 | flex-grow: 1; 15 | display: flex; 16 | flex-flow: row nowrap; 17 | align-items: stretch; 18 | gap: 0.75rem; 19 | 20 | .input-path-text { 21 | flex-grow: 1; 22 | } 23 | 24 | .controlled-button.reset { 25 | aspect-ratio: 1; 26 | padding: 0; 27 | text-align: center; 28 | 29 | .bi { 30 | margin: auto; 31 | line-height: 1rem; 32 | font-size: 1.75rem; 33 | } 34 | } 35 | } 36 | } 37 | 38 | .controlled-button { 39 | .button-label { 40 | font-size: 1.5rem; 41 | } 42 | } 43 | } 44 | 45 | @media screen and (max-width: sizing.$small-max-width) { 46 | .path-input { 47 | .input-section { 48 | flex-flow: row wrap; 49 | gap: 0.5rem; 50 | 51 | .input-label { 52 | width: 100%; 53 | } 54 | } 55 | } 56 | } 57 | 58 | @media screen and (min-width: sizing.$medium-min-width) { 59 | .path-input { 60 | .input-section { 61 | flex-flow: row nowrap; 62 | gap: 1rem; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/path/path-input.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { FileBrowserMode } from 'types/file-browser'; 3 | import { DirectoryItemType } from 'types/directory'; 4 | import FileBrowser from 'components/modules/file-browser/file-browser'; 5 | import './path-input.scss'; 6 | 7 | type Params = { 8 | id: string; 9 | label: string; 10 | startPath: string; 11 | rootPath: string; 12 | mode: FileBrowserMode; 13 | allowClear?: boolean; 14 | allowCreate?: boolean; 15 | value: string; 16 | setValue?: (value: string) => void; 17 | onConfirm?: (item: DirectoryItemType) => void; 18 | }; 19 | 20 | export default function PathInput({ 21 | id, 22 | label, 23 | startPath, 24 | rootPath, 25 | mode, 26 | allowClear = false, 27 | allowCreate = false, 28 | value, 29 | setValue, 30 | onConfirm, 31 | }: Params) { 32 | const [showFileBrowser, setShowFileBrowser] = useState(false); 33 | 34 | const handleClear = (event: React.MouseEvent) => { 35 | event.preventDefault(); 36 | if (setValue) { 37 | setValue(''); 38 | setShowFileBrowser(false); 39 | } else { 40 | console.error( 41 | `[client] Cannot reset path-input with id '${id}' because the 'setValue' parameter has not been set.` 42 | ); 43 | } 44 | }; 45 | 46 | const handleConfirm = (item: DirectoryItemType) => { 47 | if (onConfirm) { 48 | onConfirm(item); 49 | } 50 | setShowFileBrowser(false); 51 | }; 52 | 53 | return ( 54 |
55 |
56 | {label && ( 57 | 60 | )} 61 |
62 | 70 | {allowClear && value && ( 71 | 79 | )} 80 | 95 |
96 |
97 | {showFileBrowser && ( 98 |
99 | 106 |
107 | )} 108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/select/select-input.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/sizing'; 3 | 4 | .select-input { 5 | display: inline-flex; 6 | align-items: center; 7 | 8 | label { 9 | white-space: nowrap; 10 | } 11 | 12 | select { 13 | max-width: 100%; 14 | background-color: colors.$background-tertiary; 15 | } 16 | } 17 | 18 | @media screen and (max-width: sizing.$small-max-width) { 19 | .select-input { 20 | flex-flow: row wrap; 21 | gap: 0.5rem; 22 | 23 | label { 24 | width: fit-content; 25 | } 26 | } 27 | } 28 | 29 | @media screen and (min-width: sizing.$medium-min-width) { 30 | .select-input { 31 | flex-flow: row nowrap; 32 | gap: 1rem; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/select/select-input.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import './select-input.scss'; 3 | import { EndWithColon } from 'funcs/string.funcs'; 4 | 5 | type Params = PropsWithChildren & { 6 | id: string; 7 | label?: string; 8 | value: any; 9 | setValue?: React.Dispatch>; 10 | onChange?: (value: string) => void; 11 | }; 12 | 13 | export default function SelectInput({ id, label, value, setValue, onChange, children }: Params) { 14 | const handleChange = (event: React.ChangeEvent) => { 15 | if (setValue) { 16 | setValue(event.target.value); 17 | } 18 | if (onChange) { 19 | onChange(event.target.value); 20 | } 21 | }; 22 | 23 | return ( 24 |
25 | {label && } 26 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/text/text-input.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/sizing'; 2 | 3 | .text-input { 4 | display: flex; 5 | align-items: center; 6 | 7 | label { 8 | white-space: nowrap; 9 | } 10 | 11 | input[type='text'] { 12 | flex-grow: 1; 13 | } 14 | } 15 | 16 | @media screen and (max-width: sizing.$small-max-width) { 17 | .text-input { 18 | flex-flow: row wrap; 19 | gap: 0.5rem; 20 | 21 | label { 22 | width: 100%; 23 | } 24 | } 25 | } 26 | 27 | @media screen and (min-width: sizing.$medium-min-width) { 28 | .text-input { 29 | flex-flow: row nowrap; 30 | gap: 1rem; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/text/text-input.tsx: -------------------------------------------------------------------------------- 1 | import { EndWithColon } from 'funcs/string.funcs'; 2 | import './text-input.scss'; 3 | 4 | type Params = { 5 | id: string; 6 | label?: string; 7 | value: string; 8 | setValue?: React.Dispatch>; 9 | onChange?: (value: string) => void; 10 | onSubmit?: (value: string) => void; 11 | disabled?: boolean; 12 | }; 13 | 14 | export default function TextInput({ 15 | id, 16 | label, 17 | value, 18 | setValue, 19 | onChange, 20 | onSubmit, 21 | disabled = false, 22 | }: Params) { 23 | const handleChange = (event: React.ChangeEvent) => { 24 | if (onChange) { 25 | onChange(event.target.value); 26 | } 27 | if (setValue) { 28 | setValue(event.target.value); 29 | } 30 | }; 31 | 32 | const handleKeyDown = (event: React.KeyboardEvent) => { 33 | if (event.key == 'Enter' && onSubmit) { 34 | onSubmit(value); 35 | } 36 | }; 37 | 38 | return ( 39 |
40 | {label && } 41 | 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/toggle/toggle-input.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .toggle-input { 4 | .toggle-input-label { 5 | margin-right: 1rem; 6 | white-space: nowrap; 7 | 8 | &.disabled { 9 | color: colors.$text-color-disabled !important; 10 | text-decoration: line-through; 11 | } 12 | } 13 | 14 | .toggle-input-checkbox { 15 | position: relative; 16 | display: inline-block; 17 | vertical-align: middle; 18 | height: 3rem; 19 | width: 6rem; 20 | background-color: colors.$handbrake-red; 21 | box-shadow: 0 0 1rem black; 22 | border-radius: 2rem; 23 | cursor: pointer; 24 | 25 | &::after { 26 | content: ''; 27 | position: absolute; 28 | top: 0.3rem; 29 | left: 0.3rem; 30 | width: 2.4rem; 31 | height: 2.4rem; 32 | border-radius: 50%; 33 | background-color: colors.$text-color-tertiary; 34 | box-shadow: 0 0 0.3rem black; 35 | transition: 0.25s; 36 | } 37 | 38 | &:has(input#checkbox-input:checked) { 39 | background-color: colors.$handbrake-blue; 40 | 41 | &::after { 42 | left: 3.2rem; 43 | } 44 | } 45 | 46 | &:has(input#checkbox-input:disabled) { 47 | opacity: 0.4; 48 | cursor: not-allowed; 49 | } 50 | 51 | input#checkbox-input:checked + &::after { 52 | left: 2.25rem; 53 | } 54 | 55 | input#checkbox-input { 56 | display: none; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/components/base/inputs/toggle/toggle-input.tsx: -------------------------------------------------------------------------------- 1 | import { EndWithColon } from 'funcs/string.funcs'; 2 | import './toggle-input.scss'; 3 | 4 | type Params = { 5 | id: string; 6 | label?: string; 7 | value: boolean; 8 | setValue?: React.Dispatch>; 9 | onChange?: (value: boolean) => void; 10 | disabled?: boolean; 11 | }; 12 | 13 | export default function ToggleInput({ 14 | id, 15 | label, 16 | value, 17 | setValue, 18 | onChange, 19 | disabled = false, 20 | }: Params) { 21 | const handleChange = (event: React.ChangeEvent) => { 22 | if (setValue) { 23 | setValue(event.target.checked); 24 | } 25 | if (onChange) { 26 | onChange(event.target.checked); 27 | } 28 | }; 29 | 30 | return ( 31 |
32 | {label && ( 33 | 39 | )} 40 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /client/src/components/base/progress/progress-bar.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .progress-bar { 4 | position: relative; 5 | min-width: 10rem; 6 | height: 2rem; 7 | overflow: hidden; 8 | border-radius: 0.5rem; 9 | background-color: hsla(0, 0%, 100%, 0.1); 10 | 11 | .progress-value { 12 | height: 100%; 13 | background-color: colors.$handbrake-blue; 14 | display: block; 15 | } 16 | 17 | .progress-label { 18 | font-size: 1.5rem; 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | width: 100%; 23 | text-align: center; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/components/base/progress/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import './progress-bar.scss'; 2 | 3 | type Params = { 4 | percentage: number; 5 | }; 6 | 7 | export default function ProgressBar({ percentage }: Params) { 8 | return ( 9 |
10 | 11 | {percentage.toFixed(2)} % 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/preset-card.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/font'; 3 | @use '@style/modules/sizing'; 4 | 5 | .preset-card { 6 | padding: 1rem; 7 | background-color: colors.$background-tertiary; 8 | border-radius: 1rem; 9 | 10 | .preset-header { 11 | display: flex; 12 | flex-flow: row nowrap; 13 | align-items: center; 14 | justify-content: space-between; 15 | gap: 1rem; 16 | margin: 1rem 0 1.5rem 0; 17 | 18 | .preset-label { 19 | flex-grow: 1; 20 | margin: 0; 21 | padding: 0.5rem 1rem; 22 | } 23 | 24 | .header-label-form { 25 | flex-grow: 1; 26 | 27 | .header-label-input { 28 | width: 100%; 29 | padding: 0.5rem 1rem; 30 | 31 | font-family: font.$font-family-secondary; 32 | font-weight: bold; 33 | font-size: 1.75rem; 34 | 35 | border: none; 36 | border-radius: 4rem; 37 | 38 | background-color: transparent; 39 | color: colors.$text-color-secondary; 40 | 41 | &:hover, 42 | &:focus { 43 | background-color: colors.$background-four; 44 | } 45 | } 46 | } 47 | 48 | .preset-buttons { 49 | display: flex; 50 | flex-flow: row nowrap; 51 | justify-content: right; 52 | gap: 1rem; 53 | 54 | * { 55 | font-size: 1.75rem; 56 | } 57 | } 58 | } 59 | 60 | .preset-warning { 61 | margin-top: -1rem; 62 | margin-bottom: 0.75rem; 63 | padding: 0rem 2rem; 64 | // border: 0.1rem solid colors.$handbrake-yellow; 65 | // border-radius: 4rem; 66 | text-align: center; 67 | color: colors.$handbrake-yellow; 68 | 69 | .bi { 70 | margin-right: 0.5rem; 71 | } 72 | 73 | a { 74 | color: colors.$handbrake-yellow; 75 | font-weight: bold; 76 | } 77 | } 78 | 79 | .preset-body { 80 | .preset-tabs { 81 | display: flex; 82 | flex-flow: row wrap; 83 | 84 | .tab-button-container { 85 | max-height: 2.75rem; 86 | 87 | button { 88 | height: 200%; 89 | padding: 0.5rem 1rem; 90 | border: none; 91 | border-radius: 0; 92 | font-family: font.$font-family-primary; 93 | font-size: 1.25rem; 94 | border-right: 0.2rem solid colors.$background-five; 95 | border-radius: 1rem 1rem 0 0; 96 | background-color: colors.$background-four; 97 | color: colors.$text-color-tertiary; 98 | display: inline-flex; 99 | 100 | &.current { 101 | background-color: colors.$background-five; 102 | color: colors.$text-color-secondary; 103 | } 104 | 105 | &:hover { 106 | background-color: colors.$background-five; 107 | border-right: 0.2rem solid colors.$background-six; 108 | } 109 | 110 | .tab-button-label { 111 | // max-height: 2.75rem; 112 | } 113 | } 114 | } 115 | 116 | @media screen and (max-width: sizing.$small-max-width) { 117 | .tab-button-container { 118 | flex-grow: 1; 119 | button { 120 | width: 100%; 121 | border-top: 0.2rem solid colors.$background-five; 122 | } 123 | } 124 | } 125 | } 126 | 127 | .current-tab { 128 | background-color: colors.$background-five; 129 | padding: 1rem; 130 | border-radius: 1rem; 131 | position: relative; 132 | z-index: 1; 133 | 134 | .preset-card-section { 135 | max-width: 100%; 136 | display: flex; 137 | flex-flow: row wrap; 138 | gap: 2rem; 139 | 140 | .preset-card-subsection { 141 | max-width: 100%; 142 | flex-grow: 1; 143 | display: flex; 144 | flex-flow: column nowrap; 145 | gap: 0.5rem; 146 | 147 | .preset-card-subsection-header { 148 | font-size: 1.25rem; 149 | font-weight: bold; 150 | color: colors.$text-color-tertiary; 151 | } 152 | 153 | .side-by-side { 154 | display: grid; 155 | grid-template-columns: repeat(2, fit-content(50%)); 156 | row-gap: 0.5rem; 157 | column-gap: 1rem; 158 | 159 | & > * { 160 | white-space: nowrap; 161 | } 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/audio/preset-card-audio.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/font'; 3 | 4 | .preset-card > .preset-body > .current-tab > .preset-card-section#audio { 5 | .audio-list-entry { 6 | display: flex; 7 | flex-flow: row wrap; 8 | gap: 1rem; 9 | } 10 | 11 | table { 12 | border-spacing: 0; 13 | tbody { 14 | td { 15 | padding: 0.25rem 1rem 0.25rem 0rem; 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/audio/preset-card-audio.tsx: -------------------------------------------------------------------------------- 1 | import { HandbrakePresetDataType } from 'types/preset'; 2 | import { PresetAudioEncoderDict } from 'dict/presets.dict'; 3 | import TextInfo from 'components/base/info/text-info/text-info'; 4 | import './preset-card-audio.scss'; 5 | import { FirstLetterUpperCase } from 'funcs/string.funcs'; 6 | import { LanguageCodeToName } from 'funcs/locale.funcs'; 7 | import { BooleanToConfirmation } from 'funcs/string.funcs'; 8 | 9 | type Params = { 10 | preset: HandbrakePresetDataType; 11 | }; 12 | 13 | export default function PresetCardAudio({ preset }: Params) { 14 | return ( 15 |
16 |
17 |
Source Track Selection
18 | 19 | {FirstLetterUpperCase(preset.AudioTrackSelectionBehavior)} 20 | 21 | 22 | {preset.AudioLanguageList.map((language) => LanguageCodeToName(language)).join( 23 | ', ' 24 | )} 25 | 26 |
27 |
28 |
Auto Passthru Behavior
29 |
30 | {Object.keys(PresetAudioEncoderDict) 31 | .filter((entry) => entry.includes('copy:')) 32 | .map((entry, index) => ( 33 | 37 | {BooleanToConfirmation(preset.AudioCopyMask.includes(entry))} 38 | 39 | ))} 40 |
41 |
42 |
43 |
44 | Audio encoder settings for each chosen track 45 |
46 | {!(preset.AudioList.length > 0) &&
N/A
} 47 | {preset.AudioList.length > 0 && ( 48 |
49 | 50 | 51 | {preset.AudioList.sort((entry) => 52 | entry.AudioEncoder.includes('copy') ? -1 : 1 53 | ).map((entry, index) => ( 54 | 55 | 60 | {!entry.AudioEncoder.includes('copy') && ( 61 | <> 62 | {!entry.AudioTrackQualityEnable && ( 63 | 68 | )} 69 | {entry.AudioTrackQualityEnable && ( 70 | 75 | )} 76 | 81 | 86 | 91 | 96 | 97 | )} 98 | 99 | ))} 100 | 101 |
56 | 57 | {PresetAudioEncoderDict[entry.AudioEncoder]} 58 | 59 | 64 | 65 | {entry.AudioBitrate} 66 | 67 | 71 | 72 | {entry.AudioTrackQuality} 73 | 74 | 77 | 78 | {entry.AudioMixdown} 79 | 80 | 82 | 83 | {entry.AudioSamplerate} 84 | 85 | 87 | 88 | {entry.AudioCompressionLevel} 89 | 90 | 92 | 93 | {entry.AudioTrackGainSlider} 94 | 95 |
102 |
103 | )} 104 |
105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/chapters/preset-card-chapters.tsx: -------------------------------------------------------------------------------- 1 | import TextInfo from 'components/base/info/text-info/text-info'; 2 | import { BooleanToConfirmation } from 'funcs/string.funcs'; 3 | import { HandbrakePresetDataType } from 'types/preset'; 4 | 5 | type Params = { 6 | preset: HandbrakePresetDataType; 7 | }; 8 | 9 | export default function PresetCardChapters({ preset }: Params) { 10 | return ( 11 |
12 | 13 | {BooleanToConfirmation(preset.ChapterMarkers)} 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/dimensions/preset-card-dimensions.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .preset-card > .preset-body > .current-tab > .preset-card-section#dimensions { 4 | .preset-card-subsection { 5 | .cropping-values, 6 | .padding-values { 7 | display: grid; 8 | grid-template-columns: repeat(2, fit-content(50%)); 9 | row-gap: 0.5rem; 10 | column-gap: 1rem; 11 | 12 | & > * { 13 | white-space: nowrap; 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/dimensions/preset-card-dimensions.tsx: -------------------------------------------------------------------------------- 1 | import { PresetPropertiesDict } from 'dict/presets.dict'; 2 | import { HandbrakePresetDataType, PictureCropMode } from 'types/preset'; 3 | import TextInfo from 'components/base/info/text-info/text-info'; 4 | import './preset-card-dimensions.scss'; 5 | import { BooleanToConfirmation } from 'funcs/string.funcs'; 6 | 7 | type Params = { 8 | preset: HandbrakePresetDataType; 9 | }; 10 | 11 | export default function PresetCardDimensions({ preset }: Params) { 12 | const rotationMatch = preset.PictureRotate ? preset.PictureRotate.match(/(\d+):([01])/) : null; 13 | 14 | return ( 15 |
16 |
17 |
Orientation and Cropping
18 | 19 | {rotationMatch && BooleanToConfirmation(rotationMatch[2] != null)} 20 | 21 | 22 | {rotationMatch && rotationMatch[1] ? rotationMatch[1] : 'N/A'} 23 | 24 | {PictureCropMode[preset.PictureCropMode]} 25 |
26 | {preset.PictureTopCrop}px 27 | {preset.PictureBottomCrop}px 28 | {preset.PictureLeftCrop}px 29 | {preset.PictureRightCrop}px 30 |
31 |
32 |
33 |
Resolution and Scaling
34 | 35 | {preset.PictureWidth}x{preset.PictureHeight} 36 | 37 | {PresetPropertiesDict[preset.PicturePAR]} 38 | 39 | {preset.PicturePARWidth}x{preset.PicturePARHeight} 40 | 41 | 42 | {BooleanToConfirmation(preset.PictureUseMaximumSize)} 43 | 44 | 45 | {BooleanToConfirmation(preset.PictureAllowUpscaling)} 46 | 47 |
48 |
49 |
Borders
50 | {PresetPropertiesDict[preset.PicturePadMode]} 51 | 52 | {preset.PicturePadColor 53 | ? !preset.PicturePadColor.match(/^\d/) 54 | ? preset.PicturePadColor.charAt(0).toUpperCase() + 55 | preset.PicturePadColor.slice(1) 56 | : preset.PicturePadColor 57 | : 'N/A'} 58 | 59 |
60 | {preset.PicturePadTop}px 61 | {preset.PicturePadBottom}px 62 | {preset.PicturePadLeft}px 63 | {preset.PicturePadRight}px 64 |
65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/filters/preset-card-filters.scss: -------------------------------------------------------------------------------- 1 | .preset-card > .preset-body > .current-tab > .preset-card-section#filters { 2 | } 3 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/filters/preset-card-filters.tsx: -------------------------------------------------------------------------------- 1 | import { PresetPropertiesDict } from 'dict/presets.dict'; 2 | import { HandbrakePresetDataType } from 'types/preset'; 3 | import TextInfo from 'components/base/info/text-info/text-info'; 4 | import './preset-card-filters.scss'; 5 | import { BooleanToConfirmation } from 'funcs/string.funcs'; 6 | 7 | type Params = { 8 | preset: HandbrakePresetDataType; 9 | }; 10 | 11 | export default function PresetCardFilters({ preset }: Params) { 12 | return ( 13 |
14 |
15 | 16 | {PresetPropertiesDict[preset.PictureDetelecine] || preset.PictureDetelecine} 17 | {preset.PictureDetelecine == 'custom' && 18 | ` - '${preset.PictureDetelecineCustom}'`} 19 | 20 | 21 | 22 | {PresetPropertiesDict[preset.PictureCombDetectPreset] || 23 | preset.PictureCombDetectPreset} 24 | {preset.PictureCombDetectPreset == 'custom' && 25 | ` - '${preset.PictureCombDetectCustom}'`} 26 | 27 | 28 | 29 | {PresetPropertiesDict[preset.PictureDeinterlaceFilter] || 30 | preset.PictureDeinterlaceFilter} 31 | {preset.PictureDeinterlaceFilter != 'off' && 32 | preset.PictureDeinterlacePreset != 'custom' && 33 | `, Preset - ${ 34 | PresetPropertiesDict[preset.PictureDeinterlacePreset] || 35 | preset.PictureDeinterlacePreset 36 | }`} 37 | {preset.PictureDeinterlacePreset == 'custom' && 38 | `, Custom - '${preset.PictureDeinterlaceCustom}'`} 39 | 40 | 41 | 42 | {PresetPropertiesDict[preset.PictureDenoiseFilter] || 43 | preset.PictureDenoiseFilter} 44 | {preset.PictureDenoiseFilter != 'off' && 45 | preset.PictureDenoisePreset != 'custom' && 46 | `, Preset - ${ 47 | PresetPropertiesDict[preset.PictureDenoisePreset] || 48 | preset.PictureDenoisePreset 49 | }`} 50 | {preset.PictureDenoisePreset == 'custom' && 51 | `, Custom - '${preset.PictureDenoiseCustom}'`} 52 | 53 | 54 | 55 | {PresetPropertiesDict[preset.PictureChromaSmoothPreset] || 56 | preset.PictureChromaSmoothPreset} 57 | {preset.PictureChromaSmoothPreset != 'off' && 58 | preset.PictureChromaSmoothPreset != 'custom' && 59 | `, Tune - ${ 60 | PresetPropertiesDict[preset.PictureChromaSmoothTune] || 61 | preset.PictureChromaSmoothTune 62 | }`} 63 | {preset.PictureChromaSmoothPreset == 'custom' && 64 | ` - '${preset.PictureChromaSmoothCustom}'`} 65 | 66 | 67 | 68 | {PresetPropertiesDict[preset.PictureSharpenFilter] || 69 | preset.PictureSharpenFilter} 70 | {preset.PictureSharpenFilter != 'off' && 71 | preset.PictureSharpenPreset != 'custom' && 72 | `, Preset - ${ 73 | PresetPropertiesDict[preset.PictureSharpenPreset] || 74 | preset.PictureSharpenPreset 75 | }`} 76 | {preset.PictureSharpenPreset == 'custom' && 77 | `, Custom - '${preset.PictureSharpenCustom}'`} 78 | 79 | 80 | 81 | {PresetPropertiesDict[preset.PictureDeblockPreset] || 82 | preset.PictureDeblockPreset} 83 | {preset.PictureDeblockPreset != 'off' && 84 | preset.PictureDeblockPreset != 'custom' && 85 | `, Tune - ${ 86 | PresetPropertiesDict[preset.PictureDeblockTune] || 87 | preset.PictureDeblockTune 88 | }`} 89 | {preset.PictureDeblockPreset == 'custom' && 90 | ` - '${preset.PictureDeblockCustom}'`} 91 | 92 | 93 | 94 | {PresetPropertiesDict[preset.PictureColorspacePreset] || 95 | preset.PictureColorspacePreset} 96 | {preset.PictureColorspacePreset == 'custom' && 97 | ` - '${preset.PictureColorspaceCustom}'`} 98 | 99 | 100 | 101 | {BooleanToConfirmation(preset.VideoGrayScale)} 102 | 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/subtitles/preset-card-subtitles.scss: -------------------------------------------------------------------------------- 1 | .preset-card > .preset-body > .current-tab > .preset-card-section#subtitles { 2 | } 3 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/subtitles/preset-card-subtitles.tsx: -------------------------------------------------------------------------------- 1 | import TextInfo from 'components/base/info/text-info/text-info'; 2 | import { HandbrakePresetDataType } from 'types/preset'; 3 | import './preset-card-subtitles.scss'; 4 | import { FirstLetterUpperCase, BooleanToConfirmation } from 'funcs/string.funcs'; 5 | import { LanguageCodeToName } from 'funcs/locale.funcs'; 6 | 7 | type Params = { 8 | preset: HandbrakePresetDataType; 9 | }; 10 | 11 | export default function PresetCardSubtitles({ preset }: Params) { 12 | return ( 13 |
14 |
15 |
Source Track Selection
16 | 17 | {FirstLetterUpperCase(preset.SubtitleTrackSelectionBehavior)} 18 | 19 | 20 | {preset.SubtitleLanguageList.map((language) => 21 | LanguageCodeToName(language) 22 | ).join(', ')} 23 | 24 |
25 |
26 |
Options
27 | 28 | {BooleanToConfirmation(preset.SubtitleAddCC)} 29 | 30 | 31 | {BooleanToConfirmation(preset.SubtitleAddForeignAudioSearch)} 32 | 33 | 34 | {FirstLetterUpperCase(preset.SubtitleBurnBehavior)} 35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/summary/preset-card-summary.scss: -------------------------------------------------------------------------------- 1 | .preset-card > .preset-body > .current-tab > .preset-card-section#summary { 2 | } 3 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/summary/preset-card-summary.tsx: -------------------------------------------------------------------------------- 1 | import TextInfo from 'components/base/info/text-info/text-info'; 2 | import { 3 | PresetAudioEncoderDict, 4 | PresetEncoderDict, 5 | PresetFormatDict, 6 | PresetPropertiesDict, 7 | } from 'dict/presets.dict'; 8 | import { LanguageCodeToName } from 'funcs/locale.funcs'; 9 | import { BooleanToConfirmation } from 'funcs/string.funcs'; 10 | import { HandbrakePresetDataType } from 'types/preset'; 11 | import './preset-card-summary.scss'; 12 | 13 | type Params = { 14 | preset: HandbrakePresetDataType; 15 | }; 16 | 17 | export default function PresetCardSummary({ preset }: Params) { 18 | const filters = [ 19 | preset.PictureDetelecine != 'off' 20 | ? `Detelecine (${PresetPropertiesDict[preset.PictureDetelecine]})` 21 | : 'off', 22 | preset.PictureDeinterlaceFilter != 'off' 23 | ? `Deinterlace (${PresetPropertiesDict[preset.PictureDeinterlaceFilter]})` 24 | : 'off', 25 | preset.PictureDenoiseFilter != 'off' 26 | ? `Denoise (${PresetPropertiesDict[preset.PictureDenoiseFilter]})` 27 | : 'off', 28 | preset.PictureChromaSmoothPreset != 'off' 29 | ? `Chroma Smooth (${PresetPropertiesDict[preset.PictureChromaSmoothPreset]})` 30 | : 'off', 31 | preset.PictureSharpenFilter != 'off' 32 | ? `Sharpen (${PresetPropertiesDict[preset.PictureSharpenFilter]})` 33 | : 'off', 34 | preset.PictureDeblockPreset != 'off' 35 | ? `Deblock (${PresetPropertiesDict[preset.PictureDeblockPreset]})` 36 | : 'off', 37 | preset.PictureColorspacePreset != 'off' 38 | ? `Colorspace (${PresetPropertiesDict[preset.PictureColorspacePreset]})` 39 | : 'off', 40 | preset.VideoGrayScale ? 'Grayscale' : undefined, 41 | ].filter((filter) => filter && filter.toLowerCase() != 'off'); 42 | 43 | return ( 44 |
45 |
46 |
Format
47 | {PresetFormatDict[preset.FileFormat]} 48 | 49 | {BooleanToConfirmation(preset.MetadataPassthrough)} 50 | 51 |
52 |
53 |
Video Track
54 | {PresetEncoderDict[preset.VideoEncoder]} 55 | 56 | {preset.PictureWidth}x{preset.PictureHeight} 57 | 58 | 59 | {preset.VideoFrameRate ? preset.VideoFrameRate : 'Same As Source'} 60 | {', '} 61 | {preset.VideoFramerateMode.toUpperCase()} 62 | 63 | 64 | {BooleanToConfirmation(preset.ChapterMarkers)} 65 | 66 |
67 |
68 |
Audio Tracks
69 | {preset.AudioList.map((track, index) => ( 70 |
71 | {track.AudioEncoder 72 | ? PresetAudioEncoderDict[track.AudioEncoder] 73 | : track.AudioEncoder} 74 |
75 | ))} 76 |
77 |
78 |
Subtitles
79 | {preset.SubtitleLanguageList.length > 0 ? ( 80 | preset.SubtitleLanguageList.map((language, index) => ( 81 |
82 | {LanguageCodeToName(language)} 83 |
84 | )) 85 | ) : ( 86 |
N/A
87 | )} 88 |
89 |
90 |
Filters
91 | {filters.length > 0 ? ( 92 | filters.map((filter, index) => ( 93 |
{filter}
94 | )) 95 | ) : ( 96 |
N/A
97 | )} 98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/video/preset-card-video.scss: -------------------------------------------------------------------------------- 1 | .preset-card > .preset-body > .current-tab > .preset-card-section#video { 2 | } 3 | -------------------------------------------------------------------------------- /client/src/components/cards/preset-card/tabs/video/preset-card-video.tsx: -------------------------------------------------------------------------------- 1 | import TextInfo from 'components/base/info/text-info/text-info'; 2 | import { HandbrakePresetDataType, VideoQualityType } from 'types/preset'; 3 | import './preset-card-video.scss'; 4 | import { PresetEncoderDict, PresetPropertiesDict } from 'dict/presets.dict'; 5 | import { BooleanToConfirmation, FirstLetterUpperCase } from 'funcs/string.funcs'; 6 | 7 | type Params = { 8 | preset: HandbrakePresetDataType; 9 | }; 10 | 11 | export default function PresetCardVideo({ preset }: Params) { 12 | return ( 13 |
14 |
15 |
Video
16 | {PresetEncoderDict[preset.VideoEncoder]} 17 | 18 | {preset.VideoFrameRate ? preset.VideoFrameRate : 'Same as source'} 19 | {', '} 20 | {preset.VideoFramerateMode} 21 | 22 |
23 |
24 |
Quality
25 | {preset.VideoQualityType == VideoQualityType.ConstantQuality && ( 26 | {preset.VideoQualitySlider} 27 | )} 28 | {preset.VideoQualityType == VideoQualityType.AvgBitrate && ( 29 | <> 30 | {preset.VideoAvgBitrate} 31 | 32 | {BooleanToConfirmation(preset.VideoMultiPass)} 33 | 34 | 35 | {BooleanToConfirmation(preset.VideoTurboMultiPass)} 36 | 37 | 38 | )} 39 |
40 |
41 |
Encoder Options
42 | 43 | {PresetPropertiesDict[preset.VideoPreset] || 44 | FirstLetterUpperCase(preset.VideoPreset)} 45 | 46 | {preset.VideoTune || 'N/A'} 47 | 48 | {PresetPropertiesDict[preset.VideoProfile] || preset.VideoProfile} 49 | 50 | 51 | {PresetPropertiesDict[preset.VideoLevel] || preset.VideoLevel} 52 | 53 | 54 | {preset.VideoOptionExtra ? preset.VideoOptionExtra : 'N/A'} 55 | 56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /client/src/components/cards/queue-card/components/queue-card-section.tsx: -------------------------------------------------------------------------------- 1 | import BadgeInfo from 'components/base/info/badge-info/badge-info'; 2 | import { PropsWithChildren } from 'react'; 3 | 4 | type Params = PropsWithChildren & { 5 | label: string; 6 | title?: string; 7 | }; 8 | 9 | export default function QueueCardSection({ children, label, title }: Params) { 10 | return ( 11 |
12 |
13 | {label} 14 | {title && } 15 |
16 |
17 |
{children}
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /client/src/components/cards/queue-card/queue-card.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .queue-job-outer { 4 | padding: 0.5rem 1rem; 5 | } 6 | 7 | .queue-job-outer > .queue-job-inner { 8 | display: flex; 9 | flex-flow: row nowrap; 10 | overflow: hidden; 11 | 12 | border-radius: 1rem; 13 | background-color: colors.$background-tertiary; 14 | 15 | h5 { 16 | margin: 0; 17 | } 18 | 19 | .job-number { 20 | padding: 0 1rem; 21 | display: flex; 22 | flex-flow: column nowrap; 23 | align-items: center; 24 | justify-content: center; 25 | gap: 0.5rem; 26 | background-color: colors.$transparent-light-primary; 27 | cursor: grab; 28 | 29 | h3 { 30 | margin: 0; 31 | } 32 | 33 | i { 34 | color: colors.$text-color-disabled; 35 | } 36 | } 37 | 38 | .job-info { 39 | padding: 1rem 2rem; 40 | flex-grow: 1; 41 | display: flex; 42 | flex-flow: column nowrap; 43 | gap: 2rem; 44 | 45 | .job-section { 46 | flex-grow: 1; 47 | min-width: fit-content; 48 | 49 | .job-section-label { 50 | min-width: fit-content; 51 | white-space: nowrap; 52 | } 53 | } 54 | 55 | .job-info-section { 56 | display: flex; 57 | flex-flow: row wrap; 58 | gap: 1rem; 59 | 60 | #current-fps, 61 | #average-fps { 62 | width: 10%; 63 | } 64 | 65 | #time-elapsed, 66 | #time-left { 67 | width: 15%; 68 | } 69 | 70 | #progress { 71 | min-width: 50%; 72 | width: 50%; 73 | } 74 | 75 | .job-log-link { 76 | a { 77 | color: colors.$handbrake-blue; 78 | } 79 | } 80 | } 81 | } 82 | 83 | .job-actions { 84 | display: grid; 85 | grid-template-columns: 1; 86 | gap: 0.2rem; 87 | 88 | button { 89 | padding: 0.5rem 0.75rem; 90 | 91 | border: none; 92 | border-radius: 0; 93 | background-color: colors.$transparent-light-secondary; 94 | 95 | .bi { 96 | color: colors.$text-color-secondary; 97 | font-size: 1.5rem; 98 | } 99 | 100 | &:hover:not(:disabled) { 101 | background-color: colors.$transparent-light-primary; 102 | cursor: pointer; 103 | } 104 | 105 | &:hover:disabled { 106 | // background-color: $transparent-light-primary; 107 | cursor: not-allowed; 108 | } 109 | 110 | &:active { 111 | background-color: colors.$transparent-light-tertiary; 112 | } 113 | 114 | &:disabled { 115 | background-color: colors.$transparent-light-tertiary; 116 | .bi { 117 | color: colors.$text-color-disabled; 118 | } 119 | } 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /client/src/components/cards/watcher-card/watcher-card.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | WatcherDefinitionWithRulesType, 3 | WatcherRuleBaseMethods, 4 | WatcherRuleDefinitionType, 5 | WatcherRuleFileInfoMethods, 6 | WatcherRuleMaskMethods, 7 | WatcherRuleStringComparisonMethods, 8 | } from 'types/watcher'; 9 | import ButtonInput from 'components/base/inputs/button/button-input'; 10 | import TextInfo from 'components/base/info/text-info/text-info'; 11 | import './watcher-card.scss'; 12 | import WatcherCardRule from './components/watcher-card-rule'; 13 | 14 | type Params = { 15 | watcherID: number; 16 | watcher: WatcherDefinitionWithRulesType; 17 | index: number; 18 | handleRemoveWatcher: (id: number) => void; 19 | handleAddRule: (id: number, rule: WatcherRuleDefinitionType) => void; 20 | handleUpdateRule: (id: number, rule: WatcherRuleDefinitionType) => void; 21 | handleRemoveRule: (ruleID: number) => void; 22 | }; 23 | 24 | export default function WatcherCard({ 25 | watcherID, 26 | watcher, 27 | index, 28 | handleRemoveWatcher, 29 | handleAddRule, 30 | handleUpdateRule, 31 | handleRemoveRule, 32 | }: Params) { 33 | const defaultRuleDefinition: WatcherRuleDefinitionType = { 34 | name: 'New Watcher Rule', 35 | mask: WatcherRuleMaskMethods.Include, 36 | base_rule_method: WatcherRuleBaseMethods.FileInfo, 37 | rule_method: WatcherRuleFileInfoMethods.FileName, 38 | comparison_method: WatcherRuleStringComparisonMethods.EqualTo, 39 | comparison: '', 40 | }; 41 | 42 | console.log(watcher.rules); 43 | 44 | return ( 45 |
46 |
47 |

{index + 1}

48 |
49 |
50 |
51 |
52 |
Info
53 | handleRemoveWatcher(watcherID)} 58 | /> 59 |
60 |
61 | {watcher.watch_path || 'N/A'} 62 | {watcher.output_path || 'N/A'} 63 | {watcher.preset_id} 64 |
65 |
66 |
67 |
68 |
Rules
69 | handleAddRule(watcherID, defaultRuleDefinition)} 74 | /> 75 |
76 |
77 | {Object.keys(watcher.rules).length == 0 && ( 78 |
79 | N/A 80 |
81 | )} 82 | {Object.keys(watcher.rules) 83 | .map((ruleID) => parseInt(ruleID)) 84 | .map((ruleID, index) => ( 85 | 93 | ))} 94 |
95 |
96 |
97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /client/src/components/modules/file-browser/components/file-browser-add-directory.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import ButtonInput from 'components/base/inputs/button/button-input'; 3 | import TextInput from 'components/base/inputs/text/text-input'; 4 | import { DirectoryItemsType } from 'types/directory'; 5 | 6 | type Params = { 7 | existingItems: DirectoryItemsType; 8 | onCancel: () => void; 9 | onSubmit: (directoryName: string) => void; 10 | }; 11 | 12 | export default function AddDirectory({ existingItems, onCancel, onSubmit }: Params) { 13 | const [directoryName, setDirectoryName] = useState(''); 14 | const [existingName, setExistingName] = useState(false); 15 | 16 | const handleNameChange = (value: string) => { 17 | const nameExists = existingItems 18 | .filter((item) => item.isDirectory) 19 | .map((item) => item.name) 20 | .includes(value); 21 | setExistingName(nameExists); 22 | }; 23 | 24 | const handleCancel = (event: React.MouseEvent) => { 25 | event.preventDefault(); 26 | onCancel(); 27 | }; 28 | 29 | const handleSubmit = (event: React.MouseEvent) => { 30 | event.preventDefault(); 31 | onSubmit(directoryName); 32 | }; 33 | 34 | const handleEnter = (value: string) => { 35 | if (value) { 36 | onSubmit(value); 37 | } 38 | }; 39 | 40 | return ( 41 |
42 |
43 |

Add Directory

44 | {existingName && ( 45 | 46 | 47 | Directory already exists. 48 | 49 | )} 50 | 57 |
58 | 59 | 65 |
66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /client/src/components/modules/file-browser/components/file-browser-body.tsx: -------------------------------------------------------------------------------- 1 | import mime from 'mime'; 2 | import { FileBrowserMode } from 'types/file-browser'; 3 | import { DirectoryType, DirectoryItemType } from 'types/directory'; 4 | 5 | type Params = { 6 | mode: FileBrowserMode; 7 | rootPath: string; 8 | directory: DirectoryType | null; 9 | updateDirectory: (newPath: string) => void; 10 | selectedItem: DirectoryItemType | undefined; 11 | setSelectedItem: React.Dispatch>; 12 | }; 13 | 14 | export default function FileBrowserBody({ 15 | mode, 16 | rootPath, 17 | directory, 18 | updateDirectory, 19 | selectedItem, 20 | setSelectedItem, 21 | }: Params) { 22 | const onClickFile = (item: DirectoryItemType) => { 23 | switch (mode) { 24 | case FileBrowserMode.SingleFile: 25 | setSelectedItem(item); 26 | console.log(`[client] [file-browser] Selected item set to ${item.path}`); 27 | break; 28 | case FileBrowserMode.Directory: 29 | console.error( 30 | "[client] [file-browser] [error] You shouldn't be seeing single files in directory mode." 31 | ); 32 | break; 33 | } 34 | }; 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 37 | const onDoubleClickFile = (_item: DirectoryItemType) => { 38 | switch (mode) { 39 | case FileBrowserMode.SingleFile: 40 | break; 41 | case FileBrowserMode.Directory: 42 | console.error( 43 | "[client] [file-browser] [error] You shouldn't be seeing single files in directory mode." 44 | ); 45 | break; 46 | } 47 | }; 48 | 49 | const onClickFolder = (item: DirectoryItemType) => { 50 | switch (mode) { 51 | case FileBrowserMode.SingleFile: 52 | break; 53 | case FileBrowserMode.Directory: 54 | setSelectedItem(item); 55 | break; 56 | } 57 | }; 58 | 59 | const onDoubleClickFolder = (item: DirectoryItemType) => { 60 | updateDirectory(item.path); 61 | if (mode == FileBrowserMode.Directory) { 62 | setSelectedItem(item); 63 | } 64 | console.log(`[client] [file-browser] Current path set to '${item.path}'.`); 65 | }; 66 | 67 | return ( 68 | <> 69 | {/* Show directory up button if */} 70 | {directory && rootPath != directory.current.path && directory.parent && ( 71 | 82 | )} 83 | {directory != null && 84 | directory.items.map((child) => { 85 | const isSelected = selectedItem?.path == child.path; 86 | // const isFile = child.children == undefined; 87 | const icon = child.isDirectory ? 'bi-folder-fill' : 'bi-file-earmark-fill'; 88 | const mimeType = mime.getType(child.path); 89 | // console.log(mimeType); 90 | 91 | const onClick = (event: React.MouseEvent) => { 92 | event.preventDefault(); 93 | child.isDirectory ? onClickFolder(child) : onClickFile(child); 94 | }; 95 | 96 | const onDoubleClick = ( 97 | event: React.MouseEvent 98 | ) => { 99 | event.preventDefault(); 100 | child.isDirectory ? onDoubleClickFolder(child) : onDoubleClickFile(child); 101 | }; 102 | 103 | const disabled = 104 | (!child.isDirectory && mode == FileBrowserMode.Directory) || 105 | (!child.isDirectory && !mimeType?.includes('video')); 106 | 107 | return ( 108 | 118 | ); 119 | })} 120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /client/src/components/modules/file-browser/file-browser.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/sizing'; 3 | 4 | .file-browser { 5 | overflow: hidden; 6 | 7 | border-radius: 1rem; 8 | border: 0.2rem solid colors.$background-primary; 9 | 10 | .file-browser-header { 11 | padding: 0 1rem; 12 | display: flex; 13 | flex-flow: row nowrap; 14 | justify-content: space-between; 15 | align-items: stretch; 16 | 17 | background-color: colors.$background-primary; 18 | 19 | .current-path { 20 | padding: 0.5rem 0; 21 | flex-grow: 1; 22 | } 23 | 24 | .add-directory { 25 | height: 3rem; 26 | width: 3rem; 27 | aspect-ratio: 1; 28 | padding: 0; 29 | border: none; 30 | border-radius: 0.5rem; 31 | font-size: 2rem; 32 | background-color: transparent; 33 | color: colors.$text-color-primary; 34 | 35 | &:hover { 36 | background-color: colors.$transparent-light-primary; 37 | } 38 | } 39 | } 40 | 41 | .file-browser-main { 42 | position: relative; 43 | 44 | .file-browser-body { 45 | padding: 1rem; 46 | min-height: 25rem; 47 | max-height: 25rem; 48 | overflow-y: scroll; 49 | 50 | .directory-item { 51 | width: 100%; 52 | padding: 0.5rem 0.75rem; 53 | display: block; 54 | 55 | border: none; 56 | border-bottom: 0.1rem solid colors.$background-primary; 57 | background-color: transparent; 58 | color: colors.$text-color-primary; 59 | 60 | font-size: 1.5rem; 61 | text-align: left; 62 | // outline: none; 63 | 64 | .icon { 65 | margin-right: 1rem; 66 | display: inline; 67 | vertical-align: middle; 68 | font-size: 1.75rem; 69 | } 70 | 71 | .label { 72 | display: inline; 73 | vertical-align: middle; 74 | } 75 | 76 | &:disabled { 77 | color: colors.$text-color-disabled; 78 | } 79 | 80 | &:hover { 81 | background-color: colors.$transparent-light-primary; 82 | cursor: pointer; 83 | 84 | &:disabled { 85 | cursor: not-allowed; 86 | } 87 | } 88 | 89 | &:active, 90 | &.selected { 91 | background-color: colors.$transparent-light-secondary; 92 | } 93 | } 94 | } 95 | 96 | .file-browser-footer { 97 | padding: 0.5rem 1rem; 98 | 99 | .selected-file { 100 | display: flex; 101 | align-items: center; 102 | 103 | .selected-file-label { 104 | white-space: nowrap; 105 | } 106 | 107 | .selected-file-path { 108 | flex-grow: 1; 109 | padding: 0.5rem 1rem; 110 | border-radius: 0.75rem; 111 | background-color: colors.$transparent-light-primary; 112 | } 113 | } 114 | } 115 | 116 | .add-directory-prompt { 117 | position: absolute; 118 | top: 0; 119 | left: 0; 120 | width: 100%; 121 | height: 100%; 122 | display: flex; 123 | justify-content: center; 124 | align-items: center; 125 | background-color: colors.$transparent-dark-primary; 126 | 127 | .prompt-window { 128 | padding: 2rem 1rem; 129 | display: flex; 130 | flex-flow: column nowrap; 131 | align-items: center; 132 | gap: 1.5rem; 133 | 134 | border-radius: 1rem; 135 | background-color: colors.$background-tertiary; 136 | 137 | .already-exists { 138 | color: colors.$handbrake-red; 139 | } 140 | 141 | .text-input { 142 | min-width: 25rem !important; 143 | input { 144 | text-align: center; 145 | } 146 | } 147 | 148 | .buttons { 149 | display: flex; 150 | flex-flow: row wrap; 151 | gap: 1rem; 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | @media screen and (max-width: sizing.$small-max-width) { 159 | .file-browser { 160 | .file-browser-footer { 161 | .selected-file { 162 | flex-flow: row wrap; 163 | gap: 0.5rem; 164 | 165 | .selected-file-label { 166 | width: 100%; 167 | } 168 | } 169 | } 170 | } 171 | } 172 | 173 | @media screen and (min-width: sizing.$medium-min-width) { 174 | .file-browser { 175 | .file-browser-footer { 176 | .selected-file { 177 | flex-flow: row nowrap; 178 | gap: 1rem; 179 | } 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /client/src/components/modules/side-bar/side-bar.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/sizing'; 3 | 4 | @media (max-width: sizing.$medium-max-width) { 5 | .side-bar { 6 | position: absolute !important; 7 | z-index: 999; 8 | 9 | min-width: 0 !important; 10 | 11 | transition: max-width 0.25s ease-in-out; 12 | // transition: background-color 0.25s linear; 13 | } 14 | 15 | .side-bar:not(.expanded) { 16 | // background-color: transparent; 17 | max-width: 0 !important; 18 | // max-width: 0 !important; 19 | } 20 | 21 | .side-bar.expanded { 22 | // background-color: red; 23 | // min-width: 100% !important; 24 | max-width: sizing.$sidebar-width !important; 25 | } 26 | } 27 | 28 | .side-bar { 29 | // position: relative; 30 | height: 100%; 31 | min-width: sizing.$sidebar-width; 32 | // max-width: $sidebar-width; 33 | // max-width: 100px; 34 | 35 | // display: flex; 36 | // flex-flow: column nowrap; 37 | // align-items: center; 38 | overflow: hidden; 39 | 40 | .side-bar-background { 41 | position: absolute; 42 | width: 100%; 43 | } 44 | 45 | .side-bar-inner { 46 | height: 100%; 47 | min-width: sizing.$sidebar-width; 48 | max-width: sizing.$sidebar-width; 49 | 50 | display: flex; 51 | flex-flow: column nowrap; 52 | 53 | background-color: colors.$background-secondary; 54 | 55 | .side-bar-header { 56 | width: 100%; 57 | text-align: center; 58 | padding-top: 2.5rem; 59 | 60 | border-bottom: 0.2rem solid colors.$background-primary; 61 | 62 | img { 63 | width: 35%; 64 | } 65 | 66 | h2 { 67 | color: colors.$text-color-secondary; 68 | } 69 | } 70 | 71 | .side-bar-nav { 72 | width: 100%; 73 | flex-grow: 1; 74 | 75 | ul { 76 | list-style: none; 77 | margin: 0; 78 | padding: 0; 79 | 80 | li { 81 | // width: 100%; 82 | font-size: 1.75rem; 83 | border-bottom: 0.2rem solid colors.$background-primary; 84 | 85 | a { 86 | width: 100%; 87 | padding: 1rem 2rem; 88 | 89 | display: block; 90 | color: colors.$text-color-secondary; 91 | text-decoration: none; 92 | 93 | .bi { 94 | margin-right: 1.5rem; 95 | } 96 | } 97 | 98 | a.active { 99 | color: colors.$text-color-primary; 100 | font-weight: bold; 101 | text-decoration: underline; 102 | } 103 | 104 | a:hover { 105 | background-color: colors.$background-tertiary; 106 | } 107 | } 108 | } 109 | } 110 | 111 | .side-bar-application-version { 112 | padding: 0.5rem; 113 | border-top: 0.1rem solid colors.$background-four; 114 | text-align: center; 115 | font-size: 1.25rem; 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /client/src/components/modules/side-bar/side-bar.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom'; 2 | import { Socket } from 'socket.io-client'; 3 | import { ConfigType } from 'types/config'; 4 | import VersionInfo from '../version-info/version-info'; 5 | import './side-bar.scss'; 6 | 7 | type Params = { 8 | showSidebar: boolean; 9 | setShowSidebar: React.Dispatch>; 10 | socket: Socket; 11 | config: ConfigType | undefined; 12 | }; 13 | 14 | export default function SideBar({ showSidebar, setShowSidebar, socket, config }: Params) { 15 | const handleNavLinkClick = () => { 16 | if (showSidebar) { 17 | setShowSidebar(false); 18 | } 19 | }; 20 | 21 | return ( 22 |
23 |
24 |
25 |
26 | HandBrake Icon 31 |

HandBrake Web

32 |
33 |
34 |
    35 |
  • 36 | 37 | 38 | Dashboard 39 | 40 |
  • 41 |
  • 42 | 43 | 44 | Queue 45 | 46 |
  • 47 |
  • 48 | 49 | 50 | Presets 51 | 52 |
  • 53 |
  • 54 | 55 | 56 | Watchers 57 | 58 |
  • 59 |
  • 60 | 61 | 62 | Workers 63 | 64 |
  • 65 |
  • 66 | 67 | 68 | Settings 69 | 70 |
  • 71 |
72 |
73 |
74 | 75 |
76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /client/src/components/modules/version-info/version-info.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .version-info { 4 | .current-version { 5 | color: colors.$text-color-tertiary; 6 | } 7 | 8 | .latest-version { 9 | display: block; 10 | width: fit-content; 11 | margin: 1rem auto 0.5rem auto; 12 | padding: 0.5rem 1rem; 13 | text-decoration: none; 14 | border-radius: 2rem; 15 | border: 0.1rem solid colors.$handbrake-yellow; 16 | color: colors.$handbrake-yellow; 17 | 18 | .bi { 19 | margin-right: 0.5rem; 20 | } 21 | 22 | .version { 23 | text-decoration: underline; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/modules/version-info/version-info.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Socket } from 'socket.io-client'; 3 | import { GithubReleaseResponseType } from 'types/version'; 4 | import './version-info.scss'; 5 | import { ConfigType } from 'types/config'; 6 | 7 | type Params = { 8 | socket: Socket; 9 | config: ConfigType | undefined; 10 | }; 11 | 12 | export default function VersionInfo({ socket, config }: Params) { 13 | const [currentVersion, setCurrentVersion] = useState(null); 14 | const [latestVersion, setLatestVersion] = useState(null); 15 | 16 | const getCurrentVersionInfo = async () => { 17 | const info: GithubReleaseResponseType | null = await socket.emitWithAck( 18 | 'get-current-version-info' 19 | ); 20 | console.log(info ? info.name : info); 21 | setCurrentVersion(info); 22 | }; 23 | 24 | const getLatestVersionInfo = async () => { 25 | const info: GithubReleaseResponseType | null = await socket.emitWithAck( 26 | 'get-latest-version-info' 27 | ); 28 | console.log(info ? info.name : info); 29 | setLatestVersion(info); 30 | }; 31 | 32 | useEffect(() => { 33 | (async () => { 34 | if (socket.connected) { 35 | await getCurrentVersionInfo(); 36 | 37 | if (config && config.version['check-interval'] != 0) { 38 | await getLatestVersionInfo(); 39 | } 40 | } 41 | })(); 42 | }, [socket.connected]); 43 | 44 | return ( 45 |
46 | {currentVersion ? ( 47 | 48 | {currentVersion.name} 49 | 50 | ) : ( 51 | v{APP_VERSION} 52 | )} 53 | {latestVersion && ( 54 | 55 | 56 | {latestVersion.name} 57 | Update Available 58 | 59 | )} 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /client/src/components/overlays/create-job/create-job-funcs.ts: -------------------------------------------------------------------------------- 1 | import mime from 'mime'; 2 | import { Socket } from 'socket.io-client'; 3 | import { DirectoryType, DirectoryItemsType, DirectoryRequestType } from 'types/directory'; 4 | import { HandbrakeOutputExtensions } from 'types/file-extensions'; 5 | 6 | export async function RequestDirectory(socket: Socket, path: string, isRecursive: boolean = false) { 7 | const request: DirectoryRequestType = { 8 | path: path, 9 | isRecursive: isRecursive, 10 | }; 11 | const response: DirectoryType = await socket.emitWithAck('get-directory', request); 12 | return response; 13 | } 14 | 15 | export function FilterVideoFiles(items: DirectoryItemsType) { 16 | return items 17 | .filter((item) => !item.isDirectory) 18 | .filter((item) => mime.getType(item.path)?.includes('video')); 19 | } 20 | 21 | export function GetOutputItemsFromInputItems( 22 | inputItems: DirectoryItemsType, 23 | extension: HandbrakeOutputExtensions 24 | ) { 25 | return inputItems.map((item) => { 26 | return { 27 | path: item.path.replace(item.extension!, extension), 28 | name: item.name, 29 | extension: extension, 30 | isDirectory: item.isDirectory, 31 | }; 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /client/src/components/overlays/create-job/create-job.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/font'; 3 | 4 | .section-overlay#create-new-job { 5 | .button-group { 6 | button { 7 | .bi { 8 | margin-right: 0.5rem; 9 | } 10 | } 11 | } 12 | 13 | form { 14 | display: flex; 15 | flex-flow: column nowrap; 16 | gap: 2rem; 17 | 18 | fieldset { 19 | padding: 1.5rem 1rem; 20 | display: flex; 21 | flex-flow: column nowrap; 22 | gap: 1rem; 23 | 24 | border: 0.1rem solid colors.$text-color-primary; 25 | border-radius: 1rem; 26 | 27 | legend { 28 | font-family: font.$font-family-secondary; 29 | font-size: 2rem; 30 | } 31 | } 32 | 33 | label { 34 | color: colors.$text-color-secondary; 35 | } 36 | } 37 | 38 | .result-section { 39 | .table-scroll { 40 | max-height: 25rem; 41 | overflow-y: scroll; 42 | 43 | table { 44 | width: 100%; 45 | 46 | thead { 47 | th { 48 | border-bottom: 0.1rem solid colors.$text-color-secondary; 49 | } 50 | } 51 | 52 | tbody { 53 | tr { 54 | &:hover { 55 | background-color: colors.$transparent-light-secondary; 56 | } 57 | 58 | td { 59 | padding: 0.25rem 0.5rem; 60 | font-size: 1.25rem; 61 | cursor: default; 62 | } 63 | 64 | .index-cell { 65 | text-align: center; 66 | } 67 | 68 | .input-cell:hover, 69 | .output-cell:hover { 70 | background-color: colors.$transparent-light-secondary; 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | .see-more { 78 | width: 100%; 79 | border: none; 80 | background-color: transparent; 81 | color: colors.$text-color-secondary; 82 | cursor: pointer; 83 | 84 | &:hover { 85 | background-color: colors.$transparent-light-primary; 86 | } 87 | } 88 | } 89 | 90 | #output-full { 91 | display: flex; 92 | flex-flow: row nowrap; 93 | align-items: center; 94 | gap: 1rem; 95 | 96 | .label { 97 | white-space: nowrap; 98 | } 99 | 100 | .text { 101 | flex-grow: 1; 102 | // background-color: white; 103 | } 104 | } 105 | 106 | .filename-conflict { 107 | text-align: center; 108 | color: colors.$handbrake-yellow; 109 | } 110 | 111 | .filename-overwrite { 112 | text-align: center; 113 | color: colors.$handbrake-red; 114 | } 115 | 116 | .buttons-section { 117 | display: flex; 118 | flex-direction: row wrap; 119 | justify-content: right; 120 | gap: 1rem; 121 | } 122 | 123 | .controlled-button { 124 | .button-label { 125 | font-size: 1.5rem; 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /client/src/components/overlays/register-watcher/register-watcher.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/font'; 3 | 4 | .section-overlay#register-watcher { 5 | .register-watcher-fields { 6 | display: flex; 7 | flex-flow: column nowrap; 8 | gap: 2rem; 9 | 10 | .fields-section { 11 | display: flex; 12 | flex-flow: column nowrap; 13 | gap: 1rem; 14 | } 15 | 16 | .buttons-section { 17 | display: flex; 18 | flex-direction: row wrap; 19 | justify-content: right; 20 | gap: 1rem; 21 | } 22 | } 23 | 24 | .controlled-button { 25 | .button-label { 26 | font-size: 1.5rem; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/overlays/upload-preset/upload-preset.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/font'; 3 | 4 | .section-overlay#upload-preset { 5 | .section-overlay-window { 6 | display: flex; 7 | flex-flow: column nowrap; 8 | gap: 1rem; 9 | 10 | label { 11 | font-size: 1.25rem; 12 | color: colors.$text-color-tertiary; 13 | } 14 | 15 | .file-section { 16 | display: flex; 17 | flex-flow: row nowrap; 18 | align-items: center; 19 | gap: 1rem; 20 | 21 | .file-upload { 22 | flex-grow: 1; 23 | 24 | font-size: 1.5rem; 25 | font-family: font.$font-family-primary; 26 | 27 | border: 0.1rem solid colors.$text-color-primary; 28 | border-radius: 0.75rem; 29 | // background-color: white; 30 | 31 | &::file-selector-button { 32 | margin-right: 1rem; 33 | padding: 0.5rem 1rem; 34 | font-family: font.$font-family-primary; 35 | border: none; 36 | background-color: colors.$background-six; 37 | color: colors.$text-color-secondary; 38 | } 39 | 40 | &::file-selector-button:hover { 41 | background-color: colors.$background-five; 42 | } 43 | } 44 | } 45 | 46 | .fields-section { 47 | margin-top: 2rem; 48 | display: flex; 49 | flex-flow: column nowrap; 50 | gap: 1rem; 51 | 52 | h3 { 53 | margin: 0; 54 | color: colors.$text-color-secondary; 55 | } 56 | 57 | .preset-overwrite { 58 | margin-bottom: 0.5rem; 59 | text-align: center; 60 | color: colors.$handbrake-red; 61 | 62 | .bi { 63 | margin-right: 0.5rem; 64 | } 65 | } 66 | 67 | .preset-name-section { 68 | display: flex; 69 | flex-flow: row nowrap; 70 | align-items: center; 71 | gap: 1rem; 72 | 73 | #preset-name { 74 | padding: 0.5rem 1rem; 75 | flex-grow: 1; 76 | font-size: 1.75rem; 77 | font-family: font.$font-family-primary; 78 | color: colors.$text-color-primary; 79 | // border: 0.2rem solid hsla(0, 0%, 100%, 0.1); 80 | border: none; 81 | border-radius: 0.75rem; 82 | background-color: hsla(0, 0%, 100%, 0.1); 83 | } 84 | } 85 | } 86 | 87 | .buttons-section { 88 | display: flex; 89 | flex-flow: row wrap; 90 | justify-content: flex-end; 91 | gap: 1rem; 92 | 93 | * { 94 | font-size: 1.75rem; 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /client/src/components/section/section-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export type SectionContextType = { 4 | scrollY: number; 5 | }; 6 | 7 | export const SectionContext = createContext({ 8 | scrollY: 0, 9 | }); 10 | -------------------------------------------------------------------------------- /client/src/components/section/section-overlay.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section-overlay { 4 | position: absolute; 5 | z-index: 100; 6 | // top: 0; 7 | left: 0; 8 | width: 100%; 9 | height: 100%; 10 | padding: 5rem 0; 11 | 12 | overflow-y: scroll; 13 | 14 | display: flex; 15 | align-items: flex-start; 16 | justify-content: center; 17 | 18 | background-color: hsla(0, 0%, 0%, 0.75); 19 | 20 | .section-overlay-window { 21 | width: 90%; 22 | max-width: 75rem; 23 | padding: 2rem; 24 | 25 | border-radius: 1rem; 26 | background-color: colors.$background-tertiary; 27 | box-shadow: 0 0 2rem 0 black; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/section/section-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useContext } from 'react'; 2 | import { SectionContext } from './section-context'; 3 | import './section-overlay.scss'; 4 | 5 | type Params = PropsWithChildren & { 6 | id: string; 7 | }; 8 | 9 | export default function SectionOverlay({ children, id }: Params) { 10 | const { scrollY } = useContext(SectionContext); 11 | 12 | return ( 13 |
14 |
{children}
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /client/src/components/section/section.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section { 4 | position: relative; 5 | height: 100%; 6 | 7 | flex-grow: 1; 8 | 9 | overflow-x: hidden; 10 | overflow-y: auto; 11 | 12 | padding: 2rem; 13 | 14 | .section-title { 15 | margin: 2rem 0; 16 | } 17 | 18 | .section-divider { 19 | margin-bottom: 3rem; 20 | } 21 | 22 | .sub-section { 23 | padding: 1rem; 24 | margin-bottom: 2rem; 25 | 26 | display: flex; 27 | flex-flow: column nowrap; 28 | gap: 1rem; 29 | 30 | border-radius: 1rem; 31 | background-color: colors.$background-secondary; 32 | 33 | h2 { 34 | margin: 0; 35 | margin-bottom: 1rem; 36 | color: colors.$text-color-tertiary; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/components/section/section.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useRef, useState } from 'react'; 2 | import { SectionContext, SectionContextType } from './section-context'; 3 | import './section.scss'; 4 | 5 | type Params = PropsWithChildren & { 6 | title: string; 7 | className?: string; 8 | id?: string; 9 | }; 10 | 11 | export default function Section({ children, title, className, id }: Params) { 12 | const [scrollY, setScrollY] = useState(0); 13 | const scrollRef = useRef(null); 14 | 15 | const context: SectionContextType = { 16 | scrollY: scrollY, 17 | }; 18 | 19 | const handleScroll = () => { 20 | if (scrollRef.current) { 21 | setScrollY(scrollRef.current.scrollTop); 22 | // console.log(scrollRef.current.scrollTop); 23 | } 24 | }; 25 | 26 | return ( 27 |
33 |

{title}

34 |
35 | {children} 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /client/src/components/section/sub-section.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/client/src/components/section/sub-section.scss -------------------------------------------------------------------------------- /client/src/components/section/sub-section.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import './sub-section.scss'; 3 | 4 | type Params = PropsWithChildren & { 5 | title?: string; 6 | id: string; 7 | }; 8 | 9 | export default function SubSection({ children, title, id }: Params) { 10 | return ( 11 |
12 | {title &&

{title}

} 13 | {children} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/index.scss: -------------------------------------------------------------------------------- 1 | // Local scope 2 | @use './style/modules/colors'; 3 | @use './style/modules/font'; 4 | @use './style/modules/sizing'; 5 | 6 | // Global scope 7 | @use './style/modules/general'; 8 | @use './style/modules/form'; 9 | @use './style/modules/headings'; 10 | @use './style/modules/table'; 11 | 12 | @import '../node_modules/bootstrap-icons/font/bootstrap-icons.css'; 13 | 14 | :root { 15 | font-size: 10px; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | 22 | body { 23 | min-height: 100vh; 24 | max-height: 100vh; 25 | // overflow: clip; 26 | 27 | margin: 0; 28 | padding: 0; 29 | 30 | // Font -------------------------------------------------------------------- 31 | font-size: 1.5rem; 32 | font-family: font.$font-family-primary; 33 | 34 | // Color ------------------------------------------------------------------- 35 | background-color: colors.$background-primary; 36 | color: colors.$text-color-primary; 37 | 38 | #root { 39 | min-width: sizing.$small-min-width; 40 | height: 100vh; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router-dom'; 3 | 4 | import './index.scss'; 5 | import '@fontsource/noto-sans'; 6 | import '@fontsource/inter'; 7 | 8 | import Primary from 'pages/primary/primary'; 9 | import Error from 'sections/error/error'; 10 | import QueueSection from 'sections/queue/queue'; 11 | import WorkersSection from 'sections/workers/workers'; 12 | import PresetsSection from 'sections/presets/presets'; 13 | import DashboardSection from 'sections/dashboard/dashboard'; 14 | import WatchersSection from 'sections/watchers/watchers'; 15 | import SettingsSection from 'sections/settings/settings'; 16 | 17 | const routes: RouteObject[] = [ 18 | { 19 | path: '/', 20 | element: , 21 | errorElement: , 22 | children: [ 23 | { 24 | path: '', 25 | element: , 26 | }, 27 | { 28 | path: 'queue', 29 | element: , 30 | }, 31 | { 32 | path: 'workers', 33 | element: , 34 | }, 35 | { 36 | path: 'presets', 37 | element: , 38 | }, 39 | { 40 | path: 'watchers', 41 | element: , 42 | }, 43 | { 44 | path: 'settings', 45 | element: , 46 | }, 47 | { 48 | path: '*', 49 | element: , 50 | errorElement: , 51 | }, 52 | ], 53 | }, 54 | ]; 55 | const router = createBrowserRouter(routes); 56 | 57 | ReactDOM.createRoot(document.getElementById('root')!).render(); 58 | -------------------------------------------------------------------------------- /client/src/pages/primary/primary-context.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io-client'; 2 | import { QueueType, QueueStatus } from 'types/queue'; 3 | import { ConnectionIDsType } from 'types/socket'; 4 | import { HandbrakePresetCategoryType } from 'types/preset'; 5 | import { ConfigType } from 'types/config'; 6 | import { WatcherDefinitionObjectType } from 'types/watcher'; 7 | 8 | export type PrimaryOutletContextType = { 9 | serverURL: string; 10 | socket: Socket; 11 | queue: QueueType; 12 | queueStatus: QueueStatus; 13 | presets: HandbrakePresetCategoryType; 14 | defaultPresets: HandbrakePresetCategoryType; 15 | connections: ConnectionIDsType; 16 | config: ConfigType; 17 | watchers: WatcherDefinitionObjectType; 18 | }; 19 | -------------------------------------------------------------------------------- /client/src/pages/primary/primary.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/sizing'; 3 | 4 | @media (max-width: sizing.$medium-max-width) { 5 | #primary { 6 | .dark-overlay { 7 | display: inherit !important; 8 | transition: background-color 0.25s ease-in-out; 9 | 10 | &.visible { 11 | background-color: rgba(0, 0, 0, 0.75); 12 | } 13 | 14 | &.hidden { 15 | background-color: transparent; 16 | } 17 | } 18 | 19 | .primary-section { 20 | min-width: 100%; 21 | 22 | .mobile-toolbar { 23 | display: inherit !important; 24 | } 25 | 26 | .content { 27 | max-height: calc(100% - 5rem) !important; 28 | } 29 | } 30 | } 31 | } 32 | 33 | #primary { 34 | height: 100%; 35 | 36 | display: flex; 37 | flex-flow: row nowrap; 38 | 39 | .dark-overlay { 40 | position: absolute; 41 | z-index: 998; 42 | height: 100%; 43 | width: 100%; 44 | display: none; 45 | pointer-events: none; 46 | } 47 | 48 | .primary-section { 49 | flex-grow: 1; 50 | max-width: 100%; 51 | max-height: 100%; 52 | display: flex; 53 | flex-flow: column nowrap; 54 | align-items: flex-start; 55 | overflow: hidden; 56 | 57 | .mobile-toolbar { 58 | position: relative; 59 | min-height: 5rem; 60 | max-height: 5rem; 61 | display: none; 62 | min-width: 100%; 63 | max-width: 100%; 64 | 65 | background-color: colors.$background-secondary; 66 | 67 | .title { 68 | min-width: 100%; 69 | max-width: 100%; 70 | display: flex; 71 | justify-content: center; 72 | align-items: center; 73 | gap: 1rem; 74 | // position: relative; 75 | color: colors.$text-color-primary; 76 | text-decoration: none; 77 | 78 | img { 79 | height: 100%; 80 | padding: 0.75rem 0; 81 | // width: 100%; 82 | object-fit: scale-down; 83 | // position: absolute; 84 | // left: 0; 85 | } 86 | 87 | h1 { 88 | font-size: 2.25rem; 89 | font-weight: 400; 90 | } 91 | } 92 | 93 | button { 94 | position: absolute; 95 | z-index: 1000; 96 | height: 100%; 97 | aspect-ratio: 1; 98 | background-color: transparent; 99 | border: none; 100 | 101 | i { 102 | color: colors.$text-color-primary; 103 | font-size: 3.5rem; 104 | } 105 | 106 | &:hover { 107 | cursor: pointer; 108 | background-color: colors.$transparent-light-secondary; 109 | } 110 | } 111 | } 112 | 113 | .content { 114 | flex-grow: 1; 115 | // min-width: none; 116 | width: 100%; 117 | max-height: 100%; 118 | // max-width: 100%; 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/dashboard.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section#dashboard { 4 | .table-scroll { 5 | max-width: 100%; 6 | overflow-x: auto; 7 | padding-bottom: 2rem; 8 | } 9 | 10 | a { 11 | text-decoration: none; 12 | 13 | i { 14 | // height: 100%; 15 | // width: 2.75rem; 16 | display: inline-block; 17 | border-radius: 50%; 18 | line-height: 1rem; 19 | // aspect-ratio: 1; 20 | // display: inline-block; 21 | // // background-color: red; 22 | // text-align: center; 23 | } 24 | 25 | &:hover { 26 | color: colors.$text-color-primary; 27 | // text-decoration: underline; 28 | i { 29 | background-color: colors.$text-color-primary; 30 | color: colors.$background-primary; 31 | } 32 | } 33 | } 34 | 35 | table { 36 | width: 100%; 37 | 38 | tr:hover { 39 | background-color: rgba(255, 255, 255, 0.1); 40 | } 41 | 42 | th, 43 | td { 44 | padding: 0.5rem 1rem; 45 | 46 | &.left { 47 | text-align: left; 48 | } 49 | 50 | &.center { 51 | text-align: center; 52 | } 53 | 54 | &.right { 55 | text-align: right; 56 | } 57 | } 58 | 59 | th { 60 | font-size: 1.25rem; 61 | border-bottom: 0.1rem solid colors.$text-color-tertiary; 62 | } 63 | 64 | td { 65 | white-space: nowrap; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useOutletContext } from 'react-router-dom'; 2 | import { PrimaryOutletContextType } from 'pages/primary/primary-context'; 3 | import Section from 'components/section/section'; 4 | import DashboardSummary from './sub-sections/dashboard-summary'; 5 | import DashboardQueue from './sub-sections/dashboard-queue'; 6 | import DashboardWorkers from './sub-sections/dashboard-workers'; 7 | import DashboardPresets from './sub-sections/dashboard-presets'; 8 | import './dashboard.scss'; 9 | import DashboardWatchers from './sub-sections/dashboard-watchers'; 10 | 11 | export default function DashboardSection() { 12 | const { socket, queue, queueStatus, presets, connections, watchers } = 13 | useOutletContext(); 14 | 15 | return ( 16 |
17 | 18 | 19 | 20 | 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-presets.scss: -------------------------------------------------------------------------------- 1 | .section#dashboard { 2 | .sub-section#presets { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-presets.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom'; 2 | import { HandbrakePresetCategoryType } from 'types/preset'; 3 | import SubSection from 'components/section/sub-section'; 4 | import './dashboard-presets.scss'; 5 | import { FirstLetterUpperCase } from 'funcs/string.funcs'; 6 | import { PresetEncoderDict, PresetFormatDict } from 'dict/presets.dict'; 7 | 8 | type Params = { 9 | presets: HandbrakePresetCategoryType; 10 | }; 11 | 12 | export default function DashboardPresets({ presets }: Params) { 13 | return ( 14 | 15 | 16 |

17 | Presets 18 |

19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {Object.keys(presets).map((category) => { 33 | return Object.values(presets[category]).map((preset) => { 34 | const info = preset.PresetList[0]; 35 | const resolution = `${info.PictureWidth}x${info.PictureHeight}`; 36 | 37 | return ( 38 | 39 | 40 | 41 | 44 | 45 | 48 | 49 | ); 50 | }); 51 | })} 52 | 53 |
CategoryNameFormatResolutionEncoder
{FirstLetterUpperCase(category)}{info.PresetName} 42 | {PresetFormatDict[info.FileFormat]} 43 | {resolution} 46 | {PresetEncoderDict[info.VideoEncoder]} 47 |
54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-queue.scss: -------------------------------------------------------------------------------- 1 | .section#dashboard { 2 | .sub-section#queue { 3 | .progress { 4 | min-width: 25rem; 5 | width: 25rem; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-queue.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom'; 2 | import { QueueType } from 'types/queue'; 3 | import { TranscodeStage } from 'types/transcode'; 4 | import { statusSorting } from 'dict/queue.dict'; 5 | import ProgressBar from 'components/base/progress/progress-bar'; 6 | import SubSection from 'components/section/sub-section'; 7 | import './dashboard-queue.scss'; 8 | import BadgeInfo from 'components/base/info/badge-info/badge-info'; 9 | 10 | type Params = { 11 | queue: QueueType; 12 | }; 13 | 14 | export default function DashboardQueue({ queue }: Params) { 15 | return ( 16 | 17 | 18 |

19 | Queue 20 |

21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {Object.keys(queue) 35 | .map((key) => parseInt(key)) 36 | .sort( 37 | (a, b) => { 38 | const stageA = queue[a].status.transcode_stage; 39 | const stageB = queue[b].status.transcode_stage; 40 | if (stageA != undefined && stageB != undefined) { 41 | // return ( 42 | // statusSorting[queue[a].status.transcode_stage] - 43 | // statusSorting[queue[b].status.transcode_stage] 44 | // ); 45 | const orderA = queue[a].order_index; 46 | const orderB = queue[b].order_index; 47 | 48 | const finishedA = queue[a].status.time_finished || 0; 49 | const finishedB = queue[b].status.time_finished || 0; 50 | 51 | return stageA == stageB 52 | ? orderA != null && orderB != null 53 | ? orderA - orderB 54 | : finishedA 55 | ? finishedB 56 | ? finishedB - finishedA 57 | : 1 58 | : finishedB 59 | ? -1 60 | : 0 61 | : statusSorting[stageA] - statusSorting[stageB]; 62 | } 63 | 64 | return 0; 65 | } 66 | // queue[a].order_index 67 | // ? queue[b].order_index 68 | // ? queue[a].order_index - queue[b].order_index 69 | // : -1 70 | // : queue[b].order_index 71 | // ? 1 72 | // : 1 73 | ) 74 | .map((key) => { 75 | const job = queue[key]; 76 | const percentage = job.status.transcode_percentage 77 | ? job.status.transcode_percentage * 100 78 | : 0; 79 | 80 | // console.log(job.order_index); 81 | 82 | return ( 83 | 84 | 85 | 89 | 93 | 117 | 120 | 121 | ); 122 | })} 123 | 124 |
#InputOutputStatusProgress
{job.order_index} 86 | {job.data.input_path.match(/[^/]+$/)} 87 | 88 | 90 | {job.data.output_path.match(/[^/]+$/)} 91 | 92 | 109 | { 110 | TranscodeStage[ 111 | job.status.transcode_stage 112 | ? job.status.transcode_stage 113 | : 0 114 | ] 115 | } 116 | 118 | 119 |
125 |
126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-summary.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section#dashboard { 4 | .sub-section#summary { 5 | .summary-info { 6 | display: flex; 7 | flex-flow: row wrap; 8 | gap: 1rem; 9 | font-size: 1.75rem; 10 | 11 | .info { 12 | min-width: 30%; 13 | padding: 1rem; 14 | flex-grow: 1; 15 | text-align: center; 16 | border-radius: 0.75rem; 17 | background-color: colors.$transparent-light-primary; 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-summary.tsx: -------------------------------------------------------------------------------- 1 | import { QueueStatus } from 'types/queue'; 2 | import SubSection from 'components/section/sub-section'; 3 | import './dashboard-summary.scss'; 4 | 5 | type Params = { 6 | connectionStatus: boolean; 7 | queueStatus: QueueStatus; 8 | }; 9 | 10 | export default function DashboardSummary({ connectionStatus, queueStatus }: Params) { 11 | return ( 12 | 13 |
14 |
15 | Server: 16 | 17 | {connectionStatus ? 'Connected' : 'Disconnected'} 18 | 19 |
20 |
21 | Queue: 22 | 33 | {QueueStatus[queueStatus]} 34 | 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-watchers.scss: -------------------------------------------------------------------------------- 1 | .section#dashboard { 2 | .sub-section#watchers { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-watchers.tsx: -------------------------------------------------------------------------------- 1 | import SubSection from 'components/section/sub-section'; 2 | import { NavLink } from 'react-router-dom'; 3 | import { WatcherDefinitionObjectType } from 'types/watcher'; 4 | import './dashboard-watchers.scss'; 5 | 6 | type Params = { 7 | watchers: WatcherDefinitionObjectType; 8 | }; 9 | 10 | export default function DashboardWatchers({ watchers }: Params) { 11 | return ( 12 | 13 | 14 |

15 | Watchers 16 |

17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {Object.keys(watchers) 30 | .map((watcherID) => parseInt(watcherID)) 31 | .map((watcherID) => { 32 | const watcher = watchers[watcherID]; 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 42 | 43 | ); 44 | })} 45 | 46 |
Watching DirectoryOutput DirectoryPresetRules
{watcher.watch_path}{watcher.output_path || 'N/A'}{watcher.preset_id} 40 | {Object.keys(watcher.rules).length} 41 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-workers.scss: -------------------------------------------------------------------------------- 1 | .section#dashboard { 2 | .sub-section#workers { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /client/src/sections/dashboard/sub-sections/dashboard-workers.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom'; 2 | import { QueueType } from 'types/queue'; 3 | import { WorkerIDType } from 'types/socket'; 4 | import SubSection from 'components/section/sub-section'; 5 | import './dashboard-workers.scss'; 6 | 7 | type Params = { 8 | queue: QueueType; 9 | workers: WorkerIDType[]; 10 | }; 11 | 12 | export default function DashboardWorkers({ queue, workers }: Params) { 13 | return ( 14 | 15 | 16 |

17 | Workers 18 |

19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {workers.map((worker) => { 31 | const status = Object.values(queue).find( 32 | (job) => job.status.worker_id == worker.workerID 33 | ) 34 | ? 'Working' 35 | : 'Idle'; 36 | 37 | return ( 38 | 39 | 40 | 41 | 48 | 49 | ); 50 | })} 51 | 52 |
Worker IDConnection IDStatus
{worker.workerID}{worker.connectionID} 46 | {status} 47 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /client/src/sections/error/error.tsx: -------------------------------------------------------------------------------- 1 | import Section from 'components/section/section'; 2 | 3 | export default function Error() { 4 | return ( 5 |
6 |

The page you are looking for does not exist :(

7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /client/src/sections/no-connection/no-connection.tsx: -------------------------------------------------------------------------------- 1 | import Section from 'components/section/section'; 2 | 3 | type Params = { 4 | url: string; 5 | }; 6 | 7 | export default function NoConnection({ url }: Params) { 8 | return ( 9 |
10 |

11 | The client is unable to reach the server at {url}. Please check your server status 12 | or configuration. 13 |

14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/sections/presets/presets.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section#presets { 4 | .sub-section#buttons { 5 | flex-flow: row wrap; 6 | justify-content: space-between; 7 | align-items: center; 8 | 9 | .preset-count { 10 | font-size: 2rem; 11 | } 12 | } 13 | 14 | .sub-section#list { 15 | .category-list { 16 | .category-list-header { 17 | margin-bottom: 1rem; 18 | padding: 0.5rem 1rem; 19 | border-radius: 4rem; 20 | color: colors.$text-color-tertiary; 21 | 22 | * { 23 | margin-right: 0.5rem; 24 | vertical-align: middle; 25 | } 26 | 27 | &:hover { 28 | background-color: colors.$background-five; 29 | // color: colors.$background-primary; 30 | } 31 | 32 | .bi-folder2, 33 | .bi-folder2-open { 34 | font-size: 2rem; 35 | } 36 | } 37 | 38 | .category-list-cards { 39 | display: flex; 40 | flex-flow: column nowrap; 41 | gap: 1.5rem; 42 | margin-bottom: 2rem; 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/sections/presets/presets.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useOutletContext } from 'react-router-dom'; 3 | import { PrimaryOutletContextType } from 'pages/primary/primary-context'; 4 | import Section from 'components/section/section'; 5 | import UploadPreset from 'components/overlays/upload-preset/upload-preset'; 6 | import PresetsButtons from './sub-sections/presets-buttons'; 7 | import PresetsList from './sub-sections/presets-list'; 8 | import './presets.scss'; 9 | 10 | export default function PresetsSection() { 11 | const { config, presets, defaultPresets, socket } = 12 | useOutletContext(); 13 | 14 | const [showUploadPreset, setShowUploadPreset] = useState(false); 15 | 16 | const handleOpenUploadPreset = () => { 17 | setShowUploadPreset(true); 18 | }; 19 | 20 | const handleCloseUploadPreset = () => { 21 | setShowUploadPreset(false); 22 | }; 23 | 24 | const handleRenamePreset = (oldName: string, newName: string, category: string) => { 25 | socket.emit('rename-preset', oldName, newName, category); 26 | }; 27 | 28 | const handleRemovePreset = (preset: string, category: string) => { 29 | socket.emit('remove-preset', preset, category); 30 | console.log(`[client] Requesting the server remove preset '${category}/${preset}'`); 31 | }; 32 | 33 | return ( 34 |
39 | 47 | 55 | {config.presets['show-default-presets'] && ( 56 | 62 | )} 63 | {showUploadPreset && ( 64 | 69 | )} 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /client/src/sections/presets/sub-sections/presets-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { HandbrakePresetCategoryType } from 'types/preset'; 2 | import ButtonInput from 'components/base/inputs/button/button-input'; 3 | import SubSection from 'components/section/sub-section'; 4 | 5 | type Params = { 6 | presets: HandbrakePresetCategoryType; 7 | handleOpenUploadPreset: () => void; 8 | }; 9 | 10 | export default function PresetsButtons({ presets, handleOpenUploadPreset }: Params) { 11 | return ( 12 | 13 |
14 | Presets:{' '} 15 | {Object.keys(presets).reduce((prev, cur) => { 16 | return prev + Object.keys(presets[cur]).length; 17 | }, 0)} 18 |
19 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /client/src/sections/presets/sub-sections/presets-list-category.tsx: -------------------------------------------------------------------------------- 1 | import PresetCard from 'components/cards/preset-card/preset-card'; 2 | import { FirstLetterUpperCase } from 'funcs/string.funcs'; 3 | import { useState } from 'react'; 4 | import { HandbrakePresetListType } from 'types/preset'; 5 | 6 | type Params = { 7 | category: string; 8 | presets: HandbrakePresetListType; 9 | collapsed: boolean; 10 | canModify: boolean; 11 | handleRenamePreset: (oldName: string, newName: string, category: string) => void; 12 | handleRemovePreset: (preset: string, category: string) => void; 13 | }; 14 | 15 | export default function PresetListCategory({ 16 | category, 17 | presets, 18 | collapsed, 19 | canModify, 20 | handleRenamePreset, 21 | handleRemovePreset, 22 | }: Params) { 23 | const [isExpanded, setIsExpanded] = useState(!collapsed); 24 | 25 | return ( 26 |
27 |
setIsExpanded(!isExpanded)}> 28 | 29 | {`${FirstLetterUpperCase(category)}${ 30 | isExpanded ? '' : ` (${Object.keys(presets).length})` 31 | }`} 32 | 33 |
34 | {isExpanded && ( 35 |
36 | {Object.keys(presets).map((preset) => ( 37 | 45 | ))} 46 |
47 | )} 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /client/src/sections/presets/sub-sections/presets-list.d.ts: -------------------------------------------------------------------------------- 1 | import { HandbrakePresetCategoryType } from 'types/preset'; 2 | type Params = { 3 | label: string; 4 | presets: HandbrakePresetCategoryType; 5 | collapsed?: boolean; 6 | allowRename?: boolean; 7 | handleRenamePreset: (oldName: string, newName: string, category: string) => void; 8 | handleRemovePreset: (preset: string, category: string) => void; 9 | }; 10 | export default function PresetsList({ label, presets, collapsed, allowRename, handleRenamePreset, handleRemovePreset, }: Params): import("react/jsx-runtime").JSX.Element | undefined; 11 | export {}; 12 | -------------------------------------------------------------------------------- /client/src/sections/presets/sub-sections/presets-list.tsx: -------------------------------------------------------------------------------- 1 | import { HandbrakePresetCategoryType } from 'types/preset'; 2 | import { getPresetCount } from 'funcs/preset.funcs'; 3 | import SubSection from 'components/section/sub-section'; 4 | import PresetListCategory from './presets-list-category'; 5 | 6 | type Params = { 7 | label: string; 8 | presets: HandbrakePresetCategoryType; 9 | collapsed?: boolean; 10 | canModify?: boolean; 11 | handleRenamePreset: (oldName: string, newName: string, category: string) => void; 12 | handleRemovePreset: (preset: string, category: string) => void; 13 | }; 14 | 15 | export default function PresetsList({ 16 | label, 17 | presets, 18 | collapsed = true, 19 | canModify = false, 20 | handleRenamePreset, 21 | handleRemovePreset, 22 | }: Params) { 23 | if (getPresetCount(presets) == 0) return; 24 | 25 | return ( 26 | 27 | {Object.keys(presets) 28 | .sort((a, b) => 29 | a.toLowerCase() > b.toLowerCase() || a == 'uncategorized' ? 1 : -1 30 | ) 31 | .filter((categoryName) => Object.keys(presets[categoryName]).length != 0) 32 | .map((categoryName) => ( 33 | 42 | ))} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /client/src/sections/queue/queue.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section#queue { 4 | .sub-section#status { 5 | .status-section { 6 | display: flex; 7 | flex-flow: row wrap; 8 | justify-content: space-between; 9 | 10 | .status-info { 11 | display: flex; 12 | flex-flow: row wrap; 13 | align-items: center; 14 | gap: 1rem; 15 | 16 | &.Stopped { 17 | color: colors.$handbrake-red; 18 | } 19 | 20 | &.Idle { 21 | color: colors.$handbrake-yellow; 22 | } 23 | 24 | &.Active { 25 | color: colors.$handbrake-blue; 26 | } 27 | 28 | .info-text { 29 | font-size: 2rem; 30 | } 31 | } 32 | 33 | .status-buttons { 34 | display: flex; 35 | flex-flow: row wrap; 36 | gap: 1rem; 37 | } 38 | } 39 | } 40 | 41 | .sub-section#jobs { 42 | .buttons { 43 | // margin: 2rem 0; 44 | margin-bottom: 1rem; 45 | display: flex; 46 | flex-flow: row wrap; 47 | gap: 1rem; 48 | } 49 | 50 | .cards { 51 | .queue-jobs-category { 52 | margin-bottom: 2rem; 53 | 54 | .queue-jobs-category-header { 55 | h4 { 56 | margin: 0rem 0 1rem 0; 57 | } 58 | 59 | .bi { 60 | margin-left: 0.5rem; 61 | display: inline-block; 62 | border-radius: 50%; 63 | line-height: 1rem; 64 | cursor: pointer; 65 | 66 | &:hover { 67 | background-color: white; 68 | color: colors.$background-tertiary; 69 | } 70 | } 71 | } 72 | 73 | .queue-jobs-category-cards { 74 | display: flex; 75 | flex-flow: column nowrap; 76 | margin: 0 -1rem; 77 | } 78 | 79 | .drop-preview { 80 | flex-grow: 1; 81 | padding: 0.5rem 1rem; 82 | 83 | hr { 84 | box-shadow: 0 0 1rem 0 colors.$text-color-primary; 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /client/src/sections/queue/queue.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useOutletContext } from 'react-router-dom'; 3 | import { PrimaryOutletContextType } from 'pages/primary/primary-context'; 4 | import Section from 'components/section/section'; 5 | import CreateJob from 'components/overlays/create-job/create-job'; 6 | import QueueJobs from './sub-sections/queue-jobs'; 7 | import QueueStatus from './sub-sections/queue-status'; 8 | import './queue.scss'; 9 | 10 | export default function QueueSection() { 11 | const { socket, queue, queueStatus } = useOutletContext(); 12 | 13 | const [showCreateJob, setShowCreateJob] = useState(false); 14 | 15 | const handleStartQueue = () => { 16 | socket.emit('start-queue'); 17 | }; 18 | 19 | const handleStopQueue = () => { 20 | socket.emit('stop-queue'); 21 | }; 22 | 23 | const handleClearAllJobs = () => { 24 | socket.emit('clear-queue', false); 25 | }; 26 | 27 | const handleClearFinishedJobs = () => { 28 | socket.emit('clear-queue', true); 29 | }; 30 | 31 | const handleAddNewJob = () => { 32 | setShowCreateJob(true); 33 | }; 34 | 35 | const handleStopJob = (id: number) => { 36 | socket.emit('stop-job', id); 37 | }; 38 | 39 | const handleResetJob = (id: number) => { 40 | socket.emit('reset-job', id); 41 | }; 42 | 43 | const handleRemoveJob = (id: number) => { 44 | socket.emit('remove-job', id); 45 | }; 46 | 47 | return ( 48 |
49 | 54 | 63 | {showCreateJob && ( 64 | { 66 | setShowCreateJob(false); 67 | }} 68 | /> 69 | )} 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /client/src/sections/queue/sub-sections/queue-job-preview.tsx: -------------------------------------------------------------------------------- 1 | type Params = { 2 | handleDrop: () => void; 3 | }; 4 | 5 | export default function QueueJobPreview({ handleDrop }: Params) { 6 | const handleDragOver = (event: React.DragEvent) => { 7 | event.preventDefault(); 8 | }; 9 | 10 | return ( 11 |
12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /client/src/sections/queue/sub-sections/queue-jobs-category.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useOutletContext } from 'react-router-dom'; 3 | import { QueueType } from 'types/queue'; 4 | import { statusSorting } from 'dict/queue.dict'; 5 | import { PrimaryOutletContextType } from 'pages/primary/primary-context'; 6 | import QueueCard from 'components/cards/queue-card/queue-card'; 7 | import QueueJobPreview from './queue-job-preview'; 8 | 9 | type Params = { 10 | queue: QueueType; 11 | id: string; 12 | label: string; 13 | showHandles?: boolean; 14 | collapsable?: boolean; 15 | startCollapsed?: boolean; 16 | handleStopJob: (id: number) => void; 17 | handleResetJob: (id: number) => void; 18 | handleRemoveJob: (id: number) => void; 19 | }; 20 | 21 | export default function QueueJobsCategory({ 22 | queue, 23 | id, 24 | label, 25 | showHandles = false, 26 | collapsable = false, 27 | startCollapsed = false, 28 | handleStopJob, 29 | handleResetJob, 30 | handleRemoveJob, 31 | }: Params) { 32 | const { socket } = useOutletContext(); 33 | 34 | const [isCollapsed, setIsCollapsed] = useState(startCollapsed); 35 | 36 | const orderedJobs = Object.keys(queue) 37 | .map((key) => parseInt(key)) 38 | .sort((a, b) => { 39 | const stageA = queue[a].status.transcode_stage; 40 | const stageB = queue[b].status.transcode_stage; 41 | if (stageA != undefined && stageB != undefined) { 42 | // return ( 43 | // statusSorting[queue[a].status.transcode_stage] - 44 | // statusSorting[queue[b].status.transcode_stage] 45 | // ); 46 | const orderA = queue[a].order_index; 47 | const orderB = queue[b].order_index; 48 | 49 | const finishedA = queue[a].status.time_finished || 0; 50 | const finishedB = queue[b].status.time_finished || 0; 51 | 52 | return stageA == stageB 53 | ? orderA != null && orderB != null 54 | ? orderA - orderB 55 | : finishedA 56 | ? finishedB 57 | ? finishedB - finishedA 58 | : 1 59 | : finishedB 60 | ? -1 61 | : 0 62 | : statusSorting[stageA] - statusSorting[stageB]; 63 | } 64 | 65 | return 0; 66 | }); 67 | 68 | // Drag n' drop related stuff 69 | const [draggedID, setDraggedID] = useState(); 70 | const [draggedInitialIndex, setDraggedInitialIndex] = useState(-1); 71 | const [draggedDesiredIndex, setDraggedDesiredIndex] = useState(-1); 72 | 73 | const handleDrop = () => { 74 | if (draggedDesiredIndex > 0) { 75 | socket.emit('reorder-job', draggedID, draggedDesiredIndex); 76 | } 77 | }; 78 | 79 | if (Object.keys(queue).length > 0) { 80 | const orderIndexOffest = queue[orderedJobs[0]].order_index - 1; 81 | const jobCards = orderedJobs.map((jobID, index) => { 82 | const job = queue[jobID]; 83 | 84 | return ( 85 | handleStopJob(jobID)} 94 | handleResetJob={() => handleResetJob(jobID)} 95 | handleRemoveJob={() => handleRemoveJob(jobID)} 96 | setDraggedID={setDraggedID} 97 | setDraggedDesiredIndex={setDraggedDesiredIndex} 98 | setDraggedInitialIndex={setDraggedInitialIndex} 99 | handleDrop={handleDrop} 100 | /> 101 | ); 102 | }); 103 | 104 | if (draggedDesiredIndex > 0) { 105 | jobCards.splice( 106 | draggedDesiredIndex > draggedInitialIndex 107 | ? draggedDesiredIndex - orderIndexOffest 108 | : draggedDesiredIndex - orderIndexOffest - 1, 109 | 0, 110 | 111 | ); 112 | } 113 | 114 | return ( 115 |
116 |
117 |

118 | {label} 119 | {collapsable && isCollapsed && ({Object.keys(queue).length})} 120 | {collapsable && ( 121 | setIsCollapsed(!isCollapsed)} 126 | /> 127 | )} 128 |

129 |
130 | {((collapsable && !isCollapsed) || !collapsable) && ( 131 |
{jobCards}
132 | )} 133 |
134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /client/src/sections/queue/sub-sections/queue-jobs.tsx: -------------------------------------------------------------------------------- 1 | import { QueueType } from 'types/queue'; 2 | import ButtonInput from 'components/base/inputs/button/button-input'; 3 | import SubSection from 'components/section/sub-section'; 4 | import QueueJobsCategory from './queue-jobs-category'; 5 | import { TranscodeStage } from 'types/transcode'; 6 | 7 | type Params = { 8 | queue: QueueType; 9 | handleAddNewJob: () => void; 10 | handleClearAllJobs: () => void; 11 | handleClearFinishedJobs: () => void; 12 | handleStopJob: (id: number) => void; 13 | handleResetJob: (id: number) => void; 14 | handleRemoveJob: (id: number) => void; 15 | }; 16 | 17 | export default function QueueJobs({ 18 | queue, 19 | handleAddNewJob, 20 | handleClearAllJobs, 21 | handleClearFinishedJobs, 22 | handleStopJob, 23 | handleResetJob, 24 | handleRemoveJob, 25 | }: Params) { 26 | const jobsInProgress: QueueType = Object.fromEntries( 27 | Object.entries(queue).filter( 28 | (entry) => 29 | entry[1].status.transcode_stage == TranscodeStage.Transcoding || 30 | entry[1].status.transcode_stage == TranscodeStage.Scanning 31 | ) 32 | ); 33 | 34 | const jobsWaiting: QueueType = Object.fromEntries( 35 | Object.entries(queue).filter( 36 | (entry) => entry[1].status.transcode_stage == TranscodeStage.Waiting 37 | ) 38 | ); 39 | 40 | const jobsStopped: QueueType = Object.fromEntries( 41 | Object.entries(queue).filter( 42 | (entry) => 43 | entry[1].status.transcode_stage == TranscodeStage.Stopped || 44 | entry[1].status.transcode_stage == TranscodeStage.Error 45 | ) 46 | ); 47 | 48 | const jobsFinshed: QueueType = Object.fromEntries( 49 | Object.entries(queue).filter( 50 | (entry) => entry[1].status.transcode_stage == TranscodeStage.Finished 51 | ) 52 | ); 53 | 54 | const onlyFinished = Object.keys(queue).length == Object.keys(jobsFinshed).length; 55 | 56 | return ( 57 | 58 |
59 | 65 | 71 | 72 |
73 |
74 | 82 | 91 | 101 | 111 |
112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /client/src/sections/queue/sub-sections/queue-status.tsx: -------------------------------------------------------------------------------- 1 | import { QueueStatus as QueueStatusType } from 'types/queue'; 2 | import SubSection from 'components/section/sub-section'; 3 | import ButtonInput from 'components/base/inputs/button/button-input'; 4 | 5 | type Params = { 6 | queueStatus: QueueStatusType; 7 | handleStartQueue: () => void; 8 | handleStopQueue: () => void; 9 | }; 10 | 11 | export default function QueueStatus({ queueStatus, handleStartQueue, handleStopQueue }: Params) { 12 | return ( 13 | 14 |
15 |
16 | 17 | {QueueStatusType[queueStatus]} 18 |
19 |
20 | 27 | 34 |
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /client/src/sections/settings/settings.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | @use '@style/modules/sizing'; 3 | 4 | .section#settings-section { 5 | @media screen and (min-width: calc(sizing.$large-min-width - sizing.$sidebar-width)) { 6 | .settings-sub-sections { 7 | display: grid; 8 | gap: 2rem; 9 | grid-template-columns: repeat(2, 1fr); 10 | } 11 | } 12 | 13 | .sub-section#buttons { 14 | flex-flow: row wrap; 15 | gap: 2rem; 16 | align-items: center; 17 | justify-content: space-between; 18 | } 19 | 20 | .settings-sub-sections { 21 | .sub-section { 22 | display: flex; 23 | flex-flow: column nowrap; 24 | gap: 1.25rem; 25 | 26 | .path-invalid-warning { 27 | margin-top: 0.5rem; 28 | margin-bottom: -1.25rem; 29 | text-align: center; 30 | font-size: 1.25rem; 31 | color: colors.$handbrake-red; 32 | 33 | .bi { 34 | margin-right: 0.5rem; 35 | } 36 | } 37 | 38 | .path-input { 39 | .input-label { 40 | min-width: 100%; 41 | } 42 | 43 | .input-section { 44 | flex-flow: row wrap; 45 | row-gap: 0.5rem; 46 | column-gap: 1rem; 47 | } 48 | } 49 | } 50 | } 51 | 52 | .input-label { 53 | font-size: 1.35rem; 54 | color: colors.$text-color-tertiary; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/src/sections/settings/settings.tsx: -------------------------------------------------------------------------------- 1 | import Section from 'components/section/section'; 2 | import './settings.scss'; 3 | import { useOutletContext } from 'react-router-dom'; 4 | import { PrimaryOutletContextType } from 'pages/primary/primary-context'; 5 | import { useEffect, useState } from 'react'; 6 | import SubSection from 'components/section/sub-section'; 7 | import ButtonInput from 'components/base/inputs/button/button-input'; 8 | import SettingsPaths from './sub-sections/settings-paths'; 9 | import SettingsPreset from './sub-sections/settings-presets'; 10 | import ToggleInput from 'components/base/inputs/toggle/toggle-input'; 11 | import SettingsApplication from './sub-sections/settings-application'; 12 | 13 | export default function SettingsSection() { 14 | const { config, socket } = useOutletContext(); 15 | const [currentConfig, setCurrentConfig] = useState(config); 16 | const [canSave, setCanSave] = useState(false); 17 | const [pathsValid, setPathsValid] = useState(true); 18 | 19 | useEffect(() => { 20 | const configUpdated = JSON.stringify(config) != JSON.stringify(currentConfig); 21 | setCanSave(configUpdated && pathsValid); 22 | }, [config, currentConfig, pathsValid]); 23 | 24 | const handleSaveConfig = () => { 25 | socket.emit('config-update', currentConfig); 26 | }; 27 | 28 | const handleAutoFixChange = (value: boolean) => { 29 | setCurrentConfig({ 30 | ...currentConfig, 31 | config: { 32 | 'auto-fix': value, 33 | }, 34 | }); 35 | }; 36 | 37 | return ( 38 |
39 | 40 | 46 | 52 | 53 |
54 | 59 | 60 | 61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /client/src/sections/settings/sub-sections/settings-application.tsx: -------------------------------------------------------------------------------- 1 | import NumberInput from 'components/base/inputs/number/number-input'; 2 | import SubSection from 'components/section/sub-section'; 3 | import { ConfigType, ConfigVersionType } from 'types/config'; 4 | 5 | type Params = { 6 | config: ConfigType; 7 | setConfig: React.Dispatch>; 8 | }; 9 | 10 | export default function SettingsApplication({ config, setConfig }: Params) { 11 | const updateVersionConfigProperty = ( 12 | key: K, 13 | value: ConfigVersionType[K] 14 | ) => { 15 | setConfig({ ...config, version: { ...config.version, [key]: value } }); 16 | }; 17 | 18 | return ( 19 | 20 | updateVersionConfigProperty('check-interval', value)} 25 | /> 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /client/src/sections/settings/sub-sections/settings-paths.tsx: -------------------------------------------------------------------------------- 1 | import PathInput from 'components/base/inputs/path/path-input'; 2 | import SubSection from 'components/section/sub-section'; 3 | import { useState } from 'react'; 4 | import { ConfigPathsType, ConfigType } from 'types/config'; 5 | import { FileBrowserMode } from 'types/file-browser'; 6 | 7 | type Params = { 8 | config: ConfigType; 9 | setConfig: React.Dispatch>; 10 | setValid: React.Dispatch>; 11 | }; 12 | 13 | const InvalidWarning = ({ name }: { name: string }) => { 14 | return ( 15 |
16 | 17 | Error: '{name}' needs to be a child of your 'Root Media Path'. 18 |
19 | ); 20 | }; 21 | 22 | export default function SettingsPaths({ config, setConfig, setValid }: Params) { 23 | const [validPaths, setValidPaths] = useState({ 24 | 'input-path': true, 25 | 'output-path': true, 26 | }); 27 | 28 | const updatePathProperty = ( 29 | key: K, 30 | value: ConfigPathsType[K] 31 | ) => { 32 | setConfig({ ...config, paths: { ...config.paths, [key]: value } }); 33 | }; 34 | 35 | const checkPathsValid = (paths: ConfigPathsType) => { 36 | const mediaPathRegex = new RegExp(`^${paths['media-path']}`); 37 | console.log(mediaPathRegex); 38 | 39 | const inputPathValid = paths['input-path'].match(mediaPathRegex); 40 | const outputPathValid = paths['output-path'].match(mediaPathRegex); 41 | const newValidPaths = { 42 | 'input-path': inputPathValid ? true : false, 43 | 'output-path': outputPathValid || !paths['output-path'] ? true : false, 44 | }; 45 | 46 | setValid(Object.values(newValidPaths).every((value) => value)); 47 | setValidPaths(newValidPaths); 48 | }; 49 | 50 | return ( 51 | 52 | { 61 | updatePathProperty('media-path', item.path); 62 | checkPathsValid({ 63 | 'media-path': item.path, 64 | 'input-path': config.paths['input-path'], 65 | 'output-path': config.paths['output-path'], 66 | }); 67 | }} 68 | /> 69 | {!validPaths['input-path'] && } 70 | { 79 | updatePathProperty('input-path', item.path); 80 | checkPathsValid({ 81 | 'media-path': config.paths['media-path'], 82 | 'input-path': item.path, 83 | 'output-path': config.paths['output-path'], 84 | }); 85 | }} 86 | /> 87 | {!validPaths['output-path'] && } 88 | 89 | updatePathProperty('output-path', value)} 99 | onConfirm={(item) => { 100 | updatePathProperty('output-path', item.path); 101 | checkPathsValid({ 102 | 'media-path': config.paths['media-path'], 103 | 'input-path': config.paths['input-path'], 104 | 'output-path': item.path, 105 | }); 106 | }} 107 | /> 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /client/src/sections/settings/sub-sections/settings-presets.tsx: -------------------------------------------------------------------------------- 1 | import ToggleInput from 'components/base/inputs/toggle/toggle-input'; 2 | import SubSection from 'components/section/sub-section'; 3 | import { ConfigPresetsType, ConfigType } from 'types/config'; 4 | 5 | type Params = { 6 | config: ConfigType; 7 | setConfig: React.Dispatch>; 8 | }; 9 | 10 | export default function SettingsPreset({ config, setConfig }: Params) { 11 | const updatePresetsConfigProperty = ( 12 | key: K, 13 | value: ConfigPresetsType[K] 14 | ) => { 15 | setConfig({ ...config, presets: { ...config.presets, [key]: value } }); 16 | }; 17 | 18 | return ( 19 | 20 | updatePresetsConfigProperty('show-default-presets', value)} 25 | /> 26 | updatePresetsConfigProperty('allow-preset-creator', value)} 31 | disabled 32 | /> 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /client/src/sections/watchers/watchers.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section#watchers { 4 | .sub-section#watchers-status { 5 | display: flex; 6 | flex-flow: row wrap; 7 | justify-content: space-between; 8 | align-items: center; 9 | 10 | .watcher-count { 11 | font-size: 2rem; 12 | } 13 | } 14 | 15 | .sub-section#registered-watchers { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/sections/watchers/watchers.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useOutletContext } from 'react-router-dom'; 3 | import { PrimaryOutletContextType } from 'pages/primary/primary-context'; 4 | import ButtonInput from 'components/base/inputs/button/button-input'; 5 | import RegisterWatcher from 'components/overlays/register-watcher/register-watcher'; 6 | import Section from 'components/section/section'; 7 | import SubSection from 'components/section/sub-section'; 8 | import WatcherCard from 'components/cards/watcher-card/watcher-card'; 9 | import './watchers.scss'; 10 | import { WatcherRuleDefinitionType } from 'types/watcher'; 11 | 12 | export default function WatchersSection() { 13 | const { socket, watchers } = useOutletContext(); 14 | 15 | const [showRegisterOverlay, setShowRegisterOverlay] = useState(false); 16 | 17 | const handleNewWatcher = () => { 18 | setShowRegisterOverlay(true); 19 | }; 20 | 21 | const handleRemoveWatcher = (rowid: number) => { 22 | socket.emit('remove-watcher', rowid); 23 | }; 24 | 25 | const handleAddRule = (id: number, rule: WatcherRuleDefinitionType) => { 26 | socket.emit('add-watcher-rule', id, rule); 27 | }; 28 | 29 | const handleUpdateRule = (id: number, rule: WatcherRuleDefinitionType) => { 30 | socket.emit('update-watcher-rule', id, rule); 31 | }; 32 | 33 | const handleRemoveRule = (id: number) => { 34 | socket.emit('remove-watcher-rule', id); 35 | }; 36 | 37 | const watcherIDs = Object.keys(watchers).map((id) => parseInt(id)); 38 | 39 | return ( 40 |
41 | 42 |
Watchers: {watcherIDs.length}
43 | 49 |
50 | {watcherIDs.length > 0 && ( 51 | 52 | {watcherIDs.map((watcherID, index) => ( 53 | 63 | ))} 64 | 65 | )} 66 | {showRegisterOverlay && ( 67 | setShowRegisterOverlay(false)} /> 68 | )} 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /client/src/sections/workers/sub-sections/workers-status.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section#workers { 4 | .sub-section#status { 5 | .table-scroll { 6 | overflow-x: auto !important; 7 | padding-bottom: 1rem; 8 | } 9 | 10 | .workers-table { 11 | width: 100%; 12 | font-size: 1.75rem; 13 | 14 | thead { 15 | th { 16 | border-bottom: 0.1rem solid colors.$text-color-tertiary; 17 | } 18 | } 19 | 20 | tbody { 21 | tr:hover { 22 | background-color: hsla(0, 0%, 100%, 0.1); 23 | } 24 | td { 25 | padding: 0.5rem 2rem 0.5rem 0.5rem; 26 | vertical-align: middle; 27 | white-space: nowrap; 28 | } 29 | 30 | .status { 31 | .bi { 32 | vertical-align: middle; 33 | font-size: 0.5rem; 34 | margin-right: 0.5rem; 35 | } 36 | } 37 | 38 | .status.idle { 39 | color: colors.$handbrake-yellow; 40 | } 41 | 42 | .status, 43 | .job, 44 | .progress { 45 | text-align: right; 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /client/src/sections/workers/sub-sections/workers-status.tsx: -------------------------------------------------------------------------------- 1 | import ProgressBar from 'components/base/progress/progress-bar'; 2 | import SubSection from 'components/section/sub-section'; 3 | import { WorkerInfo } from '../workers'; 4 | import './workers-status.scss'; 5 | 6 | type Params = { 7 | workerInfo: WorkerInfo; 8 | }; 9 | 10 | export default function WorkersStatus({ workerInfo }: Params) { 11 | return ( 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {Object.keys(workerInfo).map((worker) => ( 25 | 26 | 27 | 37 | 38 | 47 | 48 | ))} 49 | 50 |
IDStatusJobProgress
{worker} 34 | 35 | {workerInfo[worker].status} 36 | {workerInfo[worker].job} 39 | {workerInfo[worker].job != 'N/A' ? ( 40 | 43 | ) : ( 44 | workerInfo[worker].progress 45 | )} 46 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /client/src/sections/workers/sub-sections/workers-summary.scss: -------------------------------------------------------------------------------- 1 | @use '@style/modules/colors'; 2 | 3 | .section#workers { 4 | .sub-section#summary { 5 | .summary-info { 6 | display: flex; 7 | flex-flow: row wrap; 8 | justify-content: space-between; 9 | gap: 1rem; 10 | 11 | font-size: 2rem; 12 | } 13 | 14 | .info { 15 | padding: 1rem 2rem; 16 | flex-grow: 1; 17 | border-radius: 1rem; 18 | background-color: hsla(0, 0%, 100%, 0.1); 19 | text-align: center; 20 | } 21 | 22 | .info.total { 23 | color: colors.$handbrake-blue; 24 | } 25 | 26 | .info.idle { 27 | color: colors.$handbrake-yellow; 28 | } 29 | 30 | .info.active { 31 | color: colors.$handbrake-green; 32 | } 33 | 34 | .info.jobs { 35 | color: colors.$text-color-tertiary; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/sections/workers/sub-sections/workers-summary.tsx: -------------------------------------------------------------------------------- 1 | import { QueueType } from 'types/queue'; 2 | import { TranscodeStage } from 'types/transcode'; 3 | import SubSection from 'components/section/sub-section'; 4 | import { WorkerInfo } from '../workers'; 5 | 6 | import './workers-summary.scss'; 7 | 8 | type Params = { 9 | workerInfo: WorkerInfo; 10 | queue: QueueType; 11 | }; 12 | 13 | export default function WorkersSummary({ workerInfo, queue }: Params) { 14 | return ( 15 | 16 |
17 |
18 | Total Workers: 19 | {Object.keys(workerInfo).length} 20 |
21 |
22 | Idle Workers: 23 | 24 | { 25 | Object.values(workerInfo).filter((worker) => worker.status == 'Idle') 26 | .length 27 | } 28 | 29 |
30 |
31 | Active Workers: 32 | 33 | { 34 | Object.values(workerInfo).filter((worker) => worker.status == 'Working') 35 | .length 36 | } 37 | 38 |
39 |
40 | Available Jobs: 41 | 42 | { 43 | Object.values(queue).filter( 44 | (job) => 45 | job.status.transcode_stage != 46 | (TranscodeStage.Finished || TranscodeStage.Transcoding) 47 | ).length 48 | } 49 | 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /client/src/sections/workers/workers.scss: -------------------------------------------------------------------------------- 1 | .section#workers { 2 | } 3 | -------------------------------------------------------------------------------- /client/src/sections/workers/workers.tsx: -------------------------------------------------------------------------------- 1 | import { useOutletContext } from 'react-router-dom'; 2 | import { PrimaryOutletContextType } from 'pages/primary/primary-context'; 3 | import Section from 'components/section/section'; 4 | import WorkersSummary from './sub-sections/workers-summary'; 5 | import WorkersStatus from './sub-sections/workers-status'; 6 | import './workers.scss'; 7 | 8 | export type WorkerInfo = { 9 | [index: string]: { 10 | status: string; 11 | job: string; 12 | progress: string; 13 | }; 14 | }; 15 | 16 | export default function WorkersSection() { 17 | const { connections, queue } = useOutletContext(); 18 | 19 | const workerInfo = connections.workers.reduce((prev: WorkerInfo, cur) => { 20 | const job = Object.values(queue).find((job) => job.status.worker_id == cur.workerID); 21 | prev[cur.workerID] = { 22 | status: job ? 'Working' : 'Idle', 23 | job: job ? job.data.input_path : 'N/A', 24 | progress: 25 | job && job.status.transcode_percentage 26 | ? (job.status.transcode_percentage * 100).toFixed(2) 27 | : 'N/A', 28 | }; 29 | 30 | return prev; 31 | }, {}); 32 | 33 | return ( 34 |
35 | 36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /client/src/style/modules/_colors.scss: -------------------------------------------------------------------------------- 1 | $background-primary: hsl(0, 0%, 5%); 2 | $background-secondary: hsl(0, 0%, 7.5%); 3 | $background-tertiary: hsl(0, 0%, 10%); 4 | $background-four: hsl(0, 0%, 15%); 5 | $background-five: hsl(0, 0%, 20%); 6 | $background-six: hsl(0, 0%, 25%); 7 | 8 | $text-color-primary: hsl(0, 0%, 90%); 9 | $text-color-secondary: hsl(0, 0%, 80%); 10 | $text-color-tertiary: hsl(0, 0%, 70%); 11 | $text-color-disabled: hsl(0, 0%, 50%); 12 | 13 | $handbrake-red: #ca1a0d; 14 | $handbrake-orange: #ff691c; 15 | $handbrake-yellow: #d09e00; 16 | $handbrake-green: #508f0b; 17 | $handbrake-blue: #4853ec; 18 | $handbrake-magenta: #e64fb4; 19 | 20 | $transparent-light-primary: rgba(255, 255, 255, 0.1); 21 | $transparent-light-secondary: rgba(255, 255, 255, 0.05); 22 | $transparent-light-tertiary: rgba(255, 255, 255, 0.025); 23 | 24 | $transparent-dark-primary: hsla(0, 0%, 0%, 0.75); 25 | $transparent-dark-secondary: hsla(0, 0%, 0%, 0.5); 26 | 27 | .color-red { 28 | color: $handbrake-red; 29 | } 30 | 31 | .color-orange { 32 | color: $handbrake-orange; 33 | } 34 | 35 | .color-yellow { 36 | color: $handbrake-yellow; 37 | } 38 | 39 | .color-green { 40 | color: $handbrake-green; 41 | } 42 | 43 | .color-blue { 44 | color: $handbrake-blue; 45 | } 46 | 47 | .color-magenta { 48 | color: $handbrake-magenta; 49 | } 50 | -------------------------------------------------------------------------------- /client/src/style/modules/_font.scss: -------------------------------------------------------------------------------- 1 | $font-family-primary: 'Noto Sans', sans-serif; 2 | $font-family-secondary: 'Inter', sans-serif; 3 | 4 | h1, 5 | h2, 6 | h3, 7 | h4, 8 | h5, 9 | h6 { 10 | font-family: $font-family-secondary; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/style/modules/_form.scss: -------------------------------------------------------------------------------- 1 | @use './colors'; 2 | @use './font'; 3 | 4 | .form-item { 5 | padding: 0.5rem 1rem; 6 | 7 | font-family: font.$font-family-primary; 8 | font-size: 1.5rem; 9 | 10 | border-radius: 0.75rem; 11 | border: 0.1rem solid colors.$text-color-primary; 12 | 13 | background-color: transparent; 14 | color: colors.$text-color-primary; 15 | 16 | &:disabled, 17 | &.disabled { 18 | color: colors.$text-color-disabled; 19 | } 20 | 21 | &:empty::after { 22 | content: '\200b'; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/style/modules/_general.scss: -------------------------------------------------------------------------------- 1 | .no-scroll-y { 2 | overflow-y: hidden !important; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/style/modules/_headings.scss: -------------------------------------------------------------------------------- 1 | h1, 2 | h2, 3 | h3, 4 | h4, 5 | h5, 6 | h6 { 7 | &.no-margin { 8 | margin: 0; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/style/modules/_sizing.scss: -------------------------------------------------------------------------------- 1 | $header-height: 5rem; 2 | 3 | $sidebar-width: 30rem; 4 | 5 | $small-min-width: 375px; 6 | $small-max-width: 767px; 7 | 8 | $medium-min-width: 768px; 9 | $medium-max-width: 1439px; 10 | 11 | $large-min-width: 1440px; 12 | -------------------------------------------------------------------------------- /client/src/style/modules/_table.scss: -------------------------------------------------------------------------------- 1 | .table-scroll { 2 | max-width: 100%; 3 | overflow-x: scroll; 4 | padding-bottom: 2rem; 5 | 6 | table { 7 | width: 100%; 8 | 9 | th, 10 | td { 11 | white-space: nowrap; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const APP_VERSION: string; 4 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "emitDeclarationOnly": true, 18 | // "noEmit": true, 19 | "jsx": "react-jsx", 20 | 21 | /* Linting */ 22 | "strict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true, 26 | 27 | "baseUrl": ".", 28 | "paths": { 29 | "@style/*": ["./src/style/*"], 30 | "components/*": ["./src/components/*"], 31 | "pages/*": ["./src/pages/*"], 32 | "sections/*": ["./src/sections/*"], 33 | "types/*": ["../shared/types/*"], 34 | "dict/*": ["../shared/dict/*"], 35 | "funcs/*": ["../shared/funcs/*"] 36 | } 37 | }, 38 | "references": [ 39 | { 40 | "path": "../shared" 41 | } 42 | ], 43 | "include": ["src"] 44 | } 45 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import path from 'path'; 3 | import { env } from 'process'; 4 | import { defineConfig } from 'vite'; 5 | import tsconfigPaths from 'vite-tsconfig-paths'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | build: { 10 | outDir: './build', 11 | emptyOutDir: true, 12 | }, 13 | plugins: [react(), tsconfigPaths()], 14 | resolve: { 15 | alias: { 16 | '@style': path.resolve(__dirname, './src/style'), 17 | components: path.resolve(__dirname, './src/components'), 18 | pages: path.resolve(__dirname, './src/pages'), 19 | sections: path.resolve(__dirname, './src/sections'), 20 | types: path.resolve(__dirname, '../shared/types'), 21 | dict: path.resolve(__dirname, '../shared/dict'), 22 | funcs: path.resolve(__dirname, '../shared/funcs'), 23 | }, 24 | }, 25 | server: { 26 | host: '127.0.0.1', 27 | port: 5173, 28 | }, 29 | define: { 30 | APP_VERSION: JSON.stringify(env.npm_package_version), 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /docs/Home.md: -------------------------------------------------------------------------------- 1 | # HandBrake Web Wiki 2 | 3 | > [!IMPORTANT] 4 | > Documentation is currently limited/non-existent. If you need clarification on how something works, please feel free to open up an issue or discussion. Creating documentation is planned, and contributions/PRs are welcome! 5 | 6 | ### Quick Links 7 | 8 | `There's nothing here currently...` 9 | -------------------------------------------------------------------------------- /images/readme/readme-preset-export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/readme/readme-preset-export.png -------------------------------------------------------------------------------- /images/readme/readme-preset-save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/readme/readme-preset-save.png -------------------------------------------------------------------------------- /images/readme/readme-preset-upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/readme/readme-preset-upload.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/screenshots/screenshot-dashboard.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-presets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/screenshots/screenshot-presets.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/screenshots/screenshot-queue.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/screenshots/screenshot-settings.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-watchers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/screenshots/screenshot-watchers.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-workers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNickOfTime/handbrake-web/091f4b07e90b62332d74f4642a189f689bf88614/images/screenshots/screenshot-workers.png -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build client ------------------------------------------------------------------------------------- 2 | FROM node:alpine AS client-build 3 | COPY client /handbrake-web/client 4 | COPY shared /handbrake-web/shared 5 | WORKDIR /handbrake-web/client 6 | 7 | RUN npm install 8 | RUN npm run build 9 | 10 | # Main --------------------------------------------------------------------------------------------- 11 | FROM node:alpine3.20 AS main 12 | 13 | COPY --from=client-build /handbrake-web/client/build /handbrake-web/client 14 | COPY server /handbrake-web/server 15 | COPY shared /handbrake-web/shared 16 | WORKDIR /handbrake-web/server 17 | 18 | # Install node dependencies 19 | ENV NODE_ENV=production 20 | RUN npm install 21 | 22 | # Create directories 23 | RUN mkdir /data && chown node /data && mkdir /video && chown node /data 24 | 25 | # Default environment variables & ports 26 | EXPOSE 9999 27 | ENV HANDBRAKE_MODE=server 28 | ENV DATA_PATH=/data 29 | ENV VIDEO_PATH=/video 30 | 31 | # Dumb-init 32 | RUN apk add dumb-init 33 | 34 | # Start application 35 | ENTRYPOINT ["/usr/bin/dumb-init", "--"] 36 | CMD ["npm", "run", "prod"] -------------------------------------------------------------------------------- /server/Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .git 3 | .github 4 | 5 | **/node_modules 6 | **/build 7 | 8 | /data 9 | /images 10 | /temp 11 | /video 12 | /worker 13 | 14 | .env 15 | .gitignore 16 | .prettierrc 17 | config.yaml 18 | sample.env -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "handbrake-web", 3 | "version": "0.8.0", 4 | "description": "HandBrake Web", 5 | "scripts": { 6 | "dev": "tsx watch src/server.ts", 7 | "prod": "tsx src/server.ts", 8 | "build": "tsc --project ./tsconfig.json" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://git.goodforyou.games/ncunningham/handbrake-web.git" 13 | }, 14 | "dependencies": { 15 | "better-sqlite3": "^11.9.0", 16 | "chokidar": "^4.0.3", 17 | "compare-versions": "^6.1.1", 18 | "cors": "^2.8.5", 19 | "dotenv": "^16.4.5", 20 | "express": "^4.21.1", 21 | "ffprobe": "^1.1.2", 22 | "ffprobe-static": "^3.1.0", 23 | "mime": "^4.0.3", 24 | "object-hash": "^3.0.0", 25 | "socket.io": "^4.8.0", 26 | "tsconfig-paths": "^4.2.0", 27 | "tsx": "^4.16.2", 28 | "winston": "^3.14.2", 29 | "winston-daily-rotate-file": "^5.0.0", 30 | "yaml": "^2.4.5" 31 | }, 32 | "devDependencies": { 33 | "@types/better-sqlite3": "^7.6.11", 34 | "@types/express": "^5.0.0", 35 | "@types/ffprobe": "^1.1.8", 36 | "@types/ffprobe-static": "^2.0.3", 37 | "@types/object-hash": "^3.0.6", 38 | "@typescript-eslint/eslint-plugin": "^8.26.1", 39 | "@typescript-eslint/parser": "^8.26.1", 40 | "eslint": "^9.22.0", 41 | "typescript": "^5.8.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/src/html/development/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | HandBrake Web 7 | 43 | 44 | 45 |
46 |
47 |

Nothing to see here...

48 |

49 | During development, please access the client interface via the command 50 | npm run client. 51 |

52 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /server/src/routes/client.ts: -------------------------------------------------------------------------------- 1 | import express, { Express, Request } from 'express'; 2 | import { GetJobLogByID } from 'logging'; 3 | import path from 'path'; 4 | 5 | export default function ClientRoutes(app: Express) { 6 | const clientBuildPath = path.join('/handbrake-web/client'); 7 | const isProduction = process.env.NODE_ENV == 'production'; 8 | 9 | if (isProduction) { 10 | app.use(express.static(clientBuildPath)); 11 | } 12 | 13 | app.get('/logs/jobs', async (req: Request<{}, {}, {}, { id: number }>, res) => { 14 | const id = req.query.id; 15 | if (id) { 16 | const log = await GetJobLogByID(id); 17 | if (log) { 18 | res.download(log); 19 | } else { 20 | res.end(); 21 | } 22 | } 23 | }); 24 | 25 | app.get('*', (req, res) => { 26 | const htmlPath = isProduction 27 | ? path.join(clientBuildPath, '/index.html') 28 | : path.join(__dirname, '../html/development/index.html'); 29 | res.sendFile(htmlPath, (err) => { 30 | if (err) { 31 | console.error(err); 32 | } 33 | }); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /server/src/scripts/connections.ts: -------------------------------------------------------------------------------- 1 | import logger from 'logging'; 2 | import { Socket } from 'socket.io'; 3 | 4 | type Client = Socket; 5 | 6 | type Worker = Socket; 7 | 8 | type Connections = { 9 | clients: Client[]; 10 | workers: Worker[]; 11 | }; 12 | 13 | const connections: Connections = { 14 | clients: [], 15 | workers: [], 16 | }; 17 | 18 | export function AddClient(client: Client) { 19 | connections.clients.push(client); 20 | updateConnections(); 21 | } 22 | 23 | export function RemoveClient(client: Client) { 24 | connections.clients.splice(connections.clients.indexOf(client)); 25 | updateConnections(); 26 | } 27 | 28 | export function AddWorker(worker: Worker) { 29 | connections.workers.push(worker); 30 | updateConnections(); 31 | } 32 | 33 | export function RemoveWorker(worker: Worker) { 34 | connections.workers.splice(connections.workers.indexOf(worker)); 35 | updateConnections(); 36 | } 37 | 38 | export function GetWorkers() { 39 | return connections.workers; 40 | } 41 | 42 | export function GetWorkerIDs() { 43 | return connections.workers.map((worker) => worker.id); 44 | } 45 | 46 | export function GetWorkerWithID(id: string) { 47 | return connections.workers.find((worker) => GetWorkerID(worker) == id); 48 | } 49 | 50 | export function GetWorkerID(worker: Worker) { 51 | return worker.handshake.query['workerID'] as string; 52 | } 53 | 54 | export function EmitToAllClients(event: string, data?: any) { 55 | connections.clients.forEach((client) => { 56 | client.emit(event, data); 57 | }); 58 | } 59 | 60 | export function EmitToAllWorkers(event: string, data?: any) { 61 | connections.workers.forEach((worker) => { 62 | worker.emit(event, data); 63 | }); 64 | } 65 | 66 | export function EmitToWorkerWithID(workerID: string, event: string, data?: any) { 67 | const worker = GetWorkerWithID(workerID); 68 | if (worker) { 69 | worker.emit(event, data); 70 | } else { 71 | logger.error( 72 | `[server] [error] Could not find a worker with id '${workerID}'. Could not emit event '${event}'.` 73 | ); 74 | } 75 | } 76 | 77 | export function EmitToAllConnections(event: string, data?: any) { 78 | EmitToAllClients(event, data); 79 | EmitToAllWorkers(event, data); 80 | } 81 | 82 | const updateConnections = () => { 83 | // logger.info(connections); 84 | const clients = connections.clients.map((client) => client.id); 85 | const workers = connections.workers.map((worker) => ({ 86 | workerID: GetWorkerID(worker), 87 | connectionID: worker.id, 88 | })); 89 | const data = { 90 | clients: clients, 91 | workers: workers, 92 | }; 93 | EmitToAllClients('connections-update', data); 94 | }; 95 | -------------------------------------------------------------------------------- /server/src/scripts/data.ts: -------------------------------------------------------------------------------- 1 | import { access, constants } from 'fs/promises'; 2 | import path from 'path'; 3 | 4 | export const dataPath = process.env.DATA_PATH || path.join(__dirname, '../../data'); 5 | 6 | export async function CheckDataDirectoryPermissions() { 7 | try { 8 | let error = false; 9 | 10 | // Check read permissions 11 | try { 12 | await access(dataPath, constants.R_OK); 13 | } catch (err) { 14 | console.error( 15 | `\x1b[2m${new Date(Date.now()).toLocaleTimeString('en-US', { 16 | hour12: false, 17 | })}\x1B[22m \x1b[31m[data] [error]\x1B[39m The application does not have read permissions for the directory '${dataPath}':` 18 | ); 19 | console.error(err); 20 | error = true; 21 | } 22 | 23 | try { 24 | await access(dataPath, constants.W_OK); 25 | } catch (err) { 26 | console.error( 27 | `\x1b[2m${new Date(Date.now()).toLocaleTimeString('en-US', { 28 | hour12: false, 29 | })}\x1B[22m \x1b[31m[data] [error]\x1B[39m The application does not have write permissions for the directory '${dataPath}':` 30 | ); 31 | console.error(err); 32 | error = true; 33 | } 34 | 35 | if (error) { 36 | throw new Error(); 37 | } 38 | } catch (error) { 39 | // shut down application 40 | console.error( 41 | `\x1b[2m${new Date(Date.now()).toLocaleTimeString('en-US', { 42 | hour12: false, 43 | })}\x1B[22m \x1b[31m[data] [error]\x1B[39m The application does not have adequate permissions for '${dataPath}'.` 44 | ); 45 | console.error( 46 | `\x1b[2m${new Date(Date.now()).toLocaleTimeString('en-US', { 47 | hour12: false, 48 | })}\x1B[22m \x1b[31m[data] [error]\x1B[39m Did you create the directories you mapped in the docker compose file prior to the creation of the container?\n\tIf not, docker creates these directories for you with root permissions.\n\tPlease run 'chown' or otherwise modify permissions to have read & write access for the user you are running this container as.` 49 | ); 50 | console.error( 51 | `\x1b[2m${new Date(Date.now()).toLocaleTimeString('en-US', { 52 | hour12: false, 53 | })}\x1B[22m \x1b[31m[data] [error]\x1B[39m The application cannot run without proper permissions for the data folder. The application will now shutdown.` 54 | ); 55 | 56 | process.exit(0); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/src/scripts/database/database-status.ts: -------------------------------------------------------------------------------- 1 | import { StatusTableType } from 'types/database'; 2 | import logger from 'logging'; 3 | import { database } from './database'; 4 | 5 | // export function InitializeStatus() { 6 | // const; 7 | // } 8 | 9 | export function GetStatusFromDatabase(id: string) { 10 | try { 11 | const statusStatement = database.prepare<{ id: string }, StatusTableType>( 12 | 'SELECT state FROM status WHERE id = $id' 13 | ); 14 | const statusQuery = statusStatement.get({ id: id }); 15 | return statusQuery; 16 | } catch (err) { 17 | logger.error(`[server] [database] [error] Could not get the status of '${id}'.`); 18 | logger.error(err); 19 | } 20 | } 21 | 22 | export function UpdateStatusInDatabase(id: string, state: number) { 23 | try { 24 | const updateStatement = database.prepare( 25 | 'INSERT INTO status (id, state) VALUES ($id, $state) ON CONFLICT (id) DO UPDATE SET state = $state' 26 | ); 27 | updateStatement.run({ id: id, state: state }); 28 | // logger.info(`[server] [database] Sucessfully updated the status of '${id}' to '${state}'.`); 29 | } catch (err) { 30 | logger.error(`[server] [database] [error] Could not update the status of '${id}'.`); 31 | logger.error(err); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/scripts/database/migrations/database-migration-0.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'better-sqlite3'; 2 | import logger from 'logging'; 3 | 4 | export default function DatabaseMigration0(database: Database) { 5 | const tables = database 6 | .prepare<[], { name: string }>( 7 | `SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'` 8 | ) 9 | .all() 10 | .map((result) => result.name); 11 | if (!tables.includes('database_version')) { 12 | const transaction = database.transaction(() => { 13 | const tableCreateStatement = database.prepare( 14 | 'CREATE TABLE IF NOT EXISTS database_version(version INT NOT NULL PRIMARY KEY)' 15 | ); 16 | tableCreateStatement.run(); 17 | logger.info(`[database] [migration-0] Created table 'database_version'.`); 18 | 19 | const insertVersionStatement = database.prepare( 20 | 'INSERT INTO database_version(version) VALUES(0)' 21 | ); 22 | insertVersionStatement.run(); 23 | logger.info( 24 | `[database] [migration-0] Inserted version with value '0' into the table 'database-version'.` 25 | ); 26 | }); 27 | 28 | transaction(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/scripts/database/migrations/database-migrations.ts: -------------------------------------------------------------------------------- 1 | import logger from 'logging'; 2 | import { database, databaseVersion } from '../database'; 3 | import DatabaseMigration0 from './database-migration-0'; 4 | import DatabaseMigration1 from './database-migration-1'; 5 | 6 | export default function DatabaseMigrations(version: number) { 7 | try { 8 | logger.warn( 9 | `[server] [database] [migration] The database_version is out of date, performing migrations for versions ${version} - ${databaseVersion}...` 10 | ); 11 | for (let i = version + 1; i <= databaseVersion; i++) { 12 | RunDatabaseMigration(i); 13 | } 14 | logger.info( 15 | `[server] [database] [migration] Database migrations have completed, the database_version is up to date.` 16 | ); 17 | } catch (error) { 18 | logger.error(`[database] [migration] Could not complete migrations.`); 19 | console.error(error); 20 | } 21 | } 22 | 23 | function RunDatabaseMigration(version: number) { 24 | logger.warn( 25 | `[server] [database] [migration] Running migration script for database_version ${version}...` 26 | ); 27 | 28 | switch (version) { 29 | case 0: 30 | DatabaseMigration0(database); 31 | break; 32 | case 1: 33 | DatabaseMigration1(database); 34 | break; 35 | } 36 | 37 | const upgradeVersionStatement = database.prepare<{ version: number }>( 38 | 'UPDATE database_version SET version = $version' 39 | ); 40 | upgradeVersionStatement.run({ version: version }); 41 | // logger.info( 42 | // `[server] [database] [migration] The database_version has been upgraded to version ${version}.` 43 | // ); 44 | } 45 | -------------------------------------------------------------------------------- /server/src/scripts/media.ts: -------------------------------------------------------------------------------- 1 | import ffprobe from 'ffprobe'; 2 | import ffprobeStatic from 'ffprobe-static'; 3 | 4 | export async function GetMediaInfo(mediaPath: string) { 5 | const mediaInfo = await ffprobe(mediaPath, { path: ffprobeStatic.path }); 6 | return mediaInfo; 7 | } 8 | 9 | export function ConvertBitsToKilobits(bytes: number) { 10 | return bytes / Math.pow(10, 3); 11 | } 12 | 13 | export function ConvertBytesToMegabytes(bytes: number) { 14 | return bytes / Math.pow(10, 6); 15 | } 16 | -------------------------------------------------------------------------------- /server/src/server-shutdown.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'node:http'; 2 | import { Server as SocketServer } from 'socket.io'; 3 | 4 | import { DatabaseDisconnect } from 'scripts/database/database'; 5 | import logger from 'logging'; 6 | 7 | export function RegisterExitListeners(socket: SocketServer) { 8 | process.on('SIGINT', () => { 9 | logger.info( 10 | `[server] [shutdown] The process has been interrupted, HandBrake Web will now begin to shutdown...` 11 | ); 12 | Shutdown(socket); 13 | }); 14 | 15 | process.on('SIGTERM', () => { 16 | logger.info( 17 | `[server] [shutdown] The process has been terminated, HandBrake Web will now begin to shutdown...` 18 | ); 19 | Shutdown(socket); 20 | }); 21 | } 22 | 23 | export default async function Shutdown(socket: SocketServer) { 24 | try { 25 | // Shutdown the socket server 26 | await new Promise((resolve) => { 27 | socket.close((err) => { 28 | if (err) throw err; 29 | resolve(); 30 | }); 31 | }); 32 | 33 | await DatabaseDisconnect(); 34 | logger.info(`[server] [shutdown] Shutdown steps have completed.`); 35 | } catch (error) { 36 | logger.error(`[server] [shutdown] [error] Could not complete shutdown steps.`); 37 | logger.error(error); 38 | } 39 | 40 | process.exit(0); 41 | } 42 | -------------------------------------------------------------------------------- /server/src/server-startup.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http'; 2 | import express from 'express'; 3 | import { Server as SocketServer } from 'socket.io'; 4 | import 'dotenv/config'; 5 | import cors from 'cors'; 6 | 7 | import logger from 'logging'; 8 | import { LoadDefaultPresets, LoadPresets } from 'scripts/presets'; 9 | import { InitializeQueue } from 'scripts/queue'; 10 | import { DatabaseConnect } from 'scripts/database/database'; 11 | import { InitializeWatchers } from 'scripts/watcher'; 12 | import { LoadConfig } from 'scripts/config'; 13 | import ClientRoutes from 'routes/client'; 14 | import ClientSocket from 'socket/client-socket'; 15 | import WorkerSocket from 'socket/worker-socket'; 16 | import { RegisterExitListeners } from './server-shutdown'; 17 | import { CheckForVersionUpdate } from 'scripts/version'; 18 | 19 | export default async function ServerStartup() { 20 | // Config--------------------------------------------------------------------------------------- 21 | await LoadConfig(); 22 | 23 | // Presets ------------------------------------------------------------------------------------- 24 | await LoadDefaultPresets(); 25 | await LoadPresets(); 26 | 27 | // Database ------------------------------------------------------------------------------------ 28 | await DatabaseConnect(); 29 | InitializeQueue(); 30 | InitializeWatchers(); 31 | 32 | // Setup Server -------------------------------------------------------------------------------- 33 | const app = express(); 34 | const server = createServer(app); 35 | const socket = new SocketServer(server, { 36 | cors: { 37 | origin: '*', 38 | }, 39 | pingTimeout: 5000, 40 | }); 41 | 42 | app.use(cors()); 43 | 44 | // Routes ------------------------------------------------------------------------------ 45 | ClientRoutes(app); 46 | 47 | // Socket Listeners -------------------------------------------------------------------- 48 | ClientSocket(socket); 49 | WorkerSocket(socket); 50 | 51 | // Shutdown ------------------------------------------------------------------------------------ 52 | RegisterExitListeners(socket); 53 | 54 | // Start Server -------------------------------------------------------------------------------- 55 | const url = process.env.SERVER_URL || 'http://localhost'; 56 | const port = 9999; 57 | 58 | await new Promise((resolve) => { 59 | server.listen(port, () => { 60 | const hasPrefix = url.match(/^https?:\/\//); 61 | const serverAddress = `${hasPrefix ? url : 'http://' + url}:${port}`; 62 | logger.info(`[server] Available at '${serverAddress}'.`); 63 | resolve(); 64 | }); 65 | socket.attach(server); 66 | }); 67 | 68 | // Check Version ------------------------------------------------------------------------------- 69 | await CheckForVersionUpdate(); 70 | } 71 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import { CheckDataDirectoryPermissions } from 'scripts/data'; 2 | 3 | async function Server() { 4 | // Check critical permissions 5 | await CheckDataDirectoryPermissions(); 6 | 7 | // Startup only occurs if the previous functions ever finish 8 | const startup = await import('./server-startup'); 9 | startup.default(); 10 | } 11 | 12 | Server(); 13 | -------------------------------------------------------------------------------- /server/src/socket/worker-socket.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Server } from 'socket.io'; 3 | import { AddWorker, RemoveWorker } from 'scripts/connections'; 4 | import { TranscodeStage } from 'types/transcode'; 5 | import { GetQueue, StopJob, UpdateQueue, WorkerForAvailableJobs } from 'scripts/queue'; 6 | import { JobDataType, JobStatusType } from 'types/queue'; 7 | import { 8 | GetJobDataFromTable, 9 | GetJobStatusFromTable, 10 | UpdateJobOrderIndexInDatabase, 11 | UpdateJobStatusInDatabase, 12 | } from 'scripts/database/database-queue'; 13 | import { HandbrakePresetType } from 'types/preset'; 14 | import { GetDefaultPresetByName, GetPresetByName, GetPresets } from 'scripts/presets'; 15 | import logger, { logPath, WriteWorkerLogToFile } from 'logging'; 16 | 17 | export default function WorkerSocket(io: Server) { 18 | io.of('/worker').on('connection', (socket) => { 19 | const workerID = socket.handshake.query['workerID'] as string; 20 | 21 | logger.info(`[socket] Worker '${workerID}' has connected with ID '${socket.id}'.`); 22 | AddWorker(socket); 23 | WorkerForAvailableJobs(workerID); 24 | 25 | socket.on('disconnect', () => { 26 | logger.info(`[socket] Worker '${workerID}' with ID '${socket.id}' has disconnected.`); 27 | RemoveWorker(socket); 28 | const queue = GetQueue(); 29 | if (queue) { 30 | const workersJob = Object.keys(queue) 31 | .map((key) => parseInt(key)) 32 | .find((jobID) => queue[jobID].status.worker_id == workerID); 33 | if (workersJob) { 34 | StopJob(workersJob); 35 | logger.info( 36 | `[socket] Disconnected worker '${workerID}' was working on job '${workersJob}' when disconnected - setting job to stopped.` 37 | ); 38 | } 39 | } 40 | }); 41 | 42 | socket.on( 43 | 'get-job-data', 44 | (jobID: number, callback: (jobData: JobDataType | undefined) => void) => { 45 | const jobData = GetJobDataFromTable(jobID); 46 | callback(jobData); 47 | } 48 | ); 49 | 50 | socket.on( 51 | 'get-preset-data', 52 | ( 53 | presetCategory: string, 54 | presetID: string, 55 | callback: (presetData: HandbrakePresetType | undefined) => void 56 | ) => { 57 | const isDefaultPreset = presetCategory.match(/^Default:\s/); 58 | const jobData = isDefaultPreset 59 | ? GetDefaultPresetByName(presetCategory.replace(/Default:\s/, ''), presetID) 60 | : GetPresetByName(presetCategory, presetID); 61 | callback(jobData); 62 | } 63 | ); 64 | 65 | socket.on('transcode-stopped', (job_id: number, status: JobStatusType) => { 66 | logger.info( 67 | `[socket] Worker '${workerID}' with ID '${socket.id}' has stopped transcoding.` 68 | ); 69 | 70 | // StopJob(job_id); 71 | }); 72 | 73 | socket.on('transcode-update', (job_id: number, status: JobStatusType) => { 74 | UpdateJobStatusInDatabase(job_id, status); 75 | UpdateQueue(); 76 | }); 77 | 78 | socket.on('transcode-error', (job_id: number) => { 79 | logger.error( 80 | `[socket] An error has occurred with job '${job_id}'. The job will be stopped and it's state set to 'Error'.` 81 | ); 82 | 83 | StopJob(job_id, true); 84 | }); 85 | 86 | socket.on('transcode-finished', (job_id: number, status: JobStatusType) => { 87 | UpdateJobStatusInDatabase(job_id, status); 88 | UpdateJobOrderIndexInDatabase(job_id, 0); 89 | UpdateQueue(); 90 | WorkerForAvailableJobs(workerID); 91 | }); 92 | 93 | socket.on('send-log', (logName: string, logContents: string) => { 94 | logger.info( 95 | `[socket] Worker '${workerID}' has sent the log '${logName}' to be saved to '${logPath}'.` 96 | ); 97 | WriteWorkerLogToFile(workerID, logName, logContents); 98 | }); 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /server/src/template/config.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | auto-fix: true 3 | paths: 4 | media-path: '/video' 5 | input-path: '/video' 6 | output-path: '' 7 | presets: 8 | show-default-presets: true 9 | allow-preset-creator: false 10 | version: 11 | check-interval: 12 12 | -------------------------------------------------------------------------------- /shared/dict/presets.dict.ts: -------------------------------------------------------------------------------- 1 | import { ClientLookupDict } from '../types/dict'; 2 | 3 | export const PresetFormatDict: ClientLookupDict = { 4 | av_mp4: '.mp4', 5 | av_mkv: '.mkv', 6 | av_webm: '.webm', 7 | }; 8 | 9 | export const PresetEncoderDict: ClientLookupDict = { 10 | svt_av1: 'AV1 (SVT)', 11 | svt_av1_10bit: 'AV1 10-bit (SVT)', 12 | x264: 'H.264 (x264)', 13 | x264_10bit: 'H.264 10-bit (x264)', 14 | nvenc_h264: 'H.264 (NVEnc)', 15 | x265: 'H.256 (x265)', 16 | x265_10bit: 'H.256 10-bit (x265)', 17 | x265_12bit: 'H.256 12-bit (x265)', 18 | nvenc_h265: 'H.256 (NVEnc)', 19 | nvenc_h265_10bit: 'H.256 (NVEnc)', 20 | mpeg4: 'MPEG-4', 21 | mpeg2: 'MPEG-2', 22 | VP8: 'VP8', 23 | VP9: 'VP9', 24 | VP9_10bit: 'VP9 10-bit', 25 | theora: 'theora', 26 | }; 27 | 28 | export const PresetAudioEncoderDict: ClientLookupDict = { 29 | copy: 'Auto Passthru', 30 | 'copy:aac': 'AAC Passthru', 31 | 'copy:ac3': 'AC3 Passthru', 32 | 'copy:eac3': 'E-AC3 Passthru', 33 | 'copy:truehd': 'TrueHD Passthru', 34 | 'copy:dts': 'DTS Passthru', 35 | 'copy:dtshd': 'DTS-HD Passthru', 36 | 'copy:mp2': 'MP2 Passthru', 37 | 'copy:mp3': 'MP3 Passthru', 38 | 'copy:flac': 'FLAC Passthru', 39 | 'copy:opus': 'Opus Passthru', 40 | ac3: 'AC3', 41 | av_aac: 'AAC (avcodec)', 42 | eac3: 'E-AC3', 43 | }; 44 | 45 | export const PresetPropertiesDict: ClientLookupDict = { 46 | // general 47 | off: 'Off', 48 | none: 'None', 49 | default: 'Default', 50 | auto: 'Automatic', 51 | custom: 'Custom', 52 | 53 | // filter properties 54 | ultralight: 'Ultralight', 55 | light: 'Light', 56 | medium: 'Medium', 57 | strong: 'Strong', 58 | stronger: 'Stronger', 59 | verystrong: 'Very Strong', 60 | ultrafine: 'Ultrafine', 61 | fine: 'Fine', 62 | coarse: 'Coarse', 63 | verycoarse: 'Very Coarse', 64 | tiny: 'Tiny', 65 | small: 'Small', 66 | large: 'Large', 67 | wide: 'Wide', 68 | verywide: 'Very Wide', 69 | 70 | // filter names 71 | bob: 'Bob', 72 | bt2020: 'BT.2020', 73 | 'bt601-6-525': 'BT.601 SMPTE-C', 74 | 'bt601-6-625': 'BT.601 EBU', 75 | bt709: 'BT.709', 76 | chroma_smooth: 'Chroma Smooth', 77 | deblock: 'Deblock', 78 | decomb: 'Decomb', 79 | detelecine: 'Detelecine', 80 | eedi2: 'EEDI2', 81 | eedi2bob: 'EEDI2 Bob', 82 | grayscale: 'Grayscale', 83 | lapsharp: 'LapSharp', 84 | nlmeans: 'NLMeans', 85 | 'skip-spatial': 'Skip Spatial Check', 86 | unsharp: 'UnSharp', 87 | yadif: 'Yadif', 88 | 89 | //encoder profiles 90 | main: 'Main', 91 | main10: 'Main 10', 92 | baseline: 'Baseline', 93 | high: 'High', 94 | }; 95 | -------------------------------------------------------------------------------- /shared/dict/queue.dict.ts: -------------------------------------------------------------------------------- 1 | import { TranscodeStage } from '../types/transcode'; 2 | 3 | export const statusSorting: { [key in TranscodeStage]: number } = { 4 | [TranscodeStage.Transcoding]: 1, 5 | [TranscodeStage.Scanning]: 2, 6 | [TranscodeStage.Waiting]: 3, 7 | [TranscodeStage.Stopped]: 4, 8 | [TranscodeStage.Error]: 5, 9 | [TranscodeStage.Finished]: 6, 10 | }; 11 | -------------------------------------------------------------------------------- /shared/funcs/locale.funcs.ts: -------------------------------------------------------------------------------- 1 | export function LanguageCodeToName(code: string) { 2 | return new Intl.DisplayNames(['en'], { type: 'language' }).of(code); 3 | } 4 | -------------------------------------------------------------------------------- /shared/funcs/preset.funcs.ts: -------------------------------------------------------------------------------- 1 | import { HandbrakePresetCategoryType } from '../types/preset'; 2 | 3 | export const getPresetCount = (presets: HandbrakePresetCategoryType) => { 4 | return Object.keys(presets).reduce((result, current) => { 5 | return result + Object.keys(presets[current]).length; 6 | }, 0); 7 | }; 8 | -------------------------------------------------------------------------------- /shared/funcs/string.funcs.ts: -------------------------------------------------------------------------------- 1 | export function FirstLetterUpperCase(text: string) { 2 | return text.charAt(0).toUpperCase() + text.slice(1); 3 | } 4 | 5 | export function BooleanToConfirmation(bool: boolean) { 6 | return bool ? 'Yes' : 'No'; 7 | } 8 | 9 | export function EndWithColon(text: string) { 10 | return text.replace(/[:\s]+$/, '') + ':'; 11 | } 12 | -------------------------------------------------------------------------------- /shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "outDir": "./build", 8 | "composite": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /shared/types/config.ts: -------------------------------------------------------------------------------- 1 | export type ConfigType = { 2 | config: ConfigMetaType; 3 | paths: ConfigPathsType; 4 | presets: ConfigPresetsType; 5 | version: ConfigVersionType; 6 | }; 7 | 8 | export type ConfigMetaType = { 9 | 'auto-fix': boolean; 10 | }; 11 | 12 | export type ConfigPathsType = { 13 | 'media-path': string; 14 | 'input-path': string; 15 | 'output-path': string; 16 | }; 17 | 18 | export type ConfigPresetsType = { 19 | 'show-default-presets': boolean; 20 | 'allow-preset-creator': boolean; 21 | }; 22 | 23 | export type ConfigVersionType = { 24 | 'check-interval': number; 25 | }; 26 | 27 | export type ConfigValidationType = { 28 | [Section in keyof ConfigType]: { 29 | isValid: boolean; 30 | properties: { 31 | [Property in keyof ConfigType[Section]]: { 32 | isValid: boolean; 33 | value: ConfigType[Section][Property]; 34 | }; 35 | }; 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /shared/types/database.ts: -------------------------------------------------------------------------------- 1 | export type QueueTableType = { 2 | id: number; 3 | job: string; 4 | }; 5 | 6 | // Jobs -------------------------------------------------------------------------------------------- 7 | export type JobsTableType = { 8 | job_id: number; 9 | input_path: string; 10 | output_path: string; 11 | preset_category: string; 12 | preset_id: string; 13 | }; 14 | 15 | export type JobInsertType = { 16 | [Property in Exclude]: JobsTableType[Property]; 17 | }; 18 | 19 | export type JobsStatusTableType = { 20 | job_id: number; 21 | worker_id: string | null; 22 | transcode_stage: number; 23 | transcode_percentage: number; 24 | transcode_eta: number; 25 | transcode_fps_current: number; 26 | transcode_fps_average: number; 27 | time_started: number; 28 | time_finished: number; 29 | }; 30 | 31 | export type JobStatusInsertType = { 32 | job_id: number; 33 | } & { 34 | [Property in Exclude]?: JobsStatusTableType[Property]; 35 | }; 36 | 37 | export type JobOrderTableType = { 38 | job_id: number; 39 | order_index: number; 40 | }; 41 | 42 | // Queue Status ------------------------------------------------------------------------------------ 43 | export type StatusTableType = { 44 | id: string; 45 | state: number; 46 | }; 47 | 48 | // Watchers ---------------------------------------------------------------------------------------- 49 | export type WatcherTableType = { 50 | watcher_id: number; 51 | watch_path: string; 52 | output_path: string | null; 53 | preset_category: string; 54 | preset_id: string; 55 | // default_mask: number; 56 | }; 57 | 58 | export type WatcherRuleTableType = { 59 | watcher_id: number; 60 | rule_id: number; 61 | name: string; 62 | mask: number; 63 | base_rule_method: number; 64 | rule_method: number; 65 | comparison_method: number; 66 | comparison: string; 67 | }; 68 | -------------------------------------------------------------------------------- /shared/types/dict.ts: -------------------------------------------------------------------------------- 1 | export type ClientLookupDict = { [index: string]: string }; 2 | -------------------------------------------------------------------------------- /shared/types/directory.ts: -------------------------------------------------------------------------------- 1 | export type DirectoryItemType = { 2 | path: string; 3 | name: string; 4 | extension?: string; 5 | isDirectory: boolean; 6 | }; 7 | 8 | export type DirectoryItemsType = DirectoryItemType[]; 9 | 10 | export type DirectoryType = { 11 | parent?: DirectoryItemType; 12 | current: DirectoryItemType; 13 | items: DirectoryItemType[]; 14 | }; 15 | 16 | export type DirectoryRequestType = { 17 | path: string; 18 | isRecursive: boolean; 19 | }; 20 | 21 | export type CreateDirectoryRequestType = { 22 | path: string; 23 | name: string; 24 | }; 25 | -------------------------------------------------------------------------------- /shared/types/file-browser.ts: -------------------------------------------------------------------------------- 1 | export enum FileBrowserMode { 2 | SingleFile, 3 | Directory, 4 | } 5 | -------------------------------------------------------------------------------- /shared/types/file-extensions.ts: -------------------------------------------------------------------------------- 1 | export enum HandbrakeOutputExtensions { 2 | mkv = '.mkv', 3 | mp4 = '.mp4', 4 | } 5 | -------------------------------------------------------------------------------- /shared/types/handbrake.ts: -------------------------------------------------------------------------------- 1 | export type HandbrakeOutputType = { 2 | State: string; 3 | Scanning?: Scanning; 4 | Working?: Working; 5 | Muxing?: Muxing; 6 | WorkDone?: WorkDone; 7 | }; 8 | 9 | export type Scanning = { 10 | Preview: number; 11 | PreviewCount: number; 12 | Progress: number; 13 | SequenceID: number; 14 | Title: number; 15 | TitleCount: number; 16 | }; 17 | 18 | export type Working = { 19 | ETASeconds: number; 20 | Hours: number; 21 | Minutes: number; 22 | Pass: number; 23 | PassCount: number; 24 | PassID: number; 25 | Paused: number; 26 | Progress: number; 27 | Rate: number; 28 | RateAvg: number; 29 | Seconds: number; 30 | SequenceID: number; 31 | }; 32 | 33 | export type Muxing = { 34 | Progress: number; 35 | }; 36 | 37 | export type WorkDone = { 38 | Error: number; 39 | SequenceID: number; 40 | }; 41 | -------------------------------------------------------------------------------- /shared/types/queue.ts: -------------------------------------------------------------------------------- 1 | import { HandbrakePresetType } from './preset'; 2 | import { TranscodeStage } from './transcode'; 3 | 4 | export type QueueRequestType = { 5 | input: string; 6 | output: string; 7 | category: string; 8 | preset: string; 9 | }; 10 | 11 | export type JobType = { 12 | data: JobDataType; 13 | status: JobStatusType; 14 | order_index: number; 15 | }; 16 | 17 | export type JobDataType = { 18 | input_path: string; 19 | output_path: string; 20 | preset_category: string; 21 | preset_id: string; 22 | }; 23 | 24 | export type JobStatusType = { 25 | worker_id?: string | null; 26 | transcode_stage?: TranscodeStage; 27 | transcode_percentage?: number; 28 | transcode_eta?: number; 29 | transcode_fps_current?: number; 30 | transcode_fps_average?: number; 31 | time_started?: number; 32 | time_finished?: number; 33 | }; 34 | 35 | export type QueueEntryType = { 36 | id: number; 37 | job: JobType; 38 | }; 39 | 40 | export type QueueType = { 41 | [index: number]: JobType; 42 | }; 43 | 44 | export enum QueueStatus { 45 | Stopped, 46 | Idle, 47 | Active, 48 | } 49 | -------------------------------------------------------------------------------- /shared/types/socket.ts: -------------------------------------------------------------------------------- 1 | // import { Socket } from 'socket.io'; 2 | 3 | // export type Client = Socket; 4 | 5 | // export type Worker = Socket; 6 | 7 | export type ClientIDType = string; 8 | 9 | export type WorkerIDType = { 10 | workerID: string; 11 | connectionID: string; 12 | }; 13 | 14 | // export type Connections = { 15 | // clients: Client[]; 16 | // workers: Worker[]; 17 | // }; 18 | 19 | export type ConnectionIDsType = { 20 | clients: ClientIDType[]; 21 | workers: WorkerIDType[]; 22 | }; 23 | -------------------------------------------------------------------------------- /shared/types/transcode.ts: -------------------------------------------------------------------------------- 1 | export enum TranscodeStage { 2 | Waiting, 3 | Scanning, 4 | Transcoding, 5 | Finished, 6 | Stopped, 7 | Error, 8 | } 9 | -------------------------------------------------------------------------------- /shared/types/version.ts: -------------------------------------------------------------------------------- 1 | export type GithubReleaseResponseType = { 2 | url: string; 3 | assets_url: string; 4 | upload_url: string; 5 | html_url: string; 6 | id: number; 7 | author: {}; 8 | node_id: string; 9 | tag_name: string; 10 | target_commitish: string; 11 | name: string; 12 | draft: boolean; 13 | prerelease: boolean; 14 | created_at: string; 15 | published_at: string; 16 | assets: []; 17 | tarball_url: string; 18 | zipball_url: string; 19 | body: string; 20 | mentions_count: number; 21 | }; 22 | -------------------------------------------------------------------------------- /shared/types/watcher.ts: -------------------------------------------------------------------------------- 1 | export enum WatcherRuleMaskMethods { 2 | Include, 3 | Exclude, 4 | } 5 | 6 | export enum WatcherRuleBaseMethods { 7 | FileInfo, 8 | MediaInfo, 9 | } 10 | 11 | export enum WatcherRuleFileInfoMethods { 12 | FileName, 13 | FileExtension, 14 | FileSize, 15 | } 16 | 17 | export enum WatcherRuleMediaInfoMethods { 18 | MediaWidth, 19 | MediaHeight, 20 | MediaBitrate, 21 | MediaEncoder, 22 | } 23 | 24 | export enum WatcherRuleComparisonMethods { 25 | String, 26 | Number, 27 | } 28 | 29 | export enum WatcherRuleStringComparisonMethods { 30 | EqualTo, 31 | Contains, 32 | RegularExpression, 33 | } 34 | 35 | export enum WatcherRuleNumberComparisonMethods { 36 | LessThan, 37 | LessThanOrEqualTo, 38 | EqualTo, 39 | GreaterThanOrEqualTo, 40 | GreaterThan, 41 | } 42 | 43 | export const WatcherRuleComparisonLookup: { [index: string]: number } = { 44 | FileName: WatcherRuleComparisonMethods.String, 45 | FileExtension: WatcherRuleComparisonMethods.String, 46 | FileSize: WatcherRuleComparisonMethods.Number, 47 | MediaWidth: WatcherRuleComparisonMethods.Number, 48 | MediaHeight: WatcherRuleComparisonMethods.Number, 49 | MediaBitrate: WatcherRuleComparisonMethods.Number, 50 | MediaContainer: WatcherRuleComparisonMethods.String, 51 | MediaEncoder: WatcherRuleComparisonMethods.String, 52 | }; 53 | 54 | export type WatcherRuleDefinitionType = { 55 | name: string; 56 | mask: WatcherRuleMaskMethods; 57 | base_rule_method: WatcherRuleBaseMethods; 58 | rule_method: WatcherRuleFileInfoMethods | WatcherRuleMediaInfoMethods; 59 | comparison_method: WatcherRuleStringComparisonMethods | WatcherRuleNumberComparisonMethods; 60 | comparison: string; 61 | }; 62 | 63 | export type WatcherRuleDefinitionObjectType = { 64 | [index: number]: WatcherRuleDefinitionType; 65 | }; 66 | 67 | export type WatcherDefinitionType = { 68 | watch_path: string; 69 | output_path: string | null; 70 | preset_category: string; 71 | preset_id: string; 72 | // default_mask: WatcherRuleMaskMethods; 73 | }; 74 | 75 | export type WatcherDefinitionWithRulesType = WatcherDefinitionType & { 76 | rules: WatcherRuleDefinitionObjectType; 77 | }; 78 | 79 | export type WatcherDefinitionObjectType = { 80 | [index: number]: WatcherDefinitionWithRulesType; 81 | }; 82 | 83 | export type WatcherDefinitionWithIDType = { 84 | id: number; 85 | } & WatcherDefinitionType; 86 | -------------------------------------------------------------------------------- /worker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Node --------------------------------------------------------------------------------------------- 2 | FROM node:bookworm-slim AS main 3 | 4 | ARG TARGETARCH 5 | 6 | # Configure apt (referenced from the immich project at 'https://github.com/immich-app/base-images/tree/main') 7 | RUN sed -i -e's/ main/ main contrib non-free non-free-firmware/g' /etc/apt/sources.list.d/debian.sources 8 | # RUN sed -i -e's/ bookworm-updates/ bookworm-updates sid/g' /etc/apt/sources.list.d/debian.sources 9 | # RUN touch /etc/apt/preferences.d/prferences && \ 10 | # echo "Package: *" >> /etc/apt/preferences.d/prferences && \ 11 | # echo "Pin: release a=unstable" >> /etc/apt/preferences.d/prferences && \ 12 | # echo "Pin-Priority: 450" >> /etc/apt/preferences.d/prferences 13 | RUN apt update 14 | 15 | # Install mesa and intel media drivers 16 | RUN if [ $TARGETARCH = "amd64" ]; then \ 17 | apt install -y mesa-utils mesa-va-drivers mesa-vulkan-drivers intel-media-va-driver-non-free vainfo \ 18 | ; fi 19 | # Install handbrake 20 | RUN apt install -y handbrake-cli 21 | # Install dumb-init 22 | RUN apt install -y dumb-init 23 | 24 | COPY worker /handbrake-web/worker 25 | COPY shared /handbrake-web/shared 26 | WORKDIR /handbrake-web/worker 27 | 28 | # Install node dependencies 29 | ENV NODE_ENV=production 30 | RUN npm install 31 | 32 | # Create directories 33 | RUN mkdir /data && chown node /data && mkdir /video && chown node /data 34 | 35 | # Default environment variables 36 | ENV HANDBRAKE_MODE=worker 37 | ENV DATA_PATH=/data 38 | ENV VIDEO_PATH=/video 39 | 40 | # Start application 41 | ENTRYPOINT ["/usr/bin/dumb-init", "--"] 42 | CMD ["npm", "run", "prod"] -------------------------------------------------------------------------------- /worker/Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .git 3 | .github 4 | 5 | **/node_modules 6 | **/build 7 | 8 | /client 9 | /data 10 | /images 11 | /server 12 | /temp 13 | /video 14 | 15 | .env 16 | .gitignore 17 | .prettierrc 18 | config.yaml 19 | sample.env -------------------------------------------------------------------------------- /worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "handbrake-web", 3 | "version": "0.8.0", 4 | "description": "HandBrake Web", 5 | "scripts": { 6 | "dev": "tsx watch src/worker.ts", 7 | "prod": "tsx src/worker.ts", 8 | "build": "tsc --project ./tsconfig.json" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://git.goodforyou.games/ncunningham/handbrake-web.git" 13 | }, 14 | "dependencies": { 15 | "dotenv": "^16.4.5", 16 | "socket.io-client": "^4.7.5", 17 | "winston": "^3.14.2" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^22.13.10", 21 | "eslint": "^9.22.0", 22 | "tsconfig-paths": "^4.2.0", 23 | "tsx": "^4.16.2", 24 | "typescript": "^5.8.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /worker/src/socket/server-socket.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io-client'; 2 | import { StartTranscode, StopTranscode } from '../scripts/transcode'; 3 | import { serverAddress } from '../worker-startup'; 4 | import logger from 'logging'; 5 | 6 | const workerID = process.env.WORKER_ID; 7 | 8 | export default function ServerSocket(server: Socket) { 9 | server.on('connect', () => { 10 | logger.info(`[socket] Connected to the server '${serverAddress}' with id '${server.id}'.`); 11 | }); 12 | 13 | server.on('connect_error', (error) => { 14 | if (server.active) { 15 | logger.info(`[socket] Connection to server lost, will attempt reconnection...`); 16 | } else { 17 | logger.error(`[socket] Connection to server lost, will not attempt reconnection...`); 18 | logger.error(error); 19 | } 20 | }); 21 | 22 | server.on('disconnect', (reason, details) => { 23 | logger.info(`[socket] Disconnected from the server with reason '${reason}'.`); 24 | if (details) { 25 | logger.info(details); 26 | } 27 | }); 28 | 29 | server.on('start-transcode', (jobID: number) => { 30 | logger.info(`[socket] Request to transcode queue entry '${jobID}'.`); 31 | StartTranscode(jobID, server); 32 | }); 33 | 34 | server.on('stop-transcode', (jobID: number) => { 35 | logger.info(`[socket] Request to stop transcoding the current job with id '${jobID}'.`); 36 | StopTranscode(jobID, server); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /worker/src/worker-shutdown.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io-client'; 2 | import logger from 'logging'; 3 | import { currentJobID, StopTranscode } from 'scripts/transcode'; 4 | 5 | export function RegisterExitListeners(socket: Socket) { 6 | process.on('SIGINT', () => { 7 | logger.info( 8 | `[shutdown] The process has been interrupted, HandBrake Web will now begin to shutdown...` 9 | ); 10 | Shutdown(socket); 11 | }); 12 | 13 | process.on('SIGTERM', () => { 14 | logger.info( 15 | `[shutdown] The process has been terminated, HandBrake Web will now begin to shutdown...` 16 | ); 17 | Shutdown(socket); 18 | }); 19 | } 20 | 21 | export default async function Shutdown(socket: Socket) { 22 | try { 23 | if (currentJobID) { 24 | StopTranscode(currentJobID, socket); 25 | } 26 | 27 | socket.disconnect(); 28 | 29 | logger.info(`[shutdown] Shutdown steps have completed.`); 30 | } catch (error) { 31 | logger.error(`[shutdown] Could not complete shutdown steps.`); 32 | console.error(error); 33 | } 34 | 35 | process.exit(0); 36 | } 37 | -------------------------------------------------------------------------------- /worker/src/worker-startup.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { io } from 'socket.io-client'; 3 | import logger from 'logging'; 4 | import ServerSocket from 'socket/server-socket'; 5 | import { RegisterExitListeners } from './worker-shutdown'; 6 | 7 | export let serverAddress = ''; 8 | 9 | export default async function WorkerStartup() { 10 | // Setup ------------------------------------------------------------------------------------------- 11 | 12 | // Get worker ID from env variable, exit process if it is not set -------------- 13 | const workerID = process.env.WORKER_ID; 14 | if (!workerID) { 15 | logger.error( 16 | "No 'WORKER_ID' envrionment variable is set - this worker will not be set up. Please set this via your docker-compose environment section." 17 | ); 18 | process.exit(0); 19 | } 20 | 21 | // Setup the server ------------------------------------------------------------ 22 | const serverURL = process.env.SERVER_URL; 23 | const serverURLPrefix = serverURL?.match(/^https?:\/\//); 24 | const serverPort = process.env.SERVER_PORT; 25 | serverAddress = `${serverURLPrefix ? serverURL : 'http://' + serverURL}:${serverPort}/worker`; 26 | 27 | const canConnect = serverURL != undefined && serverPort != undefined; 28 | const server = io(serverAddress, { 29 | autoConnect: false, 30 | query: { workerID: workerID }, 31 | }); 32 | 33 | // Event listeners --------------------------------------------------------------------------------- 34 | ServerSocket(server); 35 | RegisterExitListeners(server); 36 | 37 | // Worker Start ------------------------------------------------------------------------------------ 38 | if (canConnect) { 39 | server.connect(); 40 | logger.info('The worker process has started.'); 41 | } else { 42 | logger.error( 43 | 'The SERVER_URL or SERVER_PORT environment variables are not set, no valid server to connect to.' 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /worker/src/worker.ts: -------------------------------------------------------------------------------- 1 | async function Worker() { 2 | const startup = await import('./worker-startup'); 3 | startup.default(); 4 | } 5 | 6 | Worker(); 7 | --------------------------------------------------------------------------------