mirror of
https://github.com/khairul169/home-lab.git
synced 2025-04-28 16:49:36 +07:00
feat: add yt mp3 downloader
This commit is contained in:
parent
93cd056cd8
commit
07f1598795
@ -1,5 +1,6 @@
|
||||
# Config
|
||||
JWT_SECRET=
|
||||
TMP_DIR=
|
||||
|
||||
AUTH_USERNAME=
|
||||
AUTH_PASSWORD=
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Mime } from "mime/lite";
|
||||
import standardTypes from "mime/types/standard.js";
|
||||
import otherTypes from "mime/types/other.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const formatBytes = (bytes: number) => {
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
@ -29,3 +30,36 @@ mime.define(
|
||||
export const getMimeType = (path: string) => {
|
||||
return mime.getType(path) || "application/octet-stream";
|
||||
};
|
||||
|
||||
export const slugify = (text: string, lowerCase = true) => {
|
||||
let str = text.replace(/^\s+|\s+$/g, "");
|
||||
|
||||
// Make the string lowercase
|
||||
if (lowerCase) {
|
||||
str = str.toLowerCase();
|
||||
}
|
||||
|
||||
// Remove accents, swap ñ for n, etc
|
||||
const from =
|
||||
"ÁÄÂÀÃÅČÇĆĎÉĚËÈÊẼĔȆÍÌÎÏŇÑÓÖÒÔÕØŘŔŠŤÚŮÜÙÛÝŸŽáäâàãåčçćďéěëèêẽĕȇíìîïňñóöòôõøðřŕšťúůüùûýÿžþÞĐđ߯a·/_,:;";
|
||||
const to =
|
||||
"AAAAAACCCDEEEEEEEEIIIINNOOOOOORRSTUUUUUYYZaaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------";
|
||||
for (let i = 0, l = from.length; i < l; i += 1) {
|
||||
str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i));
|
||||
}
|
||||
|
||||
// Remove invalid chars
|
||||
str = str
|
||||
// .replace(/[^A-Za-z0-9 -]/g, '')
|
||||
.replace(/[\\/:"*?<>|]/g, "")
|
||||
// Collapse whitespace and replace by -
|
||||
.replace(/\s+/g, "-")
|
||||
// Collapse dashes
|
||||
.replace(/-+/g, "-");
|
||||
|
||||
if (!str.length) {
|
||||
str = nanoid();
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
219
backend/lib/yt2mp3.ts
Normal file
219
backend/lib/yt2mp3.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import YTDL from "ytdl-core";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import fs from "node:fs";
|
||||
import fetch from "node-fetch";
|
||||
import path from "node:path";
|
||||
import { slugify } from "./utils";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const TMP_DIR = process.env.TMP_DIR || "/tmp/homelab";
|
||||
|
||||
const ytDownload = (url: string): Promise<YTDownloadFnResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dl = YTDL(url, {
|
||||
quality: "highestaudio",
|
||||
filter: (i) => i.hasAudio,
|
||||
});
|
||||
|
||||
dl.on("info", async (info: YTDL.videoInfo, format: YTDL.videoFormat) => {
|
||||
const type = format.container;
|
||||
|
||||
// Download thumbnail
|
||||
const thumbnail = getBestThumbnail(info.videoDetails.thumbnails);
|
||||
const uid = nanoid();
|
||||
const thumbDest = `${TMP_DIR}/${uid}.jpeg`;
|
||||
const albumCover = await downloadFile(thumbnail.url, thumbDest);
|
||||
|
||||
const filename = `${uid}.${type}`;
|
||||
const tmpSrc = `${TMP_DIR}/${filename}`;
|
||||
|
||||
const onClean = () => {
|
||||
fs.unlinkSync(tmpSrc);
|
||||
if (albumCover) fs.unlinkSync(albumCover);
|
||||
};
|
||||
|
||||
dl.pipe(fs.createWriteStream(tmpSrc));
|
||||
dl.on("end", () => {
|
||||
resolve({ info, path: tmpSrc, clean: onClean, album: albumCover });
|
||||
});
|
||||
});
|
||||
|
||||
dl.on("progress", (_, progress: number, total: number) => {
|
||||
console.log("info", `${Math.round((progress / total) * 100)}%`);
|
||||
});
|
||||
|
||||
dl.on("error", reject);
|
||||
});
|
||||
};
|
||||
|
||||
type YTDownloadFnResult = {
|
||||
info: YTDL.videoInfo;
|
||||
path: string;
|
||||
album: string | null;
|
||||
clean: () => void;
|
||||
};
|
||||
|
||||
const convertToAudio = (
|
||||
src: string,
|
||||
output: string,
|
||||
format = "mp3"
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cmd = ffmpeg(src)
|
||||
.output(output)
|
||||
.format(format)
|
||||
.on("end", () => {
|
||||
resolve(output);
|
||||
});
|
||||
|
||||
cmd.on("error", (err, stdout, stderr) => {
|
||||
console.log(`Cannot process video: ${err.message}`);
|
||||
console.log(stdout, stderr);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// cmd.on("start", (cmdline) => {
|
||||
// console.log("cmdline", cmdline);
|
||||
// });
|
||||
|
||||
cmd.run();
|
||||
});
|
||||
};
|
||||
|
||||
const embedMetadata = (
|
||||
src: string,
|
||||
output: string,
|
||||
title: string,
|
||||
album: string | null
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cmd = ffmpeg(src)
|
||||
.output(output)
|
||||
.outputOptions(
|
||||
// '-c:a libmp3lame',
|
||||
"-id3v2_version",
|
||||
"3",
|
||||
"-write_id3v1",
|
||||
"1",
|
||||
"-metadata",
|
||||
`title=${title}`
|
||||
// '-metadata',
|
||||
// 'comment="Cover (front)"'
|
||||
)
|
||||
.on("end", () => {
|
||||
resolve(output);
|
||||
});
|
||||
|
||||
if (album) {
|
||||
cmd.addInput(album);
|
||||
cmd.addOutputOptions(["-map 0:0", "-map 1:0"]);
|
||||
}
|
||||
|
||||
cmd.on("error", (err, stdout, stderr) => {
|
||||
console.log(`Cannot process video: ${err.message}`);
|
||||
console.log(stdout, stderr);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
cmd.on("start", (cmdline) => {
|
||||
console.log("cmdline", cmdline);
|
||||
});
|
||||
|
||||
cmd.run();
|
||||
});
|
||||
};
|
||||
|
||||
type YT2MP3Options = {
|
||||
filename?: string;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
const yt2mp3 = async (url: string, outDir: string, options?: YT2MP3Options) => {
|
||||
let srcFile: YTDownloadFnResult | null = null;
|
||||
let tmpAudio: string | undefined;
|
||||
let result: string | undefined;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(TMP_DIR)) {
|
||||
fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
srcFile = await ytDownload(url);
|
||||
const { info } = srcFile;
|
||||
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const { title } = info.videoDetails;
|
||||
const format = options?.format || "mp3";
|
||||
const tmpAudioPath = path.join(TMP_DIR, `${Date.now()}.${format}`);
|
||||
tmpAudio = await convertToAudio(srcFile.path, tmpAudioPath, format);
|
||||
|
||||
const audioPath = path.join(
|
||||
outDir,
|
||||
options?.filename || `${slugify(title, false)}.${format}`
|
||||
);
|
||||
await embedMetadata(tmpAudio, audioPath, title, srcFile.album);
|
||||
|
||||
result = audioPath;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
// clean tmp files
|
||||
if (srcFile) {
|
||||
srcFile.clean();
|
||||
}
|
||||
|
||||
if (tmpAudio) {
|
||||
fs.unlinkSync(tmpAudio);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getVideoInfo = async (url: string) => {
|
||||
const info = await YTDL.getBasicInfo(url);
|
||||
const { videoDetails: details } = info;
|
||||
|
||||
return {
|
||||
url: details.video_url,
|
||||
title: details.title,
|
||||
length: details.lengthSeconds,
|
||||
thumb: getBestThumbnail(details.thumbnails),
|
||||
};
|
||||
};
|
||||
|
||||
function getBestThumbnail(thumbnails: YTDL.thumbnail[]) {
|
||||
let thumbnail = thumbnails[0] || null;
|
||||
|
||||
thumbnails.forEach((thumb) => {
|
||||
if (!thumbnail) {
|
||||
thumbnail = thumb;
|
||||
return;
|
||||
}
|
||||
if (thumb.width > thumbnail.width || thumb.height > thumbnail.height) {
|
||||
thumbnail = thumb;
|
||||
}
|
||||
});
|
||||
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, out: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
|
||||
res.body.pipe(fs.createWriteStream(out));
|
||||
return out;
|
||||
} catch (err) {
|
||||
console.log("err download file", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default yt2mp3;
|
@ -7,6 +7,8 @@
|
||||
"start": "tsx index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-queue": "^3.8.6",
|
||||
"@types/fluent-ffmpeg": "^2.1.24",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/node": "^20.11.28",
|
||||
"@types/ws": "^8.5.10",
|
||||
@ -18,14 +20,18 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.8.2",
|
||||
"@hono/zod-validator": "^0.2.0",
|
||||
"better-queue": "^3.8.12",
|
||||
"dotenv": "^16.4.5",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"hono": "^4.1.0",
|
||||
"mime": "^4.0.1",
|
||||
"nanoid": "^5.0.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"node-pty": "^1.0.0",
|
||||
"systeminformation": "^5.22.2",
|
||||
"wol": "^1.0.7",
|
||||
"ws": "^8.16.0",
|
||||
"ytdl-core": "^4.11.5",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
@ -1,195 +0,0 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import fs from "node:fs/promises";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { getMimeType } from "../lib/utils";
|
||||
|
||||
const getFilesSchema = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
})
|
||||
.partial()
|
||||
.optional();
|
||||
|
||||
const uploadSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
size: z.string().min(1),
|
||||
});
|
||||
|
||||
const filesDirList = process.env.FILE_DIRS
|
||||
? process.env.FILE_DIRS.split(";").map((i) => ({
|
||||
name: i.split("/").at(-1),
|
||||
path: i,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const route = new Hono()
|
||||
.get("/", zValidator("query", getFilesSchema), async (c) => {
|
||||
const input: z.infer<typeof getFilesSchema> = c.req.query();
|
||||
const { baseName, path, pathname } = getFilePath(input.path);
|
||||
|
||||
if (!baseName?.length) {
|
||||
return c.json(
|
||||
filesDirList.map((i) => ({
|
||||
name: i.name,
|
||||
path: "/" + i.name,
|
||||
isDirectory: true,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const entities = await fs.readdir(path, { withFileTypes: true });
|
||||
|
||||
const files = entities
|
||||
.filter((e) => !e.name.startsWith("."))
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: [pathname, e.name].join("/"),
|
||||
isDirectory: e.isDirectory(),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) {
|
||||
return -1;
|
||||
} else if (!a.isDirectory && b.isDirectory) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
return c.json(files);
|
||||
} catch (err) {}
|
||||
|
||||
return c.json([]);
|
||||
})
|
||||
.post("/upload", async (c) => {
|
||||
const input: any = (await c.req.parseBody()) as never;
|
||||
const data = await uploadSchema.parseAsync(input);
|
||||
|
||||
const size = parseInt(input.size);
|
||||
if (Number.isNaN(size) || !size) {
|
||||
throw new HTTPException(400, { message: "Size is empty!" });
|
||||
}
|
||||
|
||||
const files: File[] = [...Array(size)]
|
||||
.map((_, idx) => input[`files.${idx}`])
|
||||
.filter((i) => !!i);
|
||||
|
||||
if (!files.length) {
|
||||
throw new HTTPException(400, { message: "Files is empty!" });
|
||||
}
|
||||
|
||||
const { baseDir, path: targetDir } = getFilePath(data.path);
|
||||
if (!baseDir?.length) {
|
||||
throw new HTTPException(400, { message: "Path not found!" });
|
||||
}
|
||||
|
||||
// files.forEach((file) => {
|
||||
// const filepath = targetDir + "/" + file.name;
|
||||
// if (existsSync(filepath)) {
|
||||
// throw new HTTPException(400, { message: "File already exists!" });
|
||||
// }
|
||||
// });
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const filepath = targetDir + "/" + file.name;
|
||||
const buffer = await file.arrayBuffer();
|
||||
await fs.writeFile(filepath, new Uint8Array(buffer));
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.get("/download/*", async (c) => {
|
||||
const dlFile = c.req.query("dl") === "true";
|
||||
const url = new URL(c.req.url, `http://${c.req.header("host")}`);
|
||||
const pathname = decodeURI(url.pathname).split("/");
|
||||
const pathSlice = pathname.slice(pathname.indexOf("download") + 1);
|
||||
const baseName = pathSlice[0];
|
||||
const path = "/" + pathSlice.slice(1).join("/");
|
||||
const filename = path.substring(1);
|
||||
|
||||
try {
|
||||
if (!baseName?.length) {
|
||||
throw new Error("baseName is empty");
|
||||
}
|
||||
|
||||
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
|
||||
if (!baseDir) {
|
||||
throw new Error("baseDir not found");
|
||||
}
|
||||
|
||||
const filepath = baseDir + path;
|
||||
const stat = await fs.stat(filepath);
|
||||
const size = stat.size;
|
||||
|
||||
if (dlFile) {
|
||||
c.header("Content-Type", "application/octet-stream");
|
||||
c.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(filename)}"`
|
||||
);
|
||||
} else {
|
||||
c.header("Content-Type", getMimeType(filepath));
|
||||
}
|
||||
|
||||
if (c.req.method == "HEAD" || c.req.method == "OPTIONS") {
|
||||
c.header("Content-Length", size.toString());
|
||||
c.status(200);
|
||||
return c.body(null);
|
||||
}
|
||||
|
||||
const range = c.req.header("range") || "";
|
||||
|
||||
if (!range || dlFile) {
|
||||
c.header("Content-Length", size.toString());
|
||||
return c.body(createReadStream(filepath), 200);
|
||||
}
|
||||
|
||||
c.header("Accept-Ranges", "bytes");
|
||||
c.header("Date", stat.birthtime.toUTCString());
|
||||
|
||||
const parts = range.replace(/bytes=/, "").split("-", 2);
|
||||
const start = parts[0] ? parseInt(parts[0], 10) : 0;
|
||||
let end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||
if (size < end - start + 1) {
|
||||
end = size - 1;
|
||||
}
|
||||
|
||||
const chunksize = end - start + 1;
|
||||
const stream = createReadStream(filepath, { start, end });
|
||||
|
||||
c.header("Content-Length", chunksize.toString());
|
||||
c.header("Content-Range", `bytes ${start}-${end}/${stat.size}`);
|
||||
|
||||
return c.body(stream, 206);
|
||||
} catch (err) {
|
||||
// console.log("err", err);
|
||||
throw new HTTPException(404, { message: "Not Found!" });
|
||||
}
|
||||
});
|
||||
|
||||
function getFilePath(path?: string) {
|
||||
const pathSlices =
|
||||
path
|
||||
?.replace(/\/{2,}/g, "/")
|
||||
.replace(/\/$/, "")
|
||||
.split("/") || [];
|
||||
const baseName = pathSlices[1] || null;
|
||||
const filePath = pathSlices.slice(2).join("/");
|
||||
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
|
||||
|
||||
return {
|
||||
path: [baseDir || "", filePath].join("/").replace(/\/$/, ""),
|
||||
pathname: ["", baseName, filePath].join("/").replace(/\/$/, ""),
|
||||
baseName,
|
||||
baseDir,
|
||||
filePath,
|
||||
};
|
||||
}
|
||||
|
||||
export default route;
|
75
backend/routes/files/download.ts
Normal file
75
backend/routes/files/download.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { createReadStream } from "fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { getMimeType } from "../../lib/utils";
|
||||
import { filesDirList } from "./utils";
|
||||
import type { Context } from "hono";
|
||||
|
||||
export const download = async (c: Context) => {
|
||||
const dlFile = c.req.query("dl") === "true";
|
||||
const url = new URL(c.req.url, `http://${c.req.header("host")}`);
|
||||
const pathname = decodeURI(url.pathname).split("/");
|
||||
const pathSlice = pathname.slice(pathname.indexOf("download") + 1);
|
||||
const baseName = pathSlice[0];
|
||||
const path = "/" + pathSlice.slice(1).join("/");
|
||||
const filename = path.substring(1);
|
||||
|
||||
try {
|
||||
if (!baseName?.length) {
|
||||
throw new Error("baseName is empty");
|
||||
}
|
||||
|
||||
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
|
||||
if (!baseDir) {
|
||||
throw new Error("baseDir not found");
|
||||
}
|
||||
|
||||
const filepath = baseDir + path;
|
||||
const stat = await fs.stat(filepath);
|
||||
const size = stat.size;
|
||||
|
||||
if (dlFile) {
|
||||
c.header("Content-Type", "application/octet-stream");
|
||||
c.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(filename)}"`
|
||||
);
|
||||
} else {
|
||||
c.header("Content-Type", getMimeType(filepath));
|
||||
}
|
||||
|
||||
if (c.req.method == "HEAD" || c.req.method == "OPTIONS") {
|
||||
c.header("Content-Length", size.toString());
|
||||
c.status(200);
|
||||
return c.body(null);
|
||||
}
|
||||
|
||||
const range = c.req.header("range") || "";
|
||||
|
||||
if (!range || dlFile) {
|
||||
c.header("Content-Length", size.toString());
|
||||
return c.body(createReadStream(filepath), 200);
|
||||
}
|
||||
|
||||
c.header("Accept-Ranges", "bytes");
|
||||
c.header("Date", stat.birthtime.toUTCString());
|
||||
|
||||
const parts = range.replace(/bytes=/, "").split("-", 2);
|
||||
const start = parts[0] ? parseInt(parts[0], 10) : 0;
|
||||
let end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||
if (size < end - start + 1) {
|
||||
end = size - 1;
|
||||
}
|
||||
|
||||
const chunksize = end - start + 1;
|
||||
const stream = createReadStream(filepath, { start, end });
|
||||
|
||||
c.header("Content-Length", chunksize.toString());
|
||||
c.header("Content-Range", `bytes ${start}-${end}/${stat.size}`);
|
||||
|
||||
return c.body(stream, 206);
|
||||
} catch (err) {
|
||||
// console.log("err", err);
|
||||
throw new HTTPException(404, { message: "Not Found!" });
|
||||
}
|
||||
};
|
44
backend/routes/files/get.ts
Normal file
44
backend/routes/files/get.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { Context } from "hono";
|
||||
import type { GetFilesSchema } from "./schema";
|
||||
import fs from "node:fs/promises";
|
||||
import { filesDirList, getFilePath } from "./utils";
|
||||
|
||||
export const getFiles = async (c: Context) => {
|
||||
const input: GetFilesSchema = c.req.query();
|
||||
const { baseName, path, pathname } = getFilePath(input.path);
|
||||
|
||||
if (!baseName?.length) {
|
||||
return c.json(
|
||||
filesDirList.map((i) => ({
|
||||
name: i.name,
|
||||
path: "/" + i.name,
|
||||
isDirectory: true,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const entities = await fs.readdir(path, { withFileTypes: true });
|
||||
|
||||
const files = entities
|
||||
.filter((e) => !e.name.startsWith("."))
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: [pathname, e.name].join("/"),
|
||||
isDirectory: e.isDirectory(),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) {
|
||||
return -1;
|
||||
} else if (!a.isDirectory && b.isDirectory) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
return c.json(files);
|
||||
} catch (err) {}
|
||||
|
||||
return c.json([]);
|
||||
};
|
15
backend/routes/files/index.ts
Normal file
15
backend/routes/files/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { getFilesSchema, ytdlSchema } from "./schema";
|
||||
import { getFiles } from "./get";
|
||||
import { download } from "./download";
|
||||
import { getYtdl, ytdl } from "./ytdl";
|
||||
|
||||
const route = new Hono()
|
||||
.get("/", zValidator("query", getFilesSchema), getFiles)
|
||||
.post("/upload")
|
||||
.post("/ytdl", zValidator("json", ytdlSchema), ytdl)
|
||||
.get("/ytdl/:id", getYtdl)
|
||||
.get("/download/*", download);
|
||||
|
||||
export default route;
|
24
backend/routes/files/schema.ts
Normal file
24
backend/routes/files/schema.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const getFilesSchema = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
})
|
||||
.partial()
|
||||
.optional();
|
||||
|
||||
export type GetFilesSchema = z.infer<typeof getFilesSchema>;
|
||||
|
||||
export const uploadSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
size: z.string().min(1),
|
||||
});
|
||||
|
||||
export type UploadSchema = z.infer<typeof uploadSchema>;
|
||||
|
||||
export const ytdlSchema = z.object({
|
||||
url: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
});
|
||||
|
||||
export type YtdlSchema = z.infer<typeof ytdlSchema>;
|
45
backend/routes/files/upload.ts
Normal file
45
backend/routes/files/upload.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { Context } from "hono";
|
||||
import fs from "node:fs/promises";
|
||||
import { uploadSchema } from "./schema";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { getFilePath } from "./utils";
|
||||
|
||||
export const upload = async (c: Context) => {
|
||||
const input: any = (await c.req.parseBody()) as never;
|
||||
const data = await uploadSchema.parseAsync(input);
|
||||
|
||||
const size = parseInt(input.size);
|
||||
if (Number.isNaN(size) || !size) {
|
||||
throw new HTTPException(400, { message: "Size is empty!" });
|
||||
}
|
||||
|
||||
const files: File[] = [...Array(size)]
|
||||
.map((_, idx) => input[`files.${idx}`])
|
||||
.filter((i) => !!i);
|
||||
|
||||
if (!files.length) {
|
||||
throw new HTTPException(400, { message: "Files is empty!" });
|
||||
}
|
||||
|
||||
const { baseDir, path: targetDir } = getFilePath(data.path);
|
||||
if (!baseDir?.length) {
|
||||
throw new HTTPException(400, { message: "Path not found!" });
|
||||
}
|
||||
|
||||
// files.forEach((file) => {
|
||||
// const filepath = targetDir + "/" + file.name;
|
||||
// if (existsSync(filepath)) {
|
||||
// throw new HTTPException(400, { message: "File already exists!" });
|
||||
// }
|
||||
// });
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const filepath = targetDir + "/" + file.name;
|
||||
const buffer = await file.arrayBuffer();
|
||||
await fs.writeFile(filepath, new Uint8Array(buffer));
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ success: true });
|
||||
};
|
25
backend/routes/files/utils.ts
Normal file
25
backend/routes/files/utils.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export const filesDirList = process.env.FILE_DIRS
|
||||
? process.env.FILE_DIRS.split(";").map((i) => ({
|
||||
name: i.split("/").at(-1),
|
||||
path: i,
|
||||
}))
|
||||
: [];
|
||||
|
||||
export function getFilePath(path?: string) {
|
||||
const pathSlices =
|
||||
path
|
||||
?.replace(/\/{2,}/g, "/")
|
||||
.replace(/\/$/, "")
|
||||
.split("/") || [];
|
||||
const baseName = pathSlices[1] || null;
|
||||
const filePath = pathSlices.slice(2).join("/");
|
||||
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
|
||||
|
||||
return {
|
||||
path: [baseDir || "", filePath].join("/").replace(/\/$/, ""),
|
||||
pathname: ["", baseName, filePath].join("/").replace(/\/$/, ""),
|
||||
baseName,
|
||||
baseDir,
|
||||
filePath,
|
||||
};
|
||||
}
|
77
backend/routes/files/ytdl.ts
Normal file
77
backend/routes/files/ytdl.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import type { Context } from "hono";
|
||||
import type { YtdlSchema } from "./schema";
|
||||
import Queue from "better-queue";
|
||||
import { nanoid } from "nanoid";
|
||||
import yt2mp3, { getVideoInfo } from "../../lib/yt2mp3";
|
||||
import { getFilePath } from "./utils";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
path: string;
|
||||
url: string;
|
||||
title: string;
|
||||
thumb: string;
|
||||
length: string;
|
||||
status?: "pending" | "resolved" | "rejected";
|
||||
};
|
||||
|
||||
const tasks = new Map<string, Task>();
|
||||
const queue = new Queue<Task>((input: Task, cb) => {
|
||||
const { id, path, url } = input;
|
||||
|
||||
const task = tasks.get(id);
|
||||
if (!task) return cb(new Error("Task not found!"));
|
||||
|
||||
const out = getFilePath(path);
|
||||
if (!out.path) return cb(new Error("Path not found!"));
|
||||
|
||||
tasks.set(id, { ...task, status: "pending" });
|
||||
|
||||
yt2mp3(url, out.path)
|
||||
.then((data) => cb(null, data))
|
||||
.catch(cb);
|
||||
});
|
||||
|
||||
queue.on("task_finish", (taskId: string) => {
|
||||
const task = tasks.get(taskId);
|
||||
task && tasks.set(taskId, { ...task, status: "resolved" });
|
||||
console.log("task finish!", taskId);
|
||||
});
|
||||
|
||||
queue.on("task_failed", (taskId: string) => {
|
||||
const task = tasks.get(taskId);
|
||||
task && tasks.set(taskId, { ...task, status: "rejected" });
|
||||
console.error("task failed!", taskId);
|
||||
});
|
||||
|
||||
export const ytdl = async (c: Context) => {
|
||||
const input: YtdlSchema = await c.req.json();
|
||||
|
||||
try {
|
||||
const info = await getVideoInfo(input.url);
|
||||
|
||||
const task: Task = {
|
||||
id: nanoid(),
|
||||
url: input.url,
|
||||
title: info.title,
|
||||
path: input.path,
|
||||
thumb: info.thumb?.url,
|
||||
length: info.length,
|
||||
};
|
||||
|
||||
queue.push(task);
|
||||
tasks.set(task.id, task);
|
||||
|
||||
return c.json(task);
|
||||
} catch (err) {
|
||||
throw new HTTPException(400, {
|
||||
message: (err as any)?.message || "Cannot download from youtube!",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getYtdl = async (c: Context) => {
|
||||
const id = c.req.param("id");
|
||||
return c.json(tasks.get(id));
|
||||
};
|
@ -127,6 +127,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@hono/zod-validator/-/zod-validator-0.2.0.tgz#77d8f1f167cba85b008a52f594ed823c841b2300"
|
||||
integrity sha512-PC7akbA/DCFY406BL3+ogjYb+7Fgfs/6XPvyURBYMczo0M7kYsTUMnF8hA9mez1RORNCWPqXuFGfKrkoUVPvrQ==
|
||||
|
||||
"@types/better-queue@^3.8.6":
|
||||
version "3.8.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/better-queue/-/better-queue-3.8.6.tgz#24366d66cf8f34304b90ff76585688b7676d6b3f"
|
||||
integrity sha512-iC8L2LmVwgA0lcfrw9bLt0qQ8BVs2HOK/c2vtUnqQvoltMGn1GQ4OQChZFLRSx9AXgtdF6FDVsGDqyaV/urChw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/fluent-ffmpeg@^2.1.24":
|
||||
version "2.1.24"
|
||||
resolved "https://registry.yarnpkg.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz#f53c57700bc4360ac638554545c8da2c465434c1"
|
||||
integrity sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/jsonwebtoken@^9.0.6":
|
||||
version "9.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz#d1af3544d99ad992fb6681bbe60676e06b032bd3"
|
||||
@ -148,6 +162,30 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
async@>=0.2.9:
|
||||
version "3.2.5"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66"
|
||||
integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==
|
||||
|
||||
better-queue-memory@^1.0.1:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/better-queue-memory/-/better-queue-memory-1.0.4.tgz#f390d6b30bb3b36aaf2ce52b37a483e8a7a81a22"
|
||||
integrity sha512-SWg5wFIShYffEmJpI6LgbL8/3Dqhku7xI1oEiy6FroP9DbcZlG0ZDjxvPdP9t7hTGW40IpIcC6zVoGT1oxjOuA==
|
||||
|
||||
better-queue@^3.8.12:
|
||||
version "3.8.12"
|
||||
resolved "https://registry.yarnpkg.com/better-queue/-/better-queue-3.8.12.tgz#15c18923d0f9778be94f19c3ef2bd85c632d0db3"
|
||||
integrity sha512-D9KZ+Us+2AyaCz693/9AyjTg0s8hEmkiM/MB3i09cs4MdK1KgTSGJluXRYmOulR69oLZVo2XDFtqsExDt8oiLA==
|
||||
dependencies:
|
||||
better-queue-memory "^1.0.1"
|
||||
node-eta "^0.9.0"
|
||||
uuid "^9.0.0"
|
||||
|
||||
data-uri-to-buffer@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
|
||||
integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
|
||||
|
||||
dotenv@^16.4.5:
|
||||
version "16.4.5"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
|
||||
@ -182,6 +220,29 @@ esbuild@~0.19.10:
|
||||
"@esbuild/win32-ia32" "0.19.12"
|
||||
"@esbuild/win32-x64" "0.19.12"
|
||||
|
||||
fetch-blob@^3.1.2, fetch-blob@^3.1.4:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
|
||||
integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
|
||||
dependencies:
|
||||
node-domexception "^1.0.0"
|
||||
web-streams-polyfill "^3.0.3"
|
||||
|
||||
fluent-ffmpeg@^2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz#c952de2240f812ebda0aa8006d7776ee2acf7d74"
|
||||
integrity sha512-IZTB4kq5GK0DPp7sGQ0q/BWurGHffRtQQwVkiqDgeO6wYJLLV5ZhgNOQ65loZxxuPMKZKZcICCUnaGtlxBiR0Q==
|
||||
dependencies:
|
||||
async ">=0.2.9"
|
||||
which "^1.1.1"
|
||||
|
||||
formdata-polyfill@^4.0.10:
|
||||
version "4.0.10"
|
||||
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
|
||||
integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
|
||||
dependencies:
|
||||
fetch-blob "^3.1.2"
|
||||
|
||||
fsevents@~2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
@ -199,11 +260,29 @@ hono@^4.1.0:
|
||||
resolved "https://registry.yarnpkg.com/hono/-/hono-4.1.0.tgz#62cef81df0dbf731643155e1e5c1b9dffb230dc4"
|
||||
integrity sha512-9no6DCHb4ijB1tWdFXU6JnrnFgzwVZ1cnIcS1BjAFnMcjbtBTOMsQrDrPH3GXbkNEEEkj8kWqcYBy8Qc0bBkJQ==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
m3u8stream@^0.8.6:
|
||||
version "0.8.6"
|
||||
resolved "https://registry.yarnpkg.com/m3u8stream/-/m3u8stream-0.8.6.tgz#0d6de4ce8ee69731734e6b616e7b05dd9d9a55b1"
|
||||
integrity sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==
|
||||
dependencies:
|
||||
miniget "^4.2.2"
|
||||
sax "^1.2.4"
|
||||
|
||||
mime@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.1.tgz#ad7563d1bfe30253ad97dedfae2b1009d01b9470"
|
||||
integrity sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==
|
||||
|
||||
miniget@^4.2.2:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/miniget/-/miniget-4.2.3.tgz#3707a24c7c11c25d359473291638ab28aab349bd"
|
||||
integrity sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==
|
||||
|
||||
nan@^2.17.0:
|
||||
version "2.19.0"
|
||||
resolved "https://registry.yarnpkg.com/nan/-/nan-2.19.0.tgz#bb58122ad55a6c5bc973303908d5b16cfdd5a8c0"
|
||||
@ -214,6 +293,25 @@ nanoid@^5.0.6:
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.6.tgz#7f99a033aa843e4dcf9778bdaec5eb02f4dc44d5"
|
||||
integrity sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==
|
||||
|
||||
node-domexception@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
|
||||
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
|
||||
|
||||
node-eta@^0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/node-eta/-/node-eta-0.9.0.tgz#9fb0b099bcd2a021940e603c64254dc003d9a7a8"
|
||||
integrity sha512-mTCTZk29tmX1OGfVkPt63H3c3VqXrI2Kvua98S7iUIB/Gbp0MNw05YtUomxQIxnnKMyRIIuY9izPcFixzhSBrA==
|
||||
|
||||
node-fetch@^3.3.2:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b"
|
||||
integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==
|
||||
dependencies:
|
||||
data-uri-to-buffer "^4.0.0"
|
||||
fetch-blob "^3.1.4"
|
||||
formdata-polyfill "^4.0.10"
|
||||
|
||||
node-pty@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd"
|
||||
@ -226,6 +324,11 @@ resolve-pkg-maps@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f"
|
||||
integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
|
||||
|
||||
sax@^1.1.3, sax@^1.2.4:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0"
|
||||
integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==
|
||||
|
||||
systeminformation@^5.22.2:
|
||||
version "5.22.3"
|
||||
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.22.3.tgz#33ef8bd045125d672f64e7000015fefae07a77cb"
|
||||
@ -246,6 +349,23 @@ undici-types@~5.26.4:
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
|
||||
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
|
||||
|
||||
uuid@^9.0.0:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
|
||||
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
|
||||
|
||||
web-streams-polyfill@^3.0.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
||||
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
||||
|
||||
which@^1.1.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
|
||||
integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
|
||||
dependencies:
|
||||
isexe "^2.0.0"
|
||||
|
||||
wol@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/wol/-/wol-1.0.7.tgz#a2e70efca2a28324a744a5d12331d359da470ff7"
|
||||
@ -256,6 +376,15 @@ ws@^8.16.0:
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4"
|
||||
integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==
|
||||
|
||||
ytdl-core@^4.11.5:
|
||||
version "4.11.5"
|
||||
resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.11.5.tgz#8cc3dc9e4884e24e8251250cfb56313a300811f0"
|
||||
integrity sha512-27LwsW4n4nyNviRCO1hmr8Wr5J1wLLMawHCQvH8Fk0hiRqrxuIu028WzbJetiYH28K8XDbeinYW4/wcHQD1EXA==
|
||||
dependencies:
|
||||
m3u8stream "^0.8.6"
|
||||
miniget "^4.2.2"
|
||||
sax "^1.1.3"
|
||||
|
||||
zod@^3.22.4:
|
||||
version "3.22.4"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
|
||||
|
@ -4,7 +4,7 @@ import api from "@/lib/api";
|
||||
import { useAuth } from "@/stores/authStore";
|
||||
import BackButton from "@ui/BackButton";
|
||||
import Input from "@ui/Input";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
import FileDrop from "@/components/pages/files/FileDrop";
|
||||
@ -16,6 +16,9 @@ import FileInlineViewer from "@/components/pages/files/FileInlineViewer";
|
||||
import { FilesContext } from "@/components/pages/files/FilesContext";
|
||||
import { FileItem } from "@/types/files";
|
||||
import Head from "@/components/utility/Head";
|
||||
import Container from "@ui/Container";
|
||||
import FileUpload from "@/components/pages/files/FileUpload";
|
||||
import ActionButton from "@/components/pages/files/ActionButton";
|
||||
|
||||
const FilesPage = () => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
@ -65,26 +68,29 @@ const FilesPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<FilesContext.Provider value={{ files: data, viewFile, setViewFile }}>
|
||||
<FilesContext.Provider
|
||||
value={{
|
||||
path: params.path,
|
||||
files: data,
|
||||
viewFile,
|
||||
setViewFile,
|
||||
refresh: refetch,
|
||||
}}
|
||||
>
|
||||
<Head title="Files" />
|
||||
<Stack.Screen
|
||||
options={{ headerLeft: () => <BackButton />, title: "Files" }}
|
||||
/>
|
||||
|
||||
<Container className="flex-1">
|
||||
<HStack className="px-2 py-2 bg-white gap-2">
|
||||
<Button
|
||||
<ActionButton
|
||||
icon={<Ionicons name="chevron-back" />}
|
||||
disabled={parentPath == null}
|
||||
className="px-3 border-gray-300"
|
||||
labelClasses="text-gray-500"
|
||||
variant="outline"
|
||||
onPress={() => setParams({ ...params, path: parentPath })}
|
||||
/>
|
||||
<Button
|
||||
<ActionButton
|
||||
icon={<Ionicons name="home-outline" />}
|
||||
className="px-3 border-gray-300"
|
||||
labelClasses="text-gray-500"
|
||||
variant="outline"
|
||||
onPress={() => setParams({ ...params, path: "" })}
|
||||
/>
|
||||
<Input
|
||||
@ -93,6 +99,7 @@ const FilesPage = () => {
|
||||
onChangeText={(path) => setParams({ path })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<FileUpload />
|
||||
</HStack>
|
||||
|
||||
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
|
||||
@ -107,6 +114,7 @@ const FilesPage = () => {
|
||||
}}
|
||||
/>
|
||||
</FileDrop>
|
||||
</Container>
|
||||
|
||||
<FileInlineViewer file={viewFile} onClose={() => setViewFile(null)} />
|
||||
</FilesContext.Provider>
|
||||
|
20
src/components/pages/files/ActionButton.tsx
Normal file
20
src/components/pages/files/ActionButton.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Button from "@ui/Button";
|
||||
|
||||
type ActionButtonProps = {
|
||||
icon: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ActionButton = ({ icon, onPress, disabled }: ActionButtonProps) => (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={icon}
|
||||
className="px-3 border-gray-300"
|
||||
labelClasses="text-gray-500"
|
||||
variant="outline"
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
|
||||
export default ActionButton;
|
169
src/components/pages/files/FileUpload.tsx
Normal file
169
src/components/pages/files/FileUpload.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ActionButton from "./ActionButton";
|
||||
import { Ionicons } from "@ui/Icons";
|
||||
import Modal, { createModal } from "@ui/Modal";
|
||||
import { HStack, VStack } from "@ui/Stack";
|
||||
import Button from "@ui/Button";
|
||||
import Box from "@ui/Box";
|
||||
import { useFilesContext } from "./FilesContext";
|
||||
import Input from "@ui/Input";
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
import api from "@/lib/api";
|
||||
import { showToast } from "@/stores/toastStore";
|
||||
import Text from "@ui/Text";
|
||||
|
||||
const modal = createModal();
|
||||
|
||||
const FileUpload = () => {
|
||||
// const [type, setType] = useState<"file" | "youtube" | "url">("file");
|
||||
const [type, setType] = useState<"file" | "youtube" | "url">("youtube");
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionButton
|
||||
icon={<Ionicons name="cloud-upload-outline" />}
|
||||
onPress={() => modal.open()}
|
||||
/>
|
||||
<Modal modal={modal} className="max-w-xl" title="Upload File">
|
||||
<HStack className="gap-2 mt-4">
|
||||
{/* <Button
|
||||
variant={type === "file" ? "default" : "ghost"}
|
||||
onPress={() => setType("file")}
|
||||
>
|
||||
File
|
||||
</Button> */}
|
||||
<Button
|
||||
variant={type === "youtube" ? "default" : "ghost"}
|
||||
onPress={() => setType("youtube")}
|
||||
>
|
||||
Youtube
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant={type === "url" ? "default" : "ghost"}
|
||||
onPress={() => setType("url")}
|
||||
>
|
||||
URL
|
||||
</Button> */}
|
||||
</HStack>
|
||||
|
||||
<Box className="mt-4">
|
||||
{type === "file" && <FileDownload />}
|
||||
{type === "youtube" && <YoutubeDownload />}
|
||||
{type === "url" && <UrlDownload />}
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FileDownload = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
type YtDlTask = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
path: string;
|
||||
length: string;
|
||||
thumb: string;
|
||||
status?: "pending" | "resolved" | "rejected";
|
||||
};
|
||||
|
||||
const YoutubeDownload = () => {
|
||||
const { path } = useFilesContext();
|
||||
const [url, setUrl] = useState("");
|
||||
const [tasks, setTasks] = useState<YtDlTask[]>([]);
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.files.ytdl.$post({ json: { path, url } });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setTasks((i) => [data, ...i]);
|
||||
setUrl("");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast((err as any)?.message || "An error occured!", {
|
||||
type: "danger",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack className="gap-3">
|
||||
<Input
|
||||
placeholder="URL"
|
||||
className="flex-1"
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<Ionicons name="cloud-upload" />}
|
||||
disabled={!url.length || start.isLoading}
|
||||
onPress={() => start.mutate()}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<VStack className="gap-2 mt-4">
|
||||
{tasks.map((task) => (
|
||||
<YtDownloadTaskStatus key={task.id} task={task} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const YtDownloadTaskStatus = ({ task }: { task: YtDlTask }) => {
|
||||
const { refresh } = useFilesContext();
|
||||
const [isPending, setPending] = useState(true);
|
||||
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ["ytdl", task.id],
|
||||
queryFn: async () =>
|
||||
api.files.ytdl[":id"]
|
||||
.$get({ param: { id: task.id } })
|
||||
.then((i) => i.json()),
|
||||
enabled: isPending,
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && ["rejected", "resolved"].includes(data.status)) {
|
||||
setPending(false);
|
||||
refresh();
|
||||
}
|
||||
}, [data, isError, refresh]);
|
||||
|
||||
const status = data?.status || task.status;
|
||||
|
||||
return (
|
||||
<HStack className="gap-2">
|
||||
{!status ? <Ionicons name="hourglass" size={24} /> : null}
|
||||
{status === "pending" ? (
|
||||
<Ionicons size={24} name="sync-circle-outline" />
|
||||
) : null}
|
||||
{status === "resolved" ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="green" />
|
||||
) : null}
|
||||
{status === "rejected" ? (
|
||||
<Ionicons name="close-circle" size={24} color="red" />
|
||||
) : null}
|
||||
|
||||
<Text className="text-lg font-medium flex-1" numberOfLines={1}>
|
||||
{task.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const UrlDownload = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default React.memo(FileUpload);
|
@ -2,15 +2,19 @@ import { FileItem } from "@/types/files";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type FilesContextType = {
|
||||
path: string;
|
||||
files: FileItem[];
|
||||
viewFile: FileItem | null;
|
||||
setViewFile: (file: FileItem | null) => void;
|
||||
refresh: () => void;
|
||||
};
|
||||
|
||||
export const FilesContext = createContext<FilesContextType>({
|
||||
path: "",
|
||||
files: [],
|
||||
viewFile: null,
|
||||
setViewFile: () => null,
|
||||
refresh: () => null,
|
||||
});
|
||||
|
||||
export const useFilesContext = () => useContext(FilesContext);
|
||||
|
@ -13,7 +13,7 @@ const Container = ({
|
||||
children,
|
||||
scrollable = false,
|
||||
}: ContainerProps) => {
|
||||
const style = cn("mx-auto w-full max-w-xl", className);
|
||||
const style = cn("mx-auto w-full max-w-3xl", className);
|
||||
|
||||
if (scrollable) {
|
||||
return (
|
||||
|
@ -33,6 +33,7 @@ const BaseInput = ({
|
||||
"border border-gray-300 rounded-lg px-3 h-10 w-full",
|
||||
inputClassName
|
||||
)}
|
||||
placeholderTextColor="#787878"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
|
64
src/components/ui/Modal.tsx
Normal file
64
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import BaseModal from "react-native-modal";
|
||||
import Container from "./Container";
|
||||
import { createStore, useStore } from "zustand";
|
||||
import Text from "./Text";
|
||||
import { HStack } from "./Stack";
|
||||
import Button from "./Button";
|
||||
|
||||
type ModalStore = {
|
||||
isOpen: boolean;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
export const createModal = <T,>() => {
|
||||
const store = createStore<ModalStore>(() => ({
|
||||
isOpen: false,
|
||||
data: null,
|
||||
}));
|
||||
const open = (data?: T) => store.setState({ isOpen: true, data });
|
||||
const close = () => store.setState({ isOpen: false });
|
||||
return { ...store, open, close };
|
||||
};
|
||||
|
||||
type CreateModalReturn = ReturnType<typeof createModal>;
|
||||
|
||||
type ModalProps = {
|
||||
modal: CreateModalReturn;
|
||||
className?: string;
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Modal = ({ title, modal, actions, children, className }: ModalProps) => {
|
||||
const { isOpen } = useStore(modal);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isOpen}
|
||||
onBackdropPress={modal.close}
|
||||
onBackButtonPress={modal.close}
|
||||
animationIn="fadeInDown"
|
||||
>
|
||||
<Container
|
||||
className={["bg-white rounded-lg p-6 md:p-8 max-w-xl", className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<Text className="text-2xl font-medium">{title}</Text>
|
||||
|
||||
{children}
|
||||
|
||||
<HStack className="justify-end gap-4 mt-6">
|
||||
<Button variant="ghost" onPress={modal.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
{actions}
|
||||
</HStack>
|
||||
</Container>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
Loading…
x
Reference in New Issue
Block a user