├── .devcontainer
├── Dockerfile
├── devcontainer.json
└── docker-compose.yml
├── .dockerignore
├── .editorconfig
├── .eslintignore
├── .github
└── workflows
│ └── publish.yaml
├── .gitignore
├── .prettierrc
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── apps
├── aria
│ ├── aria2.conf
│ ├── darwin
│ │ ├── arm64
│ │ │ └── aria2c
│ │ └── x64
│ │ │ └── aria2c
│ ├── linux
│ │ ├── arm64
│ │ │ └── aria2c
│ │ └── x64
│ │ │ └── aria2c
│ ├── package.json
│ ├── start.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── client
│ ├── .eslintignore
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ │ └── dload.svg
│ ├── src
│ │ ├── App.tsx
│ │ ├── hooks
│ │ │ └── useColorScheme.ts
│ │ ├── layouts
│ │ │ └── MainLayout.tsx
│ │ ├── libs
│ │ │ ├── request.ts
│ │ │ └── socket.ts
│ │ ├── main.tsx
│ │ ├── modules
│ │ │ ├── notification
│ │ │ │ ├── NotificationProvider.tsx
│ │ │ │ └── notification-service.ts
│ │ │ ├── settings
│ │ │ │ ├── SettingProvider.tsx
│ │ │ │ ├── setting-service.ts
│ │ │ │ └── setting-store.ts
│ │ │ └── tasks
│ │ │ │ ├── TaskProvider.tsx
│ │ │ │ ├── task-service.ts
│ │ │ │ └── task-store.ts
│ │ ├── pages
│ │ │ └── Main
│ │ │ │ ├── Main.tsx
│ │ │ │ ├── components
│ │ │ │ ├── AddTask.tsx
│ │ │ │ ├── Settings.tsx
│ │ │ │ ├── Task.tsx
│ │ │ │ └── TaskActions.tsx
│ │ │ │ └── index.ts
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── server
│ ├── .env
│ ├── .eslintrc.js
│ ├── package.json
│ ├── src
│ ├── libs
│ │ ├── aria2.ts
│ │ ├── logger.ts
│ │ ├── socket.ts
│ │ ├── store.ts
│ │ ├── utils.ts
│ │ └── waitToInitialize.ts
│ ├── main.ts
│ ├── modules
│ │ ├── notifications
│ │ │ └── notification-service.ts
│ │ ├── settings
│ │ │ ├── setting-controller.ts
│ │ │ └── setting-store.ts
│ │ └── tasks
│ │ │ ├── task-controller.ts
│ │ │ ├── task-service.ts
│ │ │ ├── task-store.ts
│ │ │ └── task-utils.ts
│ ├── server.ts
│ └── services
│ │ ├── database.ts
│ │ └── error-handler.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── docker
├── Dockerfile
├── nginx-dev.conf
├── nginx.conf
├── root-fs
│ └── etc
│ │ └── s6-overlay
│ │ └── s6-rc.d
│ │ ├── aria-svc
│ │ ├── dependencies.d
│ │ │ └── setup
│ │ ├── run
│ │ └── type
│ │ ├── nginx-svc
│ │ ├── dependencies.d
│ │ │ └── setup
│ │ ├── run
│ │ └── type
│ │ ├── server-svc
│ │ ├── dependencies.d
│ │ │ └── aria-svc
│ │ ├── run
│ │ └── type
│ │ ├── setup
│ │ ├── dependencies.d
│ │ │ └── base
│ │ ├── run
│ │ ├── type
│ │ └── up
│ │ └── user
│ │ └── contents.d
│ │ ├── aria-svc
│ │ ├── nginx-svc
│ │ ├── server-svc
│ │ └── setup
└── scripts
│ └── install-s6overlay.sh
├── package.json
├── packages
├── shared
│ ├── package.json
│ ├── src
│ │ ├── api_routes.ts
│ │ ├── index.ts
│ │ ├── socket_events.ts
│ │ └── types.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
└── tsconfig
│ ├── base.json
│ ├── node.json
│ ├── package.json
│ └── react.json
├── screenshots
├── Dload.png
├── download-modal.jpg
├── main-page.jpg
└── settings.jpg
├── turbo.json
└── yarn.lock
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM fuzzknob/octodevbase:latest
2 |
3 | RUN apt update && apt install nginx -y
4 |
5 | RUN export NVM_DIR="$HOME/.nvm" && \
6 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" && \
7 | [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" && \
8 | nvm use default && \
9 | npm i -g yarn
10 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dload",
3 | "dockerComposeFile": "docker-compose.yml",
4 | "service": "development",
5 | "customizations": {
6 | "vscode": {
7 | "settings": {
8 | "terminal.integrated.defaultProfile.linux": "zsh",
9 | "terminal.integrated.profiles.linux": {
10 | "zsh": {
11 | "path": "/bin/zsh"
12 | }
13 | }
14 | }
15 | }
16 | },
17 | "forwardPorts": [8080],
18 | "postAttachCommand": "nginx",
19 | "otherPortsAttributes": {
20 | "onAutoForward": "ignore"
21 | },
22 | "workspaceFolder": "/workspaces/dload"
23 | }
24 |
--------------------------------------------------------------------------------
/.devcontainer/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | development:
5 | build:
6 | context: ../
7 | dockerfile: .devcontainer/Dockerfile
8 | environment:
9 | - TZ=UTC
10 | volumes:
11 | - ..:/workspaces/dload:cached
12 | - /var/run/docker.sock:/var/run/docker.sock
13 | - ../docker/nginx-dev.conf:/etc/nginx/nginx.conf
14 | command: sleep infinity
15 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | .eslintrc.js
2 | dist
3 | *.config.js
4 | *.config.ts
5 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Image to Docker
2 |
3 | on:
4 | push:
5 | branches:
6 | - "main"
7 | tags:
8 | - "v*"
9 | workflow_dispatch:
10 |
11 | jobs:
12 | build_publish:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Docker meta
19 | id: meta
20 | uses: docker/metadata-action@v5
21 | with:
22 | images: fuzzknob/dload
23 | tags: |
24 | type=edge,branch=main
25 | type=semver,pattern={{version}}
26 | type=semver,pattern={{major}}.{{minor}}
27 |
28 | - name: Set up QEMU
29 | uses: docker/setup-qemu-action@v3
30 |
31 | - name: Set up Docker Buildx
32 | uses: docker/setup-buildx-action@v3
33 |
34 | - name: Dockerhub login
35 | uses: docker/login-action@v3
36 | with:
37 | username: ${{ secrets.DOCKER_USERNAME }}
38 | password: ${{ secrets.DOCKER_ACCESSTOKEN }}
39 |
40 | - name: Build and Publish
41 | uses: docker/build-push-action@v5
42 | with:
43 | context: .
44 | file: ./docker/Dockerfile
45 | platforms: linux/amd64,linux/arm64
46 | push: ${{ github.event_name != 'pull_request' }}
47 | tags: ${{ steps.meta.outputs.tags }}
48 | labels: ${{ steps.meta.outputs.labels }}
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build
34 |
35 | dist/
36 |
37 | temp/
38 |
39 | # Dependency directories
40 | node_modules/
41 | tests_output/
42 | jspm_packages/
43 | playground/
44 | pgdata/
45 | # TypeScript v1 declaration files
46 | typings/
47 |
48 | # Optional npm cache directory
49 | .npm
50 |
51 | # Optional eslint cache
52 | .eslintcache
53 |
54 | # Optional REPL history
55 | .node_repl_history
56 |
57 | # Output of 'npm pack'
58 | *.tgz
59 |
60 | # Yarn Integrity file
61 | .yarn-integrity
62 |
63 | # dotenv environment variables file
64 | .env.*
65 | env
66 |
67 | # next.js build output
68 | .next
69 |
70 | .parcel-cache
71 | .idea
72 | .DS_Store
73 | *.log
74 |
75 | /playground
76 | out
77 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "typescript",
3 | "semi": false,
4 | "singleQuote": true,
5 | "tabWidth": 2,
6 | "trailingComma": "all",
7 | "endOfLine": "lf",
8 | "printWidth": 80
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "EditorConfig.EditorConfig",
6 | "ms-azuretools.vscode-docker",
7 | "heybourn.headwind",
8 | "mikestead.dotenv",
9 | "albert.tabout",
10 | "bradlc.vscode-tailwindcss",
11 | "visualstudioexptteam.vscodeintellicode",
12 | "visualstudioexptteam.intellicode-api-usage-examples",
13 | "ms-vscode-remote.remote-containers",
14 | "aaron-bond.better-comments",
15 | "streetsidesoftware.code-spell-checker",
16 | "github.vscode-github-actions"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": true
4 | },
5 | "cSpell.words": [
6 | "dload"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright © 2023, Gagan Rai(@fuzzknob)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
Dload (Containerized Download Manager)
4 |
5 |
6 | Dload, A download manager that runs inside a docker container
7 |
8 | # Quick Setup
9 |
10 | 1. Install [Docker](https://docs.docker.com/engine/install/)
11 | 2. Run the following command to quickly create dload container:
12 | ```bash
13 | docker run -d \
14 | --name=dload \
15 | -e PUID=1000 \
16 | -e PGID=1000 \
17 | -p 8080:80 \
18 | -v ./config:/config \
19 | -v ./downloads:/downloads \
20 | --restart unless-stopped \
21 | fuzzknob/dload
22 | ```
23 | ### With Docker Compose
24 |
25 | 1. Create `docker-compose.yml` file with this contents:
26 | ```yml
27 | version: "3"
28 |
29 | services:
30 | dload:
31 | container_name: dload
32 | image: fuzzknob/dload
33 | environment:
34 | - PUID=1000
35 | - PGID=1000
36 | volumes:
37 | - ./config:/config
38 | - ./downloads:/downloads
39 | ports:
40 | - 8080:80
41 | restart: unless-stopped
42 | ```
43 |
44 | 2. Run the following command:
45 | ```bash
46 | docker compose up -d
47 | ```
48 |
49 | You can access the dload UI at http://localhost:8080
50 |
51 | # Screenshots
52 |
53 | ### Main Page
54 |
55 |
56 | ### Add Download
57 |
58 |
59 | ### Settings
60 |
61 |
62 | # Development
63 |
64 | For development you can either use [vscode devcontainer](https://code.visualstudio.com/docs/devcontainers/containers). Or run it locally with Node.js.
65 |
66 | ### Devcontainer (Recommended)
67 | Just open this project in devcontainer and run:
68 | ```bash
69 | yarn dev
70 | ```
71 | You can access the client in http://localhost:8080
72 |
73 | ### Local (Not Recommended)
74 | To run it locally you will need to have the following tools:
75 | * [Node.js (18 or above)](https://nodejs.org/)
76 | * [yarn (1.22.19)](https://classic.yarnpkg.com/en/docs/install)
77 |
78 | But before we run the app we need to change somethings:
79 |
80 | 1. Change the url at [client/request.ts:L4](https://github.com/fuzzknob/dload/blob/main/apps/client/src/libs/request.ts#L4) to http://localhost:8000 and add ws://localhost:8000 at [client/socket.ts:L3](https://github.com/fuzzknob/dload/blob/main/apps/client/src/libs/socket.ts#L3)
81 | 2. Replace line at [server/socket.ts:L3](https://github.com/fuzzknob/dload/blob/main/apps/server/src/libs/socket.ts#L3) with
82 |
83 | ```ts
84 | export const io = new Server({
85 | cors: {
86 | origin: 'http://localhost:3000',
87 | methods: ['GET', 'POST'],
88 | },
89 | })
90 | ```
91 |
92 | After run the following command:
93 | ```bash
94 | yarn dev
95 | ```
96 |
97 | You can access the application at:
98 | * Server: http://localhost:8000
99 | * Client: http://localhost:3000
100 |
--------------------------------------------------------------------------------
/apps/aria/aria2.conf:
--------------------------------------------------------------------------------
1 | enable-rpc=true
2 | rpc-listen-port=6800
3 | rpc-allow-origin-all=true
4 | rpc-listen-all=true
5 | allow-overwrite=false
6 | auto-file-renaming=true
7 | disk-cache=64M
8 | continue=true
9 | max-connection-per-server=64
10 | max-download-limit=0
11 | max-overall-download-limit=0
12 | max-overall-upload-limit=0
13 | split=64
14 | pause=true
15 | file-allocation=none
16 | no-file-allocation-limit=64M
17 | follow-metalink=true
18 | pause-metadata=false
19 | check-certificate=false
20 | max-file-not-found=10
21 | max-tries=0
22 | retry-wait=10
23 | connect-timeout=10
24 | timeout=10
25 | min-split-size=1M
26 | http-accept-gzip=true
27 | summary-interval=0
28 | content-disposition-default-utf8=true
29 | user-agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36'
30 |
--------------------------------------------------------------------------------
/apps/aria/darwin/arm64/aria2c:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/apps/aria/darwin/arm64/aria2c
--------------------------------------------------------------------------------
/apps/aria/darwin/x64/aria2c:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/apps/aria/darwin/x64/aria2c
--------------------------------------------------------------------------------
/apps/aria/linux/arm64/aria2c:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/apps/aria/linux/arm64/aria2c
--------------------------------------------------------------------------------
/apps/aria/linux/x64/aria2c:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/apps/aria/linux/x64/aria2c
--------------------------------------------------------------------------------
/apps/aria/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@dload/aria",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "dev": "nodemon -r tsconfig-paths/register --watch './**/*.ts' --exec 'node --experimental-specifier-resolution=node --loader ts-node/esm' start.ts",
6 | "build": "tsup",
7 | "start": "node dist/start.js",
8 | "clean": "rm -rf ./dist"
9 | },
10 | "devDependencies": {
11 | "@dload/tsconfig": "*",
12 | "nodemon": "^3.0.1",
13 | "ts-node": "^10.9.1",
14 | "tsup": "^7.2.0",
15 | "typescript": "^5.2.2"
16 | },
17 | "dependencies": {}
18 | }
19 |
--------------------------------------------------------------------------------
/apps/aria/start.ts:
--------------------------------------------------------------------------------
1 | import { platform, arch } from 'node:os'
2 | import { resolve } from 'node:path'
3 | import { spawn } from 'node:child_process'
4 | import readline from 'node:readline'
5 |
6 | const cwd = process.cwd()
7 |
8 | const aria2cPath = resolve(cwd, `./${platform()}/${arch()}/aria2c`)
9 | const aira2configPath = resolve(cwd, `./aria2.conf`)
10 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
11 |
12 |
13 | const ariaInstance = spawn(
14 | aria2cPath,
15 | [`--conf-path=${aira2configPath}`, '--disable-ipv6'],
16 | {
17 | windowsHide: false,
18 | stdio: 'pipe',
19 | },
20 | )
21 |
22 | ariaInstance.on('spawn', () => console.log('*** Started Aria2 ***'))
23 |
24 | ariaInstance.stdout.on('data', (data: string) => {
25 | console.log(data.toString())
26 | })
27 |
28 | ariaInstance.stderr.on('data', (data: string) => {
29 | console.log(data.toString())
30 | })
31 |
32 | ariaInstance.on('exit', () => {
33 | console.log('\n*** Stopped Aria2 ***')
34 | process.exit(0)
35 | })
36 |
37 | rl.on('SIGINT', () => {
38 | console.log('\n*** Stopping Aria2 ***')
39 | ariaInstance.kill('SIGINT')
40 | })
41 |
42 |
--------------------------------------------------------------------------------
/apps/aria/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@dload/tsconfig/node.json",
3 | "include": ["./**/*.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/apps/aria/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig((options) => ({
4 | entry: ['start.ts'],
5 | treeshake: true,
6 | minify: true,
7 | ...options,
8 | }))
9 |
10 |
--------------------------------------------------------------------------------
/apps/client/.eslintignore:
--------------------------------------------------------------------------------
1 | .eslintrc.js
2 | tailwind.config.js
3 | dist
4 |
--------------------------------------------------------------------------------
/apps/client/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | 'prettier',
4 | 'plugin:prettier/recommended',
5 | 'plugin:react/recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:@typescript-eslint/recommended-requiring-type-checking',
8 | ],
9 | plugins: [
10 | 'jest-dom',
11 | ],
12 | parser: '@typescript-eslint/parser',
13 | parserOptions: {
14 | project: ['./apps/*/tsconfig.json'],
15 | ecmaFeatures: {
16 | jsx: true
17 | },
18 | ecmaVersion: 12,
19 | sourceType: 'module'
20 | },
21 | rules: {
22 | 'react/prop-types': 0,
23 | 'no-console': 'warn',
24 | 'react/react-in-jsx-scope': 'off',
25 | '@typescript-eslint/array-type': 'off',
26 | '@typescript-eslint/ban-ts-ignore': 'off',
27 | '@typescript-eslint/camelcase': 'off',
28 | '@typescript-eslint/no-unsafe-member-access': 'off',
29 | '@typescript-eslint/explicit-function-return-type': 'off',
30 | '@typescript-eslint/explicit-module-boundary-types': 'off',
31 | "@typescript-eslint/no-misused-promises": "off",
32 | '@typescript-eslint/member-delimiter-style': 'off',
33 | '@typescript-eslint/no-explicit-any': 'off',
34 | '@typescript-eslint/no-non-null-assertion': 'off',
35 | '@typescript-eslint/no-object-literal-type-assertion': 'off',
36 | '@typescript-eslint/no-this-alias': 'off',
37 | '@typescript-eslint/no-floating-promises': 'off',
38 | '@typescript-eslint/no-for-in-array': 'off',
39 | '@typescript-eslint/semi': ['error', 'never'],
40 | 'react/display-name': 'off',
41 | semi: ['error', 'never'],
42 | },
43 | }
44 |
--------------------------------------------------------------------------------
/apps/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 |
--------------------------------------------------------------------------------
/apps/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Dload
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/apps/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@dload/client",
3 | "private": true,
4 | "version": "1.0.0",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "preview": "vite preview",
9 | "clean": "rm -rf ./dist"
10 | },
11 | "dependencies": {
12 | "@mantine/core": "^7.0.2",
13 | "@mantine/form": "^7.0.2",
14 | "@mantine/hooks": "^7.0.2",
15 | "@mantine/modals": "^7.0.2",
16 | "@mantine/notifications": "^7.0.2",
17 | "axios": "^1.3.6",
18 | "copy-to-clipboard": "^3.3.3",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "react-icons": "^4.4.0",
22 | "socket.io-client": "^4.5.4",
23 | "zod": "^3.21.4",
24 | "zustand": "^4.1.1"
25 | },
26 | "devDependencies": {
27 | "@dload/shared": "*",
28 | "@dload/tsconfig": "*",
29 | "@types/react": "^18.0.17",
30 | "@types/react-dom": "^18.0.6",
31 | "@typescript-eslint/eslint-plugin": "^5.56.0",
32 | "@typescript-eslint/parser": "^5.56.0",
33 | "@vitejs/plugin-react": "^2.0.1",
34 | "eslint": "^8.23.0",
35 | "eslint-config-prettier": "^8.5.0",
36 | "eslint-plugin-jest-dom": "^4.0.2",
37 | "eslint-plugin-prettier": "^4.2.1",
38 | "eslint-plugin-react": "^7.31.1",
39 | "eslint-plugin-react-hooks": "^4.6.0",
40 | "postcss": "^8.4.30",
41 | "postcss-preset-mantine": "^1.7.0",
42 | "postcss-simple-vars": "^7.0.1",
43 | "prettier": "^2.7.1",
44 | "typescript": "^4.6.4",
45 | "vite": "^3.0.7"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/apps/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-preset-mantine': {},
4 | 'postcss-simple-vars': {
5 | variables: {
6 | 'mantine-breakpoint-xs': '36em',
7 | 'mantine-breakpoint-sm': '48em',
8 | 'mantine-breakpoint-md': '62em',
9 | 'mantine-breakpoint-lg': '75em',
10 | 'mantine-breakpoint-xl': '88em',
11 | },
12 | },
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/apps/client/public/dload.svg:
--------------------------------------------------------------------------------
1 |
26 |
--------------------------------------------------------------------------------
/apps/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import '@mantine/core/styles.css'
3 | import '@mantine/notifications/styles.css'
4 | import { MantineProvider } from '@mantine/core'
5 | import { ModalsProvider } from '@mantine/modals'
6 |
7 | import TaskProvider from '@/modules/tasks/TaskProvider'
8 | import SettingProvider from '@/modules/settings/SettingProvider'
9 | import NotificationProvider from '@/modules/notification/NotificationProvider'
10 |
11 | import Main from './pages/Main'
12 |
13 | const App: React.FC = () => {
14 | return (
15 |
16 |
17 |
18 |
19 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | export default App
33 |
--------------------------------------------------------------------------------
/apps/client/src/hooks/useColorScheme.ts:
--------------------------------------------------------------------------------
1 | import { useComputedColorScheme } from '@mantine/core'
2 |
3 | export const useColorScheme = () => {
4 | const colorScheme = useComputedColorScheme('dark')
5 | return colorScheme
6 | }
7 |
--------------------------------------------------------------------------------
/apps/client/src/layouts/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Container } from '@mantine/core'
3 |
4 | interface MainLayoutProps {
5 | children: React.ReactNode
6 | }
7 |
8 | const MainLayout: React.FC = ({ children }) => {
9 | return (
10 |
17 | {children}
18 |
19 | )
20 | }
21 |
22 | export default MainLayout
23 |
--------------------------------------------------------------------------------
/apps/client/src/libs/request.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | const request = axios.create({
4 | baseURL: '/api',
5 | })
6 |
7 | export * from 'axios'
8 | export default request
9 |
--------------------------------------------------------------------------------
/apps/client/src/libs/socket.ts:
--------------------------------------------------------------------------------
1 | import { io } from 'socket.io-client'
2 |
3 | export const socket = io('')
4 |
--------------------------------------------------------------------------------
/apps/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 |
5 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
6 | ,
7 | )
8 |
--------------------------------------------------------------------------------
/apps/client/src/modules/notification/NotificationProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import { Notifications, notifications } from '@mantine/notifications'
3 | import { onNotification } from './notification-service'
4 |
5 | type NotificationProviderProps = {
6 | children: React.ReactNode
7 | }
8 |
9 | const NotificationProvider: React.FC = ({
10 | children,
11 | }) => {
12 | useEffect(() => {
13 | onNotification((notification) => {
14 | const isError = notification.type === 'ERROR'
15 | notifications.show({
16 | title: isError ? 'Failure' : 'Success',
17 | message: notification.message,
18 | color: isError ? 'red' : 'green',
19 | })
20 | })
21 | }, [])
22 |
23 | return (
24 | <>
25 |
26 | {children}
27 | >
28 | )
29 | }
30 |
31 | export default NotificationProvider
32 |
--------------------------------------------------------------------------------
/apps/client/src/modules/notification/notification-service.ts:
--------------------------------------------------------------------------------
1 | import { socket } from '@/libs/socket'
2 | import { SOCKET_EVENTS } from '@dload/shared'
3 |
4 | type Notification = {
5 | code: string
6 | message: string
7 | type?: 'SUCCESS' | 'ERROR'
8 | }
9 |
10 | export const onNotification = (cb: (notification: Notification) => void) =>
11 | socket.on(SOCKET_EVENTS.NOTIFY, cb)
12 |
--------------------------------------------------------------------------------
/apps/client/src/modules/settings/SettingProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 |
3 | import { useSettingStore } from './setting-store'
4 | import { fetchSettings } from './setting-service'
5 |
6 | interface SettingProviderProps {
7 | children: React.ReactNode
8 | }
9 |
10 | const SettingProvider: React.FC = ({ children }) => {
11 | const setSettings = useSettingStore(({ setSettings }) => setSettings)
12 | const [hasLoaded, setLoaded] = useState(false)
13 |
14 | useEffect(() => {
15 | // eslint-disable-next-line prettier/prettier
16 | (async () => {
17 | const settigs = await fetchSettings()
18 | setSettings(settigs)
19 | setLoaded(true)
20 | })()
21 | }, [])
22 |
23 | if (!hasLoaded) return
24 |
25 | return <>{children}>
26 | }
27 |
28 | export default SettingProvider
29 |
--------------------------------------------------------------------------------
/apps/client/src/modules/settings/setting-service.ts:
--------------------------------------------------------------------------------
1 | import { API_ROUTES, Settings } from '@dload/shared'
2 |
3 | import request from '@/libs/request'
4 |
5 | export const fetchSettings = async () => {
6 | const { data: settings } = await request.get(
7 | API_ROUTES.GET_SETTINGS,
8 | )
9 | return settings
10 | }
11 |
12 | export const updateSettings = (settings: Settings) =>
13 | request.post(API_ROUTES.UPDATE_SETTINGS, settings)
14 |
--------------------------------------------------------------------------------
/apps/client/src/modules/settings/setting-store.ts:
--------------------------------------------------------------------------------
1 | import create from 'zustand'
2 | import { Settings } from '@dload/shared'
3 |
4 | export const useSettingStore = create<{
5 | settings: Settings
6 | setSettings: (settings: Settings) => void
7 | }>((set) => ({
8 | settings: {
9 | downloadPaths: [],
10 | maximumActiveDownloads: 2,
11 | },
12 | setSettings: (newSettings) => set(() => ({ settings: newSettings })),
13 | }))
14 |
--------------------------------------------------------------------------------
/apps/client/src/modules/tasks/TaskProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 |
3 | import { useTaskStore } from './task-store'
4 | import { onTasksUpdate } from './task-service'
5 |
6 | interface TaskProviderProps {
7 | children: React.ReactNode
8 | }
9 |
10 | const TaskProvider: React.FC = ({ children }) => {
11 | const upsertTasks = useTaskStore(({ upsertTasks }) => upsertTasks)
12 | useEffect(() => {
13 | onTasksUpdate(upsertTasks)
14 | }, [])
15 |
16 | return <>{children}>
17 | }
18 |
19 | export default TaskProvider
20 |
--------------------------------------------------------------------------------
/apps/client/src/modules/tasks/task-service.ts:
--------------------------------------------------------------------------------
1 | import request from '@/libs/request'
2 | import { socket } from '@/libs/socket'
3 | import { SOCKET_EVENTS, API_ROUTES, Task } from '@dload/shared'
4 |
5 | export type TaskPayload = {
6 | url: string
7 | name: string
8 | type: 'DOWNLOAD' | 'QUEUE'
9 | downloadPath: string
10 | directDownload: boolean
11 | }
12 |
13 | export const onTasksUpdate = (cb: (tasks: Task[]) => void) =>
14 | socket.on(SOCKET_EVENTS.UPDATE_TASK, cb)
15 |
16 | export const addTask = (task: TaskPayload) =>
17 | request.post(API_ROUTES.ADD_TASK, task)
18 |
19 | export const removeTask = (taskId: string) =>
20 | request.post(API_ROUTES.REMOVE_TASK, { taskId })
21 |
22 | export const togglePause = (taskId: string) =>
23 | request.post(API_ROUTES.TASK_TOGGLE_PAUSE, { taskId })
24 |
--------------------------------------------------------------------------------
/apps/client/src/modules/tasks/task-store.ts:
--------------------------------------------------------------------------------
1 | import create from 'zustand'
2 | import { Task } from '@dload/shared'
3 |
4 | export const useTaskStore = create<{
5 | tasks: Task[]
6 | upsertTasks: (tasks: Task[]) => void
7 | }>((set) => ({
8 | tasks: [],
9 | upsertTasks: (newTasks) => set(() => ({ tasks: newTasks || [] })),
10 | }))
11 |
--------------------------------------------------------------------------------
/apps/client/src/pages/Main/Main.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Space, Group, Box, Title, Text, ActionIcon } from '@mantine/core'
3 | import { IoAddOutline, IoSettingsOutline } from 'react-icons/io5'
4 | import { useHotkeys } from '@mantine/hooks'
5 |
6 | import MainLayout from '@/layouts/MainLayout'
7 | import { useTaskStore } from '@/modules/tasks/task-store'
8 |
9 | import Task from './components/Task'
10 | import AddTask from './components/AddTask'
11 | import Settings from './components/Settings'
12 | import { useColorScheme } from '@/hooks/useColorScheme'
13 |
14 | const Main: React.FC = () => {
15 | const [isTaskVisible, setTaskVisible] = useState(false)
16 | const [isSettingsVisible, setSettingVisible] = useState(false)
17 | const colorScheme = useColorScheme()
18 | const tasks = useTaskStore((state) => state.tasks)
19 | const activeTasks = tasks.filter((task) =>
20 | ['DOWNLOAD', 'COPY'].includes(task.type),
21 | )
22 | const queuedTasks = tasks.filter((task) => task.type === 'QUEUE')
23 |
24 | useHotkeys([['n', () => setTaskVisible(true)]])
25 |
26 | return (
27 |
28 | setTaskVisible(false)} />
29 | setSettingVisible(false)}
32 | />
33 |
34 | setTaskVisible(true)}
38 | >
39 |
40 |
41 | setSettingVisible(true)}
45 | >
46 |
47 |
48 |
49 |
50 | {tasks.length ? (
51 | <>
52 |
53 | In Progress
54 |
55 | {activeTasks.map((task) => (
56 |
57 | ))}
58 |
59 |
60 | {!!queuedTasks.length && (
61 |
62 | In Queue
63 |
64 | {queuedTasks.map((task) => (
65 |
66 | ))}
67 |
68 | )}
69 | >
70 | ) : (
71 |
78 | No tasks in hand
79 |
80 | )}
81 |
82 | )
83 | }
84 |
85 | export default Main
86 |
--------------------------------------------------------------------------------
/apps/client/src/pages/Main/components/AddTask.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import {
3 | Box,
4 | TextInput,
5 | Modal,
6 | Textarea,
7 | Space,
8 | Select,
9 | Group,
10 | Button,
11 | } from '@mantine/core'
12 | import { z } from 'zod'
13 | import { useForm, zodResolver } from '@mantine/form'
14 | import { notifications } from '@mantine/notifications'
15 |
16 | import * as taskService from '@/modules/tasks/task-service'
17 | import { useSettingStore } from '@/modules/settings/setting-store'
18 |
19 | const addTaskSchema = z.object({
20 | url: z.string().url(),
21 | name: z.string().optional(),
22 | downloadPath: z.string(),
23 | })
24 |
25 | type AddTaskProps = {
26 | visible: boolean
27 | onClose: () => void
28 | }
29 |
30 | const AddTask: React.FC = ({ visible, onClose }) => {
31 | const [isSubmitting, setSubmitting] = useState(false)
32 | const [directDownload, setDirectDownload] = useState(true)
33 | const { downloadPaths } = useSettingStore(({ settings }) => settings)
34 | const form = useForm({
35 | validate: zodResolver(addTaskSchema),
36 | initialValues: {
37 | url: '',
38 | name: '',
39 | downloadPath: downloadPaths[0],
40 | },
41 | transformValues: (values) => ({
42 | ...values,
43 | url: values.url.trim(),
44 | name: values.name.trim(),
45 | downloadPath: values.downloadPath.trim(),
46 | }),
47 | })
48 |
49 | function close() {
50 | onClose()
51 | form.reset()
52 | setDirectDownload(false)
53 | }
54 |
55 | async function handleSubmit(type: 'DOWNLOAD' | 'QUEUE') {
56 | const result = form.validate()
57 | if (result.hasErrors) return
58 | setSubmitting(true)
59 | try {
60 | await taskService.addTask({
61 | ...form.getTransformedValues(),
62 | type,
63 | directDownload,
64 | })
65 | close()
66 | } catch (e) {
67 | notifications.show({
68 | title: 'Failure',
69 | message: 'Error while adding task',
70 | color: 'red',
71 | })
72 | } finally {
73 | setSubmitting(false)
74 | }
75 | }
76 |
77 | return (
78 |
86 |
91 |
126 |
127 |
128 | )
129 | }
130 |
131 | export default AddTask
132 |
--------------------------------------------------------------------------------
/apps/client/src/pages/Main/components/Settings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import {
3 | Box,
4 | Title,
5 | Modal,
6 | Space,
7 | TagsInput,
8 | useMantineColorScheme,
9 | NumberInput,
10 | Group,
11 | Select,
12 | Button,
13 | } from '@mantine/core'
14 | import { notifications } from '@mantine/notifications'
15 | import { z } from 'zod'
16 | import { useForm, zodResolver } from '@mantine/form'
17 |
18 | import { useSettingStore } from '@/modules/settings/setting-store'
19 | import * as settingService from '@/modules/settings/setting-service'
20 |
21 | const settingsSchema = z.object({
22 | downloadPaths: z.array(z.string()).nonempty(),
23 | maximumActiveDownloads: z.number(),
24 | })
25 |
26 | type SettingsProps = {
27 | visible: boolean
28 | onClose: () => void
29 | }
30 |
31 | const Settings: React.FC = ({ visible, onClose }) => {
32 | const [isSubmitting, setSubmitting] = useState(false)
33 | const { settings, setSettings } = useSettingStore(
34 | ({ settings, setSettings }) => ({
35 | settings,
36 | setSettings,
37 | }),
38 | )
39 | const { colorScheme, setColorScheme } = useMantineColorScheme()
40 |
41 | const form = useForm({
42 | validate: zodResolver(settingsSchema),
43 | initialValues: {
44 | downloadPaths: settings.downloadPaths,
45 | maximumActiveDownloads: settings.maximumActiveDownloads,
46 | },
47 | transformValues: (values) => ({
48 | ...values,
49 | downloadPaths: values.downloadPaths.map((path) => path.trim()),
50 | }),
51 | })
52 |
53 | function close() {
54 | onClose()
55 | }
56 |
57 | async function handleSubmit() {
58 | const result = form.validate()
59 | if (result.hasErrors) return
60 | setSubmitting(true)
61 | try {
62 | const values = form.getTransformedValues()
63 | await settingService.updateSettings(values)
64 | setSettings(values)
65 | close()
66 | } catch (error) {
67 | notifications.show({
68 | title: 'Failure',
69 | message: 'Error saving settings',
70 | color: 'red',
71 | })
72 | } finally {
73 | setSubmitting(false)
74 | }
75 | }
76 |
77 | return (
78 |
86 |
91 | Settings
92 |
93 |
103 |
104 | Server Settings
105 |
106 |
127 |
128 |
129 | )
130 | }
131 |
132 | export default Settings
133 |
--------------------------------------------------------------------------------
/apps/client/src/pages/Main/components/Task.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { IoArrowDownOutline } from 'react-icons/io5'
3 | import { Box, Space, Text, Progress, Group } from '@mantine/core'
4 | import { modals } from '@mantine/modals'
5 | import { Task } from '@dload/shared'
6 | import copyToClipboard from 'copy-to-clipboard'
7 |
8 | import * as taskService from '@/modules/tasks/task-service'
9 |
10 | import Actions from './TaskActions'
11 | import { useColorScheme } from '@/hooks/useColorScheme'
12 |
13 | interface TaskProps {
14 | task: Task
15 | }
16 |
17 | const TaskComponent: React.FC = ({ task }) => {
18 | const { type } = task
19 | const colorScheme = useColorScheme()
20 |
21 | function copyDownloadLink() {
22 | copyToClipboard(task.url)
23 | }
24 |
25 | function removeTask() {
26 | modals.openConfirmModal({
27 | title: 'Are you sure?',
28 | onConfirm: () => taskService.removeTask(task.id),
29 | })
30 | }
31 |
32 | function togglePause() {
33 | taskService.togglePause(task.id)
34 | }
35 |
36 | return (
37 |
45 |
46 |
47 | {task.name}
48 | {type === 'QUEUE' && (
49 |
50 | {task.url}
51 |
52 | )}
53 |
54 |
60 |
61 | {['COPY', 'DOWNLOAD'].includes(type) && (
62 | <>
63 |
64 |
65 | {type === 'COPY' ? 'Copying...' : 'Downloading...'}
66 |
67 |
68 |
72 |
73 |
74 | {task.downloaded && task.totalSize && (
75 |
76 | {task.downloaded} / {task.totalSize}
77 |
78 | )}
79 | {['IN_PROGRESS'].includes(task.status) && (
80 |
81 | {type === 'DOWNLOAD' && (
82 |
83 | {task.downloadSpeed && (
84 | <>
85 |
86 | {task.downloadSpeed}
87 | >
88 | )}
89 |
90 | )}
91 | {task.remainingTime && (
92 | {task.remainingTime}
93 | )}
94 |
95 | )}
96 |
97 | >
98 | )}
99 |
100 | )
101 | }
102 |
103 | export default TaskComponent
104 |
--------------------------------------------------------------------------------
/apps/client/src/pages/Main/components/TaskActions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Group, ActionIcon } from '@mantine/core'
3 | import {
4 | IoPauseOutline,
5 | IoLinkOutline,
6 | IoCloseOutline,
7 | IoPlayOutline,
8 | } from 'react-icons/io5'
9 | import { Task, TaskType } from '@dload/shared'
10 |
11 | type ActionType = 'PAUSE' | 'COPY_LINK' | 'REMOVE'
12 |
13 | const ACTION_MAP: Record = {
14 | DOWNLOAD: ['PAUSE', 'COPY_LINK', 'REMOVE'],
15 | COPY: ['COPY_LINK', 'REMOVE'],
16 | QUEUE: ['COPY_LINK', 'REMOVE'],
17 | }
18 |
19 | interface TaskActionsProps {
20 | task: Task
21 | togglePause: () => void
22 | removeTask: () => void
23 | copyDownloadLink: () => void
24 | }
25 |
26 | const TaskActions: React.FC = ({
27 | task,
28 | togglePause,
29 | removeTask,
30 | copyDownloadLink,
31 | }) => {
32 | const actions = ACTION_MAP[task.type]
33 | return (
34 |
35 | {actions.map((action, index) => (
36 |
37 | {action === 'PAUSE' && (
38 |
39 | {task.status === 'PAUSED' ? (
40 |
41 | ) : (
42 |
43 | )}
44 |
45 | )}
46 | {action === 'COPY_LINK' && (
47 |
48 |
49 |
50 | )}
51 | {action === 'REMOVE' && (
52 |
53 |
54 |
55 | )}
56 |
57 | ))}
58 |
59 | )
60 | }
61 |
62 | export default TaskActions
63 |
--------------------------------------------------------------------------------
/apps/client/src/pages/Main/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Main'
2 |
--------------------------------------------------------------------------------
/apps/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/apps/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@dload/tsconfig/react.json",
3 | "compilerOptions": {
4 | "paths": {
5 | "@/*": ["./src/*"]
6 | }
7 | },
8 | "include": ["src"],
9 | "references": [{ "path": "./tsconfig.node.json" }]
10 | }
11 |
--------------------------------------------------------------------------------
/apps/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/apps/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url'
2 |
3 | import { defineConfig } from 'vite'
4 | import react from '@vitejs/plugin-react'
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [react()],
9 | server: {
10 | port: 3000,
11 | },
12 | resolve: {
13 | alias: {
14 | '@': fileURLToPath(new URL('./src', import.meta.url)),
15 | },
16 | },
17 | })
18 |
--------------------------------------------------------------------------------
/apps/server/.env:
--------------------------------------------------------------------------------
1 | # Updating the value will only change config path for development.
2 | # If you want to change the config path for production, You also need to change it in docker/root-fs/etc/s6-overlay/s6-rc.d/setup/run
3 | CONFIG_PATH="/config"
4 |
--------------------------------------------------------------------------------
/apps/server/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | 'prettier',
4 | 'plugin:prettier/recommended',
5 | 'plugin:@typescript-eslint/recommended',
6 | 'plugin:@typescript-eslint/recommended-requiring-type-checking'
7 | ],
8 | parser: '@typescript-eslint/parser',
9 | parserOptions: {
10 | project: ['./apps/**/tsconfig.json'],
11 | ecmaVersion: 12,
12 | sourceType: 'module'
13 | },
14 | rules: {
15 | 'no-console': 'off',
16 | '@typescript-eslint/array-type': 'off',
17 | '@typescript-eslint/no-misused-promises': 'off',
18 | '@typescript-eslint/no-unsafe-assignment': 'off',
19 | '@typescript-eslint/no-unsafe-member-access': 'off',
20 | '@typescript-eslint/no-floating-promises': 'off',
21 | semi: ['error', 'never'],
22 | },
23 | }
24 |
--------------------------------------------------------------------------------
/apps/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@dload/server",
3 | "version": "1.0.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "dev": "nodemon -r tsconfig-paths/register --watch './**/*.ts' --exec 'node --experimental-specifier-resolution=node --loader ts-node/esm' src/main.ts",
7 | "build": "tsup",
8 | "start": "node dist/main.js",
9 | "clean": "rm -rf ./dist"
10 | },
11 | "dependencies": {
12 | "chalk": "^4.1.2",
13 | "cors": "^2.8.5",
14 | "dotenv-flow": "^3.3.0",
15 | "express": "^4.18.1",
16 | "fs-extra": "^10.1.0",
17 | "immer": "^9.0.15",
18 | "libaria2-ts": "^1.0.91",
19 | "lodash": "^4.17.21",
20 | "socket.io": "^4.5.2",
21 | "uuid": "^9.0.0"
22 | },
23 | "devDependencies": {
24 | "@dload/shared": "*",
25 | "@dload/tsconfig": "*",
26 | "@types/dotenv-flow": "^3.3.1",
27 | "@types/express": "^4.17.13",
28 | "@types/fs-extra": "^9.0.13",
29 | "@types/lodash": "^4.14.184",
30 | "@types/node": "^16.11.56",
31 | "@types/uuid": "^8.3.4",
32 | "@typescript-eslint/eslint-plugin": "^5.56.0",
33 | "@typescript-eslint/parser": "^5.56.0",
34 | "eslint": "^8.23.0",
35 | "eslint-config-prettier": "^8.5.0",
36 | "eslint-plugin-prettier": "^4.2.1",
37 | "nodemon": "^3.0.1",
38 | "prettier": "^2.7.1",
39 | "ts-node": "^10.9.1",
40 | "tsconfig-paths": "^4.1.0",
41 | "tslib": "^2.4.0",
42 | "tsup": "^7.2.0",
43 | "typescript": "^4.8.2"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/apps/server/src/libs/aria2.ts:
--------------------------------------------------------------------------------
1 | import { WebSocket as Aria2WebSocket } from 'libaria2-ts'
2 |
3 | const aria2 = new Aria2WebSocket.Client({
4 | host: 'localhost',
5 | port: 6800,
6 | path: '/jsonrpc',
7 | })
8 |
9 | export { Adapter as AriaAdapter } from 'libaria2-ts'
10 |
11 | export default aria2
12 |
--------------------------------------------------------------------------------
/apps/server/src/libs/logger.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import chalk from 'chalk'
3 |
4 | export function log(...parameters: any[]) {
5 | console.log(chalk.whiteBright(parameters))
6 | }
7 |
8 | export function info(...parameters: any[]) {
9 | console.log(chalk.blueBright(parameters))
10 | }
11 |
12 | export function error(...parameters: any[]) {
13 | console.log(chalk.redBright(parameters))
14 | }
15 |
--------------------------------------------------------------------------------
/apps/server/src/libs/socket.ts:
--------------------------------------------------------------------------------
1 | import { Server } from 'socket.io'
2 |
3 | export const io = new Server()
4 |
5 | export * from 'socket.io'
6 |
--------------------------------------------------------------------------------
/apps/server/src/libs/store.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer'
2 | import { Database } from '@/services/database'
3 |
4 | export interface StoreOption {
5 | name: string
6 | database: Database
7 | }
8 |
9 | export default class Store {
10 | private name: string
11 | private db: Database
12 |
13 | constructor(options: StoreOption) {
14 | this.name = options.name
15 | this.db = options.database
16 | }
17 |
18 | get() {
19 | const { name, db } = this
20 | return db.getPath(name) as T
21 | }
22 |
23 | set(data: T) {
24 | const { name, db } = this
25 | db.setPath(name, data)
26 | }
27 |
28 | safeSet(fn: (data: T) => void) {
29 | this.set(produce(this.get(), fn))
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/apps/server/src/libs/utils.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from 'uuid'
2 | import fs from 'fs-extra'
3 | import * as logger from './logger'
4 |
5 | export function formatRemainingTime(seconds: number) {
6 | const time = { hours: '', minutes: '', seconds: '' }
7 | let remaining = seconds || 0
8 |
9 | if (remaining <= 0) return ''
10 |
11 | if (remaining > 86400) return 'More than 1 day Remaining'
12 |
13 | if (remaining > 3600) {
14 | time.hours = `${Math.floor(remaining / 3600)}h `
15 | remaining %= 3600
16 | }
17 |
18 | if (remaining > 60) {
19 | time.minutes = `${Math.floor(remaining / 60)}m `
20 | remaining %= 60
21 | }
22 |
23 | if (remaining > 0) {
24 | time.seconds = `${remaining}s`
25 | }
26 |
27 | const timeRemaining = time.hours + time.minutes + time.seconds
28 | return timeRemaining ? `${timeRemaining} Remaining` : ''
29 | }
30 |
31 | export function bytesToSize(bytes: bigint, precision = 1) {
32 | const b = parseInt(bytes.toString(), 10)
33 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
34 | if (b === 0) {
35 | return '0 KB'
36 | }
37 | const i = parseInt(Math.floor(Math.log(b) / Math.log(1024)).toString(), 10)
38 | if (i === 0) {
39 | return `${b} ${sizes[i]}`
40 | }
41 | return `${(b / 1024 ** i).toFixed(precision)} ${sizes[i]}`
42 | }
43 |
44 | export function calculatePercentage(
45 | completedBytes: bigint,
46 | totalBytes: bigint,
47 | ) {
48 | const completed = parseInt(completedBytes.toString(), 10)
49 | const total = parseInt(totalBytes.toString(), 10)
50 | const percentage = (completed / total) * 100
51 | return parseFloat(percentage.toFixed(2))
52 | }
53 |
54 | export async function removeFile(path: string) {
55 | try {
56 | await fs.ensureFile(path)
57 | await fs.remove(path)
58 | } catch (e) {
59 | return
60 | }
61 | }
62 |
63 | export function wait(time: number) {
64 | return new Promise((res) => {
65 | setTimeout(() => {
66 | res(true)
67 | }, time)
68 | })
69 | }
70 |
71 | export async function suppressError(promise: Promise) {
72 | try {
73 | const result = await promise
74 | return result
75 | } catch (e) {
76 | return null
77 | // suppressed
78 | }
79 | }
80 |
81 | export function getEnvValue(
82 | key: string,
83 | options = { isOptional: false },
84 | ): string {
85 | const value = process.env[key]
86 | if (!value && !options.isOptional) {
87 | throw new Error(`${key} is required`)
88 | }
89 | return value || ''
90 | }
91 |
92 | export function generateUuid() {
93 | return uuidv4()
94 | }
95 |
96 | export function getFileExtension(name: string) {
97 | return name.lastIndexOf('.') > 0
98 | ? name.substring(name.lastIndexOf('.') + 1)
99 | : ''
100 | }
101 |
102 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
103 | export function logErrorAndRethrow(e: any) {
104 | logger.error(e)
105 | throw e
106 | }
107 |
--------------------------------------------------------------------------------
/apps/server/src/libs/waitToInitialize.ts:
--------------------------------------------------------------------------------
1 | type Fn = () => void | Promise
2 |
3 | const functions: Fn[] = []
4 |
5 | let hasInitialized = false
6 |
7 | export function waitToInitialize(fn: Fn) {
8 | if (hasInitialized) {
9 | // eslint-disable-next-line
10 | fn()
11 | return
12 | }
13 | functions.push(fn)
14 | }
15 |
16 | export async function initialize() {
17 | for (const fn of functions) {
18 | await fn()
19 | }
20 | hasInitialized = true
21 | }
22 |
--------------------------------------------------------------------------------
/apps/server/src/main.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv-flow/config'
2 | import db from '@/services/database'
3 | import { initialize as initializeWaiting } from '@/libs/waitToInitialize'
4 | import * as logger from '@/libs/logger'
5 | import { startServer } from './server'
6 | import { logErrorAndRethrow } from './libs/utils'
7 |
8 | async function main() {
9 | await db.init()
10 | await initializeWaiting()
11 | startServer()
12 | }
13 |
14 | main()
15 | .then(() => logger.info('App Initialized'))
16 | .catch((e) => logErrorAndRethrow(e))
17 |
--------------------------------------------------------------------------------
/apps/server/src/modules/notifications/notification-service.ts:
--------------------------------------------------------------------------------
1 | import { io } from '@/libs/socket'
2 | import { SOCKET_EVENTS } from '@dload/shared'
3 |
4 | type Notification = {
5 | code: string
6 | message: string
7 | type?: 'SUCCESS' | 'ERROR'
8 | }
9 |
10 | export function notify(notification: Notification) {
11 | io.emit(SOCKET_EVENTS.NOTIFY, notification)
12 | }
13 |
--------------------------------------------------------------------------------
/apps/server/src/modules/settings/setting-controller.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import { API_ROUTES, Settings } from '@dload/shared'
3 |
4 | import * as settingStore from './setting-store'
5 |
6 | const router = Router()
7 |
8 | router.get(API_ROUTES.GET_SETTINGS, (req, res) => {
9 | res.json(settingStore.getSettings())
10 | })
11 |
12 | router.post(API_ROUTES.UPDATE_SETTINGS, (req, res) => {
13 | const settings = req.body as Settings
14 | settingStore.setSettings(settings)
15 | res.json(settingStore.getSettings())
16 | })
17 |
18 | export default router
19 |
--------------------------------------------------------------------------------
/apps/server/src/modules/settings/setting-store.ts:
--------------------------------------------------------------------------------
1 | import { Settings } from '@dload/shared'
2 |
3 | import Store from '@/libs/store'
4 | import db from '@/services/database'
5 | import { waitToInitialize } from '@/libs/waitToInitialize'
6 |
7 | const DEFAULT_SETTINGS: Settings = {
8 | downloadPaths: ['/downloads'],
9 | maximumActiveDownloads: 2,
10 | }
11 |
12 | const store = new Store({
13 | name: 'settings',
14 | database: db,
15 | })
16 |
17 | export function getSettings() {
18 | return store.get()
19 | }
20 |
21 | export function setSettings(update: Partial) {
22 | return store.safeSet((settings) => ({
23 | ...settings,
24 | ...update,
25 | }))
26 | }
27 |
28 | waitToInitialize(() => {
29 | if (!store.get()) {
30 | store.set(DEFAULT_SETTINGS)
31 | }
32 | const settings = store.get()
33 | store.set({
34 | ...DEFAULT_SETTINGS,
35 | ...settings,
36 | })
37 | })
38 |
--------------------------------------------------------------------------------
/apps/server/src/modules/tasks/task-controller.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import { io } from '@/libs/socket'
3 | import { SOCKET_EVENTS, API_ROUTES, Task } from '@dload/shared'
4 | import * as taskStore from './task-store'
5 | import * as taskService from './task-service'
6 |
7 | const router = Router()
8 |
9 | io.on('connection', (socket) => {
10 | const tasks = taskStore.allTasks()
11 | socket.emit(SOCKET_EVENTS.UPDATE_TASK, Object.values(tasks))
12 | })
13 |
14 | router.post(API_ROUTES.ADD_TASK, async (req, res) => {
15 | const task = req.body as Task
16 | await taskService.addTask(task)
17 | res.json({})
18 | })
19 |
20 | router.post(API_ROUTES.REMOVE_TASK, async (req, res) => {
21 | const taskId = req.body.taskId as string
22 | const task = taskStore.getTask(taskId)
23 | await taskService.removeDownload(task)
24 | res.json({})
25 | })
26 |
27 | router.post(API_ROUTES.TASK_TOGGLE_PAUSE, async (req, res) => {
28 | const taskId = req.body.taskId as string
29 | const task = taskStore.getTask(taskId)
30 | await taskService.togglePause(task)
31 | res.json({})
32 | })
33 |
34 | export default router
35 |
--------------------------------------------------------------------------------
/apps/server/src/modules/tasks/task-service.ts:
--------------------------------------------------------------------------------
1 | import aria2 from '@/libs/aria2'
2 | import {
3 | bytesToSize,
4 | calculatePercentage,
5 | wait,
6 | generateUuid,
7 | getFileExtension,
8 | } from '@/libs/utils'
9 | import {
10 | getDetails,
11 | cleanDownload,
12 | stopDownload,
13 | formatDownloadSpeed,
14 | updateClientTasks,
15 | calculateRemainingTime,
16 | getTaskStatus,
17 | } from './task-utils'
18 | import { waitToInitialize } from '@/libs/waitToInitialize'
19 | import { notify } from '@/modules/notifications/notification-service'
20 | import { Task, TaskType } from '@dload/shared'
21 | import * as logger from '@/libs/logger'
22 |
23 | import * as taskStore from './task-store'
24 | import * as settingStore from '@/modules/settings/setting-store'
25 |
26 | export function initialize() {
27 | // aria2.addListener('aria2.onDownloadStart')
28 | aria2.addListener('aria2.onDownloadComplete', (event) =>
29 | onDownloadComplete(event.gid),
30 | )
31 | aria2.addListener('aria2.onDownloadError', (event) =>
32 | onDownloadError(event.gid),
33 | )
34 | watcher().catch((e) => logger.error('task watcher error', e))
35 | }
36 |
37 | interface TaskPayload {
38 | url: string
39 | name: string
40 | type: TaskType
41 | downloadPath: string
42 | directDownload: boolean
43 | }
44 |
45 | export async function addTask(payload: TaskPayload) {
46 | let name = payload.name
47 | let fileExtension = getFileExtension(name)
48 | if (!name || !fileExtension) {
49 | const details = await getDetails(payload.url)
50 | fileExtension = details.extension
51 | name = name ? `${name}.${fileExtension}` : details.fullName
52 | }
53 | const task: Task = {
54 | id: generateUuid(),
55 | name,
56 | fileExtension,
57 | url: payload.url,
58 | type: payload.type,
59 | downloadPath: payload.downloadPath,
60 | directDownload: payload.directDownload,
61 | progress: 0,
62 | status: 'STALLED',
63 | }
64 | taskStore.upsertTask(task)
65 | if (payload.type === 'QUEUE') return
66 | return startDownload(task)
67 | }
68 |
69 | export async function startDownload(task: Task) {
70 | const gid = await aria2.addUri(task.url, {
71 | dir: task.downloadPath,
72 | out: task.name,
73 | split: 64,
74 | })
75 | taskStore.upsertTask({
76 | ...task,
77 | gid,
78 | })
79 | }
80 |
81 | export async function removeDownload(task: Task) {
82 | if (!task.gid) return taskStore.deleteTask(task.id)
83 | await stopDownload(task.gid)
84 | await cleanDownload(`${task.downloadPath}/${task.name}`)
85 | taskStore.deleteTask(task.id)
86 | }
87 |
88 | async function onDownloadError(gid: string) {
89 | const task = taskStore.getTaskByGID(gid)
90 | if (!task) return
91 | notify({
92 | code: 'DOWNLOAD_ERROR',
93 | type: 'ERROR',
94 | message: `There was an error while downloading ${task.name}`,
95 | })
96 | await removeDownload(task) // todo: work on a retry code
97 | }
98 |
99 | export async function togglePause(task: Task) {
100 | if (!task.gid) return
101 | if (task.status === 'IN_PROGRESS') {
102 | await aria2.pause(task.gid)
103 | taskStore.upsertTask({
104 | ...task,
105 | status: 'PAUSED',
106 | })
107 | } else {
108 | await aria2.unpause(task.gid)
109 | taskStore.upsertTask({
110 | ...task,
111 | status: 'IN_PROGRESS',
112 | })
113 | }
114 | }
115 |
116 | function onDownloadComplete(gid: string) {
117 | const task = taskStore.getTaskByGID(gid)
118 | notify({
119 | code: 'DOWNLOAD_SUCCESSFUL',
120 | message: `${task ? task.name : 'File'} is downloaded`,
121 | })
122 | if (task) taskStore.deleteTask(task.id)
123 | logger.log('TASK COMPLETED', gid)
124 | }
125 |
126 | async function fetchAndUpdateAriaTasks() {
127 | const active = await aria2.tellActive()
128 | const waiting = await aria2.tellWaiting(0, 20)
129 | const items = [...active, ...waiting]
130 | items.forEach((item) => {
131 | const task = taskStore.getTaskByGID(item.gid)
132 | if (!task) return
133 | taskStore.upsertTask({
134 | ...task,
135 | status: getTaskStatus(item.status),
136 | progress: calculatePercentage(item.completedLength, item.totalLength),
137 | downloadSpeed: formatDownloadSpeed(item.downloadSpeed),
138 | remainingTime: calculateRemainingTime(
139 | item.totalLength,
140 | item.completedLength,
141 | item.downloadSpeed,
142 | ),
143 | downloaded: bytesToSize(item.completedLength),
144 | totalSize: bytesToSize(item.totalLength),
145 | })
146 | })
147 | }
148 |
149 | function processQueue() {
150 | const allTasks = Object.values(taskStore.allTasks())
151 | const activeTasks = allTasks.filter((task) => task.type === 'DOWNLOAD')
152 | const maximumActiveDownloads =
153 | settingStore.getSettings().maximumActiveDownloads
154 | if (activeTasks.length >= maximumActiveDownloads) {
155 | return
156 | }
157 | const nextInQueue = allTasks.find((task) => task.type === 'QUEUE')
158 | if (!nextInQueue) return
159 | const task: Task = {
160 | ...nextInQueue,
161 | type: 'DOWNLOAD',
162 | }
163 | taskStore.upsertTask(task)
164 | startDownload(task)
165 | }
166 |
167 | async function watcher() {
168 | while (true) {
169 | await fetchAndUpdateAriaTasks()
170 | updateClientTasks()
171 | processQueue()
172 | await wait(500)
173 | }
174 | }
175 |
176 | waitToInitialize(initialize)
177 |
--------------------------------------------------------------------------------
/apps/server/src/modules/tasks/task-store.ts:
--------------------------------------------------------------------------------
1 | import Store from '@/libs/store'
2 | import db from '@/services/database'
3 | import { Task } from '@dload/shared'
4 |
5 | const store = new Store>({
6 | name: 'tasks',
7 | database: db,
8 | })
9 |
10 | export function allTasks() {
11 | return store.get() || {}
12 | }
13 |
14 | export function getTask(id: string) {
15 | return store.get()?.[id]
16 | }
17 |
18 | export function getTaskByGID(gid: string) {
19 | const tasks = allTasks()
20 | return Object.values(tasks).find((task) => task.gid === gid)
21 | }
22 |
23 | export function upsertTask(task: Task) {
24 | store.safeSet((tasks) => {
25 | if (!tasks) tasks = {}
26 | tasks[task.id] = task
27 | return tasks
28 | })
29 | }
30 |
31 | export function deleteTask(taskId: string) {
32 | store.safeSet((tasks) => {
33 | delete tasks[taskId]
34 | })
35 | }
36 |
--------------------------------------------------------------------------------
/apps/server/src/modules/tasks/task-utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | wait,
3 | removeFile,
4 | bytesToSize,
5 | suppressError,
6 | formatRemainingTime,
7 | getFileExtension,
8 | } from '@/libs/utils'
9 | import aria2, { AriaAdapter } from '@/libs/aria2'
10 | import { io } from '@/libs/socket'
11 | import { SOCKET_EVENTS } from '@dload/shared'
12 |
13 | import * as taskStore from './task-store'
14 |
15 | export function updateClientTasks() {
16 | const tasks = taskStore.allTasks()
17 | io.emit(SOCKET_EVENTS.UPDATE_TASK, Object.values(tasks))
18 | }
19 |
20 | export function formatDownloadSpeed(downloadSpeed: bigint) {
21 | return downloadSpeed ? `${bytesToSize(downloadSpeed)}/s` : ''
22 | }
23 |
24 | export async function cleanDownload(path: string) {
25 | await removeFile(path)
26 | await removeFile(`${path}.aria2`)
27 | }
28 |
29 | export function calculateRemainingTime(
30 | total: bigint,
31 | completed: bigint,
32 | downloadSpeed: bigint,
33 | ) {
34 | if (!total || !downloadSpeed) return ''
35 | const remaining = total - completed
36 | const seconds = Math.ceil(Number(remaining / downloadSpeed))
37 | return formatRemainingTime(seconds)
38 | }
39 |
40 | export async function stopDownload(gid: string) {
41 | await suppressError(aria2.remove(gid))
42 | await suppressError(aria2.removeDownloadResult(gid))
43 | }
44 |
45 | export async function getDetails(url: string) {
46 | const gid = await aria2.addUri(url)
47 | let tries = 20
48 | let item = null
49 | while (tries > 0) {
50 | await wait(100)
51 | item = await aria2.tellStatus(gid)
52 | if (item.totalLength) break
53 | tries -= 1
54 | }
55 | await stopDownload(gid)
56 | if (item && item.totalLength) {
57 | const { path } = item.files[0]
58 | const name = path.substring(path.lastIndexOf('/') + 1)
59 | await cleanDownload(path)
60 | return {
61 | fullName: name,
62 | extension: getFileExtension(name),
63 | }
64 | }
65 | return { fullName: 'file', extension: '' }
66 | }
67 |
68 | export function getTaskStatus(status: AriaAdapter.EAria2DownloadState) {
69 | switch (status) {
70 | case AriaAdapter.EAria2DownloadState.Active:
71 | return 'IN_PROGRESS'
72 | case AriaAdapter.EAria2DownloadState.Paused:
73 | return 'PAUSED'
74 | case AriaAdapter.EAria2DownloadState.Waiting:
75 | return 'STALLED'
76 | case AriaAdapter.EAria2DownloadState.Complete:
77 | return 'COMPLETED'
78 | default:
79 | return 'ERROR'
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/apps/server/src/server.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import http from 'node:http'
3 | import cors from 'cors'
4 |
5 | import { io } from '@/libs/socket'
6 | import * as logger from '@/libs/logger'
7 | import TaskRoutes from '@/modules/tasks/task-controller'
8 | import SettingRoutes from '@/modules/settings/setting-controller'
9 | import { errorHandler } from '@/services/error-handler'
10 |
11 | const expressServer = express()
12 | expressServer.use(cors())
13 | expressServer.use(express.json())
14 |
15 | // Routes
16 | expressServer.use(TaskRoutes)
17 | expressServer.use(SettingRoutes)
18 |
19 | expressServer.use(errorHandler)
20 |
21 | const httpServer = http.createServer(expressServer)
22 |
23 | io.attach(httpServer)
24 |
25 | export function startServer() {
26 | httpServer.listen(8000, () => {
27 | logger.info('started at http://localhost:8000')
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/apps/server/src/services/database.ts:
--------------------------------------------------------------------------------
1 | import { getEnvValue, logErrorAndRethrow } from '@/libs/utils'
2 | import fs from 'fs-extra'
3 | import { get, set, throttle } from 'lodash'
4 |
5 | const DATABASE_PATH = `${getEnvValue('CONFIG_PATH')}/data/database.json`
6 |
7 | type DatabaseData = Record
8 |
9 | const throttledUpdate = throttle(
10 | (data: DatabaseData) => {
11 | return fs.writeJson(DATABASE_PATH, data)
12 | },
13 | 1000,
14 | { leading: false, trailing: true },
15 | )
16 |
17 | export class Database {
18 | private data: DatabaseData = {}
19 | private isLoaded = false
20 |
21 | async init() {
22 | try {
23 | await fs.ensureFile(DATABASE_PATH)
24 | let data: DatabaseData | null = await fs.readJSON(DATABASE_PATH, {
25 | throws: false,
26 | })
27 | if (!data) {
28 | data = { version: '1.0' }
29 | }
30 | await fs.writeJson(DATABASE_PATH, data)
31 | this.data = data
32 | this.isLoaded = true
33 | } catch (e) {
34 | const error = e as Error
35 | logErrorAndRethrow(error)
36 | }
37 | }
38 |
39 | getPath(path: string) {
40 | this.checkDatabaseLoaded()
41 | return get(this.data, path)
42 | }
43 |
44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
45 | setPath(path: string, data: any) {
46 | this.checkDatabaseLoaded()
47 | set(this.data, path, data)
48 | this.updateStorage()
49 | }
50 |
51 | getAll() {
52 | this.checkDatabaseLoaded()
53 | return this.data
54 | }
55 |
56 | setAll(data: DatabaseData) {
57 | this.checkDatabaseLoaded()
58 | this.data = data
59 | this.updateStorage()
60 | }
61 |
62 | updateStorage() {
63 | if (!this.isLoaded) return
64 | return throttledUpdate(this.data)?.catch((error) =>
65 | logErrorAndRethrow(error),
66 | )
67 | }
68 |
69 | private checkDatabaseLoaded() {
70 | if (!this.isLoaded) {
71 | logErrorAndRethrow('Database is not loaded')
72 | }
73 | }
74 | }
75 |
76 | export default new Database()
77 |
--------------------------------------------------------------------------------
/apps/server/src/services/error-handler.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express'
2 |
3 | export function errorHandler(
4 | err: Error,
5 | req: Request,
6 | res: Response,
7 | next: () => void,
8 | ) {
9 | res.status(res.statusCode && res.statusCode !== 200 ? res.statusCode : 500)
10 | res.send({
11 | message: err.message,
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/apps/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@dload/tsconfig/node.json",
3 | "compilerOptions": {
4 | "module": "CommonJS",
5 | "moduleResolution": "Node",
6 | "paths": {
7 | "@/*": ["./src/*"]
8 | }
9 | },
10 | "include": ["src/**/*.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/apps/server/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig((options) => ({
4 | entry: ['src/main.ts'],
5 | treeshake: true,
6 | minify: true,
7 | ...options,
8 | }))
9 |
10 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-bookworm as base
2 |
3 | FROM base as setup
4 |
5 | ARG TARGETPLATFORM
6 |
7 | RUN mkdir root-outputs
8 |
9 | COPY docker/scripts/install-s6overlay.sh /tmp
10 |
11 | RUN /tmp/install-s6overlay.sh "${TARGETPLATFORM}" /root-outputs
12 |
13 | WORKDIR /app
14 |
15 | RUN yarn global add turbo
16 |
17 | COPY . .
18 |
19 | RUN turbo prune --scope=@dload/aria --scope=@dload/server --scope=@dload/client --docker --out-dir=out
20 |
21 | FROM base as installer
22 |
23 | WORKDIR /app
24 |
25 | COPY .gitignore .gitignore
26 | COPY --from=setup /app/out/yarn.lock ./yarn.lock
27 | COPY --from=setup /app/out/json/ .
28 | RUN yarn config set registry https://registry.npmjs.org/
29 | RUN yarn config set network-timeout 1200000
30 | RUN yarn install
31 |
32 | COPY --from=setup /app/out/full/ .
33 | COPY turbo.json turbo.json
34 |
35 | RUN yarn clean
36 |
37 | RUN yarn build
38 |
39 | FROM debian:bookworm AS runner
40 |
41 | ENV \
42 | S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0
43 |
44 | RUN apt update && DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends \
45 | ca-certificates \
46 | procps \
47 | gnupg \
48 | bash \
49 | curl \
50 | grep \
51 | git \
52 | nginx
53 |
54 | RUN mkdir -p /etc/apt/keyrings && \
55 | curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
56 | NODE_MAJOR=20 && \
57 | echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
58 | apt update && apt install -y nodejs
59 |
60 | COPY docker/root-fs /
61 | COPY --from=setup /root-outputs /
62 | COPY docker/nginx.conf /etc/nginx/nginx.conf
63 |
64 | WORKDIR /app
65 |
66 | COPY --from=installer /app .
67 | COPY --from=installer /app/apps/client/dist /usr/share/nginx/html
68 |
69 | RUN addgroup --system --gid 1000 dload
70 | RUN adduser --system --uid 1000 dload
71 | RUN usermod -g dload dload
72 |
73 | ENTRYPOINT [ "/init" ]
74 |
--------------------------------------------------------------------------------
/docker/nginx-dev.conf:
--------------------------------------------------------------------------------
1 | events {
2 | worker_connections 1024;
3 | }
4 |
5 | http {
6 |
7 | include /etc/nginx/mime.types;
8 |
9 | access_log /var/log/nginx/access.log;
10 | error_log /var/log/nginx/error.log;
11 |
12 | server {
13 | listen 8080 default_server;
14 | listen [::]:8080 default_server;
15 |
16 | server_name dload;
17 | # root /workspaces/dload/apps/client/dist;
18 |
19 | location / {
20 | add_header X-Served-By $host;
21 | proxy_set_header Host $host;
22 | proxy_set_header X-Forwarded-Scheme $scheme;
23 | proxy_set_header X-Forwarded-Proto $scheme;
24 | proxy_set_header X-Forwarded-For $remote_addr;
25 | proxy_pass http://localhost:3000;
26 |
27 | proxy_read_timeout 15m;
28 | proxy_send_timeout 15m;
29 |
30 | }
31 |
32 | location /socket.io/ {
33 | proxy_http_version 1.1;
34 | proxy_set_header Upgrade $http_upgrade;
35 | proxy_set_header Connection "upgrade";
36 |
37 | proxy_pass http://127.0.0.1:8000/socket.io/;
38 | }
39 |
40 | location /api/ {
41 | add_header X-Served-By $host;
42 | proxy_set_header Host $host;
43 | proxy_set_header X-Forwarded-Scheme $scheme;
44 | proxy_set_header X-Forwarded-Proto $scheme;
45 | proxy_set_header X-Forwarded-For $remote_addr;
46 | proxy_pass http://127.0.0.1:8000/;
47 |
48 | proxy_read_timeout 15m;
49 | proxy_send_timeout 15m;
50 | }
51 |
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/docker/nginx.conf:
--------------------------------------------------------------------------------
1 | events {
2 | worker_connections 1024;
3 | }
4 |
5 | http {
6 |
7 | include /etc/nginx/mime.types;
8 |
9 | access_log /var/log/nginx/access.log;
10 | error_log /var/log/nginx/error.log;
11 |
12 | server {
13 | listen 80 default_server;
14 | listen [::]:80 default_server;
15 |
16 | server_name dload;
17 | root /usr/share/nginx/html;
18 |
19 | location /socket.io/ {
20 | proxy_http_version 1.1;
21 | proxy_set_header Upgrade $http_upgrade;
22 | proxy_set_header Connection "upgrade";
23 |
24 | proxy_pass http://127.0.0.1:8000/socket.io/;
25 | }
26 |
27 | location /api/ {
28 | add_header X-Served-By $host;
29 | proxy_set_header Host $host;
30 | proxy_set_header X-Forwarded-Scheme $scheme;
31 | proxy_set_header X-Forwarded-Proto $scheme;
32 | proxy_set_header X-Forwarded-For $remote_addr;
33 | proxy_pass http://127.0.0.1:8000/;
34 |
35 | proxy_read_timeout 15m;
36 | proxy_send_timeout 15m;
37 | }
38 |
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/aria-svc/dependencies.d/setup:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/aria-svc/dependencies.d/setup
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/aria-svc/run:
--------------------------------------------------------------------------------
1 | #!/command/with-contenv bash
2 | # shellcheck shell=bash
3 |
4 | exec \
5 | cd /app/apps/aria \
6 | s6-setuidgid dload node dist/start.js
7 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/aria-svc/type:
--------------------------------------------------------------------------------
1 | longrun
2 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/nginx-svc/dependencies.d/setup:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/nginx-svc/dependencies.d/setup
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/nginx-svc/run:
--------------------------------------------------------------------------------
1 | #!/command/with-contenv bash
2 | # shellcheck shell=bash
3 |
4 | nginx -g 'daemon off;'
5 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/nginx-svc/type:
--------------------------------------------------------------------------------
1 | longrun
2 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/server-svc/dependencies.d/aria-svc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/server-svc/dependencies.d/aria-svc
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/server-svc/run:
--------------------------------------------------------------------------------
1 | #!/command/with-contenv bash
2 | # shellcheck shell=bash
3 |
4 | exec \
5 | cd /app/apps/server \
6 | s6-setuidgid dload node dist/main.js
7 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/server-svc/type:
--------------------------------------------------------------------------------
1 | longrun
2 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/setup/dependencies.d/base:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/setup/dependencies.d/base
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/setup/run:
--------------------------------------------------------------------------------
1 | #!/command/with-contenv bash
2 | # shellcheck shell=bash
3 |
4 | mkdir -p /config
5 |
6 | chown -R dload:dload /app
7 | chown -R dload:dload /config
8 | chown -R dload:dload /downloads
9 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/setup/type:
--------------------------------------------------------------------------------
1 | oneshot
2 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/setup/up:
--------------------------------------------------------------------------------
1 | /etc/s6-overlay/s6-rc.d/setup/run
2 |
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/aria-svc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/aria-svc
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx-svc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx-svc
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/server-svc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/server-svc
--------------------------------------------------------------------------------
/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/setup:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/docker/root-fs/etc/s6-overlay/s6-rc.d/user/contents.d/setup
--------------------------------------------------------------------------------
/docker/scripts/install-s6overlay.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | TARGETPLATFORM=${1:-linux/amd64}
4 | OUTPUT=${2:-/}
5 | S6_OVERLAY_VERSION=3.1.5.0
6 | S6_OVERLAY_ARCH=x86_64
7 |
8 | if [ $TARGETPLATFORM = "linux/arm64" ]; then
9 | S6_OVERLAY_ARCH=aarch64
10 | elif [ $TARGETPLATFORM = "linux/amd64" ]; then
11 | S6_OVERLAY_ARCH=x86_64
12 | else
13 | echo "$TARGETPLATFORM PLATFORM NOT SUPPORTED"
14 | exit 1
15 | fi
16 |
17 | curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" -o '/tmp/s6-overlay-noarch.tar.xz'
18 | curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz" -o "/tmp/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz"
19 | tar -C $OUTPUT -Jxpf "/tmp/s6-overlay-noarch.tar.xz"
20 | tar -C $OUTPUT -Jxpf "/tmp/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz"
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dload",
3 | "version": "1.0.2",
4 | "description": "Downloader for server",
5 | "author": "fuzzknob",
6 | "license": "MIT",
7 | "private": true,
8 | "workspaces": [
9 | "packages/*",
10 | "apps/*"
11 | ],
12 | "scripts": {
13 | "start": "turbo run start",
14 | "build": "turbo run build",
15 | "dev": "turbo run dev",
16 | "clean": "turbo run clean",
17 | "aria:dev": "turbo run dev --scope=aria",
18 | "client:dev": "turbo run dev --scope=client",
19 | "server:dev": "turbo run dev --scope=server"
20 | },
21 | "dependencies": {
22 | "turbo": "^1.7.0"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@dload/shared",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "module": "dist/index.mjs",
6 | "scripts": {
7 | "build": "tsup"
8 | },
9 | "devDependencies": {
10 | "@dload/tsconfig": "*",
11 | "tsup": "^7.2.0",
12 | "typescript": "^5.2.2"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shared/src/api_routes.ts:
--------------------------------------------------------------------------------
1 | export const ADD_TASK = '/task/add'
2 | export const REMOVE_TASK = '/task/remove'
3 | export const TASK_TOGGLE_PAUSE = '/task/toggle-pause'
4 |
5 | export const GET_SETTINGS = '/settings'
6 | export const UPDATE_SETTINGS = '/settings/update'
7 |
--------------------------------------------------------------------------------
/packages/shared/src/index.ts:
--------------------------------------------------------------------------------
1 | export * as SOCKET_EVENTS from './socket_events'
2 | export * as API_ROUTES from './api_routes'
3 | export type { Task, TaskType, Settings } from './types'
4 |
--------------------------------------------------------------------------------
/packages/shared/src/socket_events.ts:
--------------------------------------------------------------------------------
1 | // TASKS
2 | export const UPDATE_TASK = 'UPDATE_TASK'
3 |
4 | // NOTIFICATIONS
5 | export const NOTIFY = 'NOTIFY'
6 |
--------------------------------------------------------------------------------
/packages/shared/src/types.ts:
--------------------------------------------------------------------------------
1 | export type TaskType = 'DOWNLOAD' | 'COPY' | 'QUEUE'
2 |
3 | export interface Task {
4 | id: string
5 | name: string
6 | fileExtension: string
7 | status: 'STALLED' | 'IN_PROGRESS' | 'PAUSED' | 'COMPLETED' | 'ERROR'
8 | url: string
9 | downloadPath: string
10 | type: TaskType
11 | progress: number
12 | directDownload: boolean
13 | gid?: string
14 | remainingTime?: string
15 | downloadSpeed?: string
16 | downloaded?: string
17 | totalSize?: string
18 | }
19 |
20 |
21 | export interface Settings {
22 | downloadPaths: string[]
23 | maximumActiveDownloads: number
24 | }
25 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@dload/tsconfig/node.json",
3 | "include": ["src/**/*.ts"],
4 | }
5 |
--------------------------------------------------------------------------------
/packages/shared/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig((options) => ({
4 | entry: ['src/**/*.ts'],
5 | format: ['esm', 'cjs'],
6 | splitting: true,
7 | dts: true,
8 | treeshake: true,
9 | minify: true,
10 | clean: true,
11 | ...options,
12 | }))
13 |
--------------------------------------------------------------------------------
/packages/tsconfig/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "skipLibCheck": true,
5 | "resolveJsonModule": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "strict": true,
8 | "noFallthroughCasesInSwitch": true
9 | },
10 | "exclude": ["node_modules", "dist"]
11 | }
12 |
--------------------------------------------------------------------------------
/packages/tsconfig/node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./base.json",
3 | "compilerOptions": {
4 | "target": "ES2020",
5 | "module": "NodeNext",
6 | "moduleResolution": "NodeNext",
7 | "declaration": true,
8 | "outDir": "./dist",
9 | "lib": ["ES2020"],
10 | "importHelpers": true,
11 | "esModuleInterop": true
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/tsconfig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@dload/tsconfig",
3 | "version": "0.0.1"
4 | }
5 |
--------------------------------------------------------------------------------
/packages/tsconfig/react.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./base.json",
3 | "compilerOptions": {
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "allowSyntheticDefaultImports": true,
7 | "lib": ["DOM", "DOM.Iterable", "ES2020"],
8 | "module": "ESNext",
9 | "moduleResolution": "Node",
10 | "isolatedModules": true,
11 | "noEmit": true,
12 | "jsx": "react-jsx"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/screenshots/Dload.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/screenshots/Dload.png
--------------------------------------------------------------------------------
/screenshots/download-modal.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/screenshots/download-modal.jpg
--------------------------------------------------------------------------------
/screenshots/main-page.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/screenshots/main-page.jpg
--------------------------------------------------------------------------------
/screenshots/settings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fuzzknob/dload/536759535d741cfd28cc7112b75dd81b9851a30e/screenshots/settings.jpg
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": ["dist/**"],
7 | "cache": true
8 | },
9 | "dev": {
10 | "dependsOn": ["^build"],
11 | "cache": false,
12 | "persistent": true
13 | },
14 | "start": {
15 | "cache": false,
16 | "persistent": true
17 | },
18 | "clean": {
19 | "cache": false
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------