Compare commits

..

74 Commits

Author SHA1 Message Date
Marco
2ae4a5da3d New home view layout (#58)
* Home layout refactor, moved new download to dialog

* sort downloads by date
2023-06-23 10:48:38 +02:00
13cc89fe3b Merge remote-tracking branch 'origin/master' into feat-authentication 2023-06-23 09:29:11 +02:00
32844bbe3e updated readme 2023-06-23 09:28:38 +02:00
e69829fcef code refactoring, updated dockerfile 2023-06-23 09:21:09 +02:00
d6c0646756 frontend performance, rpc/rest jwt authentication 2023-06-22 11:31:24 +02:00
Pachi23
1df308f388 Added Catalan Language (#57)
* Updated Spanish Language

* Fix spanish

* Added Catalan Language
2023-06-04 11:05:22 +02:00
Pachi23
6a11249fbc Updated Spanish Language (#56)
* Updated Spanish Language

* Fix spanish
2023-06-04 08:37:40 +02:00
78c1559e84 code refactoring 2023-05-31 10:21:30 +02:00
Marco
cfd6b78695 Update README.md 2023-05-26 17:47:33 +02:00
5d97873748 file browser overhaul 2023-05-26 17:31:00 +02:00
58b05e1403 code refactoring 2023-05-26 15:10:23 +02:00
985629fd2e code refactoring 2023-05-26 14:55:14 +02:00
cafaf2707e filebrowser upper level bugfix 2023-05-26 14:41:12 +02:00
Marco
823a725df4 Update README.md 2023-05-26 14:24:03 +02:00
40b25ed385 handle "upper level" on file browser 2023-05-26 14:14:30 +02:00
8632d313c3 handle "upper level" on file browser 2023-05-26 14:07:17 +02:00
Marco
98f794c822 Update README.md 2023-05-26 13:17:25 +02:00
Marco
c2a02bb0b7 Update README.md 2023-05-26 13:16:38 +02:00
f19718d46c bugfix 2023-05-26 13:02:18 +02:00
1e0e625d1a code refactoring, dependencies update 2023-05-26 11:29:59 +02:00
Marco
5b70d25bef Improved filebrowser (#52)
* file archive refactor, list dir perf optimization

* code refactoring
2023-05-26 11:10:10 +02:00
Marco
8cf130ec23 Merge pull request #51 from marcopeocchi/50-request-for-download-link-and-option-to-delete-downloadded-video
50 request for download link and option to delete downloadded video
2023-05-25 11:42:01 +02:00
fd0b40ac46 code refactoring, enabled memory db persist to fs. 2023-05-25 11:13:46 +02:00
acfc5aa064 code refactoring 2023-05-24 13:31:48 +02:00
b1c6f7248c code refactoring 2023-05-24 13:31:05 +02:00
ac6fe98dc8 ui refactor, downloaded files view enabled 2023-05-24 13:29:54 +02:00
908f4c6636 first implementation of downloaded files viewer 2023-05-24 13:19:04 +02:00
3737e86de3 backend functions for list, download, and delete local files 2023-05-17 18:32:46 +02:00
Marco
77f9eb0c2a Update Home.tsx 2023-04-19 18:24:08 +02:00
e00333a97e reduced chunks size 2023-04-19 15:01:37 +02:00
3d86b4c372 bug fix 2023-04-19 14:16:43 +02:00
fa7cd1a691 code refactoring, switch to rxjs websocket wrapper 2023-04-19 14:14:15 +02:00
621164589f Code refactoring and bump deps 2023-04-13 11:13:40 +02:00
Marco
7f602f1e20 Update docker-publish.yml 2023-03-21 22:58:14 +01:00
Marco
5977a57686 Update docker-image.yml 2023-03-21 22:57:43 +01:00
Marco
73a557d318 Merge pull request #42 from mimaburao/test
update japanese
2023-03-04 00:03:53 +01:00
mimaburao
ae1da10d6e update japanese 2023-03-03 22:44:12 +09:00
Marco
cd7ce6f55c Merge pull request #41 from marcopeocchi/opt-sync-map
changed map+rwMutext to sync.Map
2023-03-01 15:09:20 +01:00
aaad68a42c changed map+rwMutext to sync.Map 2023-03-01 15:06:11 +01:00
Marco
72857882e4 Merge pull request #37 from cnbeining/fix-websocket-wss
Fix WebSocket protocol detecton under HTTPS
2023-02-19 13:14:12 +01:00
David Zhuang
59abd76966 Fix WebSocket protocol detecton under HTTPS 2023-02-18 17:51:01 -05:00
Marco
8ab7c4db4d Update Dockerfile 2023-02-18 00:14:26 +01:00
Marco
17d48354cb Merge pull request #35 from Skyr/show-selectformat-button
If formats selection enabled: Show "select format" string in button
2023-02-08 17:54:25 +01:00
Marco
ac54a1dd13 Merge pull request #34 from Skyr/show-download-size
In format selection: Show resolution and download size (if available)
2023-02-08 17:54:12 +01:00
Stefan Schlott
75c6c84c5c If formats selection enabled: Show "select format" string in button
(instead of start)
2023-02-04 12:24:42 +01:00
Stefan Schlott
cdad7ca873 In format selection: Show resolution and download size (if available) 2023-02-04 12:13:20 +01:00
Marco
1f6d6d7839 Update Dockerfile 2023-01-22 10:28:42 +01:00
Marco
e59cf383d5 Update Dockerfile 2023-01-22 10:16:24 +01:00
Marco
643c752b6a Update Dockerfile 2023-01-21 21:47:59 +01:00
Marco
5e51bf7ff5 Update Dockerfile 2023-01-21 18:04:01 +01:00
Marco
245b70f654 Update docker-publish.yml 2023-01-21 10:54:42 +01:00
Marco
2d1fc0dda5 Update Dockerfile 2023-01-21 10:52:43 +01:00
ee83bad6e8 Update Dockerfile 2023-01-20 22:00:05 +01:00
Marco
3609f573a2 Update docker-image.yml 2023-01-20 21:55:10 +01:00
e258dea2ca reviewed Dockerfile 2023-01-20 21:50:35 +01:00
Marco
f2622adc7e Update README.md 2023-01-20 21:48:01 +01:00
Marco
4f4348cb91 Update docker-image.yml 2023-01-20 21:42:21 +01:00
Marco
fabe1c7d5e Update docker-image.yml 2023-01-20 19:49:30 +01:00
570b9eb2da reviewed dockerfile 2023-01-20 19:39:09 +01:00
0c737b2a3e enable viewing results as listview 2023-01-20 12:50:45 +01:00
1f192f48f4 bump frontend dependencies 2023-01-20 10:12:21 +01:00
Marco
0b5f84f4bd Merge pull request #32 from marcopeocchi/add-license-1
Create LICENSE.md
2023-01-18 16:48:42 +01:00
Marco
61a8fda9e5 Create LICENSE.md 2023-01-18 16:48:34 +01:00
Marco
8f1177dfd0 Update docker-image.yml 2023-01-18 15:34:10 +01:00
Marco
3b30ebe28b Update docker-image.yml 2023-01-18 15:14:21 +01:00
Marco
28fad63e34 Update docker-image.yml 2023-01-18 15:13:14 +01:00
Marco
aa51c93fec Update and rename .docker-image.old to docker-image.yml 2023-01-18 15:12:15 +01:00
Marco
6a7fb4ee09 Update README.md 2023-01-15 22:41:38 +01:00
Marco
6e15206887 Update README.md 2023-01-15 13:39:09 +01:00
Marco
d9566c2fcc Update README.md 2023-01-13 20:10:14 +01:00
Marco
dfd29e9e0b Merge pull request #29 from deluxghost/patch-3
Update chinese translation
2023-01-13 20:09:20 +01:00
deluxghost
fc61a00beb Update chinese translation 2023-01-14 01:17:44 +08:00
Marco
a05ae0b9f4 Delete fetch-yt-dlp.sh 2023-01-13 18:00:04 +01:00
185d6efc5a fixed issues addressed by #9 2023-01-13 17:39:02 +01:00
54 changed files with 2871 additions and 1158 deletions

View File

@@ -1,17 +1,16 @@
name: Docker Image CI name: Docker Image CI (Dockerhub)
on: on:
push: push:
branches: [ master ] branches: [ master ]
pull_request: pull_request:
branches: [ master ] branches: [ master ]
schedule:
- cron: '0 1 * * *'
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Login to Docker Hub - name: Login to Docker Hub
@@ -20,7 +19,10 @@ jobs:
DOCKER_PASSWORD: ${{secrets.DOCKER_HUB_PASSWORD}} DOCKER_PASSWORD: ${{secrets.DOCKER_HUB_PASSWORD}}
run: | run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD 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 - name: Build the Docker image
run: docker build . --file Dockerfile --tag ${{secrets.DOCKER_HUB_USERNAME}}/yt-dlp-webui:latest run: docker buildx build . --file Dockerfile --tag ${{secrets.DOCKER_HUB_USERNAME}}/yt-dlp-webui:latest --push --platform linux/amd64,linux/arm/v7,linux/arm64
- name: Publish the Docker image
run: docker push ${{secrets.DOCKER_HUB_USERNAME}}/yt-dlp-webui:latest

View File

@@ -1,4 +1,4 @@
name: Docker name: Docker (ghcr.io)
# This workflow uses actions that are not certified by GitHub. # This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by # They are provided by a third-party and are governed by
@@ -6,14 +6,15 @@ name: Docker
# documentation. # documentation.
on: on:
# schedule:
# - cron: '39 13 * * *'
release: release:
branches: [ master ] branches: [ master ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ] tags: [ 'v*.*.*' ]
# pull_request: push:
# branches: [ master ] branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- cron : '0 1 * * 0'
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ downloads
.DS_Store .DS_Store
build/ build/
yt-dlp-webui yt-dlp-webui
session.dat

View File

@@ -1,20 +1,33 @@
FROM alpine:3.17 FROM golang:1.20-alpine AS build
# folder structure # folder structure
WORKDIR /usr/src/yt-dlp-webui/downloads
VOLUME /downloads
WORKDIR /usr/src/yt-dlp-webui WORKDIR /usr/src/yt-dlp-webui
# install core dependencies # install core dependencies
RUN apk update RUN apk update && \
RUN apk add curl wget psmisc ffmpeg nodejs npm go yt-dlp apk add nodejs npm go
# copy srcs # copia la salsa
COPY . . COPY . .
# install node dependencies # build frontend
WORKDIR /usr/src/yt-dlp-webui/frontend WORKDIR /usr/src/yt-dlp-webui/frontend
RUN npm i RUN npm install
RUN npm run build RUN npm run build
# install go dependencies # build backend + incubator
WORKDIR /usr/src/yt-dlp-webui WORKDIR /usr/src/yt-dlp-webui
RUN go build -o yt-dlp-webui RUN CGO_ENABLED=0 GOOS=linux go build -o yt-dlp-webui
# expose and run
# but here yes :)
FROM alpine:edge
WORKDIR /downloads
VOLUME /downloads
WORKDIR /app
RUN apk update && \
apk add psmisc ffmpeg yt-dlp
COPY --from=build /usr/src/yt-dlp-webui/yt-dlp-webui /app
ENV JWT_SECRET=secret
EXPOSE 3033 EXPOSE 3033
CMD [ "./yt-dlp-webui" , "--out", "/downloads" ] ENTRYPOINT [ "./yt-dlp-webui" , "--out", "/downloads" ]

373
LICENSE.md Normal file
View File

@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

View File

@@ -1,14 +1,14 @@
default: default:
go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
all: all:
cd frontend && pnpm build && cd .. cd frontend && pnpm build && cd ..
go build -o yt-dlp-webui main.go CGO_ENABLED=0 go build -o yt-dlp-webui main.go
multiarch: multiarch:
GOOS=linux GOARCH=arm go build -o yt-dlp-webui_linux-arm *.go CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -o yt-dlp-webui_linux-arm main.go
GOOS=linux GOARCH=arm64 go build -o yt-dlp-webui_linux-arm64 *.go CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o yt-dlp-webui_linux-arm64 main.go
GOOS=linux GOARCH=amd64 go build -o yt-dlp-webui_linux-amd64 *.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 mv yt-dlp-webui* build

View File

@@ -7,17 +7,28 @@ Intended to be used with docker and in standalone mode. 😎👍
Developed to be as lightweight as possible (because my server is basically an intel atom sbc). Developed to be as lightweight as possible (because my server is basically an intel atom sbc).
The bottleneck remains yt-dlp startup time (until yt-dlp will provide a rpc interface). The bottleneck remains yt-dlp startup time.
**I strongly recomend the ghcr build instead of docker hub one.** **Docker images are available on [Docker Hub](https://hub.docker.com/r/marcobaobao/yt-dlp-webui) or [ghcr.io](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui)**.
```sh ```sh
docker pull marcobaobao/yt-dlp-webui
```
```sh
# latest stable
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
# latest dev version
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
``` ```
![](https://i.ibb.co/RCpfg7q/image.png) ![](https://i.ibb.co/RCpfg7q/image.png)
![](https://i.ibb.co/N2749CD/image.png) ![](https://i.ibb.co/N2749CD/image.png)
### Integrated File browser
Stream or download your content, easily.
![](https://i.ibb.co/k0qzLds/image.png)
## Changelog ## Changelog
``` ```
05/03/22: Korean translation by kimpig 05/03/22: Korean translation by kimpig
@@ -41,7 +52,7 @@ Refactoring and JSDoc.
08/06/22: ARM builds. 08/06/22: ARM builds.
28/02/22: Reworked resume download feature. Now it's pratically instantaneous. It no longer stops and restarts each process, references to each process are saved in memory. 28/06/22: Reworked resume download feature. Now it's pratically instantaneous. It no longer stops and restarts each process, references to each process are saved in memory.
12/01/23: Switched from TypeScript to Golang on the backend. It was a great effort but it was worth it. 12/01/23: Switched from TypeScript to Golang on the backend. It was a great effort but it was worth it.
``` ```
@@ -85,19 +96,26 @@ Future releases will have:
## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation ## [Docker](https://github.com/marcopeocchi/yt-dlp-web-ui/pkgs/container/yt-dlp-web-ui) installation
```sh ```sh
# recomended for ARM and x86 devices # recomended for ARM and x86 devices
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker pull marcobaobao/yt-dlp-webui
docker run -d -p 3022:3022 -v <your dir>:/usr/src/yt-dlp-webui/downloads ghcr.io/marcopeocchi/yt-dlp-web-ui:latest docker run -d -p 3033:3033 -v <your dir>:/downloads marcobaobao/yt-dlp-webui
# or even
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
docker create --name yt-dlp-webui -p 8082:3022 -v <your dir>:/usr/src/yt-dlp-webui/downloads ghcr.io/marcopeocchi/yt-dlp-web-ui:latest
``` ```
Or with docker but building the container manually. 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 3022:3022 -v <your dir>:/usr/src/yt-dlp-webui/downloads yt-dlp-webui docker run -d -p 3033:3033 -v <your dir>:/downloads yt-dlp-webui
```
If you opt to add RPC authentication...
```sh
docker run -d \
-p 3033:3033 \
-e JWT_SECRET randomsecret
-v /path/to/downloads:/downloads \
marcobaobao/yt-dlp-webui \
--auth \
--secret your_rpc_secret
``` ```
## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation ## [Prebuilt binaries](https://github.com/marcopeocchi/yt-dlp-web-ui/releases) installation
@@ -127,6 +145,10 @@ The config file **will overwrite what have been passed as cli argument**.
port: 8989 port: 8989
downloadPath: /home/ren/archive downloadPath: /home/ren/archive
downloaderPath: /usr/local/bin/yt-dlp downloaderPath: /usr/local/bin/yt-dlp
# Optional settings
require_auth: true
rpc_secret: my_random_secret
``` ```
### Systemd integration ### Systemd integration
@@ -182,16 +204,10 @@ For more information open an issue on GitHub and I will provide more info ASAP.
## FAQ ## FAQ
- **Will it availabe for Raspberry Pi/ generic ARM devices?** - **Will it availabe for Raspberry Pi/ generic ARM devices?**
- Yes, it's currently available through ghcr.io - Yes, it's cross platform :)
```
docker pull ghcr.io/marcopeocchi/yt-dlp-web-ui:master
```
If you plan to use it on a Raspberry Pi ensure to have fast and durable storage. If you plan to use it on a Raspberry Pi ensure to have fast and durable storage.
- **Why the docker image is so heavy?** - **Why the docker image is so heavy?**
- Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp. - Originally it was 1.8GB circa, now it has been slimmed to ~340MB compressed. This is due to the fact that it encapsule a basic Alpine linux image + FFmpeg + Node.js + Python3 + yt-dlp.
- **Update**: Since Golang migration and Multi-Stage builds the Docker image is now 75MB circa. A reduction of over 400% in size :D.
- **Why is it so slow to start a download?**
- I genuinely don't know. I know that standalone yt-dlp is slow to start up even on my M1 Mac, so....
## What yt-dlp-webui is not ## What yt-dlp-webui is not
`yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS. `yt-dlp-webui` isn't your ordinary website where to download stuff from the internet, so don't try asking for links of where this is hosted. It's a self hosted platform for a Linux NAS.

View File

@@ -1,13 +0,0 @@
#!/bin/sh
echo "Downloading latest yt-dlp build..."
rm -f yt-dlp
RELEASE=$(curl --silent "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
wget "https://github.com/yt-dlp/yt-dlp/releases/download/$RELEASE/yt-dlp"
chmod +x yt-dlp
echo "Done!"

View File

@@ -1,37 +1,37 @@
{ {
"name": "yt-dlp-webui", "name": "yt-dlp-webui",
"version": "1.1.0", "version": "2.0.7",
"description": "A terrible webUI for yt-dlp, all-in-one solution.", "description": "Frontend compontent of yt-dlp-webui",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build" "build": "vite build"
}, },
"author": "marcobaobao", "author": "marcopeocchi",
"license": "ISC", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.9.0", "@emotion/react": "^11.11.0",
"@emotion/styled": "^11.8.1", "@emotion/styled": "^11.11.0",
"@koa/cors": "^3.3.0", "@mui/icons-material": "^5.11.16",
"@mui/icons-material": "^5.6.2", "@mui/material": "^5.13.2",
"@mui/material": "^5.6.4", "@reduxjs/toolkit": "^1.9.5",
"@reduxjs/toolkit": "^1.8.1", "fp-ts": "^2.16.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^8.0.1", "react-redux": "^8.0.5",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.11.2",
"rxjs": "^7.4.0", "rxjs": "^7.8.1",
"uuid": "^8.3.2" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.2", "@modyfi/vite-plugin-yaml": "^1.0.4",
"@types/node": "^18.11.18", "@types/node": "^20.2.4",
"@types/react": "^18.0.21", "@types/react": "^18.2.7",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.10",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/uuid": "^8.3.4", "@types/uuid": "^9.0.1",
"@vitejs/plugin-react": "^1.3.2", "@vitejs/plugin-react": "^4.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"typescript": "^4.6.4", "typescript": "^5.0.4",
"vite": "^2.9.10" "vite": "^4.3.8"
} }
} }

View File

@@ -1,176 +1,12 @@
import { ThemeProvider } from "@emotion/react"; import { Provider } from 'react-redux'
import { import { RouterProvider } from 'react-router-dom'
ChevronLeft, import { router } from './router'
Dashboard, import { store } from './stores/store'
// Download,
Menu, Settings as SettingsIcon,
SettingsEthernet,
Storage
} from "@mui/icons-material";
import {
Box,
createTheme, CssBaseline,
Divider,
IconButton, List,
ListItemIcon, ListItemText, Toolbar,
Typography
} from "@mui/material";
import { grey } from "@mui/material/colors";
import ListItemButton from '@mui/material/ListItemButton';
import { useMemo, useState } from "react";
import { Provider, useSelector } from "react-redux";
import {
BrowserRouter as Router, Link, Route,
Routes
} from 'react-router-dom';
import { AppBar } from "./components/AppBar";
import { Drawer } from "./components/Drawer";
import Home from "./Home";
import Settings from "./Settings";
import { RootState, store } from './stores/store';
import { formatGiB, getWebSocketEndpoint } from "./utils";
function AppContent() {
const [open, setOpen] = useState(false)
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const socket = useMemo(() => new WebSocket(getWebSocketEndpoint()), [])
const mode = settings.theme
const theme = useMemo(() =>
createTheme({
palette: {
mode: settings.theme,
background: {
default: settings.theme === 'light' ? grey[50] : '#121212'
},
},
}), [settings.theme]
)
const toggleDrawer = () => {
setOpen(!open)
}
return (
<ThemeProvider theme={theme}>
<Router>
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar position="absolute" open={open}>
<Toolbar sx={{ pr: '24px' }}>
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={toggleDrawer}
sx={{
marginRight: '36px',
...(open && { display: 'none' }),
}}
>
<Menu />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
yt-dlp WebUI
</Typography>
{
status.freeSpace ?
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<Storage />
<span>&nbsp;{formatGiB(status.freeSpace)}&nbsp;</span>
</div>
: null
}
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<SettingsEthernet />
<span>&nbsp;{status.connected ? settings.serverAddr : 'not connected'}</span>
</div>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<Toolbar
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
px: [1],
}}
>
<IconButton onClick={toggleDrawer}>
<ChevronLeft />
</IconButton>
</Toolbar>
<Divider />
<List component="nav">
<Link to={'/'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton disabled={status.downloading}>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary="Home" />
</ListItemButton>
</Link>
<Link to={'/settings'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton disabled={status.downloading}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</Link>
</List>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
}}
>
<Toolbar />
<Routes>
<Route path="/" element={<Home socket={socket} />} />
<Route path="/settings" element={<Settings socket={socket} />} />
</Routes>
</Box>
</Box>
</Router>
</ThemeProvider>
);
}
export function App() { export function App() {
return ( return (
<Provider store={store}> <Provider store={store}>
<AppContent /> <RouterProvider router={router} />
</Provider> </Provider>
); )
} }

View File

@@ -1,488 +0,0 @@
import { FileUpload } from "@mui/icons-material";
import {
Backdrop,
Button,
ButtonGroup,
CircularProgress,
Container,
FormControl,
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Snackbar,
styled,
TextField,
Typography
} from "@mui/material";
import { Buffer } from 'buffer';
import { Fragment, useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { StackableResult } from "./components/StackableResult";
import { CliArguments } from "./features/core/argsParser";
import I18nBuilder from "./features/core/intl";
import { RPCClient } from "./features/core/rpcClient";
import { connected, setFreeSpace } from "./features/status/statusSlice";
import { RootState } from "./stores/store";
import { IDLMetadata, RPCResult } from "./types";
import { isValidURL, toFormatArgs } from "./utils";
type Props = {
socket: WebSocket
}
export default function Home({ socket }: Props) {
// redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
// ephemeral state
const [activeDownloads, setActiveDownloads] = useState(new Array<RPCResult>());
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
const [pickedBestFormat, setPickedBestFormat] = useState('');
const [customArgs, setCustomArgs] = useState('');
const [downloadPath, setDownloadPath] = useState(0);
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]);
const [fileNameOverride, setFilenameOverride] = useState('');
const [url, setUrl] = useState('');
const [workingUrl, setWorkingUrl] = useState('');
const [showBackdrop, setShowBackdrop] = useState(false);
const [showToast, setShowToast] = useState(true);
// memos
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(socket), [settings.serverAddr, settings.serverPort])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
useEffect(() => {
socket.onopen = () => {
dispatch(connected())
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
}
}, [])
useEffect(() => {
if (status.connected) {
client.running()
const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval)
}
}, [status.connected])
useEffect(() => {
client.freeSpace()
.then(bytes => dispatch(setFreeSpace(bytes.result)))
}, [])
useEffect(() => {
socket.onmessage = (event) => {
const res = client.decode(event.data)
switch (typeof res.result) {
case 'object':
setActiveDownloads(
(res.result ?? [])
.filter((r: RPCResult) => !!r.info.url)
.sort((a: RPCResult, b: RPCResult) => a.info.title.localeCompare(b.info.title))
)
break
default:
break
}
}
}, [])
useEffect(() => {
if (activeDownloads.length > 0 && showBackdrop) {
setShowBackdrop(false)
}
}, [activeDownloads, showBackdrop])
useEffect(() => {
client.directoryTree()
.then(data => {
setAvailableDownloadPaths(data.result)
})
}, [])
/* -------------------- component functions -------------------- */
/**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/
const sendUrl = (immediate?: string) => {
const codes = new Array<string>();
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat);
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat);
if (pickedBestFormat !== '') codes.push(pickedBestFormat);
client.download(
immediate || url || workingUrl,
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
availableDownloadPaths[downloadPath] ?? '',
fileNameOverride
)
setUrl('')
setWorkingUrl('')
setFilenameOverride('')
setTimeout(() => {
resetInput()
setShowBackdrop(true)
setDownloadFormats(undefined)
}, 250);
}
/**
* Retrive url from input and display the formats selection view
*/
const sendUrlFormatSelection = () => {
setWorkingUrl(url)
setUrl('')
setPickedAudioFormat('')
setPickedVideoFormat('')
setPickedBestFormat('')
setShowBackdrop(true)
client.formats(url)
?.then(formats => {
setDownloadFormats(formats.result)
setShowBackdrop(false)
resetInput()
})
}
/**
* Update the url state whenever the input value changes
* @param e Input change event
*/
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value)
}
/**
* Update the filename override state whenever the input value changes
* @param e Input change event
*/
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilenameOverride(e.target.value)
}
/**
* Update the custom args state whenever the input value changes
* @param e Input change event
*/
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomArgs(e.target.value)
localStorage.setItem("last-input-args", e.target.value)
}
/**
* Abort a specific download if id's provided, other wise abort all running ones.
* @param id The download id / pid
* @returns void
*/
const abort = (id?: string) => {
if (id) {
client.kill(id)
return
}
client.killAll()
}
const parseUrlListFile = (event: any) => {
const urlList = event.target.files
const reader = new FileReader()
reader.addEventListener('load', $event => {
const base64 = $event.target?.result!.toString().split(',')[1]
Buffer.from(base64!, 'base64')
.toString()
.trimEnd()
.split('\n')
.filter(_url => isValidURL(_url))
.forEach(_url => sendUrl(_url))
})
reader.readAsDataURL(urlList[0])
}
const resetInput = () => {
const input = document.getElementById('urlInput') as HTMLInputElement;
input.value = '';
const filename = document.getElementById('customFilenameInput') as HTMLInputElement;
if (filename) {
filename.value = '';
}
}
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
});
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={showBackdrop}
>
<CircularProgress color="primary" />
</Backdrop>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<TextField
fullWidth
id="urlInput"
label={i18n.t('urlInput')}
variant="outlined"
onChange={handleUrlChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<label htmlFor="icon-button-file">
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
<IconButton color="primary" aria-label="upload file" component="span">
<FileUpload />
</IconButton>
</label>
</InputAdornment>
),
}}
/>
</Grid>
<Grid container spacing={1} sx={{ mt: 1 }}>
{
settings.enableCustomArgs ?
<Grid item xs={12}>
<TextField
id="customArgsInput"
fullWidth
label={i18n.t('customArgsInput')}
variant="outlined"
onChange={handleCustomArgsChange}
value={customArgs}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
/>
</Grid> :
null
}
{
settings.fileRenaming ?
<Grid item xs={8}>
<TextField
id="customFilenameInput"
fullWidth
label={i18n.t('customFilename')}
variant="outlined"
onChange={handleFilenameOverrideChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
/>
</Grid> :
null
}
{
settings.pathOverriding ?
<Grid item xs={4}>
<FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel>
<Select
label={i18n.t('customPath')}
defaultValue={0}
variant={'outlined'}
value={downloadPath}
onChange={(e) => setDownloadPath(Number(e.target.value))}
>
{availableDownloadPaths.map((val: string, idx: number) => (
<MenuItem key={idx} value={idx}>{val}</MenuItem>
))}
</Select>
</FormControl>
</Grid> :
null
}
</Grid>
<Grid container spacing={1} pt={2}>
<Grid item>
<Button
variant="contained"
disabled={url === ''}
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
>
{i18n.t('startButton')}
</Button>
</Grid>
<Grid item>
<Button
variant="contained"
onClick={() => abort()}
>
{i18n.t('abortAllButton')}
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid >
{/* Format Selection grid */}
{
downloadFormats ? <Grid container spacing={2} mt={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12}>
<Typography variant="h6" component="div" pb={1}>
{downloadFormats.title}
</Typography>
{/* <Skeleton variant="rectangular" height={180} /> */}
</Grid>
<Grid item xs={12} pb={1}>
<img src={downloadFormats.thumbnail} height={260} width="100%" style={{ objectFit: 'cover' }} />
</Grid>
{/* video only */}
<Grid item xs={12}>
<Typography variant="body1" component="div">
Best quality
</Typography>
</Grid>
<Grid item pr={2} py={1}>
<Button
variant="contained"
disabled={pickedBestFormat !== ''}
onClick={() => {
setPickedBestFormat(downloadFormats.best.format_id)
setPickedVideoFormat('')
setPickedAudioFormat('')
}}>
{downloadFormats.best.format_note || downloadFormats.best.format_id} - {downloadFormats.best.vcodec}+{downloadFormats.best.acodec}
</Button>
</Grid>
{/* video only */}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
<Grid item xs={12}>
<Typography variant="body1" component="div">
Video data {downloadFormats.formats[1].acodec}
</Typography>
</Grid>
: null
}
{downloadFormats.formats
.filter(format => format.acodec === 'none' && format.vcodec !== 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => {
setPickedVideoFormat(format.format_id)
setPickedBestFormat('')
}}
disabled={pickedVideoFormat === format.format_id}
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
</Button>
</Grid>
))
}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
<Grid item xs={12}>
<Typography variant="body1" component="div">
Audio data
</Typography>
</Grid>
: null
}
{downloadFormats.formats
.filter(format => format.acodec !== 'none' && format.vcodec === 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => {
setPickedAudioFormat(format.format_id)
setPickedBestFormat('')
}}
disabled={pickedAudioFormat === format.format_id}
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
</Button>
</Grid>
))
}
<Grid item xs={12} pt={2}>
<ButtonGroup disableElevation variant="contained">
<Button
onClick={() => sendUrl()}
disabled={!pickedBestFormat && !(pickedAudioFormat || pickedVideoFormat)}
> Download
</Button>
<Button
onClick={() => {
setPickedAudioFormat('');
setPickedVideoFormat('');
setPickedBestFormat('');
}}
> Clear
</Button>
</ButtonGroup>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid> : null
}
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
{
activeDownloads.map(download => (
<Grid item xs={4} sm={8} md={6} key={download.id}>
<Fragment>
<StackableResult
title={download.info.title}
thumbnail={download.info.thumbnail}
percentage={download.progress.percentage}
stopCallback={() => abort(download.id)}
resolution={download.info.resolution ?? ''}
speed={download.progress.speed}
size={download.info.filesize_approx ?? 0}
/>
</Fragment>
</Grid>
))
}
</Grid>
<Snackbar
open={showToast === status.connected}
autoHideDuration={1500}
message="Connected"
onClose={() => setShowToast(false)}
/>
</Container >
);
}

181
frontend/src/Layout.tsx Normal file
View File

@@ -0,0 +1,181 @@
import { ThemeProvider } from '@emotion/react'
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard'
import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import Storage from '@mui/icons-material/Storage'
import { Box, createTheme } from '@mui/material'
import DownloadIcon from '@mui/icons-material/Download'
import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors'
import { useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import { Link, Outlet } from 'react-router-dom'
import { RootState } from './stores/store'
import AppBar from './components/AppBar'
import Drawer from './components/Drawer'
import Logout from './components/Logout'
import { formatGiB } from './utils'
import ThemeToggler from './components/ThemeToggler'
export default function Layout() {
const [open, setOpen] = useState(false)
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const mode = settings.theme
const theme = useMemo(() =>
createTheme({
palette: {
mode: settings.theme,
background: {
default: settings.theme === 'light' ? grey[50] : '#121212'
},
},
}), [settings.theme]
)
const toggleDrawer = () => {
setOpen(state => !state)
}
return (
<ThemeProvider theme={theme}>
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar position="absolute" open={open}>
<Toolbar sx={{ pr: '24px' }}>
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={toggleDrawer}
sx={{
marginRight: '36px',
...(open && { display: 'none' }),
}}
>
<Menu />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
yt-dlp WebUI
</Typography>
{
status.freeSpace ?
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<Storage />
<span>&nbsp;{formatGiB(status.freeSpace)}&nbsp;</span>
</div>
: null
}
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<SettingsEthernet />
<span>&nbsp;{status.connected ? settings.serverAddr : 'not connected'}</span>
</div>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<Toolbar
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
px: [1],
}}
>
<IconButton onClick={toggleDrawer}>
<ChevronLeft />
</IconButton>
</Toolbar>
<Divider />
<List component="nav">
<Link to={'/'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton disabled={status.downloading}>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary="Home" />
</ListItemButton>
</Link>
<Link to={'/archive'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton disabled={status.downloading}>
<ListItemIcon>
<DownloadIcon />
</ListItemIcon>
<ListItemText primary="Archive" />
</ListItemButton>
</Link>
<Link to={'/settings'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton disabled={status.downloading}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</Link>
<ThemeToggler />
<Logout />
</List>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
</ThemeProvider>
)
}

View File

@@ -4,6 +4,7 @@ languages:
urlInput: YouTube or other supported service video URL urlInput: YouTube or other supported service video URL
statusTitle: Status statusTitle: Status
statusReady: Ready statusReady: Ready
selectFormatButton: Select format
startButton: Start startButton: Start
abortAllButton: Abort All abortAllButton: Abort All
updateBinButton: Update yt-dlp binary updateBinButton: Update yt-dlp binary
@@ -27,6 +28,7 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
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
@@ -54,6 +56,7 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error nella connessione al server RPC
chinese: chinese:
urlInput: YouTube 或其他受支持服务的视频网址 urlInput: YouTube 或其他受支持服务的视频网址
statusTitle: 状态 statusTitle: 状态
@@ -72,42 +75,44 @@ languages:
toastConnected: '已连接到 ' toastConnected: '已连接到 '
toastUpdated: 已更新 yt-dlp 可执行文件! toastUpdated: 已更新 yt-dlp 可执行文件!
formatSelectionEnabler: 启用视频/音频格式选择 formatSelectionEnabler: 启用视频/音频格式选择
themeSelect: 'Theme' themeSelect: '主题'
languageSelect: 'Language' languageSelect: '语言'
overridesAnchor: Overrides overridesAnchor: 覆盖
pathOverrideOption: Enable output path overriding pathOverrideOption: 启用输出路径覆盖
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: 启用输出文件名覆盖
customFilename: Custom filemame (leave blank to use default) customFilename: 自定义文件名(留空使用默认值)
customPath: Custom path customPath: 自定义路径
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: 启用自定义 yt-dlp 参数(能力越大 = 责任越大)
customArgsInput: Custom yt-dlp arguments customArgsInput: 自定义 yt-dlp 参数
rpcConnErr: Error while conencting to RPC server
spanish: spanish:
urlInput: YouTube or other supported service video url urlInput: URL de YouTube u otro servicio compatible
statusTitle: Status statusTitle: Estado
startButton: Start startButton: Iniciar
statusReady: Ready statusReady: Listo
abortAllButton: Abort All abortAllButton: Cancelar Todo
updateBinButton: Update yt-dlp binary updateBinButton: Actualizar el binario yt-dlp
darkThemeButton: Dark theme darkThemeButton: Tema oscuro
lightThemeButton: Light theme lightThemeButton: Tema claro
settingsAnchor: Settings settingsAnchor: Ajustes
serverAddressTitle: Server address serverAddressTitle: Dirección del servidor
serverPortTitle: Port serverPortTitle: Puerto
extractAudioCheckbox: Extract audio extractAudioCheckbox: Extraer audio
noMTimeCheckbox: Don't set file modification time noMTimeCheckbox: No guardar el tiempo de modificación del archivo
bgReminder: Once you close this page the download will continue in the background. bgReminder: Si cierras esta página, la descarga continuará en segundo plano.
toastConnected: 'Connected to ' toastConnected: 'Conectado a'
toastUpdated: Updated yt-dlp binary! toastUpdated: ¡El binario yt-dlp está actualizado!
formatSelectionEnabler: Enable video/audio formats selection formatSelectionEnabler: Habilitar la selección de formatos de video/audio
themeSelect: 'Theme' themeSelect: 'Tema'
languageSelect: 'Language' languageSelect: 'Idiomas'
overridesAnchor: Overrides overridesAnchor: Anulaciones
pathOverrideOption: Enable output path overriding pathOverrideOption: Sobreescribir en la ruta de salida
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: Sobreescribir el nombre del fichero
customFilename: Custom filemame (leave blank to use default) customFilename: Nombre de archivo personalizado (en blanco para usar el predeterminado)
customPath: Custom path customPath: Ruta personalizada
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Habilitar los argumentos yt-dlp personalizados (un gran poder conlleva una gran responsabilidad)
customArgsInput: Custom yt-dlp arguments customArgsInput: Argumentos yt-dlp personalizados
rpcConnErr: Error al conectarse al servidor RPC
russian: russian:
urlInput: YouTube or other supported service video url urlInput: YouTube or other supported service video url
statusTitle: Status statusTitle: Status
@@ -135,6 +140,7 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
korean: korean:
urlInput: YouTube나 다른 지원되는 사이트의 URL urlInput: YouTube나 다른 지원되는 사이트의 URL
statusTitle: 상태 statusTitle: 상태
@@ -162,10 +168,12 @@ languages:
customPath: Custom path customPath: Custom path
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: Enable custom yt-dlp args (great power = great responsabilities)
customArgsInput: Custom yt-dlp arguments customArgsInput: Custom yt-dlp arguments
rpcConnErr: Error while conencting to RPC server
japanese: japanese:
urlInput: YouTubeまたはサポート済み動画のURL urlInput: YouTubeまたはサポート済み動画のURL
statusTitle: 状態 statusTitle: 状態
statusReady: 準備 statusReady: 準備
selectFormatButton: フォーマット選択
startButton: 開始 startButton: 開始
abortAllButton: すべて中止 abortAllButton: すべて中止
updateBinButton: yt-dlp更新 updateBinButton: yt-dlp更新
@@ -180,12 +188,41 @@ languages:
toastConnected: '接続中 ' toastConnected: '接続中 '
toastUpdated: yt-dlpを更新しました! toastUpdated: yt-dlpを更新しました!
formatSelectionEnabler: 選択可能な動画/音源 formatSelectionEnabler: 選択可能な動画/音源
themeSelect: 'Theme' themeSelect: 'テーマ'
languageSelect: 'Language' languageSelect: '言語'
overridesAnchor: Overrides overridesAnchor: 上書き
pathOverrideOption: Enable output path overriding pathOverrideOption: 保存するディレクトリ
filenameOverrideOption: Enable output file name overriding filenameOverrideOption: ファイル名の上書き
customFilename: Custom filemame (leave blank to use default) customFilename: (空白の場合は元のファイル名)
customPath: Custom path customPath: 保存先
customArgs: Enable custom yt-dlp args (great power = great responsabilities) customArgs: yt-dlpのオプションの有効化 (最適設定にする場合)
customArgsInput: Custom yt-dlp arguments customArgsInput: yt-dlpのオプション
rpcConnErr: Error while conencting to RPC server
catalan:
urlInput: URL de YouTube o d'un altre servei compatible
statusTitle: Estat
startButton: Iniciar
statusReady: Llest
abortAllButton: Cancel·lar Tot
updateBinButton: Actualitzar el binari yt-dlp
darkThemeButton: Tema fosc
lightThemeButton: Tema clar
settingsAnchor: Configuració
serverAddressTitle: Direcció del servidor
serverPortTitle: Port
extractAudioCheckbox: Extreure àudio
noMTimeCheckbox: No guardar el temps de modificació de l'arxiu
bgReminder: Si tanques aquesta pàgina, la descàrrega continuarà en segon pla.
toastConnected: 'Connectat a'
toastUpdated: El binari yt-dlp està actualitzat!
formatSelectionEnabler: Habilitar la selecció de formats de vídeo/àudio
themeSelect: 'Tema'
languageSelect: 'Idiomes'
overridesAnchor: Anul·lacions
pathOverrideOption: Sobreescriure en la ruta de sortida
filenameOverrideOption: Sobreescriure el nom del fitxer
customFilename: Nom d'arxiu personalitzat (en blanc per utilitzar el predeterminat)
customPath: Ruta personalitzada
customArgs: Habilitar els arguments yt-dlp personalitzats (un gran poder comporta una gran responsabilitat)
customArgsInput: Arguments yt-dlp personalitzats
rpcConnErr: Error en connectar-se al servidor RPC

View File

@@ -7,7 +7,7 @@ interface AppBarProps extends MuiAppBarProps {
const drawerWidth = 240; const drawerWidth = 240;
export const AppBar = styled(MuiAppBar, { const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== 'open', shouldForwardProp: (prop) => prop !== 'open',
})<AppBarProps>(({ theme, open }) => ({ })<AppBarProps>(({ theme, open }) => ({
zIndex: theme.zIndex.drawer + 1, zIndex: theme.zIndex.drawer + 1,
@@ -24,3 +24,5 @@ export const AppBar = styled(MuiAppBar, {
}), }),
}), }),
})); }));
export default AppBar

View File

@@ -1,5 +1,12 @@
import { Card, CardActionArea, CardContent, CardMedia, Skeleton, Typography } from "@mui/material"; import {
import { ellipsis } from "../utils"; Card,
CardActionArea,
CardContent,
CardMedia,
Skeleton,
Typography
} from '@mui/material'
import { ellipsis } from '../utils'
type Props = { type Props = {
title: string, title: string,

View File

@@ -0,0 +1,335 @@
import { FileUpload } from '@mui/icons-material'
import CloseIcon from '@mui/icons-material/Close'
import {
Button,
Container,
FormControl,
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
styled,
TextField
} from '@mui/material'
import AppBar from '@mui/material/AppBar'
import Dialog from '@mui/material/Dialog'
import Slide from '@mui/material/Slide'
import Toolbar from '@mui/material/Toolbar'
import { TransitionProps } from '@mui/material/transitions'
import Typography from '@mui/material/Typography'
import { Buffer } from 'buffer'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import FormatsGrid from '../components/FormatsGrid'
import { CliArguments } from '../lib/argsParser'
import I18nBuilder from '../lib/intl'
import { RPCClient } from '../lib/rpcClient'
import { RootState } from '../stores/store'
import type { DLMetadata } from '../types'
import { isValidURL, toFormatArgs } from '../utils'
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />
})
type Props = {
open: boolean
onClose: () => void
}
export default function DownloadDialog({ open, onClose }: Props) {
// redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
// ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
const [pickedBestFormat, setPickedBestFormat] = useState('')
const [customArgs, setCustomArgs] = useState('')
const [downloadPath, setDownloadPath] = useState(0)
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
const [fileNameOverride, setFilenameOverride] = useState('')
const [url, setUrl] = useState('')
const [workingUrl, setWorkingUrl] = useState('')
// memos
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
// refs
const urlInputRef = useRef<HTMLInputElement>(null)
const customFilenameInputRef = useRef<HTMLInputElement>(null)
// effects
useEffect(() => {
client.directoryTree()
.then(data => {
setAvailableDownloadPaths(data.result)
})
}, [])
useEffect(() => {
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
}, [])
/**
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
*/
const sendUrl = (immediate?: string) => {
const codes = new Array<string>()
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat)
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat)
if (pickedBestFormat !== '') codes.push(pickedBestFormat)
client.download(
immediate || url || workingUrl,
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
availableDownloadPaths[downloadPath] ?? '',
fileNameOverride
)
setUrl('')
setWorkingUrl('')
setTimeout(() => {
resetInput()
setDownloadFormats(undefined)
onClose()
}, 250)
}
/**
* Retrive url from input and display the formats selection view
*/
const sendUrlFormatSelection = () => {
setWorkingUrl(url)
setUrl('')
setPickedAudioFormat('')
setPickedVideoFormat('')
setPickedBestFormat('')
client.formats(url)
?.then(formats => {
setDownloadFormats(formats.result)
resetInput()
})
}
/**
* Update the url state whenever the input value changes
* @param e Input change event
*/
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value)
}
/**
* Update the filename override state whenever the input value changes
* @param e Input change event
*/
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilenameOverride(e.target.value)
localStorage.setItem('last-filename-override', e.target.value)
}
/**
* Update the custom args state whenever the input value changes
* @param e Input change event
*/
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomArgs(e.target.value)
localStorage.setItem("last-input-args", e.target.value)
}
const parseUrlListFile = (event: any) => {
const urlList = event.target.files
const reader = new FileReader()
reader.addEventListener('load', $event => {
const base64 = $event.target?.result!.toString().split(',')[1]
Buffer.from(base64!, 'base64')
.toString()
.trimEnd()
.split('\n')
.filter(_url => isValidURL(_url))
.forEach(_url => sendUrl(_url))
})
reader.readAsDataURL(urlList[0])
}
const resetInput = () => {
urlInputRef.current!.value = ''
if (customFilenameInputRef.current) {
customFilenameInputRef.current!.value = ''
}
}
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
})
return (
<div>
<Dialog
fullScreen
open={open}
onClose={onClose}
TransitionComponent={Transition}
>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={onClose}
aria-label="close"
>
<CloseIcon />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
Download
</Typography>
</Toolbar>
</AppBar>
<Container sx={{ mt: 4 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<TextField
fullWidth
ref={urlInputRef}
label={i18n.t('urlInput')}
variant="outlined"
onChange={handleUrlChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<label htmlFor="icon-button-file">
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
<IconButton color="primary" aria-label="upload file" component="span">
<FileUpload />
</IconButton>
</label>
</InputAdornment>
),
}}
/>
</Grid>
<Grid container spacing={1} sx={{ mt: 1 }}>
{
settings.enableCustomArgs &&
<Grid item xs={12}>
<TextField
fullWidth
label={i18n.t('customArgsInput')}
variant="outlined"
onChange={handleCustomArgsChange}
value={customArgs}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
/>
</Grid>
}
{
settings.fileRenaming &&
<Grid item xs={8}>
<TextField
ref={customFilenameInputRef}
fullWidth
label={i18n.t('customFilename')}
variant="outlined"
value={fileNameOverride}
onChange={handleFilenameOverrideChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
/>
</Grid>
}
{
settings.pathOverriding &&
<Grid item xs={4}>
<FormControl fullWidth>
<InputLabel>{i18n.t('customPath')}</InputLabel>
<Select
label={i18n.t('customPath')}
defaultValue={0}
variant={'outlined'}
value={downloadPath}
onChange={(e) => setDownloadPath(Number(e.target.value))}
>
{availableDownloadPaths.map((val: string, idx: number) => (
<MenuItem key={idx} value={idx}>{val}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
}
</Grid>
<Grid container spacing={1} pt={2}>
<Grid item>
<Button
variant="contained"
disabled={url === ''}
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
>
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')}
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid >
{/* Format Selection grid */}
{downloadFormats && <FormatsGrid
downloadFormats={downloadFormats}
onBestQualitySelected={(id) => {
setPickedBestFormat(id)
setPickedVideoFormat('')
setPickedAudioFormat('')
}}
onVideoSelected={(id) => {
setPickedVideoFormat(id)
setPickedBestFormat('')
}}
onAudioSelected={(id) => {
setPickedAudioFormat(id)
setPickedBestFormat('')
}}
onClear={() => {
setPickedAudioFormat('')
setPickedVideoFormat('')
setPickedBestFormat('')
}}
onSubmit={sendUrl}
pickedBestFormat={pickedBestFormat}
pickedVideoFormat={pickedVideoFormat}
pickedAudioFormat={pickedAudioFormat}
/>}
</Container>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Grid } from "@mui/material"
import { Fragment } from "react"
import type { RPCResult } from "../types"
import { StackableResult } from "./StackableResult"
type Props = {
downloads: RPCResult[]
abortFunction: (id: string) => void
}
export function DownloadsCardView({ downloads, abortFunction }: Props) {
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
{
downloads.map(download => (
<Grid item xs={4} sm={8} md={6} key={download.id}>
<Fragment>
<StackableResult
title={download.info.title}
thumbnail={download.info.thumbnail}
percentage={download.progress.percentage}
stopCallback={() => abortFunction(download.id)}
resolution={download.info.resolution ?? ''}
speed={download.progress.speed}
size={download.info.filesize_approx ?? 0}
/>
</Fragment>
</Grid>
))
}
</Grid>
)
}

View File

@@ -0,0 +1,82 @@
import {
Button,
Grid,
LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from "@mui/material"
import { ellipsis, formatSpeedMiB, roundMiB } from "../utils"
import type { RPCResult } from "../types"
type Props = {
downloads: RPCResult[]
abortFunction: Function
}
export function DownloadsListView({ downloads, abortFunction }: Props) {
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
<Grid item xs={12}>
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}>
<Table>
<TableHead>
<TableRow>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Title</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Progress</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Speed</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Size</Typography>
</TableCell>
<TableCell>
<Typography fontWeight={500} fontSize={15}>Actions</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{
downloads.map(download => (
<TableRow key={download.id}>
<TableCell>{ellipsis(download.info.title, 80)}</TableCell>
<TableCell>
<LinearProgress
value={
download.progress.percentage === '-1' ? 100 :
Number(download.progress.percentage.replace('%', ''))
}
variant="determinate"
color={download.progress.percentage === '-1' ? 'success' : 'primary'}
/>
</TableCell>
<TableCell>{formatSpeedMiB(download.progress.speed)}</TableCell>
<TableCell>{roundMiB(download.info.filesize_approx ?? 0)}</TableCell>
<TableCell>
<Button
variant="contained"
size="small"
onClick={() => abortFunction(download.id)}
>
{download.progress.percentage === '-1' ? 'Remove' : 'Stop'}
</Button>
</TableCell>
</TableRow>
))
}
</TableBody>
</Table>
</TableContainer>
</Grid>
</Grid>
)
}

View File

@@ -1,9 +1,9 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material'
import MuiDrawer from '@mui/material/Drawer'; import MuiDrawer from '@mui/material/Drawer'
const drawerWidth = 240; const drawerWidth = 240
export const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })( const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })(
({ theme, open }) => ({ ({ theme, open }) => ({
'& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
position: 'relative', position: 'relative',
@@ -27,4 +27,6 @@ export const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !==
}), }),
}, },
}), }),
); )
export default Drawer

View File

@@ -0,0 +1,126 @@
import { Button, ButtonGroup, Grid, Paper, Typography } from "@mui/material"
import type { DLMetadata } from '../types'
type Props = {
downloadFormats: DLMetadata
onAudioSelected: (format: string) => void
onVideoSelected: (format: string) => void
onBestQualitySelected: (format: string) => void
onSubmit: () => void
onClear: () => void
pickedBestFormat: string
pickedAudioFormat: string
pickedVideoFormat: string
}
export default function FormatsGrid({
downloadFormats,
onAudioSelected,
onVideoSelected,
onBestQualitySelected,
onSubmit,
onClear,
pickedBestFormat,
pickedAudioFormat,
pickedVideoFormat,
}: Props) {
return (
<Grid container spacing={2} mt={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<Grid item xs={12}>
<Typography variant="h6" component="div" pb={1}>
{downloadFormats.title}
</Typography>
{/* <Skeleton variant="rectangular" height={180} /> */}
</Grid>
<Grid item xs={12} pb={1}>
<img src={downloadFormats.thumbnail} height={260} width="100%" style={{ objectFit: 'cover' }} />
</Grid>
{/* video only */}
<Grid item xs={12}>
<Typography variant="body1" component="div">
Best quality
</Typography>
</Grid>
<Grid item pr={2} py={1}>
<Button
variant="contained"
disabled={pickedBestFormat !== ''}
onClick={() => onBestQualitySelected(downloadFormats.best.format_id)}
>
{downloadFormats.best.format_note || downloadFormats.best.format_id} - {downloadFormats.best.vcodec}+{downloadFormats.best.acodec}
&nbsp;({downloadFormats.best.resolution}{(downloadFormats.best.filesize_approx > 0) ? ", ~" + Math.round(downloadFormats.best.filesize_approx / 1024 / 1024) + " MiB" : ""})
</Button>
</Grid>
{/* video only */}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length &&
<Grid item xs={12}>
<Typography variant="body1" component="div">
Video data {downloadFormats.formats[1].acodec}
</Typography>
</Grid>
}
{downloadFormats.formats
.filter(format => format.acodec === 'none' && format.vcodec !== 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => onVideoSelected(format.format_id)}
disabled={pickedVideoFormat === format.format_id}
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
&nbsp;({format.resolution}{(format.filesize_approx > 0) ? ", ~" + Math.round(format.filesize_approx / 1024 / 1024) + " MiB" : ""})
</Button>
</Grid>
))
}
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length &&
<Grid item xs={12}>
<Typography variant="body1" component="div">
Audio data
</Typography>
</Grid>
}
{downloadFormats.formats
.filter(format => format.acodec !== 'none' && format.vcodec === 'none')
.map((format, idx) => (
<Grid item pr={2} py={1} key={idx}>
<Button
variant="contained"
onClick={() => onAudioSelected(format.format_id)}
disabled={pickedAudioFormat === format.format_id}
>
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
{(format.filesize_approx > 0) ? " (~" + Math.round(format.filesize_approx / 1024 / 1024) + " MiB)" : ""}
</Button>
</Grid>
))
}
<Grid item xs={12} pt={2}>
<ButtonGroup disableElevation variant="contained">
<Button
onClick={() => onSubmit()}
disabled={!pickedBestFormat && !(pickedAudioFormat || pickedVideoFormat)}
> Download
</Button>
<Button
onClick={() => onClear()}
> Clear
</Button>
</ButtonGroup>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
)
}

View File

@@ -0,0 +1,24 @@
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import LogoutIcon from '@mui/icons-material/Logout'
import { getHttpEndpoint } from '../utils'
import { useNavigate } from 'react-router-dom'
export default function Logout() {
const navigate = useNavigate()
const logout = async () => {
const res = await fetch(`${getHttpEndpoint()}/auth/logout`)
if (res.ok) {
navigate('/login')
}
}
return (
<ListItemButton onClick={logout}>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="RPC authentication" />
</ListItemButton>
)
}

View File

@@ -0,0 +1,34 @@
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import { Container, SvgIcon, Typography, styled } from '@mui/material'
const FlexContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '80vh',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
})
const Title = styled(Typography)({
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: '0.5rem'
})
export default function Splash() {
return (
<FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
<SvgIcon sx={{ fontSize: '200px' }}>
<CloudDownloadIcon />
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
No active downloads
</Title>
</FlexContainer>
)
}

View File

@@ -1,4 +1,4 @@
import { EightK, FourK, Hd, Sd } from "@mui/icons-material"; import { EightK, FourK, Hd, Sd } from '@mui/icons-material'
import { import {
Button, Button,
Card, Card,
@@ -11,9 +11,9 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Typography Typography
} from "@mui/material"; } from '@mui/material'
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { ellipsis } from "../utils"; import { ellipsis, formatSpeedMiB, roundMiB } from '../utils'
type Props = { type Props = {
title: string, title: string,
@@ -42,6 +42,8 @@ export function StackableResult({
} }
}, [percentage]) }, [percentage])
const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', ''))
const guessResolution = (xByY: string): any => { const guessResolution = (xByY: string): any => {
if (!xByY) return null; if (!xByY) return null;
if (xByY.includes('4320')) return (<EightK color="primary" />); if (xByY.includes('4320')) return (<EightK color="primary" />);
@@ -51,11 +53,6 @@ export function StackableResult({
return null; return null;
} }
const percentageToNumber = () => isCompleted ? 100 : Number(percentage.replace('%', ''))
const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
return ( return (
<Card> <Card>
<CardActionArea> <CardActionArea>
@@ -100,7 +97,8 @@ export function StackableResult({
variant="contained" variant="contained"
size="small" size="small"
color="primary" color="primary"
onClick={stopCallback}> onClick={stopCallback}
>
{isCompleted ? "Clear" : "Stop"} {isCompleted ? "Clear" : "Stop"}
</Button> </Button>
</CardActions> </CardActions>

View File

@@ -0,0 +1,27 @@
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useDispatch, useSelector } from 'react-redux'
import { setTheme } from '../features/settings/settingsSlice'
import { RootState } from '../stores/store'
import { Brightness4, Brightness5 } from '@mui/icons-material'
export default function ThemeToggler() {
const settings = useSelector((state: RootState) => state.settings)
const dispatch = useDispatch()
return (
<ListItemButton onClick={() => {
settings.theme === 'light'
? dispatch(setTheme('dark'))
: dispatch(setTheme('light'))
}}>
<ListItemIcon>
{
settings.theme === 'light'
? <Brightness4 />
: <Brightness5 />
}
</ListItemIcon>
<ListItemText primary="Toggle theme" />
</ListItemButton>
)
}

View File

@@ -0,0 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface FormatSelectionState {
bestFormat: string
audioFormat: string
videoFormat: string
}

View File

@@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit" import { createSlice, PayloadAction } from "@reduxjs/toolkit"
export type LanguageUnion = "english" | "chinese" | "russian" | "italian" | "spanish" | "korean" | "japanese" export type LanguageUnion = "english" | "chinese" | "russian" | "italian" | "spanish" | "korean" | "japanese" | "catalan"
export type ThemeUnion = "light" | "dark" export type ThemeUnion = "light" | "dark"
export interface SettingsState { export interface SettingsState {
@@ -14,6 +14,7 @@ export interface SettingsState {
fileRenaming: boolean fileRenaming: boolean
pathOverriding: boolean pathOverriding: boolean
enableCustomArgs: boolean enableCustomArgs: boolean
listView: boolean
} }
const initialState: SettingsState = { const initialState: SettingsState = {
@@ -27,6 +28,7 @@ const initialState: SettingsState = {
fileRenaming: localStorage.getItem("file-renaming") === "true", fileRenaming: localStorage.getItem("file-renaming") === "true",
pathOverriding: localStorage.getItem("path-overriding") === "true", pathOverriding: localStorage.getItem("path-overriding") === "true",
enableCustomArgs: localStorage.getItem("enable-custom-args") === "true", enableCustomArgs: localStorage.getItem("enable-custom-args") === "true",
listView: localStorage.getItem("listview") === "true",
} }
export const settingsSlice = createSlice({ export const settingsSlice = createSlice({
@@ -73,6 +75,10 @@ export const settingsSlice = createSlice({
state.enableCustomArgs = action.payload state.enableCustomArgs = action.payload
localStorage.setItem("enable-custom-args", action.payload.toString()) localStorage.setItem("enable-custom-args", action.payload.toString())
}, },
toggleListView: (state) => {
state.listView = !state.listView
localStorage.setItem("listview", state.listView.toString())
},
} }
}) })
@@ -87,6 +93,7 @@ export const {
setFileRenaming, setFileRenaming,
setPathOverriding, setPathOverriding,
setEnableCustomArgs, setEnableCustomArgs,
toggleListView
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { Observable } from 'rxjs'
/**
* Handles the subscription and unsubscription from an observable.
* Automatically disposes the subscription.
* @param source$ source observable
* @param nextHandler subscriber function
* @param errHandler error catching callback
*/
export function useSubscription<T>(
source$: Observable<T>,
nextHandler: (value: T) => void,
errHandler?: (err: any) => void,
) {
useEffect(() => {
if (source$) {
const sub = source$.subscribe({
next: nextHandler,
error: errHandler,
})
return () => sub.unsubscribe()
}
}, [source$])
}
/**
* Use an observable as state
* @param source$ source observable
* @param initialState the initial state prior to the emission
* @param errHandler error catching callback
* @returns value emitted to the observable
*/
export function useObservable<T>(
source$: Observable<T>,
initialState: T,
errHandler?: (err: any) => void,
): T {
const [value, setValue] = useState(initialState)
useSubscription(source$, setValue, errHandler)
return value
}

View File

@@ -1,10 +1,11 @@
import React from 'react' import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { App } from './App' import { App } from './App'
const root = ReactDOM.createRoot(document.getElementById('root')!) const root = createRoot(document.getElementById('root')!)
root.render( root.render(
<React.StrictMode> <StrictMode>
<App></App> <App />
</React.StrictMode> </StrictMode>
) )

View File

@@ -1,39 +1,39 @@
export class CliArguments { export class CliArguments {
private _extractAudio: boolean; private _extractAudio: boolean
private _noMTime: boolean; private _noMTime: boolean
private _proxy: string; private _proxy: string
constructor(extractAudio = false, noMTime = false) { constructor(extractAudio = false, noMTime = true) {
this._extractAudio = extractAudio; this._extractAudio = extractAudio
this._noMTime = noMTime; this._noMTime = noMTime
this._proxy = "" this._proxy = ""
} }
public get extractAudio(): boolean { public get extractAudio(): boolean {
return this._extractAudio; return this._extractAudio
} }
public toggleExtractAudio() { public toggleExtractAudio() {
this._extractAudio = !this._extractAudio; this._extractAudio = !this._extractAudio
return this; return this
} }
public disableExtractAudio() { public disableExtractAudio() {
this._extractAudio = false; this._extractAudio = false
return this; return this
} }
public get noMTime(): boolean { public get noMTime(): boolean {
return this._noMTime; return this._noMTime
} }
public toggleNoMTime() { public toggleNoMTime() {
this._noMTime = !this._noMTime; this._noMTime = !this._noMTime
return this; return this
} }
public toString(): string { public toString(): string {
let args = ''; let args = ''
if (this._extractAudio) { if (this._extractAudio) {
args += '-x ' args += '-x '
@@ -43,19 +43,19 @@ export class CliArguments {
args += '--no-mtime ' args += '--no-mtime '
} }
return args.trim(); return args.trim()
} }
public fromString(str: string): CliArguments { public fromString(str: string): CliArguments {
if (str) { if (str) {
if (str.includes('-x')) { if (str.includes('-x')) {
this._extractAudio = true; this._extractAudio = true
} }
if (str.includes('--no-mtime')) { if (str.includes('--no-mtime')) {
this._noMTime = true; this._noMTime = true
} }
} }
return this; return this
} }
} }

View File

@@ -0,0 +1,21 @@
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
type FetchInit = {
url: string,
opt?: RequestInit
}
export async function ffetch<T>(
url: string,
onSuccess: (res: T) => void,
onError: (err: string) => void,
opt?: RequestInit,
) {
const res = await fetch(url, opt)
if (!res.ok) {
onError(await res.text())
return
}
onSuccess(await res.json() as T)
}

View File

@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import i18n from "../../assets/i18n.yaml" import i18n from "../assets/i18n.yaml"
export default class I18nBuilder { export default class I18nBuilder {
private language: string private language: string

View File

@@ -1,13 +1,14 @@
import type { RPCRequest, RPCResponse, IDLMetadata } from "../../types" import type { DLMetadata, RPCRequest, RPCResponse } from '../types'
import { getHttpRPCEndpoint } from '../../utils' import { webSocket } from 'rxjs/webSocket'
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../utils'
export const socket$ = webSocket<any>(getWebSocketEndpoint())
export class RPCClient { export class RPCClient {
private socket: WebSocket
private seq: number private seq: number
constructor(socket: WebSocket) { constructor() {
this.socket = socket
this.seq = 0 this.seq = 0
} }
@@ -16,27 +17,28 @@ export class RPCClient {
} }
private send(req: RPCRequest) { private send(req: RPCRequest) {
this.socket.send(JSON.stringify(req)) socket$.next({
...req,
id: this.incrementSeq(),
})
} }
private sendHTTP<T>(req: RPCRequest) { private async sendHTTP<T>(req: RPCRequest) {
return new Promise<RPCResponse<T>>((resolve) => { const res = await fetch(getHttpRPCEndpoint(), {
fetch(getHttpRPCEndpoint(), { method: 'POST',
method: 'POST', body: JSON.stringify({
body: JSON.stringify({ ...req,
id: this.incrementSeq(), id: this.incrementSeq(),
...req
})
}) })
.then(res => res.json())
.then(data => resolve(data))
}) })
const data: RPCResponse<T> = await res.json()
return data
} }
public download(url: string, args: string, pathOverride = '', renameTo = '') { public download(url: string, args: string, pathOverride = '', renameTo = '') {
if (url) { if (url) {
this.send({ this.send({
id: this.incrementSeq(),
method: 'Service.Exec', method: 'Service.Exec',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: url.split("?list").at(0)!,
@@ -50,8 +52,7 @@ export class RPCClient {
public formats(url: string) { public formats(url: string) {
if (url) { if (url) {
return this.sendHTTP<IDLMetadata>({ return this.sendHTTP<DLMetadata>({
id: this.incrementSeq(),
method: 'Service.Formats', method: 'Service.Formats',
params: [{ params: [{
URL: url.split("?list").at(0)!, URL: url.split("?list").at(0)!,
@@ -62,7 +63,6 @@ export class RPCClient {
public running() { public running() {
this.send({ this.send({
id: this.incrementSeq(),
method: 'Service.Running', method: 'Service.Running',
params: [], params: [],
}) })
@@ -102,8 +102,4 @@ export class RPCClient {
params: [] params: []
}) })
} }
public decode(data: any): RPCResponse<any> {
return JSON.parse(data)
}
} }

50
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { CircularProgress } from '@mui/material'
import { Suspense, lazy } from 'react'
import { createBrowserRouter } from 'react-router-dom'
import Layout from './Layout'
const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login'))
const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings'))
export const router = createBrowserRouter([
{
path: '/',
Component: () => <Layout />,
children: [
{
path: '/',
element: (
<Suspense fallback={<CircularProgress />}>
<Home />
</Suspense >
)
},
{
path: '/settings',
element: (
<Suspense fallback={<CircularProgress />}>
<Settings />
</Suspense >
)
},
{
path: '/archive',
element: (
<Suspense fallback={<CircularProgress />}>
<Archive />
</Suspense >
)
},
{
path: '/login',
element: (
<Suspense fallback={<CircularProgress />}>
<Login />
</Suspense >
)
},
]
},
])

View File

@@ -1,62 +0,0 @@
export type RPCMethods =
| "Service.Exec"
| "Service.Kill"
| "Service.Clear"
| "Service.Running"
| "Service.KillAll"
| "Service.FreeSpace"
| "Service.Formats"
| "Service.DirectoryTree"
| "Service.UpdateExecutable"
export type RPCRequest = {
method: RPCMethods,
params?: any[],
id?: string
}
export type RPCResponse<T> = {
result: T,
error: number | null
id?: string
}
export type RPCResult = {
id: string
progress: {
speed: number
eta: number
percentage: string
}
info: {
url: string
filesize_approx?: number
resolution?: string
thumbnail: string
title: string
vcodec?: string
acodec?: string
ext?: string
}
}
export type RPCParams = {
URL: string
Params?: string
}
export interface IDLMetadata {
formats: Array<IDLFormat>,
best: IDLFormat,
thumbnail: string,
title: string,
}
export interface IDLFormat {
format_id: string,
format_note: string,
fps: number,
resolution: string,
vcodec: string,
acodec: string,
}

83
frontend/src/types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,83 @@
export type RPCMethods =
| "Service.Exec"
| "Service.Kill"
| "Service.Clear"
| "Service.Running"
| "Service.KillAll"
| "Service.FreeSpace"
| "Service.Formats"
| "Service.DirectoryTree"
| "Service.UpdateExecutable"
export type RPCRequest = {
method: RPCMethods
params?: any[]
id?: string
}
export type RPCResponse<T> = {
result: T
error: number | null
id?: string
}
type DownloadInfo = {
url: string
filesize_approx?: number
resolution?: string
thumbnail: string
title: string
vcodec?: string
acodec?: string
ext?: string
created_at: string
}
type DownloadProgress = {
speed: number
eta: number
percentage: string
}
export type RPCResult = {
id: string
progress: DownloadProgress
info: DownloadInfo
}
export type RPCParams = {
URL: string
Params?: string
}
export interface DLMetadata {
formats: Array<DLFormat>
best: DLFormat
thumbnail: string
title: string
}
export type DLFormat = {
format_id: string
format_note: string
fps: number
resolution: string
vcodec: string
acodec: string
filesize_approx: number
}
export type DirectoryEntry = {
name: string
path: string
size: number
shaSum: string
modTime: string
isVideo: boolean
isDirectory: boolean
}
export type DeleteRequest = Pick<DirectoryEntry, 'path' | 'shaSum'>
export type PlayRequest = Pick<DirectoryEntry, 'path'>

View File

@@ -4,8 +4,8 @@
* @returns ip validity test * @returns ip validity test
*/ */
export function validateIP(ipAddr: string): boolean { export function validateIP(ipAddr: string): boolean {
let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm
return ipRegex.test(ipAddr) return ipRegex.test(ipAddr)
} }
/** /**
@@ -18,8 +18,8 @@ export function validateIP(ipAddr: string): boolean {
* @returns domain validity test * @returns domain validity test
*/ */
export function validateDomain(domainName: string): boolean { export function validateDomain(domainName: string): boolean {
let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/ let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/
return domainRegex.test(domainName) || domainName === 'localhost' return domainRegex.test(domainName) || domainName === 'localhost'
} }
/** /**
@@ -34,15 +34,15 @@ export function validateDomain(domainName: string): boolean {
* @returns url validity test * @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)
} }
export function ellipsis(str: string, lim: number): string { export function ellipsis(str: string, lim: number): string {
if (str) { if (str) {
return str.length > lim ? `${str.substring(0, lim)}...` : str return str.length > lim ? `${str.substring(0, lim)}...` : str
} }
return '' return ''
} }
/** /**
@@ -51,36 +51,46 @@ export function ellipsis(str: string, lim: number): string {
* @returns download speed in KiB/s * @returns download speed in KiB/s
*/ */
export function detectSpeed(str: string): number { export function detectSpeed(str: string): number {
let effective = str.match(/[\d,]+(\.\d+)?/)![0] let effective = str.match(/[\d,]+(\.\d+)?/)![0]
const unit = str.replace(effective, '') const unit = str.replace(effective, '')
switch (unit) { switch (unit) {
case 'MiB/s': case 'MiB/s':
return Number(effective) * 1000 return Number(effective) * 1000
case 'KiB/s': case 'KiB/s':
return Number(effective) return Number(effective)
default: default:
return 0 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}`)
} }
if (codes.length === 1) { if (codes.length === 1) {
return ` -f ${codes[0]}`; return ` -f ${codes[0]}`;
} }
return ''; return '';
} }
export function getWebSocketEndpoint() { export function getWebSocketEndpoint() {
return `ws://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/ws-rpc` const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/ws`
} }
export function getHttpRPCEndpoint() { export function getHttpRPCEndpoint() {
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/http-rpc` return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/http`
}
export function getHttpEndpoint() {
return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}`
} }
export function formatGiB(bytes: number) { export function formatGiB(bytes: number) {
return `${(bytes / 1_000_000_000).toFixed(0)}GiB` return `${(bytes / 1_000_000_000).toFixed(0)}GiB`
} }
export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
export const dateTimeComparatorFunc = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime()

View File

@@ -0,0 +1,256 @@
import {
Backdrop,
Button,
Checkbox,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
Typography
} from '@mui/material'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import VideoFileIcon from '@mui/icons-material/VideoFile'
import { Buffer } from 'buffer'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useSelector } from 'react-redux'
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
import { useObservable } from '../hooks/observable'
import { RootState } from '../stores/store'
import { DeleteRequest, DirectoryEntry } from '../types'
import { roundMiB } from '../utils'
import { useNavigate } from 'react-router-dom'
import { ffetch } from '../lib/httpClient'
export default function Downloaded() {
const settings = useSelector((state: RootState) => state.settings)
const navigate = useNavigate()
const [openDialog, setOpenDialog] = useState(false)
const serverAddr =
`${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}`
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
const [isPending, startTransition] = useTransition()
const fetcher = () => ffetch<DirectoryEntry[]>(
`${serverAddr}/archive/downloaded`,
(d) => files$.next(d),
() => navigate('/login'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subdir: '',
})
}
)
const fetcherSubfolder = (sub: string) => {
const folders = sub.startsWith('/')
? sub.substring(1).split('/')
: sub.split('/')
const relpath = folders.length >= 2
? folders.slice(-(folders.length - 1)).join('/')
: folders.pop()
const _upperLevel = folders.slice(1, -1)
const upperLevel = _upperLevel.length === 2
? ['.', ..._upperLevel].join('/')
: _upperLevel.join('/')
fetch(`${serverAddr}/archive/downloaded`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subdir: relpath })
})
.then(res => res.json())
.then(data => {
files$.next(sub
? [{
name: '..',
isDirectory: true,
path: upperLevel,
}, ...data]
: data
)
})
}
const selectable$ = useMemo(() => files$.pipe(
combineLatestWith(selected$),
map(([data, selected]) => data.map(x => ({
...x,
selected: selected.includes(x.name)
}))),
share()
), [])
const selectable = useObservable(selectable$, [])
const addSelected = (name: string) => {
selected$.value.includes(name)
? selected$.next(selected$.value.filter(val => val !== name))
: selected$.next([...selected$.value, name])
}
const deleteSelected = () => {
Promise.all(selectable
.filter(entry => entry.selected)
.map(entry => fetch(`${serverAddr}/archive/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: entry.path,
shaSum: entry.shaSum,
} as DeleteRequest)
}))
).then(fetcher)
}
useEffect(() => {
fetcher()
}, [settings.serverAddr, settings.serverPort])
const onFileClick = (path: string) => startTransition(() => {
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)
})
const onFolderClick = (path: string) => startTransition(() => {
fetcherSubfolder(path)
})
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!(files$.observed) || isPending}
>
<CircularProgress color="primary" />
</Backdrop>
<Paper sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}>
<Typography py={1} variant="h5" color="primary">
{'Archive'}
</Typography>
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
{selectable.length === 0 && 'No files found'}
{selectable.map((file, idx) => (
<ListItem
key={idx}
secondaryAction={
<div>
{!file.isDirectory && <Typography
variant="caption"
component="span"
>
{roundMiB(file.size)}
</Typography>
}
{!file.isDirectory && <Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.name)}
/>}
</div>
}
disablePadding
>
<ListItemButton onClick={
() => file.isDirectory
? onFolderClick(file.path)
: onFileClick(file.path)
}>
<ListItemIcon>
{file.isDirectory
? <FolderIcon />
: file.isVideo
? <VideoFileIcon />
: <InsertDriveFileIcon />
}
</ListItemIcon>
<ListItemText
primary={file.name}
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Paper>
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={`Delete selected`}
tooltipOpen
onClick={() => {
if (selected$.value.length > 0) {
setOpenDialog(true)
}
}}
/>
</SpeedDial>
<Dialog
open={openDialog}
onClose={() => setOpenDialog(false)}
>
<DialogTitle>
Are you sure?
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
You're deleting:
</DialogContentText>
<ul>
{selected$.value.map((entry, idx) => (
<li key={idx}>{entry}</li>
))}
</ul>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button onClick={() => {
deleteSelected()
setOpenDialog(false)
}} autoFocus
>
Ok
</Button>
</DialogActions>
</Dialog>
</Container>
)
}

189
frontend/src/views/Home.tsx Normal file
View File

@@ -0,0 +1,189 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import {
Alert,
Backdrop,
CircularProgress,
Container,
Snackbar,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
styled
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import DownloadDialog from '../components/DownloadDialog'
import { DownloadsCardView } from '../components/DownloadsCardView'
import { DownloadsListView } from '../components/DownloadsListView'
import Splash from '../components/Splash'
import { toggleListView } from '../features/settings/settingsSlice'
import { connected, setFreeSpace } from '../features/status/statusSlice'
import I18nBuilder from '../lib/intl'
import { RPCClient, socket$ } from '../lib/rpcClient'
import { RootState } from '../stores/store'
import type { RPCResponse, RPCResult } from '../types'
import { dateTimeComparatorFunc } from '../utils'
export default function Home() {
// redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
// ephemeral state
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
const [showBackdrop, setShowBackdrop] = useState(true)
const [showToast, setShowToast] = useState(true)
const [openDialog, setOpenDialog] = useState(false)
const [socketHasError, setSocketHasError] = useState(false)
// memos
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
useEffect(() => {
if (status.connected) { return }
const sub = socket$.subscribe({
next: () => {
dispatch(connected())
},
error: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
complete: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (status.connected) {
client.running()
const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval)
}
}, [status.connected])
useEffect(() => {
client.freeSpace().then(bytes => dispatch(setFreeSpace(bytes.result)))
}, [])
useEffect(() => {
if (!status.connected) { return }
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
switch (typeof event.result) {
case 'object':
setActiveDownloads(
(event.result ?? [])
.filter((r) => !!r.info.url)
.sort((a, b) => dateTimeComparatorFunc(
b.info.created_at,
a.info.created_at,
))
)
break
default:
break
}
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (activeDownloads && activeDownloads.length >= 0) {
setShowBackdrop(false)
}
}, [activeDownloads?.length])
/**
* Abort a specific download if id's provided, other wise abort all running ones.
* @param id The download id / pid
* @returns void
*/
const abort = (id?: string) => {
if (id) {
client.kill(id)
return
}
client.killAll()
}
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
})
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={showBackdrop}
>
<CircularProgress color="primary" />
</Backdrop>
{activeDownloads?.length === 0 &&
<Splash />
}
{
settings.listView ?
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
<DownloadsCardView downloads={activeDownloads ?? []} abortFunction={abort} />
}
<Snackbar
open={showToast === status.connected}
autoHideDuration={1500}
onClose={() => setShowToast(false)}
>
<Alert variant="filled" severity="success">
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<Snackbar open={socketHasError}>
<Alert variant="filled" severity="error">
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<FormatListBulleted />}
tooltipTitle={`Table view`}
onClick={() => dispatch(toggleListView())}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={() => abort()}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={`New download`}
onClick={() => setOpenDialog(true)}
/>
</SpeedDial>
<DownloadDialog open={openDialog} onClose={() => {
setOpenDialog(false)
activeDownloads?.length === 0
? setShowBackdrop(false)
: setShowBackdrop(true)
}} />
</Container>
)
}

View File

@@ -0,0 +1,81 @@
/*
Login view component
*/
import styled from '@emotion/styled'
import {
Button,
Container,
Paper,
Stack,
TextField,
Typography
} from '@mui/material'
import { getHttpEndpoint } from '../utils'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
const LoginContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '100vh',
alignItems: 'center',
justifyContent: 'center',
})
const Title = styled(Typography)({
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: '0.5rem'
})
export default function Login() {
const [secret, setSecret] = useState('')
const [formHasError, setFormHasError] = useState(false)
const navigate = useNavigate()
const login = async () => {
const res = await fetch(`${getHttpEndpoint()}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ secret })
})
res.ok ? navigate('/') : setFormHasError(true)
}
return (
<LoginContainer>
<Paper sx={{ padding: '1.5rem', minWidth: '25%' }}>
<Stack direction="column" spacing={2}>
<Title fontWeight={'700'} fontSize={32} color={'primary'}>
yt-dlp WebUI
</Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
Authentication token will expire after 30 days.
</Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
In order to enable RPC authentication append the --auth
<br />
and --secret [secret] flags.
</Title>
<TextField
id="outlined-password-input"
label="RPC secret"
type="password"
autoComplete="current-password"
error={formHasError}
onChange={e => setSecret(e.currentTarget.value)}
/>
<Button variant="contained" size="large" onClick={() => login()}>
Submit
</Button>
</Stack>
</Paper>
</LoginContainer>
)
}

