diff --git a/backend/.env.example b/backend/.env.example index da4364e..8cf65d5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,6 @@ # Config JWT_SECRET= +TMP_DIR= AUTH_USERNAME= AUTH_PASSWORD= diff --git a/backend/lib/utils.ts b/backend/lib/utils.ts index 5d8cd22..840ba9b 100644 --- a/backend/lib/utils.ts +++ b/backend/lib/utils.ts @@ -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; +}; diff --git a/backend/lib/yt2mp3.ts b/backend/lib/yt2mp3.ts new file mode 100644 index 0000000..0202674 --- /dev/null +++ b/backend/lib/yt2mp3.ts @@ -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 => { + 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 => { + 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; diff --git a/backend/package.json b/backend/package.json index 313d043..77f6cff 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/routes/files.ts b/backend/routes/files.ts deleted file mode 100644 index 94f38f9..0000000 --- a/backend/routes/files.ts +++ /dev/null @@ -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 = 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; diff --git a/backend/routes/files/download.ts b/backend/routes/files/download.ts new file mode 100644 index 0000000..2157b49 --- /dev/null +++ b/backend/routes/files/download.ts @@ -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!" }); + } +}; diff --git a/backend/routes/files/get.ts b/backend/routes/files/get.ts new file mode 100644 index 0000000..8841149 --- /dev/null +++ b/backend/routes/files/get.ts @@ -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([]); +}; diff --git a/backend/routes/files/index.ts b/backend/routes/files/index.ts new file mode 100644 index 0000000..9dd1299 --- /dev/null +++ b/backend/routes/files/index.ts @@ -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; diff --git a/backend/routes/files/schema.ts b/backend/routes/files/schema.ts new file mode 100644 index 0000000..49cf55f --- /dev/null +++ b/backend/routes/files/schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const getFilesSchema = z + .object({ + path: z.string(), + }) + .partial() + .optional(); + +export type GetFilesSchema = z.infer; + +export const uploadSchema = z.object({ + path: z.string().min(1), + size: z.string().min(1), +}); + +export type UploadSchema = z.infer; + +export const ytdlSchema = z.object({ + url: z.string().min(1), + path: z.string().min(1), +}); + +export type YtdlSchema = z.infer; diff --git a/backend/routes/files/upload.ts b/backend/routes/files/upload.ts new file mode 100644 index 0000000..fe14b51 --- /dev/null +++ b/backend/routes/files/upload.ts @@ -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 }); +}; diff --git a/backend/routes/files/utils.ts b/backend/routes/files/utils.ts new file mode 100644 index 0000000..1f90e12 --- /dev/null +++ b/backend/routes/files/utils.ts @@ -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, + }; +} diff --git a/backend/routes/files/ytdl.ts b/backend/routes/files/ytdl.ts new file mode 100644 index 0000000..d118a28 --- /dev/null +++ b/backend/routes/files/ytdl.ts @@ -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(); +const queue = new Queue((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)); +}; diff --git a/backend/yarn.lock b/backend/yarn.lock index 8b5127d..315f88b 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -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" diff --git a/src/app/apps/files.tsx b/src/app/apps/files.tsx index bab2196..dbaec41 100644 --- a/src/app/apps/files.tsx +++ b/src/app/apps/files.tsx @@ -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,48 +68,53 @@ const FilesPage = () => { } return ( - + , title: "Files" }} /> - - */} + + {/* */} + + + + {type === "file" && } + {type === "youtube" && } + {type === "url" && } + + + + ); +}; + +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([]); + + 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 ( + + + + + {actions} + + + + ); +}; + +export default Modal;