diff --git a/.vscode/settings.json b/.vscode/settings.json index 38510cb7..a33ba024 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,5 +54,9 @@ ], "editor.tabCompletion": "on", "diffEditor.codeLens": true, - "bun.runtime": "/home/gitpod/.bun/bin/bun" -} + "bun.runtime": "/home/gitpod/.bun/bin/bun", + + "css.customData": [ + ".vscode/tailwind.json" + ] +} \ No newline at end of file diff --git a/.vscode/tailwind.json b/.vscode/tailwind.json new file mode 100644 index 00000000..96a1f579 --- /dev/null +++ b/.vscode/tailwind.json @@ -0,0 +1,55 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@tailwind", + "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" + } + ] + }, + { + "name": "@apply", + "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#apply" + } + ] + }, + { + "name": "@responsive", + "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" + } + ] + }, + { + "name": "@screen", + "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#screen" + } + ] + }, + { + "name": "@variants", + "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#variants" + } + ] + } + ] +} diff --git a/bun.lockb b/bun.lockb index ca2c5692..8771454f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 713baf6c..c4fb111c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "release": "bumpp package.json --commit --tag --push" }, "dependencies": { + "@ffmpeg/core": "^0.12.6", + "@ffmpeg/ffmpeg": "^0.12.10", "@firebase/analytics": "^0.9.5", "@firebase/app": "^0.9.15", "@firebase/firestore": "^3.13.0", @@ -42,6 +44,8 @@ "fb-comments-web": "^0.0.13", "filesize": "^10.1.0", "group-array": "^1.0.0", + "hls-parser": "^0.13.3", + "hls-to-mp4-browser": "git+https://github.com/anime-vsub/hls-to-mp4-browser.git", "htmlparser2": "^8.0.2", "idb-keyval": "^6.2.1", "iso-639-1": "^2.1.15", diff --git a/quasar.config.ts b/quasar.config.ts index 0d99cc4d..c8b5f590 100644 --- a/quasar.config.ts +++ b/quasar.config.ts @@ -121,6 +121,10 @@ export default configure(function (/* ctx */) { extendViteConf(viteConf) { extend(true, viteConf, { + optimizeDeps: { + exclude: ["@ffmpeg/ffmpeg"] + }, + resolve: { alias: { path: "path-browserify" @@ -182,7 +186,7 @@ export default configure(function (/* ctx */) { "vue-router", { quasar: ["useQuasar"], - "vue-i18n": ["useI18n"], + "vue-i18n": ["useI18n"] // "@vueuse/core": ["computedAsync"] } ], diff --git a/src/i18n/messages/en-US.json b/src/i18n/messages/en-US.json index 7dbf92ac..5a97dead 100644 --- a/src/i18n/messages/en-US.json +++ b/src/i18n/messages/en-US.json @@ -309,5 +309,14 @@ "msg-pre-resolve": "Network request resolution mode (the higher the request request, the more hardware resources are consumed)", "nong": "Hot", "tat": "Turn off", - "val-yeu-cau": "{0} request" + "val-yeu-cau": "{0} request", + "cancel-by-user": "Cancel by user", + "chap-not-found": "Chap {0} not found", + "chat-luong-tai-xuong": "Download quality", + "da-tai-xong-video": "The video has been downloaded", + "dlg": "Downloading...", + "msg-close-tab": "You can safely close this window", + "msg-keep-tab": "Keep this window open to continue", + "msg-ques-quality": "The higher the quality, the more memory it takes up and the longer it takes to load", + "tai-video-that-bai": "Video download failed" } diff --git a/src/i18n/messages/ja-JP.json b/src/i18n/messages/ja-JP.json index 55bd5a3a..9275fbaf 100644 --- a/src/i18n/messages/ja-JP.json +++ b/src/i18n/messages/ja-JP.json @@ -303,5 +303,14 @@ "msg-pre-resolve": "ネットワーク要求解決モード (要求要求が高くなるほど、より多くのハードウェア リソースが消費されます)", "nong": "熱い", "tat": "消す", - "val-yeu-cau": "{0} リクエスト" + "val-yeu-cau": "{0} リクエスト", + "cancel-by-user": "ユーザーによるキャンセル", + "chap-not-found": "章 {0} が見つかりません", + "chat-luong-tai-xuong": "ダウンロード品質", + "da-tai-xong-video": "ビデオがダウンロードされました", + "dlg": "ダウンロード中...", + "msg-close-tab": "このウィンドウは安全に閉じても大丈夫です", + "msg-keep-tab": "続行するには、このウィンドウを開いたままにしてください", + "msg-ques-quality": "品質が高くなるほど、必要なメモリが増え、読み込みに時間がかかります", + "tai-video-that-bai": "ビデオのダウンロードに失敗しました" } diff --git a/src/i18n/messages/vi-VN.json b/src/i18n/messages/vi-VN.json index fbaabe28..01b63c3c 100644 --- a/src/i18n/messages/vi-VN.json +++ b/src/i18n/messages/vi-VN.json @@ -299,5 +299,14 @@ "msg-pre-resolve": "Chế độ giải quyết yêu cầu mạng (yêu cầu giải quyết càng cao thì càng tốn tài nguyên phần cứng)", "tat": "Tắt", "val-yeu-cau": "{0} yêu cầu", - "nong": "Nóng" + "nong": "Nóng", + "da-tai-xong-video": "Đã tải xong video", + "msg-close-tab": "Bạn có thể yên tâm đóng cửa sổ này", + "tai-video-that-bai": "Tải video thất bại", + "chat-luong-tai-xuong": "Chất lượng tải xuống", + "msg-ques-quality": "Chất lượng càng cao thì chiếm dụng bộ nhớ càng lớn và thời gian tải càng lâu", + "cancel-by-user": "Cancel by user", + "dlg": "Đang tải xuống...", + "msg-keep-tab": "Giữ cửa sổ này mở để tiếp tục", + "chap-not-found": "Chap {0} not found" } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 239a71da..3a8addfc 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -303,5 +303,14 @@ "msg-pre-resolve": "网络请求解析方式(请求请求越高,消耗硬件资源越多)", "nong": "热的", "tat": "关", - "val-yeu-cau": "{0}请求" + "val-yeu-cau": "{0}请求", + "cancel-by-user": "由用户取消", + "chap-not-found": "未找到第 {0} 章", + "chat-luong-tai-xuong": "下载质量", + "da-tai-xong-video": "视频已下载", + "dlg": "正在下载...", + "msg-close-tab": "您可以安全地关闭此窗口", + "msg-keep-tab": "保持此窗口打开以继续", + "msg-ques-quality": "质量越高,占用内存越大,加载时间越长", + "tai-video-that-bai": "视频下载失败" } diff --git a/src/logic/convert-hls-to-mp4.ts b/src/logic/convert-hls-to-mp4.ts new file mode 100644 index 00000000..0a264b77 --- /dev/null +++ b/src/logic/convert-hls-to-mp4.ts @@ -0,0 +1,96 @@ +import wasmURL from "@ffmpeg/core/wasm?url" +import coreURL from "@ffmpeg/core?url" +import { FFmpeg } from "@ffmpeg/ffmpeg" +import { parse, stringify } from "hls-parser" +import pLimit from "p-limit" +import sha256 from "sha256" +import type { RetryOptions } from "ts-retry"; +import { retryAsync } from "ts-retry" + +/** + * Converts an HLS (HTTP Live Streaming) manifest to an MP4 file. + * + * @param {string} m3u8Content - The content of the HLS manifest file. + * @param {(url: string) => Promise} fetchFile - A function to fetch a file from a URL. + * @param {(current: number, total: number, timeCurrent: number, timeDuration: number) => void} onProgress - A callback function to report the progress of the conversion. + * @param {number} [concurrency=20] - The number of concurrent downloads. + * @param {RetryOptions} [retryOptions] - Options for retrying failed downloads. + * @return {Promise} The MP4 file data. + */ +export async function convertHlsToMP4( + m3u8Content: string, + fetchFile: (url: string) => Promise, + onProgress: ( + current: number, + total: number, + timeCurrent: number, + timeDuration: number, + ) => void, + concurrency = 20, + retryOptions?: RetryOptions, +) { + const ffmpeg = new FFmpeg() + + const hash = sha256(m3u8Content) + const manifest = parse(m3u8Content) + + if (!("segments" in manifest)) + throw new Error("Can't support master playlist") + + if (import.meta.env.DEV) + ffmpeg.on("log", ({ message }) => { + console.log(`[@ffmpeg/ffmpeg]: ${message}`) + }) + + if (import.meta.env.DEV) + ffmpeg.on("progress", ({ progress, time }) => { + console.log(`${progress * 100} % (transcoded time: ${time / 1000000} s)`) + }) + + await ffmpeg.load({ + coreURL, + wasmURL, + }) + + const limit = pLimit(concurrency) + + const timeDuration = manifest.segments.reduce( + (prev, cur) => cur.duration + prev, + 0, + ) + // Download all the TS segments + let downloaded = 0 + let timeCurrent = 0 + await Promise.all( + manifest.segments.map((segment, i) => + limit(() => + retryAsync(async () => { + const path = `${hash}-${i}.ts` + await ffmpeg.writeFile(path, await fetchFile(segment.uri)) + segment.uri = path + downloaded++ + timeCurrent += segment.duration + onProgress( + downloaded, + manifest.segments.length, + timeCurrent, + timeDuration, + ) + }, retryOptions), + ), + ), + ) + await ffmpeg.writeFile(`${hash}-media.m3u8`, stringify(manifest)) + + await ffmpeg.exec([ + "-i", + `${hash}-media.m3u8`, + ..."-acodec copy -vcodec copy".split(" "), + `${hash}-output.mp4`, + ]) + + // Retrieve the output file + const mp4Data = await ffmpeg.readFile(`${hash}-output.mp4`) + + return mp4Data +} diff --git a/src/logic/download-to-mp4.ts b/src/logic/download-to-mp4.ts new file mode 100644 index 00000000..1d3ee952 --- /dev/null +++ b/src/logic/download-to-mp4.ts @@ -0,0 +1,79 @@ +import { i18n } from "boot/i18n" +import type PhimId from "src/apis/parser/phim/[id]" +import type PhimIdChap from "src/apis/parser/phim/[id]/[chap]" + +import Worker from "./download-to-mp4.worker?worker" + +export function downloadToMp4( + url: string, + season: Awaited>, + chaps: Awaited>, + currentChapId: string, + onProgress: ( + current: number, + total: number, + tCurrent: number, + tDuration: number, + speed: number + ) => void +) { + return new Promise((resolve, reject) => { + const worker = new Worker() + worker.onmessage = ( + event: MessageEvent< + | { + ok: boolean + buffer: ArrayBuffer + message?: string + } + | [ + current: number, + total: number, + tCurrent: number, + tDuration: number, + speed: number + ] + > + ) => { + if ("ok" in event.data) { + if (event.data.ok) { + if (event.data.buffer) { + // save to buffer + const url = URL.createObjectURL( + new Blob([event.data.buffer], { type: "video/mp4" }) + ) + + const a = document.createElement("a") + a.href = url + a.id = "temp_download" + + a.download = `[animevsub.eu.org] ${i18n.global.t( + "tap-_chap-_name-_othername", + [ + chaps.chaps.find((item) => item.id === currentChapId)?.name, + season.name, + season.othername + ] + )}.mp4` + document.body.appendChild(a) + a.click() + + // URL.revokeObjectURL(url) + document.body.removeChild(a) + } + + resolve() + worker.terminate() + } else reject(new Error(event.data.message ?? "")) + } else { + onProgress(...event.data) + } + } + worker.onerror = (event) => reject(event) + // worker.onmessageerror = (event) => reject(event) + + worker.postMessage({ + url + }) + }) +} diff --git a/src/logic/download-to-mp4.worker.ts b/src/logic/download-to-mp4.worker.ts new file mode 100644 index 00000000..31015c7a --- /dev/null +++ b/src/logic/download-to-mp4.worker.ts @@ -0,0 +1,66 @@ +import type PhimId from "src/apis/parser/phim/[id]" +import type PhimIdChap from "src/apis/parser/phim/[id]/[chap]" + +import { convertHlsToMP4 } from "./convert-hls-to-mp4" + +async function download(url: string) { + const hlsContent = await fetch(url).then((res) => + res.ok ? res.text() : Promise.reject(res) + ) + + let lastTCurrent = 0 + let lastTimeTCurrent = 0 + const mp4File = await convertHlsToMP4( + hlsContent, + (url) => { + return fetch(`${url}#animevsub-vsub_extra`) + .then((res) => (res.ok ? res.arrayBuffer() : Promise.reject(res))) + .then((buffer) => new Uint8Array(buffer)) + }, + (current, total, tCurrent, tDuration) => { + const now = performance.now() + + const speed = (tCurrent - lastTCurrent) / (now - lastTimeTCurrent) + lastTimeTCurrent = now + lastTCurrent = tCurrent + + postMessage([current, total, tCurrent, tDuration, speed]) + }, + 10, + { maxTry: 10, delay: 3_000 } + ) + // save + + const buffer = await new Blob([mp4File], { + type: "video/mp4" + }).arrayBuffer() + + return buffer +} + +addEventListener( + "message", + async ({ + data + }: MessageEvent<{ + url: string + filename: string + realSeasonId: string + season: Awaited> + chaps: Awaited> + currentChapId: string + saveToFile: boolean + }>) => { + try { + const buffer = await download(data.url) + + if (buffer) postMessage({ ok: true, buffer }, { transfer: [buffer] }) + else postMessage({ ok: true }) + } catch (err) { + console.error(err) + postMessage({ ok: false, message: err + "" }) + } + + self.close() + } +) diff --git a/src/logic/get-vdm-store.ts b/src/logic/get-vdm-store.ts new file mode 100644 index 00000000..8cfc94b7 --- /dev/null +++ b/src/logic/get-vdm-store.ts @@ -0,0 +1,13 @@ +const vdmStoreRef = shallowRef | null>(null) +export function getVdmStoreCache() { + return vdmStoreRef.value +} +export async function getVdmStore() { + const { useVDMStore } = await import("stores/vdm") + + vdmStoreRef.value ??= useVDMStore() + + return vdmStoreRef.value +} diff --git a/src/logic/registry-before-unload.ts b/src/logic/registry-before-unload.ts new file mode 100644 index 00000000..576508a3 --- /dev/null +++ b/src/logic/registry-before-unload.ts @@ -0,0 +1,15 @@ +let count = 0 + +const fn = (event: Event) => { + event.preventDefault() + event.returnValue = true +} + +export function registerBeforeUnload() { + count++ + addEventListener("beforeunload", fn) +} +export function unRegisterBeforeUnload() { + count-- + if (count < 1) removeEventListener("beforeunload", fn) +} diff --git a/src/pages/phim/_season.vue b/src/pages/phim/_season.vue index cff40382..70c5fe58 100644 --- a/src/pages/phim/_season.vue +++ b/src/pages/phim/_season.vue @@ -261,6 +261,53 @@ t("luu") }} + + + + {{ + stateProgress + ? Array.isArray(stateProgress) + ? "Đang tải" + : "Lỗi" + : "Tải" + }} + @@ -485,6 +532,7 @@ import { getDataJson } from "src/logic/get-data-json" import { getQualityByLabel } from "src/logic/get-quality-by-label" import { getRealSeasonId } from "src/logic/getRealSeasonId" import { parseChapName } from "src/logic/parseChapName" +import { parseTime } from "src/logic/parseTime" import { unflat } from "src/logic/unflat" import { useAuthStore } from "stores/auth" import { useHistoryStore } from "stores/history" @@ -1866,6 +1914,52 @@ const skEpisode = computedAsync | null>( null, { onError: WARN, shallow: true, lazy: true } ) + +// ============== download video =============== +const vdmStoreRef = shallowRef | null>(null) + +const stateProgress = computed(() => { + if (!vdmStoreRef.value || !realIdCurrentSeason.value || !currentChap.value) + return null + + return vdmStoreRef.value?.getProgress( + realIdCurrentSeason.value, + currentChap.value + ) +}) + +async function download() { + const { useVDMStore } = await import("stores/vdm") + + vdmStoreRef.value ??= useVDMStore() + + try { + await vdmStoreRef.value.download( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + realIdCurrentSeason.value!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + data.value!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + currentDataCache.value!.response!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + currentChap.value! + ) + + $q.notify({ + message: t("da-tai-xong-video"), + caption: t("msg-close-tab"), + position: "bottom-left" + }) + } catch (err) { + $q.notify({ + message: t("tai-video-that-bai"), + caption: err + "", + position: "bottom-left" + }) + } +}