View File

@@ -16,15 +16,22 @@ import {
Switch, Switch,
TextField, TextField,
Typography Typography
} from "@mui/material"; } from '@mui/material'
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from 'react-redux'
import { debounceTime, distinctUntilChanged, map, of, takeWhile } from "rxjs"; import {
import { CliArguments } from "./features/core/argsParser"; Subject,
import I18nBuilder from "./features/core/intl"; debounceTime,
import { RPCClient } from "./features/core/rpcClient"; distinctUntilChanged,
map,
takeWhile
} from 'rxjs'
import { CliArguments } from '../lib/argsParser'
import I18nBuilder from '../lib/intl'
import { RPCClient } from '../lib/rpcClient'
import { import {
LanguageUnion, LanguageUnion,
ThemeUnion,
setCliArgs, setCliArgs,
setEnableCustomArgs, setEnableCustomArgs,
setFileRenaming, setFileRenaming,
@@ -33,32 +40,31 @@ import {
setPathOverriding, setPathOverriding,
setServerAddr, setServerAddr,
setServerPort, setServerPort,
setTheme, setTheme
ThemeUnion } from '../features/settings/settingsSlice'
} from "./features/settings/settingsSlice"; import { updated } from '../features/status/statusSlice'
import { updated } from "./features/status/statusSlice"; import { RootState } from '../stores/store'
import { RootState } from "./stores/store"; import { validateDomain, validateIP } from '../utils'
import { validateDomain, validateIP } from "./utils";
export default function Settings({ socket }: { socket: WebSocket }) { export default function Settings() {
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch() const dispatch = useDispatch()
const status = useSelector((state: RootState) => state.status)
const settings = useSelector((state: RootState) => state.settings)
const [invalidIP, setInvalidIP] = useState(false); const [invalidIP, setInvalidIP] = useState(false);
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language]) const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(socket), [settings.serverAddr, settings.serverPort])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]) const client = useMemo(() => new RPCClient(), [])
/** const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
* Update the server ip address state and localstorage whenever the input value changes.
* Validate the ip-addr then set.s const serverAddr$ = useMemo(() => new Subject<string>(), [])
* @param event Input change event const serverPort$ = useMemo(() => new Subject<string>(), [])
*/
const handleAddrChange = (event: any) => { useEffect(() => {
const $serverAddr = of(event) const sub = serverAddr$
.pipe( .pipe(
map(event => event.target.value),
debounceTime(500), debounceTime(500),
distinctUntilChanged() distinctUntilChanged()
) )
@@ -73,24 +79,21 @@ export default function Settings({ socket }: { socket: WebSocket }) {
setInvalidIP(true) setInvalidIP(true)
} }
}) })
return $serverAddr.unsubscribe() return () => sub.unsubscribe()
} }, [serverAddr$])
/** useEffect(() => {
* Set server port const sub = serverPort$
*/
const handlePortChange = (event: any) => {
const $port = of(event)
.pipe( .pipe(
map(event => event.target.value), debounceTime(500),
map(val => Number(val)), map(val => Number(val)),
takeWhile(val => isFinite(val) && val <= 65535), takeWhile(val => isFinite(val) && val <= 65535),
) )
.subscribe(port => { .subscribe(port => {
dispatch(setServerPort(port.toString())) dispatch(setServerPort(port.toString()))
}) })
return $port.unsubscribe() return () => sub.unsubscribe()
} }, [])
/** /**
* Language toggler handler * Language toggler handler
@@ -107,7 +110,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
} }
/** /**
* Send via WebSocket a message in order to update the yt-dlp binary from server * Send via WebSocket a message to update yt-dlp binary
*/ */
const updateBinary = () => { const updateBinary = () => {
client.updateExecutable().then(() => dispatch(updated())) client.updateExecutable().then(() => dispatch(updated()))
@@ -125,7 +128,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
minHeight: 240, minHeight: 240,
}} }}
> >
<Typography pb={2} variant="h6" color="primary"> <Typography pb={3} variant="h5" color="primary">
{i18n.t('settingsAnchor')} {i18n.t('settingsAnchor')}
</Typography> </Typography>
<FormGroup> <FormGroup>
@@ -136,7 +139,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
label={i18n.t('serverAddressTitle')} label={i18n.t('serverAddressTitle')}
defaultValue={settings.serverAddr} defaultValue={settings.serverAddr}
error={invalidIP} error={invalidIP}
onChange={handleAddrChange} onChange={(e) => serverAddr$.next(e.currentTarget.value)}
InputProps={{ InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>, startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}} }}
@@ -148,7 +151,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
fullWidth fullWidth
label={i18n.t('serverPortTitle')} label={i18n.t('serverPortTitle')}
defaultValue={settings.serverPort} defaultValue={settings.serverPort}
onChange={handlePortChange} onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535} error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
@@ -170,6 +173,7 @@ export default function Settings({ socket }: { socket: WebSocket }) {
<MenuItem value="russian">Russian</MenuItem> <MenuItem value="russian">Russian</MenuItem>
<MenuItem value="korean">Korean</MenuItem> <MenuItem value="korean">Korean</MenuItem>
<MenuItem value="japanese">Japanese</MenuItem> <MenuItem value="japanese">Japanese</MenuItem>
<MenuItem value="catalan">Catalan</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
@@ -186,16 +190,6 @@ export default function Settings({ socket }: { socket: WebSocket }) {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
{/* <Grid item xs={12} md={6}>
<TextField
fullWidth
label={'Max download speed' || i18n.t('serverPortTitle')}
defaultValue={settings.serverPort}
onChange={handlePortChange}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }}
/>
</Grid> */}
</Grid> </Grid>
<FormControlLabel <FormControlLabel
control={ control={

32
go.mod
View File

@@ -1,27 +1,31 @@
module github.com/marcopeocchi/yt-dlp-web-ui module github.com/marcopeocchi/yt-dlp-web-ui
go 1.19 go 1.20
require ( require (
github.com/goccy/go-json v0.10.0 github.com/goccy/go-json v0.10.2
github.com/gofiber/fiber/v2 v2.41.0 github.com/gofiber/fiber/v2 v2.47.0
github.com/gofiber/websocket/v2 v2.1.2 github.com/gofiber/websocket/v2 v2.2.1
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa
golang.org/x/sys v0.4.0 golang.org/x/sys v0.9.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/andybalholm/brotli v1.0.4 // indirect github.com/andybalholm/brotli v1.0.5 // indirect
github.com/fasthttp/websocket v1.5.0 // indirect github.com/fasthttp/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.15.14 // indirect github.com/klauspost/compress v1.16.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.4.3 // indirect github.com/philhofer/fwd v1.1.2 // indirect
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d // indirect github.com/rivo/uniseg v0.4.4 // indirect
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.43.0 // indirect github.com/valyala/fasthttp v1.48.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

110
go.sum
View File

@@ -1,61 +1,95 @@
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/fasthttp/websocket v1.5.0 h1:B4zbe3xXyvIdnqjOZrafVFklCUq5ZLo/TqCt5JA1wLE= github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.0/go.mod h1:n0BlOQvJdPbTuBkZT0O5+jk/sp/1/VCzquR1BehI2F4= github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofiber/fiber/v2 v2.40.1/go.mod h1:Gko04sLksnHbzLSRBFWPFdzM9Ws9pRxvvIaohJK1dsk= github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs=
github.com/gofiber/fiber/v2 v2.41.0 h1:YhNoUS/OTjEz+/WLYuQ01xI7RXgKEFnGBKMagAu5f0M= github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU=
github.com/gofiber/fiber/v2 v2.41.0/go.mod h1:RdebcCuCRFp4W6hr3968/XxwJVg0K+jr9/Ae0PFzZ0Q= github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.1.2 h1:EulKyLB/fJgui5+6c8irwEnYQ9FRsrLZfkrq9OfTDGc= github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/gofiber/websocket/v2 v2.1.2/go.mod h1:S+sKWo0xeC7Wnz5h4/8f6D/NxsrLFIdWDYB3SyVO9pE= 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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.14.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc=
github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db h1:SmKRgCLsImPxBTIzmUpbQyv+7FembiZaq/QTwtDqar4=
github.com/marcopeocchi/fazzoletti v0.0.0-20221114144444-1e802380a7db/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas= github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJxXMRqtUgUbTjI8/Ze8vu8GGyNFwo= github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4= github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc=
github.com/valyala/fasthttp v1.41.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g=
github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

37
main.go
View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"embed" "embed"
"flag" "flag"
"io/fs" "io/fs"
@@ -11,23 +10,28 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/config"
) )
type ContextKey interface{}
var ( var (
port int port int
configFile string
downloadPath string downloadPath string
downloaderPath string downloaderPath string
configFile string
requireAuth bool
rpcSecret string
//go:embed frontend/dist //go:embed frontend/dist
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.StringVar(&downloadPath, "out", ".", "Directory where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.StringVar(&configFile, "conf", "", "yt-dlp-WebUI config file path") flag.StringVar(&configFile, "conf", "", "yt-dlp-WebUI config file path")
flag.StringVar(&downloadPath, "out", ".", "Where files will be saved")
flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path")
flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication")
flag.StringVar(&rpcSecret, "secret", "", "Secret required for auth")
flag.Parse() flag.Parse()
} }
@@ -38,19 +42,18 @@ func main() {
log.Fatalln(err) log.Fatalln(err)
} }
cfg := config.Instance() c := config.Instance()
c.SetPort(port)
c.DownloadPath(downloadPath)
c.DownloaderPath(downloaderPath)
c.RequireAuth(requireAuth)
c.RPCSecret(rpcSecret)
if configFile != "" { if configFile != "" {
cfg.LoadFromFile(configFile) c.LoadFromFile(configFile)
} }
cfg.SetPort(port) server.RunBlocking(port, frontend)
cfg.DownloadPath(downloadPath)
cfg.DownloaderPath(downloaderPath)
ctx := context.Background()
ctx = context.WithValue(ctx, ContextKey("port"), port)
ctx = context.WithValue(ctx, ContextKey("frontend"), frontend)
server.RunBlocking(ctx)
} }

View File

@@ -7,12 +7,14 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
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"`
RPCSecret string `yaml:"rpc_secret"`
} }
type config struct { type config struct {
@@ -46,6 +48,13 @@ func (c *config) DownloaderPath(path string) {
c.cfg.DownloaderPath = path c.cfg.DownloaderPath = path
} }
func (c *config) RequireAuth(value bool) {
c.cfg.RequireAuth = value
}
func (c *config) RPCSecret(secret string) {
c.cfg.RPCSecret = secret
}
var instance *config var instance *config
func Instance() *config { func Instance() *config {

View File

@@ -1,6 +1,8 @@
package server package server
import ( import (
"errors"
"fmt"
"log" "log"
"os" "os"
"sync" "sync"
@@ -11,88 +13,76 @@ import (
"github.com/marcopeocchi/yt-dlp-web-ui/server/cli" "github.com/marcopeocchi/yt-dlp-web-ui/server/cli"
) )
// In-Memory volatile Thread-Safe Key-Value Storage // In-Memory Thread-Safe Key-Value Storage with optional persistence
type MemoryDB struct { type MemoryDB struct {
table map[string]*Process table sync.Map
mu sync.Mutex
}
// Inits the db with an empty map of string->Process pointer
func (m *MemoryDB) New() {
m.table = make(map[string]*Process)
} }
// Get a process pointer given its id // Get a process pointer given its id
func (m *MemoryDB) Get(id string) *Process { func (m *MemoryDB) Get(id string) (*Process, error) {
m.mu.Lock() entry, ok := db.table.Load(id)
res := m.table[id] if !ok {
m.mu.Unlock() return nil, errors.New("no process found for the given key")
return res }
return entry.(*Process), nil
} }
// Store a pointer of a process and return its id // Store a pointer of a process and return its id
func (m *MemoryDB) Set(process *Process) string { func (m *MemoryDB) Set(process *Process) string {
id := uuid.Must(uuid.NewRandom()).String() id := uuid.Must(uuid.NewRandom()).String()
m.mu.Lock() db.table.Store(id, process)
m.table[id] = process
m.mu.Unlock()
return id return id
} }
// Update a process info/metadata, given the process id // Update a process info/metadata, given the process id
func (m *MemoryDB) Update(id string, info DownloadInfo) { func (m *MemoryDB) UpdateInfo(id string, info DownloadInfo) error {
m.mu.Lock() entry, ok := db.table.Load(id)
if m.table[id] != nil { if ok {
m.table[id].Info = info entry.(*Process).Info = info
db.table.Store(id, entry)
return nil
} }
m.mu.Unlock() return fmt.Errorf("can't update row with id %s", id)
} }
// Update a process progress data, given the process id // Update a process progress data, given the process id
// Used for updating completition percentage or ETA // Used for updating completition percentage or ETA
func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) { func (m *MemoryDB) UpdateProgress(id string, progress DownloadProgress) error {
m.mu.Lock() entry, ok := db.table.Load(id)
if m.table[id] != nil { if ok {
m.table[id].Progress = progress entry.(*Process).Progress = progress
db.table.Store(id, entry)
return nil
} }
m.mu.Unlock() return fmt.Errorf("can't update row with id %s", id)
} }
// Removes a process progress, given the process id // Removes a process progress, given the process id
func (m *MemoryDB) Delete(id string) { func (m *MemoryDB) Delete(id string) {
m.mu.Lock() db.table.Delete(id)
delete(m.table, id)
m.mu.Unlock()
} }
// Returns a slice of all currently stored processes id func (m *MemoryDB) Keys() *[]string {
func (m *MemoryDB) Keys() []string { running := []string{}
m.mu.Lock() db.table.Range(func(key, value any) bool {
keys := make([]string, len(m.table)) running = append(running, key.(string))
i := 0 return true
for k := range m.table { })
keys[i] = k return &running
i++
}
m.mu.Unlock()
return keys
} }
// Returns a slice of all currently stored processes progess // Returns a slice of all currently stored processes progess
func (m *MemoryDB) All() []ProcessResponse { func (m *MemoryDB) All() *[]ProcessResponse {
running := make([]ProcessResponse, len(m.table)) running := []ProcessResponse{}
i := 0 db.table.Range(func(key, value any) bool {
for k, v := range m.table { running = append(running, ProcessResponse{
if v != nil { Id: key.(string),
running[i] = ProcessResponse{ Info: value.(*Process).Info,
Id: k, Progress: value.(*Process).Progress,
Info: v.Info, })
Progress: v.Progress, return true
} })
i++ return &running
}
}
return running
} }
// WIP: Persist the database in a single file named "session.dat" // WIP: Persist the database in a single file named "session.dat"
@@ -100,7 +90,7 @@ func (m *MemoryDB) Persist() {
running := m.All() running := m.All()
session, err := json.Marshal(Session{ session, err := json.Marshal(Session{
Processes: running, Processes: *running,
}) })
if err != nil { if err != nil {
log.Println(cli.Red, "Failed to persist database", cli.Reset) log.Println(cli.Red, "Failed to persist database", cli.Reset)
@@ -118,4 +108,14 @@ func (m *MemoryDB) Restore() {
feed, _ := os.ReadFile("session.dat") feed, _ := os.ReadFile("session.dat")
session := Session{} session := Session{}
json.Unmarshal(feed, &session) json.Unmarshal(feed, &session)
for _, proc := range session.Processes {
db.table.Store(proc.Id, &Process{
id: proc.Id,
url: proc.Info.URL,
Info: proc.Info,
Progress: proc.Progress,
mem: m,
})
}
} }

50
server/middleware/jwt.go Normal file
View File

@@ -0,0 +1,50 @@
package middlewares
import (
"fmt"
"os"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
)
const (
TOKEN_COOKIE_NAME = "jwt"
)
var Authenticated = func(c *fiber.Ctx) error {
if !config.Instance().GetConfig().RequireAuth {
return c.Next()
}
cookie := c.Cookies(TOKEN_COOKIE_NAME)
if cookie == "" {
return c.Status(fiber.StatusUnauthorized).SendString("invalid token")
}
token, _ := jwt.Parse(cookie, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(os.Getenv("JWT_SECRET")), nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string))
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
if time.Now().After(expiresAt) {
return c.Status(fiber.StatusBadRequest).SendString("expired token")
}
} else {
return c.Status(fiber.StatusUnauthorized).SendString("invalid token")
}
return c.Next()
}

View File

@@ -116,9 +116,12 @@ func (p *Process) Start(path, filename string) {
if err != nil { if err != nil {
log.Println("Cannot retrieve info for", p.url) log.Println("Cannot retrieve info for", p.url)
} }
info := DownloadInfo{URL: p.url} info := DownloadInfo{
URL: p.url,
CreatedAt: time.Now(),
}
json.Unmarshal(stdout, &info) json.Unmarshal(stdout, &info)
p.mem.Update(p.id, info) p.mem.UpdateInfo(p.id, info)
}() }()
// --------------- progress block --------------- // // --------------- progress block --------------- //
@@ -171,14 +174,17 @@ func (p *Process) Kill() error {
// has been spawned with setPgid = true. To properly kill // has been spawned with setPgid = true. To properly kill
// all subprocesses a SIGTERM need to be sent to the correct // all subprocesses a SIGTERM need to be sent to the correct
// process group // process group
pgid, err := syscall.Getpgid(p.proc.Pid) if p.proc != nil {
if err != nil { pgid, err := syscall.Getpgid(p.proc.Pid)
if err != nil {
return err
}
err = syscall.Kill(-pgid, syscall.SIGTERM)
log.Println("Killed process", p.id)
return err return err
} }
err = syscall.Kill(-pgid, syscall.SIGTERM) return nil
log.Println("Killed process", p.id)
return err
} }
// Returns the available format for this URL // Returns the available format for this URL

