monthly update
This commit is contained in:
@@ -7,3 +7,4 @@ lib/*.exe
|
|||||||
lib/yt-dlp
|
lib/yt-dlp
|
||||||
.env
|
.env
|
||||||
*.mp4
|
*.mp4
|
||||||
|
*.ytdl
|
||||||
18
.github/workflows/docker-image.yml
vendored
Normal file
18
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Build the Docker image
|
||||||
|
run: docker build . --file Dockerfile --tag my-image-name:$(date +%s)
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ lib/*.exe
|
|||||||
lib/yt-dlp
|
lib/yt-dlp
|
||||||
.env
|
.env
|
||||||
*.mp4
|
*.mp4
|
||||||
|
*.ytdl
|
||||||
|
*.part
|
||||||
@@ -5,6 +5,7 @@ WORKDIR /usr/src/yt-dlp-webui
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install curl ffmpeg -y
|
RUN apt-get install curl ffmpeg -y
|
||||||
|
RUN apt-get install psmisc
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -1,9 +1,12 @@
|
|||||||
# yt-dlp Web UI
|
# yt-dlp Web UI
|
||||||
|
|
||||||
A terrible web ui for yt-dlp.
|
A not so terrible web ui for yt-dlp.
|
||||||
Created for the only purpose of *consume* videos from my server/nas.
|
Created for the only purpose of *consume* videos from my server/nas.
|
||||||
I will eventually make this better as soon as I can. Not in the immediate.
|
I will eventually make this better as soon as I can. Not in the immediate.
|
||||||
|
|
||||||
|
**Background jobs now are retrieved. It's still rudimentary but it leverages**
|
||||||
|
**on yt-dlp resume feature**
|
||||||
|
|
||||||
<img src="https://i.ibb.co/7VBK1PY/1.png">
|
<img src="https://i.ibb.co/7VBK1PY/1.png">
|
||||||
|
|
||||||
## Now with dark mode
|
## Now with dark mode
|
||||||
@@ -15,9 +18,11 @@ I will eventually make this better as soon as I can. Not in the immediate.
|
|||||||
The avaible settings are currently only:
|
The avaible settings are currently only:
|
||||||
- Server address
|
- Server address
|
||||||
- Switch theme
|
- Switch theme
|
||||||
|
- Retrieve background jobs
|
||||||
|
|
||||||
Future releases will have:
|
Future releases will have:
|
||||||
- Exctract audio
|
- Multi download
|
||||||
|
- ~~Exctract audio~~
|
||||||
- Format selection
|
- Format selection
|
||||||
|
|
||||||
## Docker installation
|
## Docker installation
|
||||||
@@ -46,5 +51,5 @@ node server.js
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Todo list
|
## Todo list
|
||||||
- retrieve background tasks
|
- ~~retrieve background tasks~~
|
||||||
- better ui/ux
|
- better ui/ux
|
||||||
|
|||||||
BIN
downloads.db
Normal file
BIN
downloads.db
Normal file
Binary file not shown.
@@ -28,17 +28,27 @@ export function App() {
|
|||||||
const [updatedBin, setUpdatedBin] = useState(false)
|
const [updatedBin, setUpdatedBin] = useState(false)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [darkMode, setDarkMode] = useState(localStorage.getItem('theme') === 'dark')
|
const [darkMode, setDarkMode] = useState(localStorage.getItem('theme') === 'dark')
|
||||||
|
const [extractAudio, setExtractAudio] = useState(localStorage.getItem('-x') === 'true')
|
||||||
const [downloadInfo, setDownloadInfo] = useState<IDLInfo>()
|
const [downloadInfo, setDownloadInfo] = useState<IDLInfo>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
setShowToast(true)
|
setShowToast(true)
|
||||||
|
socket.emit('fetch-jobs')
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
socket.on('pending-jobs', (jobs: Array<any>) => {
|
||||||
|
if (jobs.length > 0) {
|
||||||
|
socket.emit('retrieve-jobs')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
darkMode ?
|
darkMode ?
|
||||||
document.body.classList.add('dark') :
|
document.body.classList.add('dark') :
|
||||||
@@ -54,15 +64,19 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socket.on('progress', (data: IMessage) => {
|
socket.on('progress', (data: IMessage) => {
|
||||||
setMessage(`operation: ${data.status || '...'} \nprogress: ${data.progress || '?'} \nsize: ${data.size || '?'} \nspeed: ${data.dlSpeed || '?'}`)
|
setMessage(`operation: ${data.status || '...'} \nprogress: ${data.progress || '?'} \nsize: ${data.size || '?'} \nspeed: ${data.dlSpeed || '?'}`)
|
||||||
if (data.status === 'Done!') {
|
if (data.status === 'Done!' || data.status === 'Aborted') {
|
||||||
setHalt(false)
|
setHalt(false)
|
||||||
setMessage('Done!')
|
setMessage('Done!')
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProgress(
|
if (data.progress) {
|
||||||
Math.ceil(Number(data.progress.replace('%', '')))
|
setProgress(Math.ceil(Number(data.progress.replace('%', ''))))
|
||||||
)
|
}
|
||||||
|
// if (data.dlSpeed) {
|
||||||
|
// const event = new CustomEvent<number>("dlSpeed", { "detail": detectSpeed(data.dlSpeed) });
|
||||||
|
// document.dispatchEvent(event);
|
||||||
|
// }
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -75,7 +89,12 @@ export function App() {
|
|||||||
|
|
||||||
const sendUrl = () => {
|
const sendUrl = () => {
|
||||||
setHalt(true)
|
setHalt(true)
|
||||||
socket.emit('send-url', url)
|
socket.emit('send-url', {
|
||||||
|
url: url,
|
||||||
|
params: {
|
||||||
|
xa: extractAudio
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -119,111 +138,135 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleExtractAudio = () => {
|
||||||
|
if (extractAudio) {
|
||||||
|
localStorage.setItem('-x', 'false')
|
||||||
|
setExtractAudio(false)
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('-x', 'true')
|
||||||
|
setExtractAudio(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<React.Fragment>
|
||||||
<Row>
|
<Container className="pb-5">
|
||||||
<Col lg={7} xs={12}>
|
<Row>
|
||||||
<div className="mt-5" />
|
<Col lg={7} xs={12}>
|
||||||
<h1 className="fw-bold">yt-dlp WebUI</h1>
|
<div className="mt-5" />
|
||||||
<div className="mt-5" />
|
<h1 className="fw-bold">yt-dlp WebUI</h1>
|
||||||
|
<div className="mt-5" />
|
||||||
|
|
||||||
<div className="p-3 stack-box shadow">
|
<div className="p-3 stack-box shadow">
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<FormControl
|
<FormControl
|
||||||
className="url-input"
|
className="url-input"
|
||||||
placeholder="YouTube or other supported service video url"
|
placeholder="YouTube or other supported service video url"
|
||||||
onChange={handleUrlChange}
|
onChange={handleUrlChange}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
<div className="mt-2 status-box">
|
<div className="mt-2 status-box">
|
||||||
<Row>
|
<Row>
|
||||||
{downloadInfo ? <p>{downloadInfo.title}</p> : null}
|
{downloadInfo ? <p>{downloadInfo.title}</p> : null}
|
||||||
<Col sm={9}>
|
<Col sm={9}>
|
||||||
<h6>Status</h6>
|
<h6>Status</h6>
|
||||||
{!message ? <pre>Ready</pre> : null}
|
{!message ? <pre>Ready</pre> : null}
|
||||||
<pre id='status'>{message}</pre>
|
<pre id='status'>{message}</pre>
|
||||||
</Col>
|
</Col>
|
||||||
<Col sm={3}>
|
<Col sm={3}>
|
||||||
<br />
|
<br />
|
||||||
<img className="img-fluid rounded" src={downloadInfo?.thumbnail} />
|
<img className="img-fluid rounded" src={downloadInfo?.thumbnail} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
{/* <Col>
|
||||||
|
<Statistics></Statistics>
|
||||||
|
</Col> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonGroup className="mt-2">
|
||||||
|
<Button onClick={() => sendUrl()} disabled={halt}>Start</Button>
|
||||||
|
<Button active onClick={() => abort()}>Abort</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
|
{progress ? <ProgressBar className="container-padding mt-2" now={progress} variant="primary" /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButtonGroup className="mt-2">
|
|
||||||
<Button onClick={() => sendUrl()} disabled={halt}>Start</Button>
|
|
||||||
<Button active onClick={() => abort()}>Abort</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
|
|
||||||
{progress ? <ProgressBar className="container-padding mt-2" now={progress} variant="primary" /> : null}
|
<div className="my-4">
|
||||||
</div>
|
<span className="settings" onClick={() => setShowSettings(!showSettings)}>Settings</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSettings ?
|
||||||
<div className="my-4">
|
<div className="p-3 stack-box shadow">
|
||||||
<span className="settings" onClick={() => setShowSettings(!showSettings)}>Settings</span>
|
<h6>Server address</h6>
|
||||||
</div>
|
<InputGroup className="mb-3 url-input" hasValidation>
|
||||||
|
<InputGroup.Text>ws://</InputGroup.Text>
|
||||||
{showSettings ?
|
<FormControl
|
||||||
<div className="p-3 stack-box shadow">
|
defaultValue={localStorage.getItem('server-addr') || 'localhost'}
|
||||||
<h6>Server address</h6>
|
placeholder="Server address"
|
||||||
<InputGroup className="mb-3 url-input" hasValidation>
|
aria-label="Server address"
|
||||||
<InputGroup.Text>ws://</InputGroup.Text>
|
onChange={handleAddrChange}
|
||||||
<FormControl
|
isInvalid={invalidIP}
|
||||||
defaultValue={localStorage.getItem('server-addr') || 'localhost'}
|
isValid={!invalidIP}
|
||||||
placeholder="Server address"
|
/>
|
||||||
aria-label="Server address"
|
<InputGroup.Text>:3022</InputGroup.Text>
|
||||||
onChange={handleAddrChange}
|
</InputGroup>
|
||||||
isInvalid={invalidIP}
|
<Button onClick={() => updateBinary()} disabled={halt}>
|
||||||
isValid={!invalidIP}
|
Update yt-dlp binary
|
||||||
/>
|
</Button>{' '}
|
||||||
<InputGroup.Text>:3022</InputGroup.Text>
|
<Button
|
||||||
</InputGroup>
|
variant={darkMode ? 'light' : 'dark'}
|
||||||
<Button onClick={() => updateBinary()} disabled={halt}>
|
onClick={() => toggleTheme()}
|
||||||
Update yt-dlp binary
|
>
|
||||||
</Button>{' '}
|
{darkMode ? 'Light theme' : 'Dark theme'}
|
||||||
<Button
|
</Button>
|
||||||
variant={darkMode ? 'light' : 'dark'}
|
<div className="pt-2">
|
||||||
onClick={() => toggleTheme()}>
|
<input type="checkbox" name="-x" id="-x"
|
||||||
{darkMode ? 'Light theme' : 'Dark theme'}
|
onClick={() => toggleExtractAudio()} checked={extractAudio} />
|
||||||
</Button>
|
<label htmlFor="-x"> Extract audio</label>
|
||||||
</div> :
|
</div>
|
||||||
null
|
</div> :
|
||||||
}
|
null
|
||||||
|
}
|
||||||
<div className="mt-5" />
|
<div className="mt-5" />
|
||||||
<div>Once you close this page the download will continue in the background.</div>
|
<div>
|
||||||
<div>It won't be possible retriving the progress though.</div>
|
<small>
|
||||||
<div className="mt-5" />
|
Once you close this page the download will continue in the background.
|
||||||
<small>Made with ❤️ by Marcobaobao</small>
|
</small>
|
||||||
</Col>
|
</div>
|
||||||
<Col>
|
</Col>
|
||||||
<Toast
|
<Col>
|
||||||
show={showToast}
|
<Toast
|
||||||
onClose={() => setShowToast(false)}
|
show={showToast}
|
||||||
bg={'primary'}
|
onClose={() => setShowToast(false)}
|
||||||
delay={1500}
|
bg={'primary'}
|
||||||
autohide
|
delay={1500}
|
||||||
className="mt-5"
|
autohide
|
||||||
>
|
className="mt-5"
|
||||||
<Toast.Body className="text-light">
|
>
|
||||||
{`Connected to ${localStorage.getItem('server-addr') || 'localhost'}`}
|
<Toast.Body className="text-light">
|
||||||
</Toast.Body>
|
{`Connected to ${localStorage.getItem('server-addr') || 'localhost'}`}
|
||||||
</Toast>
|
</Toast.Body>
|
||||||
<Toast
|
</Toast>
|
||||||
show={updatedBin}
|
<Toast
|
||||||
onClose={() => setUpdatedBin(false)}
|
show={updatedBin}
|
||||||
bg={'primary'}
|
onClose={() => setUpdatedBin(false)}
|
||||||
delay={1500}
|
bg={'primary'}
|
||||||
autohide
|
delay={1500}
|
||||||
className="mt-5"
|
autohide
|
||||||
>
|
className="mt-5"
|
||||||
<Toast.Body className="text-light">
|
>
|
||||||
Updated yt-dlp binary!
|
<Toast.Body className="text-light">
|
||||||
</Toast.Body>
|
Updated yt-dlp binary!
|
||||||
</Toast>
|
</Toast.Body>
|
||||||
</Col>
|
</Toast>
|
||||||
</Row>
|
</Col>
|
||||||
</Container>
|
</Row>
|
||||||
|
</Container>
|
||||||
|
<div className="container pb-5">
|
||||||
|
<small>Made with ❤️ by Marcobaobao</small>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
34
frontend/src/components/StackableInput.tsx
Normal file
34
frontend/src/components/StackableInput.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
FormControl,
|
||||||
|
Button,
|
||||||
|
ProgressBar
|
||||||
|
} from "react-bootstrap";
|
||||||
|
|
||||||
|
export function StackableInput(props: any) {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<InputGroup className="mt-5">
|
||||||
|
<FormControl
|
||||||
|
className="url-input"
|
||||||
|
placeholder="YouTube or other supported service video url"
|
||||||
|
onChange={props.handleUrlChange}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<div className="mt-2 status-box">
|
||||||
|
<h6>Status</h6>
|
||||||
|
<pre id='status'>{props.message}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.progress ?
|
||||||
|
<ProgressBar className="container-padding" now={props.progress} variant="danger" /> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* <Button className="my-5" variant="danger" onClick={() => sendUrl()} disabled={props.halt}>Go!</Button>{' '} */}
|
||||||
|
{/* <Button variant="danger" active onClick={() => abort()}>Abort</Button>{' '} */}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,52 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { IDLSpeed } from "../interfaces";
|
import { Line } from "react-chartjs-2";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { on } from "../events";
|
||||||
|
|
||||||
export function Statistics(props: any) {
|
ChartJS.register(
|
||||||
const [dataset, setDataset] = useState<Array<IDLSpeed>>()
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Statistics() {
|
||||||
|
const dataset = new Array<number>();
|
||||||
|
const chartRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
on('dlSpeed', (data: CustomEvent<any>) => {
|
||||||
|
dataset.push(data.detail)
|
||||||
|
chartRef.current.update()
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: dataset.map(() => ''),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: dataset,
|
||||||
|
label: 'download speed',
|
||||||
|
borderColor: 'rgb(53, 162, 235)',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart">
|
<div className="chart">
|
||||||
|
<Line data={data} ref={chartRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
3
frontend/src/events.ts
Normal file
3
frontend/src/events.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function on(eventType: string, listener: any) {
|
||||||
|
document.addEventListener(eventType, listener)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ export interface IMessage {
|
|||||||
status: string,
|
status: string,
|
||||||
progress?: string,
|
progress?: string,
|
||||||
size?: string,
|
size?: string,
|
||||||
dlSpeed?: string | IDLSpeed
|
dlSpeed?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDLInfo {
|
export interface IDLInfo {
|
||||||
@@ -14,3 +14,16 @@ export function ellipsis(str: string, lim: number): string {
|
|||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectSpeed(str: string): number {
|
||||||
|
let effective = str.match(/[\d,]+(\.\d+)?/)[0]
|
||||||
|
const unit = str.replace(effective, '')
|
||||||
|
switch (unit) {
|
||||||
|
case 'MiB/s':
|
||||||
|
return Number(effective) * 1000
|
||||||
|
case 'KiB/s':
|
||||||
|
return Number(effective)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
74
lib/db.js
Normal file
74
lib/db.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const uuid = require('uuid')
|
||||||
|
const { logger } = require('./logger')
|
||||||
|
const { existsInProc } = require('./procUtils')
|
||||||
|
|
||||||
|
const db = require('better-sqlite3')('downloads.db')
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE TABLE downloads (
|
||||||
|
uid varchar(36) NOT NULL,
|
||||||
|
url text NOT NULL,
|
||||||
|
title text,
|
||||||
|
thumbnail text,
|
||||||
|
created date,
|
||||||
|
size text,
|
||||||
|
process_pid int NOT NULL,
|
||||||
|
PRIMARY KEY (uid)
|
||||||
|
)`)
|
||||||
|
} catch (e) {
|
||||||
|
logger('db', 'Table already created, ignoring')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get_db() {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertDownload(url, title, thumbnail, size, PID) {
|
||||||
|
const uid = uuid.v1()
|
||||||
|
db
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO downloads
|
||||||
|
(uid, url, title, thumbnail, size, process_pid)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(uid, url, title, thumbnail, size, PID)
|
||||||
|
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retrieveAll() {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM downloads')
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDownloadById(uid) {
|
||||||
|
db.prepare(`DELETE FROM downloads WHERE uid=${uid}`).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDownloadByPID(PID) {
|
||||||
|
db.prepare(`DELETE FROM downloads WHERE process_pid=${PID}`).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneDownloads() {
|
||||||
|
const all = await retrieveAll()
|
||||||
|
return all.map(job => {
|
||||||
|
if (existsInProc(job.process_pid)) {
|
||||||
|
return job
|
||||||
|
} else {
|
||||||
|
deleteDownloadByPID(job.process_pid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init: init,
|
||||||
|
getDB: get_db,
|
||||||
|
insertDownload: insertDownload,
|
||||||
|
retrieveAll: retrieveAll,
|
||||||
|
deleteDownloadById: deleteDownloadById,
|
||||||
|
deleteDownloadByPID: deleteDownloadByPID,
|
||||||
|
pruneDownloads: pruneDownloads,
|
||||||
|
}
|
||||||
@@ -1,29 +1,41 @@
|
|||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const logger = require('./logger');
|
|
||||||
const { from, interval } = require('rxjs');
|
const { from, interval } = require('rxjs');
|
||||||
const { throttle } = require('rxjs/operators');
|
const { throttle } = require('rxjs/operators');
|
||||||
|
const { insertDownload, deleteDownloadByPID, pruneDownloads } = require('./db');
|
||||||
|
const { logger } = require('./logger');
|
||||||
|
const { retriveStdoutFromProcFd, killProcess } = require('./procUtils');
|
||||||
let settings;
|
let settings;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
settings = require('../settings.json')
|
settings = require('../settings.json');
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.warn("settings.json not found")
|
console.warn("settings.json not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
const download = (socket, url) => {
|
async function download(socket, payload) {
|
||||||
if (url === '' || url === null) {
|
|
||||||
socket.emit('progress', { status: 'Done!' })
|
if (!payload || payload.url === '' || payload.url === null) {
|
||||||
return
|
socket.emit('progress', { status: 'Done!' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDownloadInfo(socket, url)
|
const url = payload.url
|
||||||
|
const params = payload.params?.xa ? '-x' : '';
|
||||||
|
|
||||||
|
await getDownloadInfo(socket, url);
|
||||||
|
|
||||||
const ytldp = spawn(`./lib/yt-dlp${isWindows ? '.exe' : ''}`,
|
const ytldp = spawn(`./lib/yt-dlp${isWindows ? '.exe' : ''}`,
|
||||||
['-o', `${settings.download_path || 'downloads/'}%(title)s.%(ext)s`, url]
|
[
|
||||||
)
|
'-o', `${settings.download_path || 'downloads/'}%(title)s.%(ext)s`,
|
||||||
|
params,
|
||||||
|
url
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertDownload(url, null, null, null, ytldp.pid);
|
||||||
|
|
||||||
from(ytldp.stdout) // stdout as observable
|
from(ytldp.stdout) // stdout as observable
|
||||||
.pipe(throttle(() => interval(500))) // discard events closer than 500ms
|
.pipe(throttle(() => interval(500))) // discard events closer than 500ms
|
||||||
@@ -31,49 +43,69 @@ const download = (socket, url) => {
|
|||||||
next: (stdout) => {
|
next: (stdout) => {
|
||||||
//let _stdout = String(stdout)
|
//let _stdout = String(stdout)
|
||||||
socket.emit('progress', formatter(String(stdout))) // finally, emit
|
socket.emit('progress', formatter(String(stdout))) // finally, emit
|
||||||
//logger('download', `Fetching ${stdout}`)
|
//logger('download', `Fetching ${_stdout}`)
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
socket.emit('progress', { status: 'Done!' })
|
socket.emit('progress', { status: 'Done!' })
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
ytldp.on('exit', () => {
|
ytldp.on('exit', () => {
|
||||||
socket.emit('progress', { status: 'Done!' })
|
socket.emit('progress', { status: 'Done!' })
|
||||||
logger('download', 'Done!')
|
logger('download', 'Done!')
|
||||||
|
|
||||||
|
deleteDownloadByPID(ytldp.pid).then(() => {
|
||||||
|
logger('db', `Deleted ${ytldp.pid} because SIGKILL`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDownloadInfo = (socket, url) => {
|
async function retriveDownload(socket) {
|
||||||
|
const downloads = await pruneDownloads();
|
||||||
|
|
||||||
|
if (downloads.length > 0) {
|
||||||
|
for (const _download of downloads) {
|
||||||
|
await killProcess(_download.process_pid);
|
||||||
|
await download(socket, _download.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDownloadInfo(socket, url) {
|
||||||
let stdoutChunks = [];
|
let stdoutChunks = [];
|
||||||
const ytdlpInfo = spawn(`./lib/yt-dlp${isWindows ? '.exe' : ''}`, ['-s', '-j', url]);
|
const ytdlpInfo = spawn(`./lib/yt-dlp${isWindows ? '.exe' : ''}`, ['-s', '-j', url]);
|
||||||
|
|
||||||
ytdlpInfo.stdout.on('data', (data) => {
|
ytdlpInfo.stdout.on('data', (data) => {
|
||||||
stdoutChunks.push(data)
|
stdoutChunks.push(data);
|
||||||
})
|
});
|
||||||
|
|
||||||
ytdlpInfo.on('exit', () => {
|
ytdlpInfo.on('exit', () => {
|
||||||
const buffer = Buffer.concat(stdoutChunks)
|
try {
|
||||||
const json = JSON.parse(buffer.toString())
|
const buffer = Buffer.concat(stdoutChunks);
|
||||||
socket.emit('info', json)
|
const json = JSON.parse(buffer.toString());
|
||||||
|
socket.emit('info', json);
|
||||||
|
} catch (e) {
|
||||||
|
socket.emit('progress', { status: 'Aborted' });
|
||||||
|
logger('download', 'Done!');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortDownload = (socket) => {
|
function abortDownload(socket) {
|
||||||
const res = process.platform === 'win32' ?
|
const res = process.platform === 'win32' ?
|
||||||
spawn('taskkill', ['/IM', 'yt-dlp.exe', '/F', '/T']) :
|
spawn('taskkill', ['/IM', 'yt-dlp.exe', '/F', '/T']) :
|
||||||
spawn('killall', ['yt-dlp'])
|
spawn('killall', ['yt-dlp']);
|
||||||
res.on('exit', () => {
|
res.on('exit', () => {
|
||||||
socket.emit('progress', 'Aborted!')
|
socket.emit('progress', { status: 'Aborted' });
|
||||||
logger('download', 'Aborting downloads')
|
logger('download', 'Aborting downloads');
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatter = (stdout) => {
|
const formatter = (stdout) => {
|
||||||
const cleanStdout = stdout
|
const cleanStdout = stdout
|
||||||
.replace(/\s\s+/g, ' ')
|
.replace(/\s\s+/g, ' ')
|
||||||
.split(' ')
|
.split(' ');
|
||||||
const status = cleanStdout[0].replace(/\[|\]|\r/g, '')
|
const status = cleanStdout[0].replace(/\[|\]|\r/g, '');
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'download':
|
case 'download':
|
||||||
return {
|
return {
|
||||||
@@ -82,7 +114,7 @@ const formatter = (stdout) => {
|
|||||||
size: cleanStdout[3],
|
size: cleanStdout[3],
|
||||||
dlSpeed: cleanStdout[5],
|
dlSpeed: cleanStdout[5],
|
||||||
}
|
}
|
||||||
case 'merger':
|
case 'merge':
|
||||||
return {
|
return {
|
||||||
status: 'merging',
|
status: 'merging',
|
||||||
progress: '100',
|
progress: '100',
|
||||||
@@ -95,4 +127,5 @@ const formatter = (stdout) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
download: download,
|
download: download,
|
||||||
abortDownload: abortDownload,
|
abortDownload: abortDownload,
|
||||||
|
retriveDownload: retriveDownload,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
const logger = (proto, args) => {
|
const logger = (proto, args) => {
|
||||||
console.log(`[${proto}] ${args}`)
|
console.log(`[${proto}]\t${args}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = logger
|
const splash = () => {
|
||||||
|
console.log("-------------------------------------------------")
|
||||||
|
console.log("yt-dlp-webUI - A web-ui for yt-dlp, simply enough")
|
||||||
|
console.log("-------------------------------------------------")
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
logger: logger,
|
||||||
|
splash: splash,
|
||||||
|
}
|
||||||
38
lib/procUtils.js
Normal file
38
lib/procUtils.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const net = require('net');
|
||||||
|
const { logger } = require('./logger');
|
||||||
|
|
||||||
|
function existsInProc(pid) {
|
||||||
|
try {
|
||||||
|
return fs.statSync(`/proc/${pid}`)
|
||||||
|
} catch (e) {
|
||||||
|
logger('proc', `pid ${pid} not found in procfs`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
function retriveStdoutFromProcFd(pid) {
|
||||||
|
if (existsInProc(pid)) {
|
||||||
|
const unixSocket = fs.readlinkSync(`/proc/${pid}/fd/1`).replace('socket:[', '127.0.0.1:').replace(']', '')
|
||||||
|
if (unixSocket) {
|
||||||
|
console.log(unixSocket)
|
||||||
|
logger('proc', `found pending job on pid: ${pid} attached to UNIX socket: ${unixSocket}`)
|
||||||
|
return net.createConnection(unixSocket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function killProcess(pid) {
|
||||||
|
const res = spawn('kill', [pid])
|
||||||
|
res.on('exit', () => {
|
||||||
|
logger('proc', `Successfully killed yt-dlp process, pid: ${pid}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
existsInProc: existsInProc,
|
||||||
|
//retriveStdoutFromProcFd: retriveStdoutFromProcFd,
|
||||||
|
killProcess: killProcess,
|
||||||
|
}
|
||||||
15
package.json
15
package.json
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "A terrible webUI for yt-dlp, all-in-one solution.",
|
"description": "A terrible webUI for yt-dlp, all-in-one solution.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --harmony server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon app.js",
|
"dev": "nodemon app.js",
|
||||||
"build": "parcel build ./frontend/index.html",
|
"build": "parcel build ./frontend/index.html",
|
||||||
"fe": "parcel ./frontend/index.html --open",
|
"fe": "parcel ./frontend/index.html --open",
|
||||||
@@ -13,16 +13,23 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@koa/cors": "^3.1.0",
|
"@koa/cors": "^3.1.0",
|
||||||
|
"better-sqlite3": "^7.4.5",
|
||||||
|
"chart.js": "^3.6.0",
|
||||||
"koa": "^2.13.4",
|
"koa": "^2.13.4",
|
||||||
"koa-static": "^5.0.0",
|
"koa-static": "^5.0.0",
|
||||||
|
"ordered-binary": "^1.2.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-bootstrap": "^2.0.2",
|
"react-bootstrap": "2.0.2",
|
||||||
|
"react-chartjs-2": "^4.0.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"rxjs": "^7.4.0",
|
"rxjs": "^7.4.0",
|
||||||
"socket.io": "^4.3.2",
|
"socket.io": "^4.3.2",
|
||||||
"socket.io-client": "^4.3.2"
|
"socket.io-client": "^4.3.2",
|
||||||
|
"sqlite": "^4.0.23",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"parcel": "^2.0.1"
|
"parcel": "^2.0.1",
|
||||||
|
"typescript": "^4.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
31
server.js
31
server.js
@@ -1,12 +1,13 @@
|
|||||||
const Koa = require('koa');
|
const Koa = require('koa'),
|
||||||
const serve = require('koa-static');
|
serve = require('koa-static'),
|
||||||
const path = require('path');
|
cors = require('@koa/cors'),
|
||||||
const { Server } = require('socket.io');
|
{ logger, splash } = require('./lib/logger'),
|
||||||
const { createServer } = require('http');
|
{ join } = require('path'),
|
||||||
const cors = require('@koa/cors');
|
{ Server } = require('socket.io'),
|
||||||
const logger = require('./lib/logger');
|
{ createServer } = require('http'),
|
||||||
const { download, abortDownload } = require('./lib/downloader');
|
{ download, abortDownload, retriveDownload } = require('./lib/downloader'),
|
||||||
const { ytdlpUpdater } = require('./lib/updater');
|
{ ytdlpUpdater } = require('./lib/updater'),
|
||||||
|
db = require('./lib/db');
|
||||||
|
|
||||||
const app = new Koa()
|
const app = new Koa()
|
||||||
const server = createServer(app.callback())
|
const server = createServer(app.callback())
|
||||||
@@ -30,6 +31,12 @@ io.on('connection', socket => {
|
|||||||
socket.on('update-bin', () => {
|
socket.on('update-bin', () => {
|
||||||
ytdlpUpdater(socket)
|
ytdlpUpdater(socket)
|
||||||
})
|
})
|
||||||
|
socket.on('fetch-jobs', async () => {
|
||||||
|
socket.emit('pending-jobs', await db.pruneDownloads())
|
||||||
|
})
|
||||||
|
socket.on('retrieve-jobs', async () => {
|
||||||
|
retriveDownload(socket)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
io.on('disconnect', (socket) => {
|
io.on('disconnect', (socket) => {
|
||||||
@@ -38,8 +45,10 @@ io.on('disconnect', (socket) => {
|
|||||||
|
|
||||||
app
|
app
|
||||||
.use(cors())
|
.use(cors())
|
||||||
.use(serve(path.join(__dirname, 'dist')))
|
.use(serve(join(__dirname, 'dist')))
|
||||||
|
|
||||||
|
splash()
|
||||||
logger('koa', `Server started on port ${process.env.PORT || 3022}`)
|
logger('koa', `Server started on port ${process.env.PORT || 3022}`)
|
||||||
|
|
||||||
server.listen(process.env.PORT || 3022)
|
db.init()
|
||||||
|
.then(() => server.listen(process.env.PORT || 3022))
|
||||||
Reference in New Issue
Block a user