mirror of
https://github.com/hansputera/tiktok-dl.git
synced 2026-04-05 19:51:57 +02:00
Merge pull request #127 from hansputera/dev
feat: added SLIDES and FASTTOKSAVE provider
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM node:current-alpine3.15
|
||||
FROM node:22-alpine3.22
|
||||
|
||||
RUN apk update
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
const eslintConfigs = require('tiktok-dl-config/eslint.typescript');
|
||||
|
||||
module.exports = {
|
||||
...eslintConfigs,
|
||||
extends: eslintConfigs.extends.concat(['plugin:@next/next/recommended']),
|
||||
};
|
||||
43
apps/web/.gitignore
vendored
43
apps/web/.gitignore
vendored
@@ -1,2 +1,41 @@
|
||||
.next
|
||||
*.rdb
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
*.json
|
||||
.next
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require('tiktok-dl-config/prettier');
|
||||
21
apps/web/components.json
Normal file
21
apps/web/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Body Component.
|
||||
* @param {{children: JSX.Element}} param0 BodyComponent props.
|
||||
* @return {JSX.Element}
|
||||
*/
|
||||
export const BodyComponent = ({
|
||||
children,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<section className="min-h-screen bg-light-200">
|
||||
<div className="md:container md:mx-auto">{children}</div>
|
||||
</section>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Footer = () => (
|
||||
<React.Fragment>
|
||||
<p className="text-lg">
|
||||
© {new Date().getFullYear()}{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/hansputera/tiktok-dl.git"
|
||||
>
|
||||
TikTok-DL Project
|
||||
</a>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -1,187 +0,0 @@
|
||||
import React from 'react';
|
||||
import useSWR, {Fetcher} from 'swr';
|
||||
import {ExtractedInfo} from 'tiktok-dl-core';
|
||||
import {getTikTokURL} from '../lib/url';
|
||||
import {VideoComponent} from './Video';
|
||||
|
||||
// // ERRORS ///
|
||||
/**
|
||||
* @class InvalidUrlError
|
||||
*/
|
||||
class InvalidUrlError extends Error {
|
||||
/**
|
||||
* @param {string} msg error message.
|
||||
*/
|
||||
constructor(msg?: string) {
|
||||
super(msg);
|
||||
this.name = 'INVALID_URL';
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtractedInfoWithProvider = ExtractedInfo & {
|
||||
provider: string;
|
||||
_url: string;
|
||||
};
|
||||
|
||||
interface StateData {
|
||||
submitted: boolean;
|
||||
error?: string | Error;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const fetcher: Fetcher<ExtractedInfoWithProvider, string> = (...args) =>
|
||||
fetch(...args).then((r) => r.json());
|
||||
|
||||
/**
|
||||
* FormInput Component.
|
||||
* @return {JSX.Element}
|
||||
*/
|
||||
export const FormInputComponent = (): JSX.Element => {
|
||||
const [state, setState] = React.useState<StateData>({
|
||||
submitted: false,
|
||||
error: undefined,
|
||||
url: '',
|
||||
});
|
||||
const {data, mutate} = useSWR(
|
||||
(!state.error || !(state.error as string).length) &&
|
||||
/^http(s?)(:\/\/)([a-z]+\.)*tiktok\.com\/(.+)$/gi.test(state.url)
|
||||
? [
|
||||
'/api/download',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({url: state.url}),
|
||||
},
|
||||
]
|
||||
: null,
|
||||
fetcher,
|
||||
{
|
||||
loadingTimeout: 5_000,
|
||||
refreshInterval: 60_000,
|
||||
revalidateOnMount: false,
|
||||
onSuccess: () =>
|
||||
setState({
|
||||
...state,
|
||||
submitted: false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!/^http(s?)(:\/\/)([a-z]+\.)*tiktok\.com\/(.+)$/gi.test(
|
||||
state.url,
|
||||
) &&
|
||||
state.url.length
|
||||
) {
|
||||
setState({
|
||||
...state,
|
||||
error: new InvalidUrlError('Invalid TikTok URL'),
|
||||
});
|
||||
} else {
|
||||
// submit event trigger.
|
||||
if (state.submitted && !state.error) {
|
||||
mutate();
|
||||
}
|
||||
|
||||
try {
|
||||
const u = getTikTokURL(state.url);
|
||||
if (!u) {
|
||||
setState({
|
||||
...state,
|
||||
error: new InvalidUrlError('Invalid TikTok URL'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
...state,
|
||||
url: u,
|
||||
});
|
||||
} catch {
|
||||
setState({
|
||||
...state,
|
||||
error: new InvalidUrlError('Invalid TikTok URL'),
|
||||
});
|
||||
}
|
||||
|
||||
setState({
|
||||
...state,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
}, [state.submitted, state.url]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<section className="inline-block">
|
||||
<h1 className="text-lg leading-relaxed">
|
||||
Fill TikTok's Video URL below:
|
||||
</h1>
|
||||
<p className="text-red-400 font-sans font-semibold">
|
||||
{state.error instanceof Error
|
||||
? state.error.name.concat(
|
||||
': '.concat(state.error.message),
|
||||
)
|
||||
: state.error
|
||||
? state.error
|
||||
: ''}
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-col md:flex-row"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!state.url.length) {
|
||||
setState({
|
||||
...state,
|
||||
error: 'Please fill the URL!',
|
||||
});
|
||||
return;
|
||||
}
|
||||
!state.error &&
|
||||
setState({
|
||||
...state,
|
||||
submitted: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="url"
|
||||
onChange={(event) =>
|
||||
setState({
|
||||
...state,
|
||||
url: event.target.value,
|
||||
})
|
||||
}
|
||||
value={state.url}
|
||||
placeholder="e.g: "
|
||||
className="p-3 border border-gray-300 font-sans h-auto w-auto outline-solid-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className="p-3 lg:ml-2 mt-1 bg-sky-400 uppercase text-white shadow-sm"
|
||||
disabled={state.submitted}
|
||||
>
|
||||
download
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="mt-3 mb-3">
|
||||
{state.submitted && !data && (
|
||||
<p className={'text-base font-sans text-blue-500'}>
|
||||
Wait a minute
|
||||
</p>
|
||||
)}
|
||||
{data && data.video && data.video.urls.length && (
|
||||
<VideoComponent data={data} />
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormInputComponent;
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Next Head Component.
|
||||
* @param {{title: string, children: JSX.Element }} param0 NextHeadComponent args.
|
||||
* @return {JSX.Element}
|
||||
*/
|
||||
export const NextHeadComponent = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
children?: JSX.Element;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<title>{title ?? 'TikTok Downloader'}</title>
|
||||
<meta
|
||||
name="title"
|
||||
content="TikTok Downloader | Non&Watermark Support"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="An Open-Source Project where it could download TikTok's Video without annoying ads!"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="tiktok-downloader, tiktokdl, tiktok, download video tiktok, tiktok no watermark"
|
||||
/>
|
||||
<meta
|
||||
name="author"
|
||||
content="Hanif Dwy Putra S <github.com/hansputera>"
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import type {ExtractedInfoWithProvider} from './FormInput';
|
||||
|
||||
export const VideoComponent = ({data}: {data: ExtractedInfoWithProvider}) => {
|
||||
const copyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.alert('URL Copied');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<React.Fragment>
|
||||
This video is downloaded from{' '}
|
||||
<span className="font-semibold">{data.provider}</span>.
|
||||
{data.caption && <pre>{data.caption}</pre>}
|
||||
<div className="md:grid md:grid-cols-3 md:gap-4">
|
||||
<video
|
||||
controls={true}
|
||||
autoPlay={false}
|
||||
className="rounded-md h-64 w-80"
|
||||
>
|
||||
<source src={data.video?.urls[0]} />
|
||||
</video>
|
||||
<div className="flex flex-row font-sans basis-8 mt-2">
|
||||
{data.video?.urls.map((url, index) => (
|
||||
<button
|
||||
key={index.toString()}
|
||||
className="mr-1 bg-teal-400 md:p-2 p-1 rounded-md shadow"
|
||||
onClick={() => copyUrl(url)}
|
||||
>
|
||||
LINK {index + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* @class HttpError
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
public statusCode = 400;
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} message HttpError message,
|
||||
*/
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set http status code.
|
||||
* @param {number} code HttpError status code.
|
||||
* @return {HttpError}
|
||||
*/
|
||||
setCode(code: number): HttpError {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './httpError';
|
||||
25
apps/web/eslint.config.mjs
Normal file
25
apps/web/eslint.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './redis';
|
||||
export * from './rotator';
|
||||
export * from './url';
|
||||
@@ -1,31 +0,0 @@
|
||||
import {NextApiRequest, NextApiResponse} from 'next';
|
||||
|
||||
type Middleware = (
|
||||
request: NextApiRequest,
|
||||
response: NextApiResponse,
|
||||
) => Promise<undefined>;
|
||||
|
||||
export const applyRoute = (
|
||||
route: (req: NextApiRequest, res: NextApiResponse) => Promise<void>,
|
||||
middlewares: Middleware[],
|
||||
) => {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const middleware:
|
||||
| {
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
| undefined = await Promise.all(middlewares.map((m) => m(req, res)))
|
||||
.catch((e) => ({
|
||||
message: e.message,
|
||||
status: 500,
|
||||
}))
|
||||
.then(() => undefined);
|
||||
if (middleware)
|
||||
return res.status(middleware.status).json({
|
||||
message: middleware.message,
|
||||
statusCode: middleware.status,
|
||||
});
|
||||
else return route(req, res);
|
||||
};
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import {rateLimitConfig} from '../config';
|
||||
import {HttpError} from '../errors';
|
||||
import {client} from '../lib';
|
||||
|
||||
export const ratelimitMiddleware = async (
|
||||
req: NextApiRequest,
|
||||
_res: NextApiResponse,
|
||||
) => {
|
||||
const ip = req.headers['x-real-ip'] || req.headers['x-forwarded-for'];
|
||||
if (!rateLimitConfig.enable || process.env.NODE_ENV === 'development')
|
||||
return undefined;
|
||||
else if (!ip)
|
||||
throw new HttpError("Couldn't find your real ip address!").setCode(401);
|
||||
const result = await client.get('rate-'.concat(ip.toString()));
|
||||
if (result) {
|
||||
if (parseInt(result) > rateLimitConfig.maxRatelimitPerXSeconds) {
|
||||
throw new HttpError(
|
||||
'Please try again, you are getting ratelimit!',
|
||||
).setCode(429);
|
||||
}
|
||||
client.incr('rate-'.concat(ip.toString()));
|
||||
return undefined;
|
||||
} else {
|
||||
client.set(
|
||||
'rate-'.concat(ip.toString()),
|
||||
'1',
|
||||
'EX',
|
||||
rateLimitConfig.ratelimitTime,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
3
apps/web/next-env.d.ts
vendored
3
apps/web/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
const webpack = require('webpack');
|
||||
const withTM = require('next-transpile-modules')(['tiktok-dl-core']);
|
||||
const WindiCSSWebpackPlugin = require('windicss-webpack-plugin');
|
||||
|
||||
const {parsed: cusEnv} = require('dotenv').config({
|
||||
path: require('path').resolve(__dirname, '..', '..', '.env'),
|
||||
});
|
||||
|
||||
module.exports = withTM({
|
||||
reactStrictMode: true,
|
||||
experimental: {esmExternals: true},
|
||||
webpack(config) {
|
||||
// adding windicss plugin
|
||||
config.plugins.push(new WindiCSSWebpackPlugin());
|
||||
if (typeof cusEnv !== 'undefined') {
|
||||
config.plugins.push(new webpack.EnvironmentPlugin(cusEnv));
|
||||
}
|
||||
return config;
|
||||
},
|
||||
});
|
||||
8
apps/web/next.config.ts
Normal file
8
apps/web/next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['tiktok-dl-core'],
|
||||
serverExternalPackages: ['vm2'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,35 +1,39 @@
|
||||
{
|
||||
"name": "tiktok-dl-web",
|
||||
"description": "TikTok-DL Project Website",
|
||||
"version": "1.4.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "16.6.1",
|
||||
"ioredis": "5.7.0",
|
||||
"next": "12.2.4",
|
||||
"ow": "0.28.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"swr": "1.3.0",
|
||||
"tiktok-dl-config": "*",
|
||||
"tiktok-dl-core": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "next lint",
|
||||
"format": "prettier . --write",
|
||||
"start": "next start --port process.env.PORT"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "12.2.4",
|
||||
"@types/node": "17.0.35",
|
||||
"@types/react": "18.0.17",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"next-transpile-modules": "9.0.0",
|
||||
"prettier": "2.7.1",
|
||||
"tiktok-dl-config": "*",
|
||||
"typescript": "^5.9.2",
|
||||
"windicss-webpack-plugin": "1.7.5"
|
||||
}
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ioredis": "^5.7.0",
|
||||
"ky": "^1.9.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.2",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import 'windi.css';
|
||||
import type {AppProps} from 'next/app';
|
||||
|
||||
/**
|
||||
* TikTokApp
|
||||
* @param {AppProps} arg0 App properties.
|
||||
* @return {JSX.Element}
|
||||
*/
|
||||
export default function TikTokApp({Component, pageProps}: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Document, {Head, Html, Main, NextScript} from 'next/document';
|
||||
import {BodyComponent} from '../components/Body';
|
||||
import {NextHeadComponent} from '../components/Header';
|
||||
|
||||
/**
|
||||
* @class TikTokDocument
|
||||
*/
|
||||
export default class TikTokDocument extends Document {
|
||||
/**
|
||||
* Render TikTokDocument
|
||||
* @return {JSX.Element}
|
||||
*/
|
||||
render(): JSX.Element {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<NextHeadComponent />
|
||||
</Head>
|
||||
<body>
|
||||
<BodyComponent>
|
||||
<Main />
|
||||
</BodyComponent>
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import useSWR from 'swr';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default () => {
|
||||
const {data, error} = useSWR(
|
||||
'https://api.github.com/repos/hansputera/tiktok-dl',
|
||||
(...args) => fetch(...args).then((r) => r.json()),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>About</title>
|
||||
</Head>
|
||||
<section className="p-5">
|
||||
<h1 className="text-center text-2xl">About TikTok-DL</h1>
|
||||
|
||||
<div className="mt-3">
|
||||
{error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
data && (
|
||||
<>
|
||||
<p className="text-center font-sans">
|
||||
{data.description}
|
||||
</p>
|
||||
<ul className="mb-3 mt-3">
|
||||
<li>
|
||||
This project is based on{' '}
|
||||
<span className="font-semibold">
|
||||
{data.license.name}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Currently, we have{' '}
|
||||
{data.stargazers_count.toLocaleString()}{' '}
|
||||
stars, {data.forks.toLocaleString()}{' '}
|
||||
forks, and {data.open_issues} opened
|
||||
issues.
|
||||
</li>
|
||||
<li>
|
||||
Also, this project is created at{' '}
|
||||
<span className="font-semibold">
|
||||
{new Date(
|
||||
data.created_at,
|
||||
).toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'Asia/Jakarta',
|
||||
})}
|
||||
</span>{' '}
|
||||
by{' '}
|
||||
<span className="font-medium">
|
||||
{data.owner.login}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Last update:{' '}
|
||||
<span className="font-semibold">
|
||||
{new Date(
|
||||
data.updated_at,
|
||||
).toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'Asia/Jakarta',
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
</ul>{' '}
|
||||
If you want see the source code,{' '}
|
||||
<a
|
||||
href={data.html_url}
|
||||
className="font-semibold text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
click here
|
||||
</a>
|
||||
. And feel free to open an{' '}
|
||||
<a
|
||||
href={data.html_url.concat(
|
||||
'/issues/new/choose',
|
||||
)}
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
issue
|
||||
</a>{' '}
|
||||
if you have a problem or find a bug. Thank you!
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import ow from 'ow';
|
||||
import {getProvider, Providers, BaseProvider} from 'tiktok-dl-core';
|
||||
import {getTikTokURL} from '../../lib';
|
||||
import {rotateProvider} from '../../lib/rotator';
|
||||
import {applyRoute} from '../../middleware/apply';
|
||||
import {ratelimitMiddleware} from '../../middleware/ratelimit';
|
||||
|
||||
export default applyRoute(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
if (req.method === 'POST' && typeof req.body === 'string') {
|
||||
req.body = JSON.parse(req.body);
|
||||
}
|
||||
const providersType = Providers.map((p) => p.resourceName());
|
||||
ow(
|
||||
req.body || req.query,
|
||||
ow.object.partialShape({
|
||||
url: ow.string.url.validate((v) => ({
|
||||
validator: !!getTikTokURL(v),
|
||||
message: 'Expected (.*).tiktok.com',
|
||||
})),
|
||||
type: ow.optional.string.validate((v) => ({
|
||||
validator:
|
||||
typeof v === 'string' &&
|
||||
providersType.includes(v.toLowerCase()),
|
||||
message:
|
||||
'Invalid Provider, available provider is: ' +
|
||||
Providers.map((x) => x.resourceName()).join(', '),
|
||||
})),
|
||||
rotateOnError:
|
||||
req.method === 'POST'
|
||||
? ow.optional.boolean
|
||||
: ow.optional.string,
|
||||
}),
|
||||
);
|
||||
|
||||
let provider = getProvider(
|
||||
(req.query.type || req.body.type) ?? 'random',
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid provider',
|
||||
providers: providersType,
|
||||
});
|
||||
}
|
||||
|
||||
const params = provider.getParams();
|
||||
if (
|
||||
params &&
|
||||
provider.resourceName() ===
|
||||
(req.query.type?.toString() || req.body.type)?.toLowerCase()
|
||||
) {
|
||||
ow(req.query || req.body, ow.object.partialShape(params));
|
||||
} else if (params) {
|
||||
provider = getProvider('random');
|
||||
}
|
||||
|
||||
const url = getTikTokURL(req.query.url || req.body.url);
|
||||
|
||||
const result = await rotateProvider(
|
||||
provider as BaseProvider,
|
||||
url!,
|
||||
req.method === 'POST'
|
||||
? req.body.rotateOnError
|
||||
: !!req.query.rotateOnError,
|
||||
params
|
||||
? Object.fromEntries(
|
||||
Object.keys(params!).map((p) => [
|
||||
p,
|
||||
(req.query[p] as string) ?? req.body[p],
|
||||
]),
|
||||
)
|
||||
: {},
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
return res
|
||||
.status((e as Error).name === 'ArgumentError' ? 400 : 500)
|
||||
.json({
|
||||
error: (e as Error).name + '|' + (e as Error).message,
|
||||
});
|
||||
}
|
||||
},
|
||||
[ratelimitMiddleware],
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
|
||||
export default async (_: NextApiRequest, res: NextApiResponse) => {
|
||||
return res.redirect('https://github.com/hansputera/tiktok-dl.git');
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import {Providers} from 'tiktok-dl-core';
|
||||
import {ratelimitMiddleware} from '../../middleware/ratelimit';
|
||||
import type {Shape} from 'ow';
|
||||
import {applyRoute} from '../../middleware/apply';
|
||||
|
||||
export default applyRoute(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const providers = Providers.map((p) => ({
|
||||
name: p.resourceName(),
|
||||
url: p.client?.defaults.options.prefixUrl,
|
||||
maintenance: p.maintenance,
|
||||
params: p.getParams()
|
||||
? Object.keys(p.getParams()!).map((x) => ({
|
||||
name: x,
|
||||
type: (p.getParams()![x] as Shape).type,
|
||||
}))
|
||||
: {},
|
||||
}));
|
||||
|
||||
return res.send(providers);
|
||||
},
|
||||
[ratelimitMiddleware],
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import {client} from '../../lib';
|
||||
import {ratelimitMiddleware} from '../../middleware/ratelimit';
|
||||
import {matchLink} from 'tiktok-dl-core';
|
||||
import {applyRoute} from '../../middleware/apply';
|
||||
|
||||
export default applyRoute(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await ratelimitMiddleware(req, res);
|
||||
const keys = await client.keys('*');
|
||||
return res.status(200).json(keys.filter((x) => matchLink(x)));
|
||||
},
|
||||
[ratelimitMiddleware],
|
||||
);
|
||||
@@ -1,25 +0,0 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import {Footer} from '../components/Footer';
|
||||
|
||||
const FormInputComponentDynamic = dynamic(
|
||||
() => import('../components/FormInput'),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<section className="p-5">
|
||||
<h1 className="align-middle text-4xl font-sans font-medium">
|
||||
TikTok-DL{' '}
|
||||
<span className="font-normal md:break-words text-2xl">
|
||||
Download TikTok Video without watermark and free ads.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<FormInputComponentDynamic />
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
5
apps/web/postcss.config.mjs
Normal file
5
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
apps/web/src/api/api.ts
Normal file
9
apps/web/src/api/api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import ky from "ky";
|
||||
|
||||
export const apiClient = ky.extend({
|
||||
referrerPolicy: 'same-origin',
|
||||
credentials: 'same-origin',
|
||||
priority: 'high',
|
||||
throwHttpErrors: false,
|
||||
cache: 'force-cache',
|
||||
});
|
||||
8
apps/web/src/api/types.ts
Normal file
8
apps/web/src/api/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ExtractedInfo } from "tiktok-dl-core"
|
||||
|
||||
export type DownloadResponse = {
|
||||
data: ExtractedInfo & {
|
||||
provider: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
66
apps/web/src/app/api/download/route.ts
Normal file
66
apps/web/src/app/api/download/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { downloadValidator } from "@/validators/download.validator";
|
||||
import { rotateProvider } from "@/services/rotator";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getProvider } from "tiktok-dl-core";
|
||||
|
||||
const handleRequest = async <T>(json: T) => {
|
||||
try {
|
||||
const safeData = await downloadValidator.safeParseAsync(json);
|
||||
|
||||
if (safeData.error || !safeData.success) {
|
||||
return Response.json({
|
||||
errors: safeData.error,
|
||||
message: 'Validation errors',
|
||||
}, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const provider = getProvider(safeData.data.type);
|
||||
if (!provider) {
|
||||
return Response.json({
|
||||
message: 'Provider not found',
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
const params = provider?.getParams();
|
||||
if (params) {
|
||||
const safeDataParams = await params.safeParseAsync(safeData.data.params);
|
||||
if (safeDataParams.error || !safeDataParams.success) {
|
||||
return Response.json({
|
||||
errors: safeDataParams.error,
|
||||
message: 'Validation errors in params!',
|
||||
}, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await rotateProvider(provider, safeData.data.url, safeData.data.rotateOnError, safeData.data.params);
|
||||
return Response.json({
|
||||
data: result,
|
||||
});
|
||||
} catch (e) {
|
||||
return Response.json({
|
||||
message: (e as Error).message,
|
||||
}, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const allParams = Object.fromEntries(request.nextUrl.searchParams.entries());
|
||||
return handleRequest(allParams);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const json = await request.json();
|
||||
return handleRequest(json);
|
||||
} catch (e) {
|
||||
return Response.json({
|
||||
message: (e as Error).message,
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
apps/web/src/app/api/providers/route.ts
Normal file
21
apps/web/src/app/api/providers/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BaseProvider, Providers } from "tiktok-dl-core";
|
||||
|
||||
const transformProvider = (provider: BaseProvider) => {
|
||||
const params = provider.getParams();
|
||||
|
||||
return {
|
||||
name: provider.resourceName(),
|
||||
url: provider.client?.defaults.options.prefixUrl,
|
||||
maintenance: provider.maintenance,
|
||||
params: params ? Object.keys(params._zod.def.shape).map(k => ({
|
||||
name: k,
|
||||
type: params._zod.def.shape[k].def.type,
|
||||
})) : {},
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
data: Providers.map(transformProvider),
|
||||
});
|
||||
}
|
||||
9
apps/web/src/app/api/stored-links/route.ts
Normal file
9
apps/web/src/app/api/stored-links/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redisClient } from "@/lib/redis";
|
||||
import { matchLink } from "tiktok-dl-core";
|
||||
|
||||
export async function GET() {
|
||||
const keys = await redisClient.keys('*');
|
||||
return Response.json({
|
||||
data: keys.filter(key => matchLink(key)),
|
||||
});
|
||||
}
|
||||
74
apps/web/src/app/globals.css
Normal file
74
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,74 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(94.27% 0.0268 242.57);
|
||||
--secondary-background: oklch(100% 0 0);
|
||||
--foreground: oklch(0% 0 0);
|
||||
--main-foreground: oklch(0% 0 0);
|
||||
--main: oklch(66.9% 0.18368 248.8066);
|
||||
--border: oklch(0% 0 0);
|
||||
--ring: oklch(0% 0 0);
|
||||
--overlay: oklch(0% 0 0 / 0.8);
|
||||
--shadow: 4px 4px 0px 0px var(--border);
|
||||
--chart-1: #0099FF;
|
||||
--chart-2: #FF4D50;
|
||||
--chart-3: #FACC00;
|
||||
--chart-4: #05E17A;
|
||||
--chart-5: #7A83FF;
|
||||
--chart-active-dot: #000;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(27.08% 0.0336 240.69);
|
||||
--secondary-background: oklch(23.93% 0 0);
|
||||
--foreground: oklch(92.49% 0 0);
|
||||
--main-foreground: oklch(0% 0 0);
|
||||
--main: oklch(61.9% 0.16907 248.5982);
|
||||
--border: oklch(0% 0 0);
|
||||
--ring: oklch(100% 0 0);
|
||||
--shadow: 4px 4px 0px 0px var(--border);
|
||||
--chart-1: #008AE5;
|
||||
--chart-2: #FF6669;
|
||||
--chart-3: #E0B700;
|
||||
--chart-4: #04C86D;
|
||||
--chart-5: #7A83FF;
|
||||
--chart-active-dot: #fff;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-main: var(--main);
|
||||
--color-background: var(--background);
|
||||
--color-secondary-background: var(--secondary-background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-main-foreground: var(--main-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-overlay: var(--overlay);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--spacing-boxShadowX: 4px;
|
||||
--spacing-boxShadowY: 4px;
|
||||
--spacing-reverseBoxShadowX: -4px;
|
||||
--spacing-reverseBoxShadowY: -4px;
|
||||
--radius-base: 10px;
|
||||
--shadow-shadow: var(--shadow);
|
||||
--font-weight-base: 500;
|
||||
--font-weight-heading: 800;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply text-foreground font-base bg-background;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6{
|
||||
@apply font-heading;
|
||||
}
|
||||
}
|
||||
26
apps/web/src/app/layout.tsx
Normal file
26
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "TikTok-DL",
|
||||
description: "An Open-Source Project where it could download TikTok's Video without annoying ads!",
|
||||
keywords: 'tiktok-downloader, tiktokdl, tiktok, download video tiktok, tiktok no watermark',
|
||||
authors: {
|
||||
name: 'Hanif Dwy Putra S.',
|
||||
url: 'https://hanifu.id',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
488
apps/web/src/app/page.tsx
Normal file
488
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
'use client';
|
||||
|
||||
import { apiClient } from "@/api/api";
|
||||
import { DownloadResponse } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getTikTokURL } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Download, ExternalLink, Loader2 } from "lucide-react";
|
||||
import z from "zod";
|
||||
|
||||
const VideoPlayer = ({
|
||||
videoData,
|
||||
currentTime,
|
||||
isPlaying,
|
||||
onTimeUpdate,
|
||||
onPlayStateChange,
|
||||
isActive = true
|
||||
}: {
|
||||
videoData: DownloadResponse['data'];
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
isActive?: boolean;
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isVideoReady, setIsVideoReady] = useState(false);
|
||||
const [lastSyncTime, setLastSyncTime] = useState(0);
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const playPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const syncVideoTime = useCallback((targetTime: number) => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
const video = videoRef.current;
|
||||
if (video && isActive && isVideoReady) {
|
||||
const timeDiff = Math.abs(video.currentTime - targetTime);
|
||||
if (timeDiff > 0.5) {
|
||||
video.currentTime = targetTime;
|
||||
setLastSyncTime(targetTime);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}, [isActive, isVideoReady]);
|
||||
|
||||
const handlePlayPause = useCallback(async (shouldPlay: boolean) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isActive || !isVideoReady) return;
|
||||
|
||||
try {
|
||||
if (playPromiseRef.current) {
|
||||
await playPromiseRef.current.catch(() => {});
|
||||
}
|
||||
|
||||
if (shouldPlay && video.paused) {
|
||||
playPromiseRef.current = video.play();
|
||||
await playPromiseRef.current;
|
||||
} else if (!shouldPlay && !video.paused) {
|
||||
video.pause();
|
||||
playPromiseRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Playback error:', error);
|
||||
onPlayStateChange(false);
|
||||
}
|
||||
}, [isActive, isVideoReady]);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
setIsVideoReady(true);
|
||||
const video = videoRef.current;
|
||||
if (video && currentTime > 0) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime]);
|
||||
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (video && isActive && isVideoReady) {
|
||||
const currentVideoTime = video.currentTime;
|
||||
if (Math.abs(currentVideoTime - lastSyncTime) > 0.1) {
|
||||
onTimeUpdate(currentVideoTime);
|
||||
setLastSyncTime(currentVideoTime);
|
||||
}
|
||||
}
|
||||
}, [isActive, isVideoReady, lastSyncTime]);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
if (isActive) {
|
||||
onPlayStateChange(true);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (isActive) {
|
||||
onPlayStateChange(false);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
const handleEnded = useCallback(() => {
|
||||
onPlayStateChange(false);
|
||||
onTimeUpdate(0);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
console.error('Video error:', e);
|
||||
onPlayStateChange(false);
|
||||
setIsVideoReady(false);
|
||||
}, []);
|
||||
|
||||
const handleWaiting = useCallback(() => {
|
||||
// Buffering video...
|
||||
}, []);
|
||||
|
||||
const handleCanPlay = useCallback(() => {
|
||||
setIsVideoReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideoReady && isActive) {
|
||||
handlePlayPause(isPlaying);
|
||||
}
|
||||
}, [isPlaying, isVideoReady, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideoReady && isActive) {
|
||||
syncVideoTime(currentTime);
|
||||
}
|
||||
}, [currentTime, isVideoReady, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
if (!isActive) {
|
||||
if (!video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
} else if (isVideoReady && isPlaying) {
|
||||
if (Math.abs(video.currentTime - currentTime) > 0.5) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
if (video.paused) {
|
||||
handlePlayPause(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isActive, isVideoReady]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVideoReady(false);
|
||||
setLastSyncTime(0);
|
||||
if (playPromiseRef.current) {
|
||||
playPromiseRef.current.catch(() => {});
|
||||
playPromiseRef.current = null;
|
||||
}
|
||||
}, [videoData.video?.urls[0]]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
if (playPromiseRef.current) {
|
||||
playPromiseRef.current.catch(() => {});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg border">
|
||||
<h3 className="text-xl lg:text-2xl font-semibold mb-4">Video Preview</h3>
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="w-full aspect-video rounded-lg shadow-md bg-black"
|
||||
src={videoData.video?.urls[0]}
|
||||
poster={videoData.video?.thumb}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handleEnded}
|
||||
onError={handleError}
|
||||
onWaiting={handleWaiting}
|
||||
onCanPlay={handleCanPlay}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{!isVideoReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded-lg">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p><strong>Provider:</strong> {videoData.provider}</p>
|
||||
<p><strong>Available formats:</strong> {videoData.video?.urls.length}</p>
|
||||
<p><strong>Status:</strong> {isVideoReady ? 'Ready' : 'Loading...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadOptions = ({ videoData, onDownload }: {
|
||||
videoData: DownloadResponse['data'],
|
||||
onDownload: (url: string, index: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg border">
|
||||
<h3 className="text-xl lg:text-2xl font-semibold mb-4">
|
||||
Download Options
|
||||
<span className="text-base font-normal text-gray-500 ml-2">
|
||||
({videoData.video?.urls.length} available)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{videoData.video?.urls.map((url, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="neutral"
|
||||
onClick={() => onDownload(url, index)}
|
||||
className="w-full justify-start p-4 h-auto hover:bg-gray-50 transition-colors"
|
||||
size="lg"
|
||||
>
|
||||
<Download className="w-5 h-5 mr-3 flex-shrink-0" />
|
||||
<div className="text-left flex-1">
|
||||
<div className="font-medium text-base">
|
||||
Quality {index + 1} {index === 0 ? "(HD)" : index === 1 ? "(Standard)" : "(Alternative)"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Click to download MP4 file
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="pt-3 border-t mt-4">
|
||||
<Button
|
||||
variant="noShadow"
|
||||
onClick={() => window.open(videoData.video?.urls[0], '_blank')}
|
||||
className="w-full justify-start p-4 h-auto hover:bg-gray-50 transition-colors"
|
||||
size="lg"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5 mr-3 flex-shrink-0" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">View in new tab</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Open video directly in browser
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [videoData, setVideoData] = useState<DownloadResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [videoCurrentTime, setVideoCurrentTime] = useState(0);
|
||||
const [videoIsPlaying, setVideoIsPlaying] = useState(false);
|
||||
|
||||
const [activePlayer, setActivePlayer] = useState<'mobile' | 'desktop'>('desktop');
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setActivePlayer(window.innerWidth >= 1024 ? 'desktop' : 'mobile');
|
||||
};
|
||||
|
||||
checkScreenSize();
|
||||
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
return () => window.removeEventListener('resize', checkScreenSize);
|
||||
}, []);
|
||||
|
||||
const formSchema = z.object({
|
||||
url: z.url().refine((val) => getTikTokURL(val), {
|
||||
error: 'Invalid VT Tiktok URL',
|
||||
}),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
url: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('./api/download', {
|
||||
json: {
|
||||
url: values.url,
|
||||
},
|
||||
}).json<DownloadResponse>();
|
||||
|
||||
if (response.message) {
|
||||
form.setError('url', {
|
||||
message: response.message,
|
||||
});
|
||||
setVideoData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoData(response);
|
||||
// Reset video state untuk video baru
|
||||
setVideoCurrentTime(0);
|
||||
setVideoIsPlaying(false);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
form.setError('url', {
|
||||
message: 'Failed to process video. Please try again.',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (url: string, index: number) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `tiktok_video_${index + 1}.mp4`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setVideoData(null);
|
||||
setVideoCurrentTime(0);
|
||||
setVideoIsPlaying(false);
|
||||
form.reset();
|
||||
};
|
||||
|
||||
const handleTimeUpdate = useCallback((time: number) => {
|
||||
setVideoCurrentTime(time);
|
||||
}, []);
|
||||
|
||||
const handlePlayStateChange = useCallback((playing: boolean) => {
|
||||
setVideoIsPlaying(playing);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Mobile/Tablet Layout - Stack Vertically */}
|
||||
<div className="lg:hidden p-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-4xl font-sans text-black mb-6 text-center">
|
||||
Download TikTok Videos!
|
||||
</h1>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mb-6">
|
||||
<FormField control={form.control} name="url" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xl">VT URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)"
|
||||
className="text-lg py-3"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please provide the tiktok video URL to download
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={isLoading} className="flex-1">
|
||||
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{isLoading ? "Processing..." : "Download"}
|
||||
</Button>
|
||||
{videoData && (
|
||||
<Button type="button" variant="neutral" onClick={resetForm}>
|
||||
New Video
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* Video Results - Stack Vertically on Mobile */}
|
||||
{videoData?.data && (
|
||||
<div className="space-y-6">
|
||||
<VideoPlayer
|
||||
videoData={videoData.data}
|
||||
currentTime={videoCurrentTime}
|
||||
isPlaying={videoIsPlaying}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlayStateChange={handlePlayStateChange}
|
||||
isActive={activePlayer === 'mobile'}
|
||||
/>
|
||||
<DownloadOptions videoData={videoData.data} onDownload={handleDownload} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Screen Hero Layout - Side by Side */}
|
||||
<div className="hidden lg:flex lg:min-h-screen lg:items-center lg:justify-center">
|
||||
<div className="w-full max-w-7xl mx-auto px-6">
|
||||
<div className="flex items-start gap-12">
|
||||
{/* Hero Title */}
|
||||
<div className="flex-shrink-0">
|
||||
<h1 className="text-6xl font-sans text-black leading-tight">
|
||||
Download<br />
|
||||
TikTok<br />
|
||||
Videos!
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Form and Video Section */}
|
||||
<div className="flex-1 max-w-5xl">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mb-8">
|
||||
<FormField control={form.control} name="url" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xl">VT URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)"
|
||||
className="text-lg py-3"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-base">
|
||||
Please provide the tiktok video URL to download
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" size="lg" disabled={isLoading} className="flex-1 max-w-xs">
|
||||
{isLoading && <Loader2 className="w-5 h-5 mr-2 animate-spin" />}
|
||||
{isLoading ? "Processing..." : "Download"}
|
||||
</Button>
|
||||
{videoData && (
|
||||
<Button type="button" variant="neutral" size="lg" onClick={resetForm}>
|
||||
New Video
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* Video Results - Side by Side on Desktop */}
|
||||
{videoData?.data && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
|
||||
<VideoPlayer
|
||||
videoData={videoData.data}
|
||||
currentTime={videoCurrentTime}
|
||||
isPlaying={videoIsPlaying}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlayStateChange={handlePlayStateChange}
|
||||
isActive={activePlayer === 'desktop'}
|
||||
/>
|
||||
<DownloadOptions videoData={videoData.data} onDownload={handleDownload} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/components/ui/button.tsx
Normal file
56
apps/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-base text-sm font-base ring-offset-white transition-all gap-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"text-main-foreground bg-main border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none",
|
||||
noShadow: "text-main-foreground bg-main border-2 border-border",
|
||||
neutral:
|
||||
"bg-secondary-background text-foreground border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none",
|
||||
reverse:
|
||||
"text-main-foreground bg-main border-2 border-border hover:translate-x-reverseBoxShadowX hover:translate-y-reverseBoxShadowY hover:shadow-shadow",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3",
|
||||
lg: "h-11 px-8",
|
||||
icon: "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
167
apps/web/src/components/ui/form.tsx
Normal file
167
apps/web/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
)
|
||||
|
||||
function FormField<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({ ...props }: ControllerProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("font-heading", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm font-base text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-base text-red-500", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
19
apps/web/src/components/ui/input.tsx
Normal file
19
apps/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-base border-2 border-border bg-secondary-background selection:bg-main selection:text-main-foreground px-3 py-2 text-sm font-base text-foreground file:border-0 file:bg-transparent file:text-sm file:font-heading placeholder:text-foreground/50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
25
apps/web/src/components/ui/label.tsx
Normal file
25
apps/web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"text-sm font-heading leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -23,4 +23,4 @@ export const providerCache = 3600;
|
||||
* How much rotate retries is allowed?
|
||||
* Default: 5x
|
||||
*/
|
||||
export const maxRotateCount = 3;
|
||||
export const maxRotateCount = 3;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export const client = new Redis(
|
||||
export const redisClient = new Redis(
|
||||
process.env.REDIS_URL ?? 'redis://:@localhost:6379',
|
||||
);
|
||||
@@ -1,3 +1,10 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/** Get TikTok Video URL.
|
||||
* @param {string} url Video
|
||||
* @return {string}
|
||||
@@ -14,4 +21,4 @@ export function getTikTokURL(url: string): string | undefined {
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {maxRotateCount, providerCache} from '../config';
|
||||
import {maxRotateCount, providerCache} from '@/config/config';
|
||||
import {BaseProvider, ExtractedInfo, getRandomProvider} from 'tiktok-dl-core';
|
||||
import {client as redisClient} from './redis';
|
||||
import {redisClient} from '@/lib/redis';
|
||||
|
||||
/**
|
||||
* Rotate provider.
|
||||
@@ -87,4 +87,4 @@ export const rotateProvider = async (
|
||||
} else {
|
||||
return JSON.parse(cachedData);
|
||||
}
|
||||
};
|
||||
};
|
||||
13
apps/web/src/validators/download.validator.ts
Normal file
13
apps/web/src/validators/download.validator.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getTikTokURL } from "@/lib/utils";
|
||||
import { Providers } from "tiktok-dl-core";
|
||||
import z from "zod";
|
||||
|
||||
export const downloadValidator = z.object({
|
||||
url: z.url().refine((url) => getTikTokURL(url), {
|
||||
error: 'Invalid VT URL',
|
||||
}),
|
||||
type: z.enum(Providers.map(provider => provider.resourceName()).concat('random')).default('random'),
|
||||
rotateOnError: z.boolean().default(true),
|
||||
nocache: z.boolean().default(false),
|
||||
params: z.object().optional().default({}),
|
||||
});
|
||||
@@ -1,37 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"outDir": "dist",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"removeComments": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"incremental": true,
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import {defineConfig} from 'windicss/helpers';
|
||||
|
||||
export default defineConfig({
|
||||
extract: {
|
||||
include: ['**/*.{jsx,tsx,css}'],
|
||||
exclude: ['node_modules', '.git', '.next'],
|
||||
},
|
||||
});
|
||||
@@ -9,11 +9,12 @@ services:
|
||||
- app-network
|
||||
depends_on:
|
||||
- redis_db
|
||||
image: "hansputera/tiktok-dl:latest"
|
||||
redis_db:
|
||||
image: "redis:alpine"
|
||||
networks:
|
||||
- app-network
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
@@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"husky": "8.0.3",
|
||||
"husky": "9.1.7",
|
||||
"turbo": "1.13.4"
|
||||
},
|
||||
"workspaces": [
|
||||
|
||||
@@ -8,15 +8,5 @@
|
||||
"useragents.js",
|
||||
"useragents.d.ts",
|
||||
"prettier.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"eslint-config-google": "0.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "5.33.0",
|
||||
"@typescript-eslint/parser": "5.33.0",
|
||||
"eslint": "8.21.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"prettier": "2.7.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
"description": "TikTok-DL Project Core",
|
||||
"dependencies": {
|
||||
"got": "^12.6.1",
|
||||
"ow": "0.28.1",
|
||||
"vm2": "^3.9.19"
|
||||
"vm2": "^3.9.19",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint \"+(src)/**/*.ts\" --fix",
|
||||
"format": "prettier . --write"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "5.33.0",
|
||||
"@typescript-eslint/parser": "^8.44.0",
|
||||
"eslint": "8.21.0",
|
||||
"prettier": "2.7.1"
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Got} from 'got';
|
||||
import type {Shape} from 'ow';
|
||||
import {ZodObject} from 'zod';
|
||||
|
||||
export interface ExtractedInfo {
|
||||
error?: string;
|
||||
@@ -10,6 +10,7 @@ export interface ExtractedInfo {
|
||||
title?: string;
|
||||
duration?: string;
|
||||
};
|
||||
slides?: string[];
|
||||
music?: {
|
||||
url: string;
|
||||
title?: string;
|
||||
@@ -43,12 +44,12 @@ export interface MaintenanceProvider {
|
||||
*/
|
||||
export abstract class BaseProvider {
|
||||
abstract client?: Got;
|
||||
abstract getParams(): Shape | undefined;
|
||||
abstract getParams(): ZodObject | undefined;
|
||||
abstract maintenance?: MaintenanceProvider;
|
||||
abstract resourceName(): string;
|
||||
abstract fetch(
|
||||
url: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<ExtractedInfo>;
|
||||
abstract extract(html: string): ExtractedInfo;
|
||||
abstract extract(html: string): ExtractedInfo | Promise<ExtractedInfo>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {BaseProvider, ExtractedInfo} from './base';
|
||||
import {getFetch} from '../fetch';
|
||||
import {matchCustomDownload, matchLink, runObfuscatedScript} from './utils';
|
||||
import type {Shape} from 'ow';
|
||||
import {ZodObject} from 'zod';
|
||||
|
||||
/**
|
||||
* @class DownTikProvider
|
||||
@@ -99,10 +99,10 @@ export class DownTikProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get zod params.
|
||||
* @return {ZodObject | undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): ZodObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
95
packages/core/src/fasttokSaveProvider.ts
Normal file
95
packages/core/src/fasttokSaveProvider.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {ZodObject} from 'zod';
|
||||
import {getFetch} from '../fetch';
|
||||
import {BaseProvider, ExtractedInfo, MaintenanceProvider} from './base';
|
||||
|
||||
/**
|
||||
* @class FasttokSaveProvider
|
||||
*/
|
||||
export class FasttokSaveProvider extends BaseProvider {
|
||||
/**
|
||||
* Get provider resource name
|
||||
* @return {string}
|
||||
*/
|
||||
public resourceName(): string {
|
||||
return 'fasttoksave';
|
||||
}
|
||||
|
||||
public client = getFetch('https://www.fasttoksave.com/');
|
||||
public maintenance?: MaintenanceProvider | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Fetch tiktok video resource
|
||||
* @param {string} url TikTok URL
|
||||
* @return {Promise<ExtractedInfo>}
|
||||
*/
|
||||
async fetch(url: string): Promise<ExtractedInfo> {
|
||||
const response = await this.client
|
||||
.post('./en/wp-json/tiktok-downloader/v1/fetch', {
|
||||
json: {
|
||||
url,
|
||||
},
|
||||
})
|
||||
.json<{
|
||||
code: number;
|
||||
msg: string;
|
||||
data?: {
|
||||
author: {
|
||||
nickname: string;
|
||||
unique_id: string;
|
||||
};
|
||||
comment_count: number;
|
||||
play_count: number;
|
||||
cover: string;
|
||||
play: string;
|
||||
music_info: {
|
||||
author: string;
|
||||
cover: string;
|
||||
title: string;
|
||||
};
|
||||
wmplay: string;
|
||||
title: string;
|
||||
duration: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
if (response.code === -1 || !response.data) {
|
||||
return {
|
||||
error: 'Video not found.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
video: {
|
||||
urls: [response.data.play, response.data.wmplay],
|
||||
thumb: response.data.cover,
|
||||
duration: (response.data.duration * 1000).toString(),
|
||||
},
|
||||
music: {
|
||||
url: '',
|
||||
author: response.data.music_info.author,
|
||||
cover: response.data.music_info.cover,
|
||||
title: response.data.music_info.title,
|
||||
},
|
||||
commentsCount: response.data.comment_count,
|
||||
playsCount: response.data.play_count,
|
||||
caption: response.data.title,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract contents from HTML raw
|
||||
* @param {string} html HTML Raw
|
||||
* @return {{}}
|
||||
*/
|
||||
public extract(html: string): ExtractedInfo | Promise<ExtractedInfo> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get params
|
||||
* @return {undefined}
|
||||
*/
|
||||
public getParams(): ZodObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {DownTikProvider} from './downTikProvider';
|
||||
// import {DDDTikProvider} from './dddTikProvider';
|
||||
// import {DownloadOne} from './downloaderOneProvider';
|
||||
import {NativeProvider} from './nativeProvider';
|
||||
import {FasttokSaveProvider} from './fasttokSaveProvider';
|
||||
// import {GetVidTikProvider} from './getVidTikProvider';
|
||||
|
||||
export const Providers: BaseProvider[] = [
|
||||
@@ -28,10 +29,17 @@ export const Providers: BaseProvider[] = [
|
||||
// new DownloadOne(),
|
||||
new NativeProvider(),
|
||||
// new GetVidTikProvider(),
|
||||
new FasttokSaveProvider(),
|
||||
];
|
||||
|
||||
export const getRandomProvider = () =>
|
||||
Providers[Math.floor(Math.random() * Providers.length)];
|
||||
export const getRandomProvider = (): BaseProvider => {
|
||||
const provider = Providers[Math.floor(Math.random() * Providers.length)];
|
||||
while (provider.resourceName() === 'native') {
|
||||
return getRandomProvider();
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const getProvider = (name: string) =>
|
||||
name.toLowerCase() !== 'random'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import got from 'got';
|
||||
import {getFetch} from '../fetch';
|
||||
import {BaseProvider, ExtractedInfo} from './base';
|
||||
import type {Shape} from 'ow';
|
||||
import { matchLink } from './utils';
|
||||
import {extractMusicalyDownImages, matchLink} from './utils';
|
||||
|
||||
/**
|
||||
* @class MusicalyDown
|
||||
@@ -29,7 +29,8 @@ export class MusicalyDown extends BaseProvider {
|
||||
Accept: '*/*',
|
||||
Referer: this.client.defaults.options.prefixUrl.toString(),
|
||||
Origin: this.client.defaults.options.prefixUrl.toString(),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,36 +49,92 @@ export class MusicalyDown extends BaseProvider {
|
||||
Accept: '*/*',
|
||||
Referer: this.client.defaults.options.prefixUrl.toString(),
|
||||
Origin: this.client.defaults.options.prefixUrl.toString(),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
return this.extract(response.body);
|
||||
return this.extract(
|
||||
JSON.stringify({
|
||||
html: response.body,
|
||||
headers: {
|
||||
Cookie: res.headers['set-cookie']?.toString(),
|
||||
Accept: '*/*',
|
||||
Referer: this.client.defaults.options.prefixUrl.toString(),
|
||||
Origin: this.client.defaults.options.prefixUrl.toString(),
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} html - Raw HTML
|
||||
* @param {string} body - Raw HTML
|
||||
* @return {ExtractedInfo}
|
||||
*/
|
||||
public extract(html: string): ExtractedInfo {
|
||||
public async extract(body: string): Promise<ExtractedInfo> {
|
||||
const {html, headers} = JSON.parse(body);
|
||||
|
||||
const urls = matchLink(html);
|
||||
const matchedUrls = urls?.filter((url) => /muscdn/gi.test(url)) ?? [];
|
||||
const musicalyDownUrls = extractMusicalyDownImages(html);
|
||||
|
||||
const matchedUrls = urls?.filter(url => /muscdn/gi.test(url)) ?? [];
|
||||
const isSlide = musicalyDownUrls.length > 2;
|
||||
const nonImages = matchedUrls.filter((u) => u.includes('images'));
|
||||
const image = matchedUrls.find((u) => u.includes('images'));
|
||||
|
||||
return {
|
||||
const info: ExtractedInfo = {
|
||||
video: {
|
||||
urls: matchedUrls.filter(murl => !murl.includes('images')),
|
||||
thumb: matchedUrls.find(murl => murl.includes('images')),
|
||||
urls: !isSlide ? nonImages : [],
|
||||
thumb: image,
|
||||
},
|
||||
slides: isSlide ? musicalyDownUrls.slice(1) : undefined,
|
||||
author: !isSlide
|
||||
? {
|
||||
thumb: musicalyDownUrls[0],
|
||||
}
|
||||
: undefined,
|
||||
music: !isSlide
|
||||
? {
|
||||
url: nonImages[0],
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (isSlide) {
|
||||
const tokenRenderRegex = /data:\s*"([^"]+)"/;
|
||||
const token = tokenRenderRegex.exec(html)?.[1];
|
||||
|
||||
const response = await got
|
||||
.post('https://render.muscdn.app/slider', {
|
||||
form: {
|
||||
data: token,
|
||||
},
|
||||
headers,
|
||||
})
|
||||
.json<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
}>();
|
||||
|
||||
if (response.success && response.url?.length) {
|
||||
info.video = {
|
||||
...info.video,
|
||||
urls: [response.url],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get Params
|
||||
* @return {undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {BaseProvider, ExtractedInfo} from './base';
|
||||
import {getFetch} from '../fetch';
|
||||
import {matchTikTokData} from './utils';
|
||||
import ow, {Shape} from 'ow';
|
||||
import z from 'zod';
|
||||
|
||||
/**
|
||||
* @class NativeProvider
|
||||
@@ -93,14 +93,12 @@ export class NativeProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Return zod object for validation
|
||||
* @return {z.ZodObject}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
return {
|
||||
'user-agent': ow.string.not.empty
|
||||
.minLength(5)
|
||||
.message('Invalid user-agent'),
|
||||
};
|
||||
public getParams(): z.ZodObject {
|
||||
return z.object({
|
||||
'user-agent': z.string().min(5),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import got from 'got';
|
||||
import {getFetch} from '../fetch';
|
||||
import {BaseProvider, ExtractedInfo, MaintenanceProvider} from './base';
|
||||
import {deObfuscateSaveFromScript} from './utils';
|
||||
import type {Shape} from 'ow';
|
||||
|
||||
/**
|
||||
* @class saveFromProvider
|
||||
@@ -18,8 +18,8 @@ export class SaveFromProvider extends BaseProvider {
|
||||
public client = getFetch('https://worker.savefrom.net');
|
||||
|
||||
public maintenance: MaintenanceProvider = {
|
||||
reason: 'Need advance investigate to Reverse Engineering the response scripts.'
|
||||
}
|
||||
reason: 'Need further investigation.',
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -27,27 +27,34 @@ export class SaveFromProvider extends BaseProvider {
|
||||
* @return {Promise<ExtractedInfo>}
|
||||
*/
|
||||
public async fetch(url: string): Promise<ExtractedInfo> {
|
||||
const responseFirst = await got.get('https://en1.savefrom.net');
|
||||
const response = await this.client.post('./savefrom.php', {
|
||||
form: {
|
||||
sf_url: url,
|
||||
sf_submit: '',
|
||||
new: '2',
|
||||
lang: 'id',
|
||||
lang: 'en',
|
||||
country: 'id',
|
||||
os: 'Ubuntu',
|
||||
browser: 'Firefox',
|
||||
channel: 'Downloader',
|
||||
os: 'Linux',
|
||||
browser: 'Brave',
|
||||
channel: 'main',
|
||||
'sf-nomad': '1',
|
||||
url,
|
||||
ts: Date.now(),
|
||||
_ts: Date.now(),
|
||||
_tsc: 0,
|
||||
},
|
||||
headers: {
|
||||
Origin: 'https://id.savefrom.net',
|
||||
Referer: 'https://id.savefrom.net',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
Origin: 'https://en1.savefrom.net',
|
||||
Referer: 'https://en1.savefrom.net',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
Cookies: responseFirst.headers['set-cookie']?.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(response.body);
|
||||
|
||||
return this.extract(response.body);
|
||||
}
|
||||
|
||||
@@ -76,10 +83,10 @@ export class SaveFromProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get params
|
||||
* @return {undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {BaseProvider, ExtractedInfo} from './base';
|
||||
import {getFetch} from '../fetch';
|
||||
import {ZodObject} from 'zod';
|
||||
// import {matchLink, runObfuscatedReplaceEvalScript} from './utils';
|
||||
import type {Shape} from 'ow';
|
||||
|
||||
/**
|
||||
* @class SaveTikProvider
|
||||
@@ -32,8 +32,7 @@ export class SaveTikProvider extends BaseProvider {
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
||||
if (response.statusCode === 400)
|
||||
{
|
||||
if (response.statusCode === 400) {
|
||||
return {
|
||||
error: 'Video not found',
|
||||
};
|
||||
@@ -72,13 +71,11 @@ export class SaveTikProvider extends BaseProvider {
|
||||
|
||||
return {
|
||||
video: {
|
||||
urls: [
|
||||
json.downloadUrl,
|
||||
json.hdDownloadUrl,
|
||||
],
|
||||
urls: [json.downloadUrl, json.hdDownloadUrl],
|
||||
title: json.postinfo.media_title,
|
||||
duration: json.duration.toString(),
|
||||
},
|
||||
slides: json.items ?? undefined,
|
||||
author: {
|
||||
username: json.postinfo.unique_id,
|
||||
id: json.postinfo.uid,
|
||||
@@ -88,19 +85,19 @@ export class SaveTikProvider extends BaseProvider {
|
||||
sharesCount: json.stats.shareCount,
|
||||
playsCount: json.stats.playCount,
|
||||
commentsCount: json.stats.commentCount,
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
error: 'Video not found',
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get zod params
|
||||
* @return {ZodObject | undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): ZodObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {ZodObject} from 'zod';
|
||||
import {getFetch} from '../fetch';
|
||||
import {BaseProvider, ExtractedInfo, MaintenanceProvider} from './base';
|
||||
import {matchLink, runObfuscatedReplaceEvalScript} from './utils';
|
||||
import type {Shape} from 'ow';
|
||||
|
||||
/**
|
||||
* @class SnaptikProvider
|
||||
@@ -16,7 +16,7 @@ export class SnaptikProvider extends BaseProvider {
|
||||
return 'snaptik';
|
||||
}
|
||||
|
||||
public maintenance?: MaintenanceProvider | undefined = undefined
|
||||
public maintenance?: MaintenanceProvider | undefined = undefined;
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -66,10 +66,10 @@ export class SnaptikProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get zod params
|
||||
* @return {ZodObject | undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): ZodObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BaseProvider, ExtractedInfo} from './base';
|
||||
import {getFetch} from '../fetch';
|
||||
import type {Shape} from 'ow';
|
||||
import {ZodObject} from 'zod';
|
||||
|
||||
/**
|
||||
* @class TikDownProvider
|
||||
@@ -32,8 +32,7 @@ export class TikDownProvider extends BaseProvider {
|
||||
});
|
||||
|
||||
const body = response.body;
|
||||
if (/please double/gi.test(body))
|
||||
{
|
||||
if (/please double/gi.test(body)) {
|
||||
return {
|
||||
error: 'Video not found',
|
||||
};
|
||||
@@ -50,9 +49,9 @@ export class TikDownProvider extends BaseProvider {
|
||||
if (!responseVideo.body.length) {
|
||||
return {
|
||||
error: 'Couldnt find downloaded URL',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return this.extract(responseVideo.body);
|
||||
}
|
||||
|
||||
@@ -63,16 +62,21 @@ export class TikDownProvider extends BaseProvider {
|
||||
extract(html: string): ExtractedInfo {
|
||||
return {
|
||||
video: {
|
||||
urls: [new URL(`./${html}`, this.client.defaults.options.prefixUrl.toString()).href],
|
||||
}
|
||||
}
|
||||
urls: [
|
||||
new URL(
|
||||
`./${html}`,
|
||||
this.client.defaults.options.prefixUrl.toString(),
|
||||
).href,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get zod params
|
||||
* @return {ZodObject | undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): ZodObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {ZodObject} from 'zod';
|
||||
import {getFetch} from '../fetch';
|
||||
import {BaseProvider, ExtractedInfo} from './base';
|
||||
import {deObfuscate, matchLink} from './utils';
|
||||
import type {Shape} from 'ow';
|
||||
|
||||
/**
|
||||
* @class TikmateProvider
|
||||
@@ -28,8 +28,9 @@ export class TikmateProvider extends BaseProvider {
|
||||
|
||||
const response = await this.client('./', {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
}
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
const matchs = response.body.match(
|
||||
@@ -53,7 +54,8 @@ export class TikmateProvider extends BaseProvider {
|
||||
Origin: this.client.defaults.options.prefixUrl.toString(),
|
||||
Referer: this.client.defaults.options.prefixUrl.toString(),
|
||||
Cookie: cookies,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,10 +83,10 @@ export class TikmateProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get zod params
|
||||
* @return {ZodObject | undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): ZodObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {ZodObject} from 'zod';
|
||||
import {getFetch} from '../fetch';
|
||||
import {BaseProvider, ExtractedInfo} from './base';
|
||||
import {matchLink} from './utils';
|
||||
import type {Shape} from 'ow';
|
||||
|
||||
/**
|
||||
* @class TTDownloader
|
||||
@@ -53,18 +53,25 @@ export class TTDownloader extends BaseProvider {
|
||||
extract(html: string): ExtractedInfo {
|
||||
const urls = matchLink(html);
|
||||
urls?.pop(); // remove 'https://snaptik.fans'
|
||||
|
||||
const musicUrl = urls?.find((u) => /mp3/gi.test(u));
|
||||
return {
|
||||
video: {
|
||||
urls: (urls as string[]) ?? [],
|
||||
urls: urls?.filter((u) => u !== musicUrl) ?? [],
|
||||
},
|
||||
music: musicUrl
|
||||
? {
|
||||
url: musicUrl,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ow.Shape params.
|
||||
* @return {Shape | undefined}
|
||||
* Get zod params
|
||||
* @return {ZodObject | undefined}
|
||||
*/
|
||||
public getParams(): Shape | undefined {
|
||||
public getParams(): ZodObject | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ export const matchTikTokData = (html: string): string => {
|
||||
|
||||
export const runObfuscatedReplaceEvalScript = (jsCode: string): string => {
|
||||
return runObfuscatedScript(jsCode.replace('eval', 'module.exports = '));
|
||||
}
|
||||
};
|
||||
|
||||
export const extractMusicalyDownImages = (html: string): string[] => {
|
||||
const regex = /<img[^>]+src="(https[^"]+)"/gi;
|
||||
return [...html.matchAll(regex)].map((m) => m[1]);
|
||||
};
|
||||
|
||||
export const runObfuscatedScript = (jsCode: string): string => {
|
||||
const transformed = jsCode
|
||||
@@ -87,9 +92,8 @@ export const matchCustomDownload = (
|
||||
|
||||
export const deObfuscateSaveFromScript = (scriptContent: string): string => {
|
||||
const safeScript =
|
||||
'let result;' +
|
||||
scriptContent
|
||||
.replace(/\/\*js\-response\*\//gi, '');
|
||||
'let result = ' + scriptContent.replace(/\/\*js\-response\*\//gi, '');
|
||||
|
||||
const vm = new NodeVM({
|
||||
compiler: 'javascript',
|
||||
console: 'inherit',
|
||||
|
||||
Reference in New Issue
Block a user