mirror of
				https://github.com/khairul169/home-lab.git
				synced 2025-10-31 03:39:33 +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