Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb2831bc6 | ||
| 9361d9ce29 | |||
| d100092f35 | |||
| d64303ccfa | |||
| 6688bc3977 | |||
| 600475f603 | |||
| da4aaeac84 | |||
|
|
2d75030cbc | ||
|
|
38bc66cd03 | ||
| 3efbb9d464 | |||
| fa4ba8a211 | |||
|
|
75ec95041d | ||
| 1b4c8c751b | |||
|
|
4710db25ee | ||
|
|
8337aae438 | ||
| 2d104f4539 | |||
| 42618b61c9 | |||
| 764c1f4729 | |||
| 9bb5d7bb0d | |||
| 557cf7fbd8 | |||
|
|
2040385621 | ||
|
|
665a08ddc4 | ||
|
|
268d4cd990 | ||
|
|
a9b9973c7f | ||
|
|
0c304fc3c4 | ||
|
|
24602b3e50 | ||
|
|
8e720a15bd | ||
|
|
9d9a7fa981 | ||
|
|
d57c440afe | ||
|
|
8bbc8aa35e | ||
|
|
19062c9f41 | ||
|
|
5c8c534df4 | ||
|
|
a3234955a3 | ||
|
|
13f104646a | ||
|
|
c50c1f627e | ||
| a005f159c6 | |||
| 0607bb4495 | |||
|
|
be4641aaf0 | ||
| 232cd8e442 | |||
| 1442eb8e9d | |||
| 8c57a7bb28 | |||
|
|
e2dd54add2 | ||
| db5097c889 | |||
|
|
50a04075a3 | ||
| 4bc5e5e1c7 |
@@ -1,15 +1,17 @@
|
|||||||
node_modules
|
|
||||||
downloads
|
|
||||||
dist
|
dist
|
||||||
package-lock.json
|
package-lock.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
.pnpm-debug.log
|
.pnpm-debug.log
|
||||||
.parcel-cache
|
node_modules
|
||||||
.git
|
|
||||||
src/server/core/*.exe
|
|
||||||
src/server/core/yt-dlp
|
|
||||||
.env
|
.env
|
||||||
*.mp4
|
*.mp4
|
||||||
*.ytdl
|
*.ytdl
|
||||||
|
*.part
|
||||||
*.db
|
*.db
|
||||||
|
downloads
|
||||||
|
.DS_Store
|
||||||
build/
|
build/
|
||||||
|
yt-dlp-webui
|
||||||
|
session.dat
|
||||||
|
config.yml
|
||||||
|
cookies.txt
|
||||||
|
|||||||
28
.github/workflows/docker-image.yml
vendored
28
.github/workflows/docker-image.yml
vendored
@@ -1,28 +0,0 @@
|
|||||||
name: Docker Image CI (Dockerhub)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ master ]
|
|
||||||
schedule:
|
|
||||||
- cron: '0 1 * * *'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
env:
|
|
||||||
DOCKER_USER: ${{secrets.DOCKER_HUB_USERNAME}}
|
|
||||||
DOCKER_PASSWORD: ${{secrets.DOCKER_HUB_PASSWORD}}
|
|
||||||
run: |
|
|
||||||
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
|
|
||||||
- name: Install buildx
|
|
||||||
uses: crazy-max/ghaction-docker-buildx@v1
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
- name: Build the Docker image
|
|
||||||
run: docker buildx build . --file Dockerfile --tag ${{secrets.DOCKER_HUB_USERNAME}}/yt-dlp-webui:latest --push --platform linux/amd64,linux/arm/v7,linux/arm64
|
|
||||||
|
|
||||||
95
.github/workflows/docker-publish.yml
vendored
95
.github/workflows/docker-publish.yml
vendored
@@ -1,31 +1,14 @@
|
|||||||
name: Docker (ghcr.io)
|
name: Docker
|
||||||
|
|
||||||
# This workflow uses actions that are not certified by GitHub.
|
|
||||||
# They are provided by a third-party and are governed by
|
|
||||||
# separate terms of service, privacy policy, and support
|
|
||||||
# documentation.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
branches: [ master ]
|
types: [published]
|
||||||
tags: [ 'v*.*.*' ]
|
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
pull_request:
|
|
||||||
branches: [ master ]
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron : '0 1 * * 0'
|
- cron : '0 1 * * 0'
|
||||||
|
|
||||||
env:
|
|
||||||
# Use docker.io for Docker Hub if empty
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
# github.repository as <account>/<repo>
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
publish:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -33,65 +16,67 @@ jobs:
|
|||||||
# This is used to complete the identity challenge
|
# This is used to complete the identity challenge
|
||||||
# with sigstore/fulcio when running outside of PRs.
|
# with sigstore/fulcio when running outside of PRs.
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
|
||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
if: github.event_name != 'pull_request'
|
# v3.1.2
|
||||||
uses: sigstore/cosign-installer@1e95c1de343b5b0c23352d6417ee3e48d5bcd422
|
uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19
|
||||||
with:
|
with:
|
||||||
cosign-release: 'v1.13.1'
|
cosign-release: 'v1.13.1'
|
||||||
|
|
||||||
|
- name: Set up QEMU for ARM emulation
|
||||||
# Workaround: https://github.com/docker/build-push-action/issues/461
|
# v2.2.0
|
||||||
- name: Set up QEMU
|
uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
- name: Setup Docker buildx
|
|
||||||
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
|
|
||||||
|
|
||||||
# Login against a Docker registry except on PR
|
|
||||||
# https://github.com/docker/login-action
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
platforms: all
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
# 2.10.0
|
||||||
|
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
# 2.2.0
|
||||||
|
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
- name: Login to GHCR
|
||||||
|
# 2.2.0
|
||||||
|
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Extract metadata (tags, labels) for Docker
|
- name: Generate Docker metadata
|
||||||
# https://github.com/docker/metadata-action
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
# v4.6.0
|
||||||
|
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/yt-dlp-webui
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
|
||||||
# https://github.com/docker/build-push-action
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
# v4.2.1
|
||||||
|
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
push: true
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels}}
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
|
||||||
# repository is public to avoid leaking data. If you would like to publish
|
|
||||||
# transparency data even for private images, pass --force to cosign below.
|
|
||||||
# https://github.com/sigstore/cosign
|
|
||||||
- name: Sign the published Docker image
|
- name: Sign the published Docker image
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
env:
|
env:
|
||||||
COSIGN_EXPERIMENTAL: "true"
|
COSIGN_EXPERIMENTAL: "true"
|
||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: cosign sign ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
run: |
|
||||||
|
cosign sign ghcr.io/${{ github.repository }}@${{ steps.build-and-push.outputs.digest }}
|
||||||
|
cosign sign docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/yt-dlp-webui@${{ steps.build-and-push.outputs.digest }}
|
||||||
|
|||||||
16
.github/workflows/test-container.yml
vendored
Normal file
16
.github/workflows/test-container.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Build the Docker image
|
||||||
|
run: docker build . --file Dockerfile --tag my-image-name:$(date +%s)
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,11 +1,8 @@
|
|||||||
.parcel-cache
|
|
||||||
dist
|
dist
|
||||||
package-lock.json
|
package-lock.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
.pnpm-debug.log
|
.pnpm-debug.log
|
||||||
node_modules
|
node_modules
|
||||||
src/server/core/*.exe
|
|
||||||
src/server/core/yt-dlp
|
|
||||||
.env
|
.env
|
||||||
*.mp4
|
*.mp4
|
||||||
*.ytdl
|
*.ytdl
|
||||||
@@ -16,3 +13,5 @@ downloads
|
|||||||
build/
|
build/
|
||||||
yt-dlp-webui
|
yt-dlp-webui
|
||||||
session.dat
|
session.dat
|
||||||
|
config.yml
|
||||||
|
cookies.txt
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -1,33 +1,29 @@
|
|||||||
FROM golang:1.20-alpine AS build
|
FROM golang:1.20-alpine AS build
|
||||||
# folder structure
|
|
||||||
WORKDIR /usr/src/yt-dlp-webui
|
|
||||||
# install core dependencies
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add nodejs npm go
|
apk add nodejs npm go
|
||||||
# copia la salsa
|
|
||||||
COPY . .
|
COPY . /usr/src/yt-dlp-webui
|
||||||
# build frontend
|
|
||||||
WORKDIR /usr/src/yt-dlp-webui/frontend
|
WORKDIR /usr/src/yt-dlp-webui/frontend
|
||||||
RUN npm install
|
RUN npm install
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
# build backend + incubator
|
|
||||||
WORKDIR /usr/src/yt-dlp-webui
|
WORKDIR /usr/src/yt-dlp-webui
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
|
RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
|
||||||
|
|
||||||
# but here yes :)
|
|
||||||
FROM alpine:edge
|
FROM alpine:edge
|
||||||
|
|
||||||
WORKDIR /downloads
|
VOLUME /downloads /config
|
||||||
VOLUME /downloads
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add psmisc ffmpeg yt-dlp
|
apk add psmisc ffmpeg yt-dlp --no-cache
|
||||||
|
|
||||||
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
|
||||||
|
|
||||||
ENV JWT_SECRET=secret
|
ENV JWT_SECRET=secret
|
||||||
|
|
||||||
EXPOSE 3033
|
EXPOSE 3033
|
||||||
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads" ]
|
ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads", "--conf", "/config/config.yml" ]
|
||||||
|
|||||||
7
Makefile
7
Makefile
@@ -6,11 +6,10 @@ all:
|
|||||||
CGO_ENABLED=0 go build -o yt-dlp-webui main.go
|
CGO_ENABLED=0 go build -o yt-dlp-webui main.go
|
||||||
|
|
||||||
multiarch:
|
multiarch:
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o yt-dlp-webui_linux-arm main.go
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o yt-dlp-webui_linux-arm64 main.go
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o yt-dlp-webui_linux-amd64 main.go
|
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
mv yt-dlp-webui* build
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o build/yt-dlp-webui_linux-arm main.go
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/yt-dlp-webui_linux-arm64 main.go
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/yt-dlp-webui_linux-amd64 main.go
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build
|
rm -rf build
|
||||||
24
README.md
24
README.md
@@ -21,8 +21,8 @@ docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
|
|||||||
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
|
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
### Integrated File browser
|
### Integrated File browser
|
||||||
Stream or download your content, easily.
|
Stream or download your content, easily.
|
||||||
@@ -107,6 +107,12 @@ Or with docker but building the container manually.
|
|||||||
```sh
|
```sh
|
||||||
docker build -t yt-dlp-webui .
|
docker build -t yt-dlp-webui .
|
||||||
docker run -d -p 3033:3033 -v <your dir>:/downloads yt-dlp-webui
|
docker run -d -p 3033:3033 -v <your dir>:/downloads yt-dlp-webui
|
||||||
|
|
||||||
|
docker run -d -p 3033:3033 \
|
||||||
|
-v <your dir>:/downloads \
|
||||||
|
-v <your dir>:/config \ # optional
|
||||||
|
yt-dlp-webui
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you opt to add RPC authentication...
|
If you opt to add RPC authentication...
|
||||||
@@ -115,9 +121,11 @@ docker run -d \
|
|||||||
-p 3033:3033 \
|
-p 3033:3033 \
|
||||||
-e JWT_SECRET randomsecret
|
-e JWT_SECRET randomsecret
|
||||||
-v /path/to/downloads:/downloads \
|
-v /path/to/downloads:/downloads \
|
||||||
|
-v /path/for/config:/config \ # optional
|
||||||
marcobaobao/yt-dlp-webui \
|
marcobaobao/yt-dlp-webui \
|
||||||
--auth \
|
--auth \
|
||||||
--secret your_rpc_secret
|
--user your_username \
|
||||||
|
--pass your_pass
|
||||||
```
|
```
|
||||||
|
|
||||||
If you wish for limiting the download queue size...
|
If you wish for limiting the download queue size...
|
||||||
@@ -163,8 +171,10 @@ Usage yt-dlp-webui:
|
|||||||
Port where server will listen at (default 3033)
|
Port where server will listen at (default 3033)
|
||||||
-qs int
|
-qs int
|
||||||
Download queue size (default 8)
|
Download queue size (default 8)
|
||||||
-secret string
|
-user string
|
||||||
Secret required for auth
|
Username required for auth
|
||||||
|
-pass string
|
||||||
|
Password required for auth
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config file
|
### Config file
|
||||||
@@ -181,7 +191,9 @@ downloaderPath: /usr/local/bin/yt-dlp
|
|||||||
|
|
||||||
# Optional settings
|
# Optional settings
|
||||||
require_auth: true
|
require_auth: true
|
||||||
rpc_secret: my_random_secret
|
username: my_username
|
||||||
|
password: my_random_secret
|
||||||
|
|
||||||
queue_size: 4
|
queue_size: 4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -11,25 +11,26 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@fontsource/roboto": "^5.0.6",
|
||||||
"@mui/icons-material": "^5.11.16",
|
"@mui/icons-material": "^5.11.16",
|
||||||
"@mui/material": "^5.13.5",
|
"@mui/material": "^5.13.5",
|
||||||
|
"fp-ts": "^2.16.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.13.0",
|
"react-helmet": "^6.1.0",
|
||||||
|
"react-router-dom": "^6.17.0",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"uuid": "^9.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.8.7",
|
||||||
"@types/react": "^18.2.13",
|
"@types/react": "^18.2.29",
|
||||||
"@types/react-dom": "^18.2.6",
|
"@types/react-dom": "^18.2.14",
|
||||||
|
"@types/react-helmet": "^6.1.8",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/uuid": "^9.0.2",
|
"@vitejs/plugin-react-swc": "^3.4.0",
|
||||||
"@vitejs/plugin-react": "^4.0.3",
|
"typescript": "^5.2.2",
|
||||||
"buffer": "^6.0.3",
|
"vite": "^4.5.0"
|
||||||
"typescript": "^5.1.3",
|
|
||||||
"vite": "^4.4.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ import Toolbar from '@mui/material/Toolbar'
|
|||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { grey } from '@mui/material/colors'
|
import { grey } from '@mui/material/colors'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet'
|
||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilValue } from 'recoil'
|
||||||
import { settingsState } from './atoms/settings'
|
import { settingsState } from './atoms/settings'
|
||||||
@@ -52,6 +53,11 @@ export default function Layout() {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<SocketSubscriber>
|
<SocketSubscriber>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{settings.appTitle}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<AppBar position="absolute" open={open}>
|
<AppBar position="absolute" open={open}>
|
||||||
@@ -75,7 +81,7 @@ export default function Layout() {
|
|||||||
noWrap
|
noWrap
|
||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
yt-dlp WebUI
|
{settings.appTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FreeSpaceIndicator />
|
<FreeSpaceIndicator />
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -33,6 +33,45 @@ languages:
|
|||||||
archiveTitle: Archive
|
archiveTitle: Archive
|
||||||
clipboardAction: Copied URL to clipboard
|
clipboardAction: Copied URL to clipboard
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may close this window)
|
||||||
|
restartAppMessage: Needs a page reload to take effect
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
|
french:
|
||||||
|
urlInput: URL vidéo de YouTube ou d'un autre service pris en charge
|
||||||
|
statusTitle: Statut
|
||||||
|
statusReady: Prêt
|
||||||
|
selectFormatButton: Sélectionner le format
|
||||||
|
startButton: Démarrer
|
||||||
|
abortAllButton: Tout arrêter
|
||||||
|
updateBinButton: Mettre à jour l'exécutable yt-dlp
|
||||||
|
darkThemeButton: Thème sombre
|
||||||
|
lightThemeButton: Thème clair
|
||||||
|
settingsAnchor: Paramètres
|
||||||
|
serverAddressTitle: Adresse du serveur
|
||||||
|
serverPortTitle: Port
|
||||||
|
extractAudioCheckbox: Extraire l'audio
|
||||||
|
noMTimeCheckbox: Ne pas définir le temps de modification du fichier
|
||||||
|
bgReminder: Une fois que vous aurez fermé cette page, le téléchargement continuera en arrière-plan.
|
||||||
|
toastConnected: 'Connecté à '
|
||||||
|
toastUpdated: L'exécutable yt-dlp a été mis à jour !
|
||||||
|
formatSelectionEnabler: Activer la sélection des formats vidéo/audio
|
||||||
|
themeSelect: 'Thème'
|
||||||
|
languageSelect: 'Langue'
|
||||||
|
overridesAnchor: Remplacer
|
||||||
|
pathOverrideOption: Activer le remplacement du chemin de sortie
|
||||||
|
filenameOverrideOption: Activer le remplacement du nom du fichier de sortie
|
||||||
|
customFilename: Nom de fichier personnalisé (laisser vide pour utiliser le nom par défaut)
|
||||||
|
customPath: Chemin personnalisé
|
||||||
|
customArgs: Activer les args personnalisés yt-dlp (grand pouvoir = grandes responsabilités)
|
||||||
|
customArgsInput: Arguments yt-dlp personnalisés
|
||||||
|
rpcConnErr: Erreur lors de la connexion au serveur RPC
|
||||||
|
splashText: Aucun téléchargement actif
|
||||||
|
archiveTitle: Archive
|
||||||
|
clipboardAction: URL copiée dans le presse-papiers
|
||||||
|
playlistCheckbox: Télécharger la liste de lecture (cela prendra du temps, vous pouvez fermer cette fenêtre après l'avoir validée)
|
||||||
|
restartAppMessage: Nécessite un rechargement de la page pour prendre effet
|
||||||
|
servedFromReverseProxyCheckbox: Est derrière un sous-dossier de proxy inverse
|
||||||
|
appTitle: Nom de l'application
|
||||||
italian:
|
italian:
|
||||||
urlInput: URL di YouTube o di qualsiasi altro servizio supportato
|
urlInput: URL di YouTube o di qualsiasi altro servizio supportato
|
||||||
statusTitle: Stato
|
statusTitle: Stato
|
||||||
@@ -65,6 +104,9 @@ languages:
|
|||||||
archiveTitle: Archivio
|
archiveTitle: Archivio
|
||||||
clipboardAction: URL copiato negli appunti
|
clipboardAction: URL copiato negli appunti
|
||||||
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
playlistCheckbox: Download playlist (richiederà tempo, puoi chiudere la finestra dopo l'inoltro)
|
||||||
|
restartAppMessage: La finestra deve essere ricaricata perché abbia effetto
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: Titolo applicazione
|
||||||
chinese:
|
chinese:
|
||||||
urlInput: YouTube 或其他受支持服务的视频网址
|
urlInput: YouTube 或其他受支持服务的视频网址
|
||||||
statusTitle: 状态
|
statusTitle: 状态
|
||||||
@@ -97,7 +139,10 @@ languages:
|
|||||||
splashText: 没有正在进行的下载
|
splashText: 没有正在进行的下载
|
||||||
archiveTitle: 归档
|
archiveTitle: 归档
|
||||||
clipboardAction: 复制 URL 到剪贴板
|
clipboardAction: 复制 URL 到剪贴板
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: 下载播放列表(可能需要一段时间,提交后可以关闭页面等待)
|
||||||
|
restartAppMessage: 需要刷新页面才能生效
|
||||||
|
servedFromReverseProxyCheckbox: 处于反向代理的子目录后
|
||||||
|
appTitle: App 标题
|
||||||
spanish:
|
spanish:
|
||||||
urlInput: URL de YouTube u otro servicio compatible
|
urlInput: URL de YouTube u otro servicio compatible
|
||||||
statusTitle: Estado
|
statusTitle: Estado
|
||||||
@@ -130,6 +175,8 @@ languages:
|
|||||||
archiveTitle: Archive
|
archiveTitle: Archive
|
||||||
clipboardAction: Copied URL to clipboard
|
clipboardAction: Copied URL to clipboard
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
russian:
|
russian:
|
||||||
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
urlInput: URL-адрес YouTube или любого другого поддерживаемого сервиса
|
||||||
statusTitle: Статус
|
statusTitle: Статус
|
||||||
@@ -162,6 +209,8 @@ languages:
|
|||||||
archiveTitle: Архив
|
archiveTitle: Архив
|
||||||
clipboardAction: URL скопирован в буфер обмена
|
clipboardAction: URL скопирован в буфер обмена
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
korean:
|
korean:
|
||||||
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
urlInput: YouTube나 다른 지원되는 사이트의 URL
|
||||||
statusTitle: 상태
|
statusTitle: 상태
|
||||||
@@ -194,6 +243,8 @@ languages:
|
|||||||
archiveTitle: Archive
|
archiveTitle: Archive
|
||||||
clipboardAction: Copied URL to clipboard
|
clipboardAction: Copied URL to clipboard
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
japanese:
|
japanese:
|
||||||
urlInput: YouTubeまたはサポート済み動画のURL
|
urlInput: YouTubeまたはサポート済み動画のURL
|
||||||
statusTitle: 状態
|
statusTitle: 状態
|
||||||
@@ -227,6 +278,8 @@ languages:
|
|||||||
archiveTitle: Archive
|
archiveTitle: Archive
|
||||||
clipboardAction: Copied URL to clipboard
|
clipboardAction: Copied URL to clipboard
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
catalan:
|
catalan:
|
||||||
urlInput: URL de YouTube o d'un altre servei compatible
|
urlInput: URL de YouTube o d'un altre servei compatible
|
||||||
statusTitle: Estat
|
statusTitle: Estat
|
||||||
@@ -259,6 +312,8 @@ languages:
|
|||||||
archiveTitle: Archive
|
archiveTitle: Archive
|
||||||
clipboardAction: Copied URL to clipboard
|
clipboardAction: Copied URL to clipboard
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
ukrainian:
|
ukrainian:
|
||||||
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
urlInput: URL-адреса YouTube або будь-якого іншого підтримуваного сервісу
|
||||||
statusTitle: Статус
|
statusTitle: Статус
|
||||||
@@ -291,6 +346,8 @@ languages:
|
|||||||
archiveTitle: Архів
|
archiveTitle: Архів
|
||||||
clipboardAction: URL скопійовано в буфер обміну
|
clipboardAction: URL скопійовано в буфер обміну
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
polish:
|
polish:
|
||||||
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
urlInput: Adres URL YouTube lub innej obsługiwanej usługi
|
||||||
statusTitle: Status
|
statusTitle: Status
|
||||||
@@ -323,3 +380,5 @@ languages:
|
|||||||
archiveTitle: Archiwum
|
archiveTitle: Archiwum
|
||||||
clipboardAction: Adres URL zostanie skopiowany do schowka
|
clipboardAction: Adres URL zostanie skopiowany do schowka
|
||||||
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
playlistCheckbox: Download playlist (it will take time, after submitting you may even close this window)
|
||||||
|
servedFromReverseProxyCheckbox: Is behind a reverse proxy subfolder
|
||||||
|
appTitle: App title
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { atom, selector } from 'recoil'
|
import { atom, selector } from 'recoil'
|
||||||
|
import { prefersDarkMode } from '../utils'
|
||||||
export type Language =
|
|
||||||
| 'english'
|
|
||||||
| 'chinese'
|
|
||||||
| 'russian'
|
|
||||||
| 'italian'
|
|
||||||
| 'spanish'
|
|
||||||
| 'korean'
|
|
||||||
| 'japanese'
|
|
||||||
| 'catalan'
|
|
||||||
| 'ukrainian'
|
|
||||||
| 'polish'
|
|
||||||
|
|
||||||
export const languages = [
|
export const languages = [
|
||||||
'english',
|
'english',
|
||||||
@@ -25,19 +14,24 @@ export const languages = [
|
|||||||
'polish',
|
'polish',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type Theme = 'light' | 'dark'
|
export type Language = (typeof languages)[number]
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
|
export type ThemeNarrowed = 'light' | 'dark'
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
serverAddr: string
|
serverAddr: string
|
||||||
serverPort: number
|
serverPort: number
|
||||||
language: Language
|
language: Language
|
||||||
theme: Theme
|
theme: ThemeNarrowed
|
||||||
cliArgs: string
|
cliArgs: string
|
||||||
formatSelection: boolean
|
formatSelection: boolean
|
||||||
fileRenaming: boolean
|
fileRenaming: boolean
|
||||||
pathOverriding: boolean
|
pathOverriding: boolean
|
||||||
enableCustomArgs: boolean
|
enableCustomArgs: boolean
|
||||||
listView: boolean
|
listView: boolean
|
||||||
|
servedFromReverseProxy: boolean
|
||||||
|
appTitle: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const languageState = atom<Language>({
|
export const languageState = atom<Language>({
|
||||||
@@ -51,7 +45,7 @@ export const languageState = atom<Language>({
|
|||||||
|
|
||||||
export const themeState = atom<Theme>({
|
export const themeState = atom<Theme>({
|
||||||
key: 'themeStateState',
|
key: 'themeStateState',
|
||||||
default: localStorage.getItem('theme') as Theme || 'light',
|
default: localStorage.getItem('theme') as Theme || 'system',
|
||||||
effects: [
|
effects: [
|
||||||
({ onSet }) =>
|
({ onSet }) =>
|
||||||
onSet(l => localStorage.setItem('theme', l.toString()))
|
onSet(l => localStorage.setItem('theme', l.toString()))
|
||||||
@@ -131,9 +125,29 @@ export const listViewState = atom({
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const servedFromReverseProxyState = atom({
|
||||||
|
key: 'servedFromReverseProxyState',
|
||||||
|
default: localStorage.getItem('reverseProxy') === "true",
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('reverseProxy', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const appTitleState = atom({
|
||||||
|
key: 'appTitleState',
|
||||||
|
default: localStorage.getItem('appTitle') ?? 'yt-dlp Web UI',
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(a => localStorage.setItem('appTitle', a.toString()))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
export const serverAddressAndPortState = selector({
|
export const serverAddressAndPortState = selector({
|
||||||
key: 'serverAddressAndPortState',
|
key: 'serverAddressAndPortState',
|
||||||
get: ({ get }) => `${get(serverAddressState)}:${get(serverPortState)}`
|
get: ({ get }) => get(servedFromReverseProxyState)
|
||||||
|
? `${get(serverAddressState)}`
|
||||||
|
: `${get(serverAddressState)}:${get(serverPortState)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serverURL = selector({
|
export const serverURL = selector({
|
||||||
@@ -158,18 +172,40 @@ export const rpcHTTPEndpoint = selector({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const cookiesState = atom({
|
||||||
|
key: 'cookiesState',
|
||||||
|
default: localStorage.getItem('yt-dlp-cookies') ?? '',
|
||||||
|
effects: [
|
||||||
|
({ onSet }) =>
|
||||||
|
onSet(c => localStorage.setItem('yt-dlp-cookies', c))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const themeSelector = selector<ThemeNarrowed>({
|
||||||
|
key: 'themeSelector',
|
||||||
|
get: ({ get }) => {
|
||||||
|
const theme = get(themeState)
|
||||||
|
if ((theme === 'system' && prefersDarkMode()) || theme === 'dark') {
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const settingsState = selector<SettingsState>({
|
export const settingsState = selector<SettingsState>({
|
||||||
key: 'settingsState',
|
key: 'settingsState',
|
||||||
get: ({ get }) => ({
|
get: ({ get }) => ({
|
||||||
serverAddr: get(serverAddressState),
|
serverAddr: get(serverAddressState),
|
||||||
serverPort: get(serverPortState),
|
serverPort: get(serverPortState),
|
||||||
language: get(languageState),
|
language: get(languageState),
|
||||||
theme: get(themeState),
|
theme: get(themeSelector),
|
||||||
cliArgs: get(latestCliArgumentsState),
|
cliArgs: get(latestCliArgumentsState),
|
||||||
formatSelection: get(formatSelectionState),
|
formatSelection: get(formatSelectionState),
|
||||||
fileRenaming: get(fileRenamingState),
|
fileRenaming: get(fileRenamingState),
|
||||||
pathOverriding: get(pathOverridingState),
|
pathOverriding: get(pathOverridingState),
|
||||||
enableCustomArgs: get(enableCustomArgsState),
|
enableCustomArgs: get(enableCustomArgsState),
|
||||||
listView: get(listViewState),
|
listView: get(listViewState),
|
||||||
|
servedFromReverseProxy: get(servedFromReverseProxyState),
|
||||||
|
appTitle: get(appTitleState)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -2,5 +2,5 @@ import { atom } from 'recoil'
|
|||||||
|
|
||||||
export const loadingAtom = atom({
|
export const loadingAtom = atom({
|
||||||
key: 'loadingAtom',
|
key: 'loadingAtom',
|
||||||
default: false
|
default: true
|
||||||
})
|
})
|
||||||
161
frontend/src/components/CookiesTextField.tsx
Normal file
161
frontend/src/components/CookiesTextField.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { TextField } from '@mui/material'
|
||||||
|
import * as A from 'fp-ts/Array'
|
||||||
|
import * as E from 'fp-ts/Either'
|
||||||
|
import * as O from 'fp-ts/Option'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
|
||||||
|
import { downloadTemplateState } from '../atoms/downloadTemplate'
|
||||||
|
import { cookiesState, serverURL } from '../atoms/settings'
|
||||||
|
import { useSubscription } from '../hooks/observable'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
import { ffetch } from '../lib/httpClient'
|
||||||
|
|
||||||
|
const validateCookie = (cookie: string) => pipe(
|
||||||
|
cookie,
|
||||||
|
cookie => cookie.replace(/\s\s+/g, ' '),
|
||||||
|
cookie => cookie.replaceAll('\t', ' '),
|
||||||
|
cookie => cookie.split(' '),
|
||||||
|
E.of,
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => f.length === 7,
|
||||||
|
() => `missing parts`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => f[0].length > 0,
|
||||||
|
() => 'missing domain'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => f[1] === 'TRUE' || f[1] === 'FALSE',
|
||||||
|
() => `invalid include subdomains`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => f[2].length > 0,
|
||||||
|
() => 'invalid path'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => f[3] === 'TRUE' || f[3] === 'FALSE',
|
||||||
|
() => 'invalid secure flag'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => isFinite(Number(f[4])),
|
||||||
|
() => 'invalid expiration'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => f[5].length > 0,
|
||||||
|
() => 'invalid name'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
E.chain(
|
||||||
|
E.fromPredicate(
|
||||||
|
f => f[6].length > 0,
|
||||||
|
() => 'invalid value'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CookiesTextField: React.FC = () => {
|
||||||
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
|
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState)
|
||||||
|
const [savedCookies, setSavedCookies] = useRecoilState(cookiesState)
|
||||||
|
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
const flag = '--cookies=cookies.txt'
|
||||||
|
|
||||||
|
const cookies$ = useMemo(() => new Subject<string>(), [])
|
||||||
|
|
||||||
|
const submitCookies = (cookies: string) =>
|
||||||
|
ffetch(`${serverAddr}/api/v1/cookies`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
cookies
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
const validateNetscapeCookies = (cookies: string) => pipe(
|
||||||
|
cookies,
|
||||||
|
cookies => cookies.split('\n'),
|
||||||
|
cookies => cookies.filter(f => !f.startsWith('\n')), // empty lines
|
||||||
|
cookies => cookies.filter(f => !f.startsWith('# ')), // comments
|
||||||
|
cookies => cookies.filter(Boolean), // empty lines
|
||||||
|
A.map(validateCookie),
|
||||||
|
A.mapWithIndex((i, either) => pipe(
|
||||||
|
either,
|
||||||
|
E.matchW(
|
||||||
|
(l) => pushMessage(`Error in line ${i + 1}: ${l}`, 'warning'),
|
||||||
|
() => E.isRight(either)
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
A.filter(Boolean),
|
||||||
|
A.match(
|
||||||
|
() => false,
|
||||||
|
(c) => {
|
||||||
|
pushMessage(`Valid ${c.length} Netscape cookies`, 'info')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
useSubscription(
|
||||||
|
cookies$.pipe(
|
||||||
|
debounceTime(650),
|
||||||
|
distinctUntilChanged()
|
||||||
|
),
|
||||||
|
(cookies) => pipe(
|
||||||
|
cookies,
|
||||||
|
cookies => {
|
||||||
|
setSavedCookies(cookies)
|
||||||
|
return cookies
|
||||||
|
},
|
||||||
|
validateNetscapeCookies,
|
||||||
|
O.fromPredicate(f => f === true),
|
||||||
|
O.match(
|
||||||
|
() => {
|
||||||
|
if (customArgs.includes(flag)) {
|
||||||
|
setCustomArgs(a => a.replace(flag, ''))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
pipe(
|
||||||
|
await submitCookies(cookies),
|
||||||
|
E.match(
|
||||||
|
(l) => pushMessage(`${l}`, 'error'),
|
||||||
|
() => pushMessage(`Saved Netscape cookies`, 'success')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (!customArgs.includes(flag)) {
|
||||||
|
setCustomArgs(a => `${a} ${flag}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label="Netscape Cookies"
|
||||||
|
multiline
|
||||||
|
maxRows={20}
|
||||||
|
minRows={4}
|
||||||
|
fullWidth
|
||||||
|
defaultValue={savedCookies}
|
||||||
|
onChange={(e) => cookies$.next(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CookiesTextField
|
||||||
110
frontend/src/components/DownloadCard.tsx
Normal file
110
frontend/src/components/DownloadCard.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import EightK from '@mui/icons-material/EightK'
|
||||||
|
import FourK from '@mui/icons-material/FourK'
|
||||||
|
import Hd from '@mui/icons-material/Hd'
|
||||||
|
import Sd from '@mui/icons-material/Sd'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Chip,
|
||||||
|
LinearProgress,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { RPCResult } from '../types'
|
||||||
|
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
download: RPCResult
|
||||||
|
onStop: () => void
|
||||||
|
onCopy: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
|
||||||
|
if (!resolution) return null
|
||||||
|
if (resolution.includes('4320')) return <EightK color="primary" />
|
||||||
|
if (resolution.includes('2160')) return <FourK color="primary" />
|
||||||
|
if (resolution.includes('1080')) return <Hd color="primary" />
|
||||||
|
if (resolution.includes('720')) return <Sd color="primary" />
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
|
||||||
|
const isCompleted = () => download.progress.percentage === '-1'
|
||||||
|
|
||||||
|
const percentageToNumber = () => isCompleted()
|
||||||
|
? 100
|
||||||
|
: Number(download.progress.percentage.replace('%', ''))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardActionArea onClick={() => {
|
||||||
|
navigator.clipboard.writeText(download.info.url)
|
||||||
|
onCopy()
|
||||||
|
}}>
|
||||||
|
{download.info.thumbnail !== '' ?
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height={180}
|
||||||
|
image={download.info.thumbnail}
|
||||||
|
/> :
|
||||||
|
<Skeleton variant="rectangular" height={180} />
|
||||||
|
}
|
||||||
|
<CardContent>
|
||||||
|
{download.info.title !== '' ?
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{ellipsis(download.info.title, 54)}
|
||||||
|
</Typography> :
|
||||||
|
<Skeleton />
|
||||||
|
}
|
||||||
|
<Stack direction="row" spacing={1} py={2}>
|
||||||
|
<Chip
|
||||||
|
label={
|
||||||
|
isCompleted()
|
||||||
|
? 'Completed'
|
||||||
|
: mapProcessStatus(download.progress.process_status)
|
||||||
|
}
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Typography>
|
||||||
|
{!isCompleted() ? download.progress.percentage : ''}
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
|
||||||
|
{!isCompleted() ? formatSpeedMiB(download.progress.speed) : ''}
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
{roundMiB(download.info.filesize_approx ?? 0)}
|
||||||
|
</Typography>
|
||||||
|
<Resolution resolution={download.info.resolution} />
|
||||||
|
</Stack>
|
||||||
|
{download.progress.percentage ?
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={percentageToNumber()}
|
||||||
|
color={isCompleted() ? "secondary" : "primary"}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
<CardActions>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={onStop}
|
||||||
|
>
|
||||||
|
{isCompleted() ? "Clear" : "Stop"}
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadCard
|
||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
TextField,
|
TextField
|
||||||
styled
|
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import AppBar from '@mui/material/AppBar'
|
import AppBar from '@mui/material/AppBar'
|
||||||
import Dialog from '@mui/material/Dialog'
|
import Dialog from '@mui/material/Dialog'
|
||||||
@@ -23,7 +22,6 @@ import Slide from '@mui/material/Slide'
|
|||||||
import Toolbar from '@mui/material/Toolbar'
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { TransitionProps } from '@mui/material/transitions'
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
import { Buffer } from 'buffer'
|
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -31,7 +29,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useTransition
|
useTransition
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { downloadTemplateState } from '../atoms/downloadTemplate'
|
||||||
import { settingsState } from '../atoms/settings'
|
import { settingsState } from '../atoms/settings'
|
||||||
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
import { availableDownloadPathsState, connectedState } from '../atoms/status'
|
||||||
import FormatsGrid from '../components/FormatsGrid'
|
import FormatsGrid from '../components/FormatsGrid'
|
||||||
@@ -53,7 +52,7 @@ const Transition = forwardRef(function Transition(
|
|||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onDownloadStart: () => void
|
onDownloadStart: (url: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DownloadDialog({
|
export default function DownloadDialog({
|
||||||
@@ -72,7 +71,7 @@ export default function DownloadDialog({
|
|||||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
||||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||||
|
|
||||||
const [customArgs, setCustomArgs] = useState('')
|
const [customArgs, setCustomArgs] = useRecoilState(downloadTemplateState)
|
||||||
const [downloadPath, setDownloadPath] = useState(0)
|
const [downloadPath, setDownloadPath] = useState(0)
|
||||||
|
|
||||||
const [fileNameOverride, setFilenameOverride] = useState('')
|
const [fileNameOverride, setFilenameOverride] = useState('')
|
||||||
@@ -121,7 +120,7 @@ export default function DownloadDialog({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resetInput()
|
resetInput()
|
||||||
setDownloadFormats(undefined)
|
setDownloadFormats(undefined)
|
||||||
onDownloadStart()
|
onDownloadStart(url)
|
||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,19 +167,18 @@ export default function DownloadDialog({
|
|||||||
localStorage.setItem("last-input-args", e.target.value)
|
localStorage.setItem("last-input-args", e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseUrlListFile = (event: any) => {
|
const parseUrlListFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const urlList = event.target.files
|
const files = e.currentTarget.files
|
||||||
const reader = new FileReader()
|
if (!files || files.length < 1) {
|
||||||
reader.addEventListener('load', $event => {
|
return
|
||||||
const base64 = $event.target?.result!.toString().split(',')[1]
|
}
|
||||||
Buffer.from(base64!, 'base64')
|
|
||||||
.toString()
|
const file = await files[0].text()
|
||||||
.trimEnd()
|
|
||||||
.split('\n')
|
file
|
||||||
.filter(_url => isValidURL(_url))
|
.split('\n')
|
||||||
.forEach(_url => sendUrl(_url))
|
.filter(u => isValidURL(u))
|
||||||
})
|
.forEach(u => sendUrl(u))
|
||||||
reader.readAsDataURL(urlList[0])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetInput = () => {
|
const resetInput = () => {
|
||||||
@@ -190,12 +188,6 @@ export default function DownloadDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------- styled components -------------------- */
|
|
||||||
|
|
||||||
const Input = styled('input')({
|
|
||||||
display: 'none',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -248,11 +240,12 @@ export default function DownloadDialog({
|
|||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<label htmlFor="icon-button-file">
|
<label htmlFor="icon-button-file">
|
||||||
<Input
|
<input
|
||||||
|
hidden
|
||||||
id="icon-button-file"
|
id="icon-button-file"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".txt"
|
accept=".txt"
|
||||||
onChange={parseUrlListFile}
|
onChange={e => parseUrlListFile(e)}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ const Downloads: React.FC = () => {
|
|||||||
const listView = useRecoilValue(listViewState)
|
const listView = useRecoilValue(listViewState)
|
||||||
const active = useRecoilValue(activeDownloadsState)
|
const active = useRecoilValue(activeDownloadsState)
|
||||||
|
|
||||||
const [, setIsLoading] = useRecoilState(loadingAtom)
|
const [isLoading, setIsLoading] = useRecoilState(loadingAtom)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
if (active) {
|
||||||
setIsLoading(true)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [active?.length])
|
}, [active?.length, isLoading])
|
||||||
|
|
||||||
if (listView) {
|
if (listView) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Grid } from "@mui/material"
|
import { Grid } from '@mui/material'
|
||||||
import { Fragment } from "react"
|
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilValue } from 'recoil'
|
||||||
import { activeDownloadsState } from '../atoms/downloads'
|
import { activeDownloadsState } from '../atoms/downloads'
|
||||||
import { useToast } from "../hooks/toast"
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
import { StackableResult } from "./StackableResult"
|
import DownloadCard from './DownloadCard'
|
||||||
|
|
||||||
const DownloadsCardView: React.FC = () => {
|
const DownloadsCardView: React.FC = () => {
|
||||||
const downloads = useRecoilValue(activeDownloadsState) ?? []
|
const downloads = useRecoilValue(activeDownloadsState) ?? []
|
||||||
@@ -21,20 +20,13 @@ const DownloadsCardView: React.FC = () => {
|
|||||||
{
|
{
|
||||||
downloads.map(download => (
|
downloads.map(download => (
|
||||||
<Grid item xs={4} sm={8} md={6} key={download.id}>
|
<Grid item xs={4} sm={8} md={6} key={download.id}>
|
||||||
<Fragment>
|
<>
|
||||||
<StackableResult
|
<DownloadCard
|
||||||
url={download.info.url}
|
download={download}
|
||||||
title={download.info.title}
|
|
||||||
thumbnail={download.info.thumbnail}
|
|
||||||
percentage={download.progress.percentage}
|
|
||||||
onStop={() => abort(download.id)}
|
onStop={() => abort(download.id)}
|
||||||
onCopy={() => pushMessage(i18n.t('clipboardAction'))}
|
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
|
||||||
resolution={download.info.resolution ?? ''}
|
|
||||||
speed={download.progress.speed}
|
|
||||||
size={download.info.filesize_approx ?? 0}
|
|
||||||
status={download.progress.process_status}
|
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</>
|
||||||
</Grid>
|
</Grid>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ const ErrorBoundary: React.FC = () => {
|
|||||||
Goto Settings
|
Goto Settings
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Typography sx={{ mt: 2 }} color={'gray'} fontWeight={500}>
|
||||||
|
Or login if authentication is enabled
|
||||||
|
</Typography>
|
||||||
|
<Link to={'/login'} >
|
||||||
|
<Button variant='contained' sx={{ mt: 2 }}>
|
||||||
|
login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</FlexContainer>
|
</FlexContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { useRecoilState } from 'recoil'
|
|||||||
import { loadingAtom } from '../atoms/ui'
|
import { loadingAtom } from '../atoms/ui'
|
||||||
import DownloadDialog from './DownloadDialog'
|
import DownloadDialog from './DownloadDialog'
|
||||||
import HomeSpeedDial from './HomeSpeedDial'
|
import HomeSpeedDial from './HomeSpeedDial'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
|
|
||||||
const HomeActions: React.FC = () => {
|
const HomeActions: React.FC = () => {
|
||||||
const [, setIsLoading] = useRecoilState(loadingAtom)
|
const [, setIsLoading] = useRecoilState(loadingAtom)
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HomeSpeedDial
|
<HomeSpeedDial
|
||||||
@@ -19,7 +22,8 @@ const HomeActions: React.FC = () => {
|
|||||||
setOpenDialog(false)
|
setOpenDialog(false)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
}}
|
}}
|
||||||
onDownloadStart={() => {
|
onDownloadStart={(url) => {
|
||||||
|
pushMessage(`Requested ${url}`, 'info')
|
||||||
setOpenDialog(false)
|
setOpenDialog(false)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const LoadingBackdrop: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Backdrop
|
<Backdrop
|
||||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
open={!isLoading}
|
open={isLoading}
|
||||||
>
|
>
|
||||||
<CircularProgress color="primary" />
|
<CircularProgress color="primary" />
|
||||||
</Backdrop>
|
</Backdrop>
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { EightK, FourK, Hd, Sd } from '@mui/icons-material'
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardActionArea,
|
|
||||||
CardActions,
|
|
||||||
CardContent,
|
|
||||||
CardMedia,
|
|
||||||
Chip,
|
|
||||||
LinearProgress,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material'
|
|
||||||
import { ellipsis, formatSpeedMiB, mapProcessStatus, roundMiB } from '../utils'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
thumbnail: string
|
|
||||||
resolution: string
|
|
||||||
percentage: string
|
|
||||||
size: number
|
|
||||||
speed: number
|
|
||||||
status: number
|
|
||||||
onStop: () => void
|
|
||||||
onCopy: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StackableResult({
|
|
||||||
title,
|
|
||||||
url,
|
|
||||||
thumbnail,
|
|
||||||
resolution,
|
|
||||||
percentage,
|
|
||||||
speed,
|
|
||||||
size,
|
|
||||||
status,
|
|
||||||
onStop,
|
|
||||||
onCopy,
|
|
||||||
}: Props) {
|
|
||||||
const isCompleted = () => percentage === '-1'
|
|
||||||
|
|
||||||
const percentageToNumber = () => isCompleted()
|
|
||||||
? 100
|
|
||||||
: Number(percentage.replace('%', ''))
|
|
||||||
|
|
||||||
const guessResolution = (xByY: string): any => {
|
|
||||||
if (!xByY) return null
|
|
||||||
if (xByY.includes('4320')) return (<EightK color="primary" />)
|
|
||||||
if (xByY.includes('2160')) return (<FourK color="primary" />)
|
|
||||||
if (xByY.includes('1080')) return (<Hd color="primary" />)
|
|
||||||
if (xByY.includes('720')) return (<Sd color="primary" />)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardActionArea onClick={() => {
|
|
||||||
navigator.clipboard.writeText(url)
|
|
||||||
onCopy()
|
|
||||||
}}>
|
|
||||||
{thumbnail !== '' ?
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
height={180}
|
|
||||||
image={thumbnail}
|
|
||||||
/> :
|
|
||||||
<Skeleton variant="rectangular" height={180} />
|
|
||||||
}
|
|
||||||
<CardContent>
|
|
||||||
{title !== '' ?
|
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
|
||||||
{ellipsis(title, 54)}
|
|
||||||
</Typography> :
|
|
||||||
<Skeleton />
|
|
||||||
}
|
|
||||||
<Stack direction="row" spacing={1} py={2}>
|
|
||||||
<Chip
|
|
||||||
label={isCompleted() ? 'Completed' : mapProcessStatus(status)}
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<Typography>{!isCompleted() ? percentage : ''}</Typography>
|
|
||||||
<Typography> {!isCompleted() ? formatSpeedMiB(speed) : ''}</Typography>
|
|
||||||
<Typography>{roundMiB(size ?? 0)}</Typography>
|
|
||||||
{guessResolution(resolution)}
|
|
||||||
</Stack>
|
|
||||||
{percentage ?
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={percentageToNumber()}
|
|
||||||
color={isCompleted() ? "secondary" : "primary"}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</CardContent>
|
|
||||||
</CardActionArea>
|
|
||||||
<CardActions>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
onClick={onStop}
|
|
||||||
>
|
|
||||||
{isCompleted() ? "Clear" : "Stop"}
|
|
||||||
</Button>
|
|
||||||
</CardActions>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
import { Brightness4, Brightness5 } from '@mui/icons-material'
|
import Brightness4 from '@mui/icons-material/Brightness4'
|
||||||
|
import Brightness5 from '@mui/icons-material/Brightness5'
|
||||||
|
import BrightnessAuto from '@mui/icons-material/BrightnessAuto'
|
||||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
|
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
|
||||||
import { useRecoilState } from 'recoil'
|
import { useRecoilState } from 'recoil'
|
||||||
import { themeState } from '../atoms/settings'
|
import { Theme, themeState } from '../atoms/settings'
|
||||||
|
|
||||||
export default function ThemeToggler() {
|
const ThemeToggler: React.FC = () => {
|
||||||
const [theme, setTheme] = useRecoilState(themeState)
|
const [theme, setTheme] = useRecoilState(themeState)
|
||||||
|
|
||||||
|
const actions: Record<Theme, React.ReactNode> = {
|
||||||
|
system: <BrightnessAuto />,
|
||||||
|
light: <Brightness4 />,
|
||||||
|
dark: <Brightness5 />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes: Theme[] = ['system', 'light', 'dark']
|
||||||
|
const currentTheme = themes.indexOf(theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItemButton onClick={() => {
|
<ListItemButton onClick={() => {
|
||||||
theme === 'light'
|
setTheme(themes[(currentTheme + 1) % themes.length])
|
||||||
? setTheme('dark')
|
|
||||||
: setTheme('light')
|
|
||||||
}}>
|
}}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{
|
{actions[theme]}
|
||||||
theme === 'light'
|
|
||||||
? <Brightness4 />
|
|
||||||
: <Brightness5 />
|
|
||||||
}
|
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Toggle theme" />
|
<ListItemText primary="Toggle theme" />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ThemeToggler
|
||||||
@@ -3,17 +3,17 @@ import { useRecoilState } from 'recoil'
|
|||||||
import { toastListState } from '../atoms/toast'
|
import { toastListState } from '../atoms/toast'
|
||||||
|
|
||||||
export const useToast = () => {
|
export const useToast = () => {
|
||||||
const [toasts, setToasts] = useRecoilState(toastListState)
|
const [, setToasts] = useRecoilState(toastListState)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pushMessage: (message: string, severity?: AlertColor) => {
|
pushMessage: (message: string, severity?: AlertColor) => {
|
||||||
setToasts([{
|
setToasts(state => [...state, {
|
||||||
open: true,
|
open: true,
|
||||||
message: message,
|
message: message,
|
||||||
severity: severity,
|
severity: severity,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
}, ...toasts])
|
}])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,11 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
|
||||||
|
import '@fontsource/roboto/300.css'
|
||||||
|
import '@fontsource/roboto/400.css'
|
||||||
|
import '@fontsource/roboto/500.css'
|
||||||
|
import '@fontsource/roboto/700.css'
|
||||||
|
|
||||||
const root = createRoot(document.getElementById('root')!)
|
const root = createRoot(document.getElementById('root')!)
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
export async function ffetch<T>(
|
import { tryCatch } from 'fp-ts/TaskEither'
|
||||||
url: string,
|
import { flow } from 'fp-ts/lib/function'
|
||||||
onSuccess: (res: T) => void,
|
|
||||||
onError: (err: string) => void,
|
export const ffetch = <T>(url: string, opt?: RequestInit) => flow(
|
||||||
opt?: RequestInit,
|
tryCatch(
|
||||||
) {
|
() => fetcher<T>(url, opt),
|
||||||
|
(e) => `error while fetching: ${e}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetcher = async <T>(url: string, opt?: RequestInit) => {
|
||||||
const res = await fetch(url, opt)
|
const res = await fetch(url, opt)
|
||||||
if (!res.ok) {
|
|
||||||
onError(await res.text())
|
if (opt && !opt.headers) {
|
||||||
return
|
opt.headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onSuccess(await res.json() as T)
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw await res.text()
|
||||||
|
}
|
||||||
|
return res.json() as T
|
||||||
}
|
}
|
||||||
@@ -8,13 +8,20 @@ const Toaster: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toasts.length > 0) {
|
if (toasts.length > 0) {
|
||||||
const interval = setInterval(() => {
|
const closer = setInterval(() => {
|
||||||
setToasts(t => t.filter((x) => (Date.now() - x.createdAt) < 1500))
|
setToasts(t => t.map(t => ({ ...t, open: false })))
|
||||||
}, 1500)
|
}, 2000)
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
const cleaner = setInterval(() => {
|
||||||
|
setToasts(t => t.filter((x) => (Date.now() - x.createdAt) < 2000))
|
||||||
|
}, 2250)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(closer)
|
||||||
|
clearInterval(cleaner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [setToasts, toasts])
|
}, [setToasts, toasts.length])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -22,6 +29,7 @@ const Toaster: React.FC = () => {
|
|||||||
<Snackbar
|
<Snackbar
|
||||||
key={index}
|
key={index}
|
||||||
open={toast.open}
|
open={toast.open}
|
||||||
|
sx={index > 0 ? { marginBottom: index * 6.5 } : {}}
|
||||||
>
|
>
|
||||||
<Alert variant="filled" severity={toast.severity}>
|
<Alert variant="filled" severity={toast.severity}>
|
||||||
{toast.message}
|
{toast.message}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CircularProgress } from '@mui/material'
|
import { CircularProgress } from '@mui/material'
|
||||||
import { Suspense, lazy } from 'react'
|
import { Suspense, lazy } from 'react'
|
||||||
import { createBrowserRouter } from 'react-router-dom'
|
import { createHashRouter } from 'react-router-dom'
|
||||||
import Layout from './Layout'
|
import Layout from './Layout'
|
||||||
|
|
||||||
const Home = lazy(() => import('./views/Home'))
|
const Home = lazy(() => import('./views/Home'))
|
||||||
@@ -10,7 +10,7 @@ const Settings = lazy(() => import('./views/Settings'))
|
|||||||
|
|
||||||
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createHashRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
Component: () => <Layout />,
|
Component: () => <Layout />,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
import type { RPCResponse } from "./types"
|
import type { RPCResponse } from "./types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,31 +11,15 @@ export function validateIP(ipAddr: string): boolean {
|
|||||||
return ipRegex.test(ipAddr)
|
return ipRegex.test(ipAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function validateDomain(url: string): boolean {
|
||||||
* Validate a domain via regex.
|
const urlRegex = /(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
||||||
* The validation pass if the domain respects the following formats:
|
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||||
* - localhost
|
|
||||||
* - domain.tld
|
const [name, slug] = url.split('/')
|
||||||
* - dir.domain.tld
|
|
||||||
* @param domainName
|
return urlRegex.test(url) || name === 'localhost' && slugRegex.test(slug)
|
||||||
* @returns domain validity test
|
|
||||||
*/
|
|
||||||
export function validateDomain(domainName: string): boolean {
|
|
||||||
let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/
|
|
||||||
return domainRegex.test(domainName) || domainName === 'localhost'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a domain via regex.
|
|
||||||
* Exapmples
|
|
||||||
* - http://example.com
|
|
||||||
* - https://example.com
|
|
||||||
* - http://www.example.com
|
|
||||||
* - https://www.example.com
|
|
||||||
* - http://10.0.0.1/[something]/[something-else]
|
|
||||||
* @param url
|
|
||||||
* @returns url validity test
|
|
||||||
*/
|
|
||||||
export function isValidURL(url: string): boolean {
|
export function isValidURL(url: string): boolean {
|
||||||
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
|
||||||
return urlRegex.test(url)
|
return urlRegex.test(url)
|
||||||
@@ -47,24 +32,6 @@ export function ellipsis(str: string, lim: number): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the downlaod speed sent by server and converts it to KiB/s
|
|
||||||
* @param str the downlaod speed, ex. format: 5 MiB/s => 5000 | 50 KiB/s => 50
|
|
||||||
* @returns download speed in KiB/s
|
|
||||||
*/
|
|
||||||
export function detectSpeed(str: string): number {
|
|
||||||
let effective = str.match(/[\d,]+(\.\d+)?/)![0]
|
|
||||||
const unit = str.replace(effective, '')
|
|
||||||
switch (unit) {
|
|
||||||
case 'MiB/s':
|
|
||||||
return Number(effective) * 1000
|
|
||||||
case 'KiB/s':
|
|
||||||
return Number(effective)
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toFormatArgs(codes: string[]): string {
|
export function toFormatArgs(codes: string[]): string {
|
||||||
if (codes.length > 1) {
|
if (codes.length > 1) {
|
||||||
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
return codes.reduce((v, a) => ` -f ${v}+${a}`)
|
||||||
@@ -75,14 +42,17 @@ export function toFormatArgs(codes: string[]): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatGiB(bytes: number) {
|
export const formatGiB = (bytes: number) =>
|
||||||
return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
`${(bytes / 1_000_000_000).toFixed(0)}GiB`
|
||||||
}
|
|
||||||
|
|
||||||
export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
|
export const roundMiB = (bytes: number) =>
|
||||||
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
|
`${(bytes / 1_000_000).toFixed(2)} MiB`
|
||||||
|
|
||||||
export const datetimeCompareFunc = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime()
|
export const formatSpeedMiB = (val: number) =>
|
||||||
|
`${roundMiB(val)}/s`
|
||||||
|
|
||||||
|
export const datetimeCompareFunc = (a: string, b: string) =>
|
||||||
|
new Date(a).getTime() - new Date(b).getTime()
|
||||||
|
|
||||||
export function isRPCResponse(object: any): object is RPCResponse<any> {
|
export function isRPCResponse(object: any): object is RPCResponse<any> {
|
||||||
return 'result' in object && 'id' in object
|
return 'result' in object && 'id' in object
|
||||||
@@ -102,3 +72,13 @@ export function mapProcessStatus(status: number) {
|
|||||||
return 'Pending'
|
return 'Pending'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const prefersDarkMode = () =>
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|
||||||
|
export const base64URLEncode = (s: string) => pipe(
|
||||||
|
s,
|
||||||
|
s => String.fromCodePoint(...new TextEncoder().encode(s)),
|
||||||
|
btoa,
|
||||||
|
encodeURIComponent
|
||||||
|
)
|
||||||
@@ -26,23 +26,26 @@ import FolderIcon from '@mui/icons-material/Folder'
|
|||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||||
|
|
||||||
import { Buffer } from 'buffer'
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
||||||
|
import { pipe } from 'fp-ts/lib/function'
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilValue } from 'recoil'
|
||||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||||
import { serverURL } from '../atoms/settings'
|
import { serverURL } from '../atoms/settings'
|
||||||
import { useObservable } from '../hooks/observable'
|
import { useObservable } from '../hooks/observable'
|
||||||
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { ffetch } from '../lib/httpClient'
|
import { ffetch } from '../lib/httpClient'
|
||||||
import { DeleteRequest, DirectoryEntry } from '../types'
|
import { DeleteRequest, DirectoryEntry } from '../types'
|
||||||
import { roundMiB } from '../utils'
|
import { base64URLEncode, roundMiB } from '../utils'
|
||||||
|
|
||||||
export default function Downloaded() {
|
export default function Downloaded() {
|
||||||
const serverAddr = useRecoilValue(serverURL)
|
const serverAddr = useRecoilValue(serverURL)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { i18n } = useI18n()
|
const { i18n } = useI18n()
|
||||||
|
const { pushMessage } = useToast()
|
||||||
|
|
||||||
const [openDialog, setOpenDialog] = useState(false)
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
|
|
||||||
@@ -51,20 +54,24 @@ export default function Downloaded() {
|
|||||||
|
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const fetcher = () => ffetch<DirectoryEntry[]>(
|
const fetcher = () => pipe(
|
||||||
`${serverAddr}/archive/downloaded`,
|
ffetch<DirectoryEntry[]>(
|
||||||
(d) => files$.next(d),
|
`${serverAddr}/archive/downloaded`,
|
||||||
() => navigate('/login'),
|
{
|
||||||
{
|
method: 'POST',
|
||||||
method: 'POST',
|
body: JSON.stringify({
|
||||||
headers: {
|
subdir: '',
|
||||||
'Content-Type': 'application/json',
|
})
|
||||||
|
}
|
||||||
|
),
|
||||||
|
matchW(
|
||||||
|
(e) => {
|
||||||
|
pushMessage(e)
|
||||||
|
navigate('/login')
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
(d) => files$.next(d),
|
||||||
subdir: '',
|
)
|
||||||
})
|
)()
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const fetcherSubfolder = (sub: string) => {
|
const fetcherSubfolder = (sub: string) => {
|
||||||
const folders = sub.startsWith('/')
|
const folders = sub.startsWith('/')
|
||||||
@@ -138,7 +145,9 @@ export default function Downloaded() {
|
|||||||
}, [serverAddr])
|
}, [serverAddr])
|
||||||
|
|
||||||
const onFileClick = (path: string) => startTransition(() => {
|
const onFileClick = (path: string) => startTransition(() => {
|
||||||
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)
|
const encoded = base64URLEncode(path)
|
||||||
|
|
||||||
|
window.open(`${serverAddr}/archive/d/${encoded}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onFolderClick = (path: string) => startTransition(() => {
|
const onFolderClick = (path: string) => startTransition(() => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { serverURL } from '../atoms/settings'
|
|||||||
const LoginContainer = styled(Container)({
|
const LoginContainer = styled(Container)({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
minWidth: '100%',
|
minWidth: '100%',
|
||||||
minHeight: '100vh',
|
minHeight: '85vh',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
})
|
})
|
||||||
@@ -33,22 +33,32 @@ const Title = styled(Typography)({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [secret, setSecret] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
const [formHasError, setFormHasError] = useState(false)
|
const [formHasError, setFormHasError] = useState(false)
|
||||||
|
|
||||||
const url = useRecoilValue(serverURL)
|
const url = useRecoilValue(serverURL)
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const navigateAndReload = () => {
|
||||||
|
navigate('/')
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
const res = await fetch(`${url}/auth/login`, {
|
const res = await fetch(`${url}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ secret })
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
res.ok ? navigate('/') : setFormHasError(true)
|
res.ok ? navigateAndReload() : setFormHasError(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,17 +72,23 @@ export default function Login() {
|
|||||||
Authentication token will expire after 30 days.
|
Authentication token will expire after 30 days.
|
||||||
</Title>
|
</Title>
|
||||||
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
||||||
In order to enable RPC authentication append the --auth
|
In order to enable RPC authentication append the --auth,
|
||||||
<br />
|
<br />
|
||||||
and --secret [secret] flags.
|
--user [username] and --pass [password] flags.
|
||||||
</Title>
|
</Title>
|
||||||
<TextField
|
<TextField
|
||||||
id="outlined-password-input"
|
label="Username"
|
||||||
label="RPC secret"
|
type="text"
|
||||||
type="password"
|
autoComplete="yt-dlp-webui-username"
|
||||||
autoComplete="current-password"
|
|
||||||
error={formHasError}
|
error={formHasError}
|
||||||
onChange={e => setSecret(e.currentTarget.value)}
|
onChange={e => setUsername(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="yt-dlp-webui-password"
|
||||||
|
error={formHasError}
|
||||||
|
onChange={e => setPassword(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" size="large" onClick={() => login()}>
|
<Button variant="contained" size="large" onClick={() => login()}>
|
||||||
Submit
|
Submit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Container,
|
Container,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Language,
|
Language,
|
||||||
Theme,
|
Theme,
|
||||||
|
appTitleState,
|
||||||
enableCustomArgsState,
|
enableCustomArgsState,
|
||||||
fileRenamingState,
|
fileRenamingState,
|
||||||
formatSelectionState,
|
formatSelectionState,
|
||||||
@@ -36,10 +38,12 @@ import {
|
|||||||
languages,
|
languages,
|
||||||
latestCliArgumentsState,
|
latestCliArgumentsState,
|
||||||
pathOverridingState,
|
pathOverridingState,
|
||||||
|
servedFromReverseProxyState,
|
||||||
serverAddressState,
|
serverAddressState,
|
||||||
serverPortState,
|
serverPortState,
|
||||||
themeState
|
themeState
|
||||||
} from '../atoms/settings'
|
} from '../atoms/settings'
|
||||||
|
import CookiesTextField from '../components/CookiesTextField'
|
||||||
import { useToast } from '../hooks/toast'
|
import { useToast } from '../hooks/toast'
|
||||||
import { useI18n } from '../hooks/useI18n'
|
import { useI18n } from '../hooks/useI18n'
|
||||||
import { useRPC } from '../hooks/useRPC'
|
import { useRPC } from '../hooks/useRPC'
|
||||||
@@ -48,6 +52,7 @@ import { validateDomain, validateIP } from '../utils'
|
|||||||
|
|
||||||
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
|
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const [reverseProxy, setReverseProxy] = useRecoilState(servedFromReverseProxyState)
|
||||||
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
|
const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
|
||||||
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
|
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
|
||||||
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
|
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
|
||||||
@@ -55,6 +60,7 @@ export default function Settings() {
|
|||||||
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
|
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
|
||||||
const [serverPort, setServerPort] = useRecoilState(serverPortState)
|
const [serverPort, setServerPort] = useRecoilState(serverPortState)
|
||||||
const [language, setLanguage] = useRecoilState(languageState)
|
const [language, setLanguage] = useRecoilState(languageState)
|
||||||
|
const [appTitle, setApptitle] = useRecoilState(appTitleState)
|
||||||
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
|
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
|
||||||
const [theme, setTheme] = useRecoilState(themeState)
|
const [theme, setTheme] = useRecoilState(themeState)
|
||||||
|
|
||||||
@@ -80,9 +86,11 @@ export default function Settings() {
|
|||||||
if (validateIP(addr)) {
|
if (validateIP(addr)) {
|
||||||
setInvalidIP(false)
|
setInvalidIP(false)
|
||||||
setServerAddr(addr)
|
setServerAddr(addr)
|
||||||
|
pushMessage(i18n.t('restartAppMessage'), 'info')
|
||||||
} else if (validateDomain(addr)) {
|
} else if (validateDomain(addr)) {
|
||||||
setInvalidIP(false)
|
setInvalidIP(false)
|
||||||
setServerAddr(addr)
|
setServerAddr(addr)
|
||||||
|
pushMessage(i18n.t('restartAppMessage'), 'info')
|
||||||
} else {
|
} else {
|
||||||
setInvalidIP(true)
|
setInvalidIP(true)
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,7 @@ export default function Settings() {
|
|||||||
)
|
)
|
||||||
.subscribe(port => {
|
.subscribe(port => {
|
||||||
setServerPort(port)
|
setServerPort(port)
|
||||||
|
pushMessage(i18n.t('restartAppMessage'), 'info')
|
||||||
})
|
})
|
||||||
return () => sub.unsubscribe()
|
return () => sub.unsubscribe()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -151,16 +160,37 @@ export default function Settings() {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
|
startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
|
||||||
}}
|
}}
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={1}>
|
<Grid item xs={12} md={1}>
|
||||||
<TextField
|
<TextField
|
||||||
|
disabled={reverseProxy}
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n.t('serverPortTitle')}
|
label={i18n.t('serverPortTitle')}
|
||||||
defaultValue={serverPort}
|
defaultValue={serverPort}
|
||||||
onChange={(e) => serverPort$.next(e.currentTarget.value)}
|
onChange={(e) => serverPort$.next(e.currentTarget.value)}
|
||||||
error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
|
error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={12}>
|
||||||
|
<TextField
|
||||||
|
disabled={reverseProxy}
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('appTitle')}
|
||||||
|
defaultValue={appTitle}
|
||||||
|
onChange={(e) => setApptitle(e.currentTarget.value)}
|
||||||
|
error={appTitle === ''}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={reverseProxy}
|
||||||
|
onChange={() => setReverseProxy(state => !state)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={i18n.t('servedFromReverseProxyCheckbox')}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -192,6 +222,7 @@ export default function Settings() {
|
|||||||
>
|
>
|
||||||
<MenuItem value="light">Light</MenuItem>
|
<MenuItem value="light">Light</MenuItem>
|
||||||
<MenuItem value="dark">Dark</MenuItem>
|
<MenuItem value="dark">Dark</MenuItem>
|
||||||
|
<MenuItem value="system">System</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -268,6 +299,12 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid sx={{ mr: 1, mt: 3 }}>
|
||||||
|
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||||
|
Cookies
|
||||||
|
</Typography>
|
||||||
|
<CookiesTextField />
|
||||||
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Stack direction="row">
|
<Stack direction="row">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"target": "es6",
|
"target": "ES2018",
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from '@vitejs/plugin-react-swc'
|
||||||
import ViteYaml from '@modyfi/vite-plugin-yaml';
|
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite'
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
ViteYaml(),
|
ViteYaml(),
|
||||||
],
|
],
|
||||||
root: resolve(__dirname, '.'),
|
base: '',
|
||||||
build: {
|
build: {
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
outDir: resolve(__dirname, 'dist'),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -4,13 +4,12 @@ go 1.20
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.0.10
|
github.com/go-chi/chi/v5 v5.0.10
|
||||||
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/goccy/go-json v0.10.2
|
github.com/goccy/go-json v0.10.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
|
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
|
||||||
golang.org/x/sys v0.9.0
|
golang.org/x/sys v0.13.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/go-chi/cors v1.2.1
|
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -6,14 +6,16 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
|
||||||
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
|
||||||
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
|
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
38
main.go
38
main.go
@@ -5,36 +5,47 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
port int
|
port int
|
||||||
queueSize int
|
queueSize int
|
||||||
configFile string
|
configFile string
|
||||||
downloadPath string
|
downloadPath string
|
||||||
downloaderPath string
|
downloaderPath string
|
||||||
|
sessionFilePath string
|
||||||
|
|
||||||
requireAuth bool
|
requireAuth bool
|
||||||
rpcSecret string
|
username string
|
||||||
|
password string
|
||||||
|
|
||||||
//go:embed frontend/dist
|
userFromEnv = os.Getenv("USERNAME")
|
||||||
|
passFromEnv = os.Getenv("PASSWORD")
|
||||||
|
|
||||||
|
//go:embed frontend/dist/index.html
|
||||||
|
//go:embed frontend/dist/assets/*
|
||||||
frontend embed.FS
|
frontend embed.FS
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
||||||
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
flag.IntVar(&port, "port", 3033, "Port where server will listen at")
|
||||||
flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size")
|
flag.IntVar(&queueSize, "qs", runtime.NumCPU(), "Download queue size")
|
||||||
|
|
||||||
flag.StringVar(&configFile, "conf", "", "Config file path")
|
flag.StringVar(&configFile, "conf", "./config.yml", "Config file path")
|
||||||
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
|
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
|
||||||
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
|
||||||
|
flag.StringVar(&sessionFilePath, "session", ".", "session file path")
|
||||||
|
|
||||||
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
|
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
|
||||||
flag.StringVar(&rpcSecret, "secret", "", "Secret required for auth")
|
flag.StringVar(&username, "user", userFromEnv, "Username required for auth")
|
||||||
|
flag.StringVar(&password, "pass", passFromEnv, "Password required for auth")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
}
|
}
|
||||||
@@ -52,12 +63,15 @@ func main() {
|
|||||||
c.QueueSize(queueSize)
|
c.QueueSize(queueSize)
|
||||||
c.DownloadPath(downloadPath)
|
c.DownloadPath(downloadPath)
|
||||||
c.DownloaderPath(downloaderPath)
|
c.DownloaderPath(downloaderPath)
|
||||||
|
c.SessionFilePath(sessionFilePath)
|
||||||
|
|
||||||
c.RequireAuth(requireAuth)
|
c.RequireAuth(requireAuth)
|
||||||
c.RPCSecret(rpcSecret)
|
c.Username(username)
|
||||||
|
c.Password(password)
|
||||||
|
|
||||||
if configFile != "" {
|
// if config file is found it will be merged with the current config struct
|
||||||
c.LoadFromFile(configFile)
|
if _, err := c.LoadFromFile(configFile); err != nil {
|
||||||
|
log.Println(cli.BgRed, "config", cli.Reset, "no config file found")
|
||||||
}
|
}
|
||||||
|
|
||||||
server.RunBlocking(port, frontend)
|
server.RunBlocking(port, frontend)
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
var lock sync.Mutex
|
var lock sync.Mutex
|
||||||
|
|
||||||
type serverConfig struct {
|
type serverConfig struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
DownloadPath string `yaml:"downloadPath"`
|
DownloadPath string `yaml:"downloadPath"`
|
||||||
DownloaderPath string `yaml:"downloaderPath"`
|
DownloaderPath string `yaml:"downloaderPath"`
|
||||||
RequireAuth bool `yaml:"require_auth"`
|
RequireAuth bool `yaml:"require_auth"`
|
||||||
RPCSecret string `yaml:"rpc_secret"`
|
Username string `yaml:"username"`
|
||||||
QueueSize int `yaml:"queue_size"`
|
Password string `yaml:"password"`
|
||||||
|
QueueSize int `yaml:"queue_size"`
|
||||||
|
SessionFilePath string `yaml:"session_file_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -23,12 +25,14 @@ type config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *config) LoadFromFile(filename string) (serverConfig, error) {
|
func (c *config) LoadFromFile(filename string) (serverConfig, error) {
|
||||||
bytes, err := os.ReadFile(filename)
|
fd, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return serverConfig{}, err
|
return serverConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
yaml.Unmarshal(bytes, &c.cfg)
|
if err := yaml.NewDecoder(fd).Decode(&c.cfg); err != nil {
|
||||||
|
return serverConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return c.cfg, nil
|
return c.cfg, nil
|
||||||
}
|
}
|
||||||
@@ -53,14 +57,22 @@ func (c *config) RequireAuth(value bool) {
|
|||||||
c.cfg.RequireAuth = value
|
c.cfg.RequireAuth = value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *config) RPCSecret(secret string) {
|
func (c *config) Username(username string) {
|
||||||
c.cfg.RPCSecret = secret
|
c.cfg.Username = username
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *config) Password(password string) {
|
||||||
|
c.cfg.Password = password
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *config) QueueSize(size int) {
|
func (c *config) QueueSize(size int) {
|
||||||
c.cfg.QueueSize = size
|
c.cfg.QueueSize = size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *config) SessionFilePath(path string) {
|
||||||
|
c.cfg.SessionFilePath = path
|
||||||
|
}
|
||||||
|
|
||||||
var instance *config
|
var instance *config
|
||||||
|
|
||||||
func Instance() *config {
|
func Instance() *config {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -15,10 +16,6 @@ import (
|
|||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
TOKEN_COOKIE_NAME = "jwt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DirectoryEntry struct {
|
type DirectoryEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -133,7 +130,13 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
decoded, err := hex.DecodeString(path)
|
path, err := url.QueryUnescape(path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -145,10 +148,10 @@ func SendFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// TODO: further path / file validations
|
// TODO: further path / file validations
|
||||||
if strings.Contains(filepath.Dir(decodedStr), root) {
|
if strings.Contains(filepath.Dir(decodedStr), root) {
|
||||||
// ctx.Response().Header.Set(
|
w.Header().Add(
|
||||||
// "Content-Disposition",
|
"Content-Disposition",
|
||||||
// "inline; filename="+filepath.Base(decodedStr),
|
"inline; filename="+filepath.Base(decodedStr),
|
||||||
// )
|
)
|
||||||
|
|
||||||
http.ServeFile(w, r, decodedStr)
|
http.ServeFile(w, r, decodedStr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,28 @@ import (
|
|||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Secret string `json:"secret"`
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Login(w http.ResponseWriter, r *http.Request) {
|
func Login(w http.ResponseWriter, r *http.Request) {
|
||||||
req := new(LoginRequest)
|
req := new(LoginRequest)
|
||||||
err := json.NewDecoder(r.Body).Decode(&req)
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Instance().GetConfig().RPCSecret != req.Secret {
|
var (
|
||||||
|
username = config.Instance().GetConfig().Username
|
||||||
|
password = config.Instance().GetConfig().Password
|
||||||
|
)
|
||||||
|
|
||||||
|
if username != req.Username || password != req.Password {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -31,6 +38,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
"expiresAt": expiresAt,
|
"expiresAt": expiresAt,
|
||||||
|
"username": req.Username,
|
||||||
})
|
})
|
||||||
|
|
||||||
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||||
@@ -40,7 +48,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cookie := &http.Cookie{
|
cookie := &http.Cookie{
|
||||||
Name: TOKEN_COOKIE_NAME,
|
Name: utils.TOKEN_COOKIE_NAME,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: false,
|
Secure: false,
|
||||||
Expires: expiresAt, // 30 days
|
Expires: expiresAt, // 30 days
|
||||||
@@ -53,7 +61,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
cookie := &http.Cookie{
|
cookie := &http.Cookie{
|
||||||
Name: TOKEN_COOKIE_NAME,
|
Name: utils.TOKEN_COOKIE_NAME,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: false,
|
Secure: false,
|
||||||
Expires: time.Now(),
|
Expires: time.Now(),
|
||||||
|
|||||||
@@ -71,3 +71,8 @@ type DownloadRequest struct {
|
|||||||
Rename string `json:"rename"`
|
Rename string `json:"rename"`
|
||||||
Params []string `json:"params"`
|
Params []string `json:"params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// struct representing request of creating a netscape cookies file
|
||||||
|
type SetCookiesRequest struct {
|
||||||
|
Cookies string `json:"cookies"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// In-Memory Thread-Safe Key-Value Storage with optional persistence
|
// In-Memory Thread-Safe Key-Value Storage with optional persistence
|
||||||
@@ -93,7 +95,12 @@ func (m *MemoryDB) All() *[]ProcessResponse {
|
|||||||
func (m *MemoryDB) Persist() {
|
func (m *MemoryDB) Persist() {
|
||||||
running := m.All()
|
running := m.All()
|
||||||
|
|
||||||
fd, err := os.Create("session.dat")
|
sessionFile := filepath.Join(
|
||||||
|
config.Instance().GetConfig().SessionFilePath,
|
||||||
|
"session.dat",
|
||||||
|
)
|
||||||
|
|
||||||
|
fd, err := os.Create(sessionFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(cli.Red, "Failed to persist session", cli.Reset)
|
log.Println(cli.Red, "Failed to persist session", cli.Reset)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
|
||||||
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type metadata struct {
|
type metadata struct {
|
||||||
@@ -17,7 +18,10 @@ type metadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
||||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, req.URL, "-J")
|
var (
|
||||||
|
downloader = config.Instance().GetConfig().DownloaderPath
|
||||||
|
cmd = exec.Command(downloader, req.URL, "-J")
|
||||||
|
)
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,7 +54,9 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
|||||||
cli.BgGreen, "Playlist detected", cli.Reset, m.Count, "entries",
|
cli.BgGreen, "Playlist detected", cli.Reset, m.Count, "entries",
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, meta := range m.Entries {
|
for i, meta := range m.Entries {
|
||||||
|
delta := time.Second.Microseconds() * int64(i+1)
|
||||||
|
|
||||||
proc := &Process{
|
proc := &Process{
|
||||||
Url: meta.OriginalURL,
|
Url: meta.OriginalURL,
|
||||||
Progress: DownloadProgress{},
|
Progress: DownloadProgress{},
|
||||||
@@ -60,7 +66,7 @@ func PlaylistDetect(req DownloadRequest, mq *MessageQueue, db *MemoryDB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
proc.Info.URL = meta.OriginalURL
|
proc.Info.URL = meta.OriginalURL
|
||||||
proc.Info.CreatedAt = time.Now().Add(time.Second)
|
proc.Info.CreatedAt = time.Now().Add(time.Duration(delta))
|
||||||
|
|
||||||
db.Set(proc)
|
db.Set(proc)
|
||||||
proc.SetPending()
|
proc.SetPending()
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ const template = `download:
|
|||||||
"speed":%(progress.speed)s
|
"speed":%(progress.speed)s
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var (
|
|
||||||
cfg = config.Instance()
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusPending = iota
|
StatusPending = iota
|
||||||
StatusDownloading
|
StatusDownloading
|
||||||
@@ -75,7 +71,7 @@ func (p *Process) Start() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
out := DownloadOutput{
|
out := DownloadOutput{
|
||||||
Path: cfg.GetConfig().DownloadPath,
|
Path: config.Instance().GetConfig().DownloadPath,
|
||||||
Filename: "%(title)s.%(ext)s",
|
Filename: "%(title)s.%(ext)s",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +93,7 @@ func (p *Process) Start() {
|
|||||||
}, p.Params...)
|
}, p.Params...)
|
||||||
|
|
||||||
// ----------------- main block ----------------- //
|
// ----------------- main block ----------------- //
|
||||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, params...)
|
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, params...)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
r, err := cmd.StdoutPipe()
|
r, err := cmd.StdoutPipe()
|
||||||
@@ -192,8 +188,8 @@ func (p *Process) Kill() error {
|
|||||||
|
|
||||||
// Returns the available format for this URL
|
// Returns the available format for this URL
|
||||||
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
||||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.Url, "-J")
|
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J")
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.Output()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DownloadFormats{}, err
|
return DownloadFormats{}, err
|
||||||
@@ -209,29 +205,27 @@ func (p *Process) GetFormatsSync() (DownloadFormats, error) {
|
|||||||
|
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
|
|
||||||
err = cmd.Start()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DownloadFormats{}, err
|
return DownloadFormats{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println(
|
log.Println(
|
||||||
cli.BgRed, "Metadata", cli.Reset,
|
cli.BgRed, "Metadata", cli.Reset,
|
||||||
cli.BgBlue, p.getShortId(), cli.Reset,
|
cli.BgBlue, "Formats", cli.Reset,
|
||||||
p.Url,
|
p.Url,
|
||||||
)
|
)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
decodingError = json.NewDecoder(stdout).Decode(&info)
|
decodingError = json.Unmarshal(stdout, &info)
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
decodingError = json.NewDecoder(stdout).Decode(&best)
|
decodingError = json.Unmarshal(stdout, &best)
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
cmd.Wait()
|
|
||||||
|
|
||||||
if decodingError != nil {
|
if decodingError != nil {
|
||||||
return DownloadFormats{}, err
|
return DownloadFormats{}, err
|
||||||
@@ -247,7 +241,7 @@ func (p *Process) SetPending() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Process) SetMetadata() error {
|
func (p *Process) SetMetadata() error {
|
||||||
cmd := exec.Command(cfg.GetConfig().DownloaderPath, p.Url, "-J")
|
cmd := exec.Command(config.Instance().GetConfig().DownloaderPath, p.Url, "-J")
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
|
||||||
)
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
|
||||||
|
|
||||||
const (
|
|
||||||
TOKEN_COOKIE_NAME = "jwt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Authenticated(next http.Handler) http.Handler {
|
func Authenticated(next http.Handler) http.Handler {
|
||||||
@@ -21,7 +18,7 @@ func Authenticated(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := r.Cookie(TOKEN_COOKIE_NAME)
|
cookie, err := r.Cookie(utils.TOKEN_COOKIE_NAME)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid token", http.StatusBadRequest)
|
http.Error(w, "invalid token", http.StatusBadRequest)
|
||||||
@@ -37,7 +34,7 @@ func Authenticated(next http.Handler) http.Handler {
|
|||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
}
|
}
|
||||||
return []byte(os.Getenv("JWTSECRET")), nil
|
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ func ApplyRouter(db *internal.MemoryDB, mq *internal.MessageQueue) func(chi.Rout
|
|||||||
r.Use(middlewares.Authenticated)
|
r.Use(middlewares.Authenticated)
|
||||||
r.Post("/exec", h.Exec())
|
r.Post("/exec", h.Exec())
|
||||||
r.Get("/running", h.Running())
|
r.Get("/running", h.Running())
|
||||||
|
r.Post("/cookies", h.SetCookies())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (h *Handler) Exec() http.HandlerFunc {
|
|||||||
|
|
||||||
req := internal.DownloadRequest{}
|
req := internal.DownloadRequest{}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).DecodeContext(r.Context(), &req); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ func (h *Handler) Exec() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.NewEncoder(w).Encode(id)
|
err = json.NewEncoder(w).EncodeContext(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,34 @@ func (h *Handler) Running() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.NewEncoder(w).Encode(res)
|
err = json.NewEncoder(w).EncodeContext(r.Context(), res)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SetCookies() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
req := new(internal.SetCookiesRequest)
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).DecodeContext(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.service.SetCookies(r.Context(), req.Cookies)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewEncoder(w).EncodeContext(r.Context(), "ok")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package rest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
"github.com/marcopeocchi/yt-dlp-web-ui/server/internal"
|
||||||
)
|
)
|
||||||
@@ -36,3 +37,15 @@ func (s *Service) Running(ctx context.Context) (*[]internal.ProcessResponse, err
|
|||||||
return s.db.All(), nil
|
return s.db.All(), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetCookies(ctx context.Context, cookies string) error {
|
||||||
|
fd, err := os.Create("cookies.txt")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer fd.Close()
|
||||||
|
fd.WriteString(cookies)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ func (s *Service) Progess(args Args, progress *internal.DownloadProgress) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Progess retrieves available format for a given resource
|
// Progess retrieves available format for a given resource
|
||||||
func (s *Service) Formats(args Args, progress *internal.DownloadFormats) error {
|
func (s *Service) Formats(args Args, meta *internal.DownloadFormats) error {
|
||||||
var err error
|
var err error
|
||||||
p := internal.Process{Url: args.URL}
|
p := internal.Process{Url: args.URL}
|
||||||
*progress, err = p.GetFormatsSync()
|
*meta, err = p.GetFormatsSync()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ func RunBlocking(port int, frontend fs.FS) {
|
|||||||
mq: mq,
|
mq: mq,
|
||||||
})
|
})
|
||||||
|
|
||||||
// http-post
|
|
||||||
go gracefulShutdown(srv, &db)
|
go gracefulShutdown(srv, &db)
|
||||||
go autoPersist(time.Minute*5, &db)
|
go autoPersist(time.Minute*5, &db)
|
||||||
|
|
||||||
@@ -59,12 +58,9 @@ func newServer(c serverConfig) *http.Server {
|
|||||||
r.Use(cors.AllowAll().Handler)
|
r.Use(cors.AllowAll().Handler)
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
|
|
||||||
sh := middlewares.NewSpaHandler("index.html", c.frontend)
|
app := http.FileServer(http.FS(c.frontend))
|
||||||
sh.AddClientRoute("/settings")
|
|
||||||
sh.AddClientRoute("/archive")
|
|
||||||
sh.AddClientRoute("/login")
|
|
||||||
|
|
||||||
r.Get("/*", sh.Handler())
|
r.Mount("/", app)
|
||||||
|
|
||||||
// Archive routes
|
// Archive routes
|
||||||
r.Route("/archive", func(r chi.Router) {
|
r.Route("/archive", func(r chi.Router) {
|
||||||
|
|||||||
5
server/utils/cookie.go
Normal file
5
server/utils/cookie.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
const (
|
||||||
|
TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user