195
server/rest/handlers.go Normal file
View File

@@ -0,0 +1,195 @@
package rest
import (
"encoding/hex"
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/marcopeocchi/yt-dlp-web-ui/server/config"
"github.com/marcopeocchi/yt-dlp-web-ui/server/utils"
)
const (
TOKEN_COOKIE_NAME = "jwt"
)
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
SHASum string `json:"shaSum"`
ModTime time.Time `json:"modTime"`
IsVideo bool `json:"isVideo"`
IsDirectory bool `json:"isDirectory"`
}
func walkDir(root string) (*[]DirectoryEntry, error) {
files := []DirectoryEntry{}
dirs, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, d := range dirs {
if !utils.IsValidEntry(d) {
continue
}
path := filepath.Join(root, d.Name())
info, err := d.Info()
if err != nil {
return nil, err
}
files = append(files, DirectoryEntry{
Path: path,
Name: d.Name(),
Size: info.Size(),
SHASum: utils.ShaSumString(path),
IsVideo: utils.IsVideo(d),
IsDirectory: d.IsDir(),
ModTime: info.ModTime(),
})
}
return &files, err
}
type ListRequest struct {
SubDir string `json:"subdir"`
OrderBy string `json:"orderBy"`
}
func ListDownloaded(ctx *fiber.Ctx) error {
root := config.Instance().GetConfig().DownloadPath
req := new(ListRequest)
err := ctx.BodyParser(req)
if err != nil {
return err
}
files, err := walkDir(filepath.Join(root, req.SubDir))
if err != nil {
return err
}
if req.OrderBy == "modtime" {
sort.SliceStable(*files, func(i, j int) bool {
return (*files)[i].ModTime.After((*files)[j].ModTime)
})
}
ctx.Status(http.StatusOK)
return ctx.JSON(files)
}
type DeleteRequest = DirectoryEntry
func DeleteFile(ctx *fiber.Ctx) error {
req := new(DeleteRequest)
err := ctx.BodyParser(req)
if err != nil {
return err
}
sum := utils.ShaSumString(req.Path)
if sum != req.SHASum {
return errors.New("shasum mismatch")
}
err = os.Remove(req.Path)
if err != nil {
return err
}
ctx.Status(fiber.StatusOK)
return ctx.JSON("ok")
}
func SendFile(ctx *fiber.Ctx) error {
path := ctx.Params("id")
if path == "" {
return errors.New("inexistent path")
}
decoded, err := hex.DecodeString(path)
if err != nil {
return err
}
decodedStr := string(decoded)
root := config.Instance().GetConfig().DownloadPath
// TODO: further path / file validations
if strings.Contains(filepath.Dir(decodedStr), root) {
// ctx.Response().Header.Set(
// "Content-Disposition",
// "inline; filename="+filepath.Base(decodedStr),
// )
ctx.SendStatus(fiber.StatusOK)
return ctx.SendFile(decodedStr)
}
return ctx.SendStatus(fiber.StatusUnauthorized)
}
type LoginRequest struct {
Secret string `json:"secret"`
}
func Login(ctx *fiber.Ctx) error {
req := new(LoginRequest)
err := ctx.BodyParser(req)
if err != nil {
return ctx.SendStatus(fiber.StatusInternalServerError)
}
if config.Instance().GetConfig().RPCSecret != req.Secret {
return ctx.SendStatus(fiber.StatusBadRequest)
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"expiresAt": time.Now().Add(time.Minute * 30),
})
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
if err != nil {
return ctx.SendStatus(fiber.StatusInternalServerError)
}
ctx.Cookie(&fiber.Cookie{
Name: TOKEN_COOKIE_NAME,
HTTPOnly: true,
Secure: false,
Expires: time.Now().Add(time.Hour * 24 * 30), // 30 days
Value: tokenString,
Path: "/",
})
return ctx.SendStatus(fiber.StatusOK)
}
func Logout(ctx *fiber.Ctx) error {
ctx.Cookie(&fiber.Cookie{
Name: TOKEN_COOKIE_NAME,
HTTPOnly: true,
Secure: false,
Expires: time.Now(),
Value: "",
Path: "/",
})
return ctx.SendStatus(fiber.StatusOK)
}

