Switch from handlebars to next.js

This commit is contained in:
2021-07-22 12:22:44 -04:00
parent 635591ba89
commit 976a1a77e5
26 changed files with 3986 additions and 525 deletions

View File

@@ -1,3 +1,4 @@
dist dist
node_modules node_modules
downloads downloads
.next

View File

@@ -3,6 +3,9 @@ module.exports = {
parserOptions: { parserOptions: {
project: 'tsconfig.json', project: 'tsconfig.json',
sourceType: 'module', sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
}, },
plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'], plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'],
extends: [ extends: [

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ lerna-debug.log*
!.vscode/extensions.json !.vscode/extensions.json
/downloads /downloads
.next

View File

@@ -3,6 +3,8 @@ services:
redis: redis:
image: redis image: redis
restart: unless-stopped restart: unless-stopped
ports:
- 6379:6379
vidgrab: vidgrab:
image: tedkulp/vidgrab image: tedkulp/vidgrab
restart: unless-stopped restart: unless-stopped

View File

@@ -1,4 +1,7 @@
{ {
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src" "sourceRoot": "src",
} "compilerOptions": {
"tsConfigPath": "tsconfig.server.json"
}
}

3
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />

3890
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,11 @@
"bull": "^3.26.0", "bull": "^3.26.0",
"class-transformer": "^0.4.0", "class-transformer": "^0.4.0",
"class-validator": "^0.13.1", "class-validator": "^0.13.1",
"express-handlebars": "^5.3.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nest-next": "^9.4.0",
"next": "^11.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^6.6.6", "rxjs": "^6.6.6",
@@ -47,6 +50,8 @@
"@types/jest": "^26.0.22", "@types/jest": "^26.0.22",
"@types/lodash": "^4.14.171", "@types/lodash": "^4.14.171",
"@types/node": "^14.14.36", "@types/node": "^14.14.36",
"@types/react": "^17.0.14",
"@types/react-dom": "^17.0.9",
"@types/split2": "^3.2.1", "@types/split2": "^3.2.1",
"@types/supertest": "^2.0.10", "@types/supertest": "^2.0.10",
"@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/eslint-plugin": "^4.19.0",
@@ -81,4 +86,4 @@
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

6
pages/_app.jsx Normal file
View File

@@ -0,0 +1,6 @@
import '../styles/styles.css';
// This default export is required in a new `pages/_app.js` file.
export default function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}

48
pages/_document.jsx Normal file
View File

@@ -0,0 +1,48 @@
import Document, { Head, Html, Main, NextScript } from 'next/document';
class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() {
return (
<Html>
<Head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"
/>
</Head>
<body>
<div id="wrapper">
<section className="section has-background-light">
<div className="container">
<nav className="level">
<h1 className="title is-1 level-item has-text-centered">
Vidgrab
</h1>
</nav>
</div>
</section>
<Main />
</div>
<footer id="footer" className="footer">
<div className="content has-text-centered">
<p>
Code and "Design" by <a href="#">Ted Kulp</a>. Based on
youtube-dl.
</p>
</div>
</footer>
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;

View File

@@ -0,0 +1,40 @@
import { NextPage, NextPageContext } from 'next';
// The component's props type
type PageProps = {
extractors?: string[];
};
// extending the default next context type
type PageContext = NextPageContext & {
query: PageProps;
};
// react component
const Page: NextPage<PageProps> = ({ extractors }) => {
return (
<>
<section className="section">
<div className="container">
<div className="content">
<p>
Here is the list of currently supported services. This is
generated dynamically because youtube-dl adds new services all the
time.
</p>
<ul>{extractors && extractors.map((e) => <li key={e}>{e}</li>)}</ul>
</div>
</div>
</section>
</>
);
};
// assigning the initial props to the component's props
Page.getInitialProps = async ({ query }: PageContext) => {
return {
extractors: query?.extractors,
};
};
export default Page;

68
pages/views/Getinfo.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { NextPage, NextPageContext } from 'next';
// The component's props type
type PageProps = {
title: string;
extractor: string;
description: string;
videoUrl: string;
thumbnails: string;
upload_date: string;
duration: number;
formats: any[];
};
// extending the default next context type
type PageContext = NextPageContext & {
query: PageProps;
};
// react component
const Page: NextPage<PageProps> = (props) => {
return (
<>
<section className="section">
<div className="container">
<form method="POST" action="/queue">
<div className="field is-grouped">
<div className="control is-expanded">
<div className="select is-fullwidth">
<select name="format">
{props.formats &&
props.formats.map((f) => (
<option key={f.format_id} value={f.format_id}>
{f.format} ({f.ext})
</option>
))}
</select>
</div>
</div>
<div className="control">
<button
className="button is-primary"
id="form-submit"
type="submit"
>
Queue Download
</button>
</div>
</div>
<input type="hidden" name="url" value={props.videoUrl} />
<input type="hidden" name="title" value={props.title} />
<input type="hidden" name="extractor" value={props.extractor} />
</form>
</div>
</section>
</>
);
};
// assigning the initial props to the component's props
Page.getInitialProps = async ({ query }: PageContext) => {
return {
...query,
};
};
export default Page;

115
pages/views/Index.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { NextPage, NextPageContext } from 'next';
import Link from 'next/link';
// The component's props type
type PageProps = {
bookmarklet?: string;
jobs?: any[];
};
// extending the default next context type
type PageContext = NextPageContext & {
query: PageProps;
};
// react component
const Page: NextPage<PageProps> = ({ bookmarklet, jobs }) => {
return (
<>
<section className="section">
<div className="container">
<form method="POST" action="/getinfo">
<div className="field is-grouped">
<div className="control is-expanded">
<input
className="input is-medium"
name="url"
placeholder="URL to Download"
/>
<p className="help">
<Link href="/extractors">
* List of currently available services
</Link>
</p>
</div>
<div className="control">
<button
className="button is-primary is-medium"
id="form-submit"
type="submit"
>
Submit
</button>
</div>
</div>
</form>
</div>
</section>
<div className="container content has-text-centered">
<p>Drag this to your bookmark bar!</p>
<a
className="bookmarklet"
ref={(node) =>
node && bookmarklet && node.setAttribute('href', bookmarklet)
}
>
Vidgrab It!
</a>
</div>
<section className="section">
<div className="container">
<h2 className="subtitle is-4 has-text-centered">Recent Downloads</h2>
<table className="table is-striped is-hoverable is-fullwidth is-narrow">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>
<abbr title="Service">Svc</abbr>
</th>
<th>Title</th>
<th>
<abbr title="Percent Downloaded">%</abbr>
</th>
</tr>
</thead>
<tbody>
{jobs &&
jobs.map((j) => (
<tr key={j.id}>
<td>{j.id}</td>
<td>{j.state}</td>
<td>{j.data.extractor}</td>
<td>
<a
href={j.data.url}
title={j.data.title}
target="_blank"
rel="noopener noreferrer"
>
{j.data.title}
</a>
</td>
<td>{j.progress}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
);
};
// assigning the initial props to the component's props
Page.getInitialProps = async ({ query }: PageContext) => {
return {
bookmarklet: query?.bookmarklet?.toString(),
jobs: query?.jobs,
};
};
export default Page;

View File

@@ -1,6 +1,8 @@
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { RenderModule } from 'nest-next';
import Next from 'next';
import configuration from './config/configuration'; import configuration from './config/configuration';
import { WebModule } from './web/web.module'; import { WebModule } from './web/web.module';
@@ -13,6 +15,9 @@ import { YtdlModule } from './ytdl/ytdl.module';
isGlobal: true, isGlobal: true,
load: [configuration], load: [configuration],
}), }),
RenderModule.forRootAsync(
Next({ dev: process.env.NODE_ENV !== 'production' }),
),
BullModule.forRootAsync({ BullModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({

View File

@@ -1,5 +1,7 @@
export default () => ({ export default () => ({
redisHost: process.env.REDIS_HOST || 'localhost', redisHost: process.env.REDIS_HOST || 'localhost',
redisPort: parseInt(process.env.REDIS_PORT, 10) || 6379, redisPort: process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379,
fileDir: process.env.FILE_DIR || '/tmp', fileDir: process.env.FILE_DIR || '/tmp',
}); });

View File

@@ -1,29 +1,11 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import * as exphbs from 'express-handlebars';
import { capitalize, truncate } from 'lodash';
import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
const hbs = exphbs.create({
defaultLayout: 'main',
layoutsDir: join(__dirname, '..', 'views', 'layouts'),
helpers: {
capitalize,
truncate: (str: string, options: any) => truncate(str, options.hash),
},
extname: '.hbs',
});
app.useStaticAssets(join(__dirname, '..', 'public'));
app.setBaseViewsDir(join(__dirname, '..', 'views'));
app.engine('.hbs', hbs.engine);
app.setViewEngine('.hbs');
await app.listen(3000); await app.listen(3000);
} }
bootstrap(); bootstrap();

View File

@@ -1,10 +1,10 @@
export class UploadDto { export class UploadDto {
url: string; url = '';
} }
export class QueueDto { export class QueueDto {
format? = 'best'; format? = 'best';
url: string; url = '';
title?: string; title?: string;
extractor?: string; extractor?: string;
} }

View File

@@ -3,6 +3,7 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpCode,
Logger, Logger,
Post, Post,
Redirect, Redirect,
@@ -27,7 +28,7 @@ export class WebController {
) {} ) {}
@Get() @Get()
@Render('index') @Render('Index')
async root(@Req() request: any) { async root(@Req() request: any) {
const fullUrl = const fullUrl =
request.protocol + '://' + request.get('host') + request.originalUrl; request.protocol + '://' + request.get('host') + request.originalUrl;
@@ -51,7 +52,7 @@ export class WebController {
const progress = j.progress(); const progress = j.progress();
return { return {
...j, ...pick(j, ['id', 'name', 'data']),
progress: progress ? `${progress}%` : 'n/a', progress: progress ? `${progress}%` : 'n/a',
state: state, state: state,
}; };
@@ -60,14 +61,20 @@ export class WebController {
return { return {
bookmarklet, bookmarklet,
jobs: jobList jobs: JSON.parse(
.sort((a, b) => parseInt(b.id.toString()) - parseInt(a.id.toString())) JSON.stringify(
.slice(0, 10), jobList
.sort(
(a, b) => parseInt(b.id.toString()) - parseInt(a.id.toString()),
)
.slice(0, 10),
),
),
}; };
} }
@Get('/extractors') @Get('/extractors')
@Render('extractors') @Render('Extractors')
async listExtractors() { async listExtractors() {
const extractors = await this.ytdlService.listExtractors(); const extractors = await this.ytdlService.listExtractors();
@@ -77,7 +84,8 @@ export class WebController {
} }
@Post('/getinfo') @Post('/getinfo')
@Render('getinfo') @Render('Getinfo')
@HttpCode(200)
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async getInfo(@Body() body: UploadDto) { async getInfo(@Body() body: UploadDto) {
const videoInfo = await this.ytdlService.getVideoInfo(body.url); const videoInfo = await this.ytdlService.getVideoInfo(body.url);
@@ -86,7 +94,7 @@ export class WebController {
title: videoInfo.title, title: videoInfo.title,
extractor: videoInfo.extractor, extractor: videoInfo.extractor,
description: videoInfo.description, description: videoInfo.description,
url: videoInfo.webpage_url, videoUrl: videoInfo.webpage_url,
thumbnails: videoInfo.thumbnails, thumbnails: videoInfo.thumbnails,
upload_date: videoInfo.upload_date, upload_date: videoInfo.upload_date,
duration: videoInfo.duration, duration: videoInfo.duration,

View File

@@ -11,7 +11,7 @@ import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Job } from 'bull'; import { Job } from 'bull';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import * as split from 'split2'; import split from 'split2';
import { QueueDto } from 'src/types'; import { QueueDto } from 'src/types';
import { raw } from 'youtube-dl-exec'; import { raw } from 'youtube-dl-exec';
@@ -38,16 +38,16 @@ export class YtdlProcessor {
newline: true, newline: true,
}); });
execaProcess.stdout.pipe(split()).on('data', (line) => { execaProcess.stdout?.pipe(split()).on('data', (line: string) => {
this.logger.verbose(`youtube-dl stdout: ${line}`); this.logger.verbose(`youtube-dl stdout: ${line}`);
const percentDone = line.match(/\[download\]\s+([0-9.]+)% of/); const percentDone = line.match(/\[download\]\s+([0-9.]+)% of/);
if (percentDone) { if (percentDone) {
this.setProgress(job, percentDone[1]); this.setProgress(job, parseFloat(percentDone[1]));
} }
}); });
execaProcess.stderr.pipe(split()).on('data', (line) => { execaProcess.stderr?.pipe(split()).on('data', (line: any) => {
this.logger.error(`youtube-dl stderr: ${line}`); this.logger.error(`youtube-dl stderr: ${line}`);
}); });

19
styles/styles.css Normal file
View File

@@ -0,0 +1,19 @@
body {
display: flex;
min-height: 100vh;
flex-direction: column;
}
#wrapper {
flex: 1;
}
#footer {
padding-top: 2em;
padding-bottom: 2em;
}
.bookmarklet {
border: 1px dashed black;
padding: 10px 20px 10px 20px;
}

View File

@@ -1,15 +1,44 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "target": "esnext",
"declaration": true, "module": "esnext",
"removeComments": true, "jsx": "preserve",
"emitDecoratorMetadata": true, "strict": true,
"experimentalDecorators": true, "pretty": true,
"noImplicitAny": true,
"alwaysStrict": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"outDir": ".next",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"target": "es2017", "esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "skipLibCheck": true,
"baseUrl": "./", "lib": [
"incremental": true "es2017",
} "dom"
} ],
"baseUrl": ".",
"typeRoots": [
"node_modules/@types",
"./typings"
],
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": [
"./pages/**/*",
"./src/**/*",
"./ui/**/*",
"types.ts"
],
"exclude": [
"node_modules"
]
}

