Compare commits

...

45 Commits

Author SHA1 Message Date
Marco
8eb2831bc6 49 feat add cookies (#98)
* build client side validation and submission

* enabled cookies submission, bug fixes
2023-10-21 15:46:24 +02:00
9361d9ce29 code refactoring 2023-10-20 19:20:00 +02:00
d100092f35 toaster refactoring 2023-10-20 18:42:48 +02:00
d64303ccfa removed fmt.Println 2023-10-20 18:26:10 +02:00
6688bc3977 fix encoding url in archive 2023-10-20 18:25:33 +02:00
600475f603 removed buffer polyfill, rewrite with js web standards 2023-10-19 12:12:26 +02:00
da4aaeac84 code refactoring, fixed playlist downloads sorting 2023-10-19 11:29:56 +02:00
NickHoo
2d75030cbc fix: Echo customArgs (#96) 2023-10-12 22:05:39 +02:00
Apix
38bc66cd03 i18n: french (#95)
Updated french translation
2023-10-07 15:41:36 +02:00
3efbb9d464 code refactoring 2023-09-27 13:09:13 +02:00
fa4ba8a211 fix session file path, code refactoring 2023-09-27 13:08:42 +02:00
deluxghost
75ec95041d i18n: chinese (#92) 2023-09-27 13:04:35 +02:00
1b4c8c751b gha refactor 2023-09-26 10:37:21 +02:00
Marco
4710db25ee 82 session file location (#91)
* change session file location

* makefile refactor

* gha refactor
2023-09-26 10:25:14 +02:00
Marco
8337aae438 Update docker-publish.yml 2023-09-25 21:32:20 +02:00
2d104f4539 fix github action 2023-09-25 16:06:19 +02:00
42618b61c9 github action refactor 2023-09-25 15:37:18 +02:00
764c1f4729 code refactoring, fix jwt 2023-09-25 11:12:18 +02:00
9bb5d7bb0d code refactoring, dependencies update 2023-09-25 09:09:12 +02:00
557cf7fbd8 code refactoring 2023-09-25 08:59:58 +02:00
Marco
2040385621 change app title in the ui (#90) 2023-09-25 08:53:44 +02:00
Marco
665a08ddc4 Update docker-publish.yml 2023-09-23 15:29:20 +02:00
Marco
268d4cd990 Update docker-publish.yml 2023-09-23 14:49:31 +02:00
Marco
a9b9973c7f Update docker-publish.yml 2023-09-23 14:47:29 +02:00
Marco
0c304fc3c4 Update docker-publish.yml 2023-09-23 14:45:09 +02:00
Marco
24602b3e50 Update docker-publish.yml 2023-09-23 14:41:14 +02:00
Marco
8e720a15bd Update docker-publish.yml 2023-09-23 14:40:51 +02:00
Marco
9d9a7fa981 Update docker-publish.yml 2023-09-23 13:32:36 +02:00
Marco
d57c440afe change apptitle from settings (#88) 2023-09-23 13:25:02 +02:00
Marco
8bbc8aa35e user and password as authentication requirements (#87)
* user and password as authentication requirements

* updated README.md
2023-09-23 11:41:01 +02:00
Marco
19062c9f41 Update docker-publish.yml 2023-09-23 09:46:11 +02:00
Michael M. Chang
5c8c534df4 use docker username if differs (#84) 2023-09-14 14:35:57 +02:00
Michael M. Chang
a3234955a3 streamline dockerfile and GHA docker publishing (#83) 2023-09-11 17:54:55 +02:00
Le.NoX
13f104646a Update i18n.yaml (#81)
add ( French)
2023-09-05 22:29:44 +02:00
Marco
c50c1f627e 78 404 when the application put under nginx subdirectory with proxy pass (#79)
* use http.FileServer insetead of custom middleware

* fixed behavior under reverse proxy

* enabled reverse proxy subfolder as "domain value"

* domain validation

* code refactoring

* code refactoring

* updated translation
2023-08-21 12:24:50 +02:00
a005f159c6 added roboto font 2023-08-03 12:08:22 +02:00
0607bb4495 code refacoring 2023-08-02 18:52:48 +02:00
Marco
be4641aaf0 use swc vite plugin for dev server (#74) 2023-08-02 18:04:55 +02:00
232cd8e442 remove actions 2023-08-02 18:02:19 +02:00
1442eb8e9d dev container github action 2023-08-02 14:54:41 +02:00
8c57a7bb28 github actions rewrite 2023-08-02 14:47:38 +02:00
Marco
e2dd54add2 Expose config to docker volume (#73)
* expose config to docker volume

* fix dockerfile
2023-08-02 11:54:27 +02:00
db5097c889 hotfix formats 2023-08-01 14:43:52 +02:00
Marco
50a04075a3 Update README.md 2023-08-01 12:06:53 +02:00
4bc5e5e1c7 detect system theme, toast performance opt. 2023-08-01 11:52:50 +02:00
51 changed files with 909 additions and 509 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -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

View File

@@ -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" ]

View File

@@ -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

View File

@@ -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
``` ```
![](https://i.ibb.co/RCpfg7q/image.png) ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/a32fbdaa-b033-4aed-b914-a66701ace0ce)
![](https://i.ibb.co/N2749CD/image.png) ![image](https://github.com/marcopeocchi/yt-dlp-web-ui/assets/35533749/782c559a-f552-40be-a6fd-10e22f38e85d)
### 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
``` ```

View File

@@ -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"
} }
} }

View File

@@ -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={{

View File

@@ -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

View File

@@ -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)
}) })
}) })

View File

@@ -2,5 +2,5 @@ import { atom } from 'recoil'
export const loadingAtom = atom({ export const loadingAtom = atom({
key: 'loadingAtom', key: 'loadingAtom',
default: false default: true
}) })

View 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

View 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>
&nbsp;
{!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

View File

@@ -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"

View File

@@ -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 (

View File

@@ -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>
)) ))
} }

View File

@@ -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>
) )
} }

View File

@@ -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)
}} }}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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]) }])
} }
} }
} }

View File

@@ -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(

View File

@@ -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
} }

View File

@@ -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}

View File

@@ -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 />,

View File

@@ -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
)

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "es6", "target": "ES2018",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",

View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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(),

View File

@@ -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"`
}

View File

@@ -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)
} }

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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())
} }
} }

View File

@@ -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)
} }

View File

@@ -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
}

View File

@@ -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
} }

View File

@@ -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
View File

@@ -0,0 +1,5 @@
package utils
const (
TOKEN_COOKIE_NAME = "jwt-yt-dlp-webui"
)