View File

@@ -11,6 +11,7 @@ import "time"
// //
// Debounce emits a string from the source channel only after a particular // Debounce emits a string from the source channel only after a particular
// time span determined a Go Interval // time span determined a Go Interval
//
// --A--B--CD--EFG-------|> // --A--B--CD--EFG-------|>
// //
// -t-> |> // -t-> |>
@@ -18,7 +19,7 @@ import "time"
// -t-> |> // -t-> |>
// //
// --A-----C-----G-------|> // --A-----C-----G-------|>
func Debounce(interval time.Duration, source chan string, cb func(emit string)) { func Debounce(interval time.Duration, source chan string, f func(emit string)) {
var item string var item string
timer := time.NewTimer(interval) timer := time.NewTimer(interval)
for { for {
@@ -27,7 +28,7 @@ func Debounce(interval time.Duration, source chan string, cb func(emit string))
timer.Reset(interval) timer.Reset(interval)
case <-timer.C: case <-timer.C:
if item != "" { if item != "" {
cb(item) f(item)
} }
} }
} }

View File

@@ -8,22 +8,23 @@ import (
"log" "log"
"net/http" "net/http"
"net/rpc" "net/rpc"
"os"
"os/signal"
"syscall"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/websocket/v2" "github.com/gofiber/websocket/v2"
middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware"
"github.com/marcopeocchi/yt-dlp-web-ui/server/rest"
) )
var db MemoryDB var db MemoryDB
func init() { func RunBlocking(port int, frontend fs.FS) {
db.New() db.Restore()
}
func RunBlocking(ctx context.Context) {
fe := ctx.Value("frontend").(fs.SubFS)
port := ctx.Value("port").(int)
service := new(Service) service := new(Service)
rpc.Register(service) rpc.Register(service)
@@ -32,12 +33,39 @@ func RunBlocking(ctx context.Context) {
app.Use(cors.New()) app.Use(cors.New())
app.Use("/", filesystem.New(filesystem.Config{ app.Use("/", filesystem.New(filesystem.Config{
Root: http.FS(fe), Root: http.FS(frontend),
})) }))
// Client side routes
app.Get("/settings", func(c *fiber.Ctx) error {
return c.Redirect("/")
})
app.Get("/archive", func(c *fiber.Ctx) error {
return c.Redirect("/")
})
app.Get("/login", func(c *fiber.Ctx) error {
return c.Redirect("/")
})
// Archive routes
archive := app.Group("archive", middlewares.Authenticated)
archive.Post("/downloaded", rest.ListDownloaded)
archive.Post("/delete", rest.DeleteFile)
archive.Get("/d/:id", rest.SendFile)
// Authentication routes
app.Post("/auth/login", rest.Login)
app.Get("/auth/logout", rest.Logout)
// RPC handlers // RPC handlers
// websocket // websocket
app.Get("/ws-rpc", websocket.New(func(c *websocket.Conn) { rpc := app.Group("/rpc", middlewares.Authenticated)
rpc.Get("/ws", websocket.New(func(c *websocket.Conn) {
c.WriteMessage(websocket.TextMessage, []byte(`{
"status": "connected"
}`))
for { for {
mtype, reader, err := c.NextReader() mtype, reader, err := c.NextReader()
if err != nil { if err != nil {
@@ -53,15 +81,46 @@ func RunBlocking(ctx context.Context) {
} }
})) }))
// http-post // http-post
app.Post("/http-rpc", func(c *fiber.Ctx) error { rpc.Post("/http", func(c *fiber.Ctx) error {
reader := c.Context().RequestBodyStream() reader := c.Context().RequestBodyStream()
writer := c.Response().BodyWriter() writer := c.Response().BodyWriter()
res := NewRPCRequest(reader).Call() res := NewRPCRequest(reader).Call()
io.Copy(writer, res) io.Copy(writer, res)
return nil return nil
}) })
app.Server().StreamRequestBody = true app.Server().StreamRequestBody = true
go gracefulShutdown(app)
go autoPersist(time.Minute * 5)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
} }
func gracefulShutdown(app *fiber.App) {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt,
syscall.SIGTERM,
syscall.SIGQUIT,
)
go func() {
<-ctx.Done()
log.Println("shutdown signal received")
defer func() {
db.Persist()
stop()
app.ShutdownWithTimeout(time.Second * 5)
}()
}()
}
func autoPersist(d time.Duration) {
for {
db.Persist()
time.Sleep(d)
}
}

View File

@@ -40,7 +40,11 @@ func (t *Service) Exec(args DownloadSpecificArgs, result *string) error {
// Progess retrieves the Progress of a specific Process given its Id // Progess retrieves the Progress of a specific Process given its Id
func (t *Service) Progess(args Args, progress *DownloadProgress) error { func (t *Service) Progess(args Args, progress *DownloadProgress) error {
*progress = db.Get(args.Id).Progress proc, err := db.Get(args.Id)
if err != nil {
return err
}
*progress = proc.Progress
return nil return nil
} }
@@ -54,24 +58,29 @@ func (t *Service) Formats(args Args, progress *DownloadFormats) error {
// Pending retrieves a slice of all Pending/Running processes ids // Pending retrieves a slice of all Pending/Running processes ids
func (t *Service) Pending(args NoArgs, pending *Pending) error { func (t *Service) Pending(args NoArgs, pending *Pending) error {
*pending = Pending(db.Keys()) *pending = *db.Keys()
return nil return nil
} }
// Running retrieves a slice of all Processes progress // Running retrieves a slice of all Processes progress
func (t *Service) Running(args NoArgs, running *Running) error { func (t *Service) Running(args NoArgs, running *Running) error {
*running = db.All() *running = *db.All()
return nil return nil
} }
// Kill kills a process given its id and remove it from the memoryDB // Kill kills a process given its id and remove it from the memoryDB
func (t *Service) Kill(args string, killed *string) error { func (t *Service) Kill(args string, killed *string) error {
log.Println("Trying killing process with id", args) log.Println("Trying killing process with id", args)
proc := db.Get(args) proc, err := db.Get(args)
var err error
if err != nil {
return err
}
if proc != nil { if proc != nil {
err = proc.Kill() err = proc.Kill()
} }
db.Delete(proc.id)
return err return err
} }
@@ -81,8 +90,11 @@ func (t *Service) KillAll(args NoArgs, killed *string) error {
log.Println("Killing all spawned processes", args) log.Println("Killing all spawned processes", args)
keys := db.Keys() keys := db.Keys()
var err error var err error
for _, key := range keys { for _, key := range *keys {
proc := db.Get(key) proc, err := db.Get(key)
if err != nil {
return err
}
if proc != nil { if proc != nil {
proc.Kill() proc.Kill()
} }

View File

@@ -1,5 +1,7 @@
package server package server
import "time"
// Progress for the Running call // Progress for the Running call
type DownloadProgress struct { type DownloadProgress struct {
Percentage string `json:"percentage"` Percentage string `json:"percentage"`
@@ -9,14 +11,15 @@ type DownloadProgress struct {
// Used to deser the yt-dlp -J output // Used to deser the yt-dlp -J output
type DownloadInfo struct { type DownloadInfo struct {
URL string `json:"url"` URL string `json:"url"`
Title string `json:"title"` Title string `json:"title"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
Resolution string `json:"resolution"` Resolution string `json:"resolution"`
Size int32 `json:"filesize_approx"` Size int32 `json:"filesize_approx"`
VCodec string `json:"vcodec"` VCodec string `json:"vcodec"`
ACodec string `json:"acodec"` ACodec string `json:"acodec"`
Extension string `json:"ext"` Extension string `json:"ext"`
CreatedAt time.Time `json:"created_at"`
} }
// Used to deser the formats in the -J output // Used to deser the formats in the -J output
@@ -36,6 +39,7 @@ type Format struct {
Resolution string `json:"resolution"` Resolution string `json:"resolution"`
VCodec string `json:"vcodec"` VCodec string `json:"vcodec"`
ACodec string `json:"acodec"` ACodec string `json:"acodec"`
Size float32 `json:"filesize_approx"`
} }
// struct representing the response sent to the client // struct representing the response sent to the client

29
server/utils/file.go Normal file
View File

@@ -0,0 +1,29 @@
package utils
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"regexp"
"strings"
)
var (
videoRe = regexp.MustCompile(`(?i)/\.mov|\.mp4|\.webm|\.mvk|/gmi`)
)
func IsVideo(d fs.DirEntry) bool {
return videoRe.MatchString(d.Name())
}
func IsValidEntry(d fs.DirEntry) bool {
return !strings.HasPrefix(d.Name(), ".") &&
!strings.HasSuffix(d.Name(), ".part") &&
!strings.HasSuffix(d.Name(), ".ytdl")
}
func ShaSumString(path string) string {
h := sha256.New()
h.Write([]byte(path))
return hex.EncodeToString(h.Sum(nil))
}