22
tsconfig.server.json Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"isolatedModules": false,
"noEmit": false,
"allowJs": false
},
"include": [
"./src/**/*",
"types.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,13 +0,0 @@
<section class="section">
<div class="container">
<div class="content">
<p>Here is the list of currently supported services. This is generated dynamically because youtube-dl adds new
services all the time.</p>
<ul>
{{#each extractors}}
<li>{{this}}</li>
{{/each}}
</ul>
</div>
</div>
</section>

View File

@@ -1,33 +0,0 @@
<section class="section">
<div class="container">
<form method="POST" action="/queue">
<div class="field is-grouped">
<div class="control is-expanded">
<div class="select is-fullwidth">
<select name="format">
{{#each formats}}
<option value={{format_id}}>{{format}} ({{ext}})</option>
{{/each}}
</select>
</div>
</div>
<div class="control">
<button class="button is-primary" id="form-submit" type="submit">Queue Download</button>
</div>
</div>
<input type="hidden" name="url" value="{{url}}" />
<input type="hidden" name="title" value="{{title}}" />
<input type="hidden" name="extractor" value="{{extractor}}" />
</form>
</div>
</section>
<script>
function changeButton() {
var ele = document.getElementById('form-submit');
if (ele) {
ele.classList.toggle('is-loading');
}
}
</script>

View File

@@ -1,60 +0,0 @@
<section class="section">
<div class="container">
<form method="POST" action="/getinfo" onsubmit="changeButton()">
<div class="field is-grouped">
<div class="control is-expanded">
<input class="input is-medium" name="url" placeholder="URL to Download" />
<p class="help"><a href="/extractors">* List of currently available services</a></p>
</div>
<div class="control">
<button class="button is-primary is-medium" id="form-submit" type="submit">Submit</button>
</div>
</div>
</form>
</div>
</section>
<div class="container content has-text-centered">
<p>Drag this to your bookmark bar!</p>
<a class="bookmarklet" href="{{bookmarklet}}">Vidgrab It!</a>
</div>
<section class="section">
<div class="container">
<h2 class="subtitle is-4 has-text-centered">Recent Downloads</h2>
<table class="table is-striped is-hoverable is-fullwidth is-narrow">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th><abbr title="Service">Svc</abbr></th>
<th>Title</th>
<th><abbr title="Percent Downloaded">%</abbr></th>
</tr>
</thead>
{{#each jobs}}
<tr>
<td>{{id}}</td>
<td>{{capitalize state}}</td>
<td>{{capitalize data.extractor}}</td>
<td>
<a href="{{data.url}}" title="{{data.title}}" target="_blank" rel="noopener noreferrer">
{{data.title}}
</a>
</td>
<td>{{progress}}</td>
</tr>
{{/each}}
</table>
</div>
</section>
<script>
function changeButton() {
var ele = document.getElementById('form-submit');
if (ele) {
ele.classList.toggle('is-loading');
}
}
</script>

View File

@@ -1,55 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vidgrab</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<style type="text/css" media="screen">
body {
display: flex;
min-height: 100vh;
flex-direction: column;
}
#wrapper {
flex: 1;
}
#footer {
padding-top: 2em;
padding-bottom: 2em;
}
.bookmarklet {
border: 1px dashed black;
padding: 10px 20px 10px 20px;
}
</style>
</head>
<body>
<div id="wrapper">
<section class="section has-background-light">
<div class="container">
<nav class="level">
<h1 class="title is-1 level-item has-text-centered">Vidgrab</h1>
</nav>
<div>
</section>
{{{body}}}
</div>
<footer id="footer" class="footer">
<div class="content has-text-centered">
<p>
Code and "Design" by <a href="#">Ted Kulp</a>. Based on youtube-dl.
</p>
</div>
</footer>
</body>
</html>