diff --git a/src/lang/en/global.json b/src/lang/en/global.json index bf5bf1bec1..d2277354d1 100644 --- a/src/lang/en/global.json +++ b/src/lang/en/global.json @@ -32,5 +32,12 @@ "name": "Name", "refresh_success": "Refresh successfully", "refresh_failed": "Refresh failed", - "required": "required" + "required": "required", + "generate_strm": "Generate Strm", + "generate_strm_start": "Generation started", + "generate_strm_progress": "Generating Strm...", + "generate_strm_done": "Strm generated", + "generate_strm_failed": "Generation failed", + "generate_strm_hide": "Hide", + "generate_strm_background": "The task keeps running in the background. Track it in the task center." } diff --git a/src/lang/en/home.json b/src/lang/en/home.json index fbaf844bd9..55793aae26 100644 --- a/src/lang/en/home.json +++ b/src/lang/en/home.json @@ -158,6 +158,7 @@ "new_file": "New File", "input_filename": "Input filename", "cancel_select": "Cancel Select", + "generate_strm": "Generate Strm", "offline_download": "Offline download", "offline_download-tips": "One URL per line", "delete_policy": { diff --git a/src/lang/en/manage.json b/src/lang/en/manage.json index d5742af281..cec4ae0e5b 100644 --- a/src/lang/en/manage.json +++ b/src/lang/en/manage.json @@ -26,6 +26,7 @@ "permissions_config": "Config", "upload": "Upload", "copy": "Copy", + "strm_generate": "Strm Generate", "decompress": "Decompress", "s3_transition": "S3 Transition", "backup-restore": "Backup & Restore", diff --git a/src/lang/en/tasks.json b/src/lang/en/tasks.json index 62818e03c2..85199714ce 100644 --- a/src/lang/en/tasks.json +++ b/src/lang/en/tasks.json @@ -3,6 +3,7 @@ "offline_download_transfer": "Transfer downloaded file to corresponding storage", "upload": "Upload file to corresponding storage", "copy": "Copy file from a storage to another storage", + "strm_generate": "Generate local .strm files for a Strm storage", "decompress": "Download and decompress an archive file", "decompress_upload": "Upload extracted file into target storage", "s3_transition": "Archive and restore S3 objects", @@ -45,6 +46,9 @@ "upload": { "path": "Path" }, + "strm_generate": { + "path": "Path" + }, "offline_download": { "url": "URL", "path": "Destination Path", diff --git a/src/pages/home/toolbar/Right.tsx b/src/pages/home/toolbar/Right.tsx index 1eb5dbc1fb..4fc58f0211 100644 --- a/src/pages/home/toolbar/Right.tsx +++ b/src/pages/home/toolbar/Right.tsx @@ -13,6 +13,7 @@ import { usePath } from "~/hooks" import { Motion } from "@motionone/solid" import { isTocVisible, setTocDisabled } from "~/components" import { BiSolidBookContent } from "solid-icons/bi" +import { ToolbarStrmGenerate } from "./StrmGenerate" export const Right = () => { const { isOpen, onToggle } = createDisclosure({ @@ -122,6 +123,7 @@ export const Right = () => { }} /> + { + const { pathname } = useRouter() + const isAdmin = () => (me().role || []).includes(2) + const isStrm = () => objStore.provider === "Strm" + return ( + + + {({ start }) => ( + + )} + + + ) +} diff --git a/src/pages/manage/sidemenu_items.tsx b/src/pages/manage/sidemenu_items.tsx index 940f19b714..c810ba71e5 100644 --- a/src/pages/manage/sidemenu_items.tsx +++ b/src/pages/manage/sidemenu_items.tsx @@ -24,7 +24,7 @@ import { Component, lazy } from "solid-js" import { joinBase } from "~/utils" import { Group, UserRole } from "~/types" import { FaSolidBook, FaSolidDatabase } from "solid-icons/fa" -import { TbArchive, TbDevices2 } from "solid-icons/tb" +import { TbArchive, TbDevices2, TbFileExport } from "solid-icons/tb" import { TbLink } from "solid-icons/tb" import { FaSolidUserGear } from "solid-icons/fa" import { BiRegularMessageAltDetail } from "solid-icons/bi" @@ -180,6 +180,13 @@ export const side_menu_items: SideMenuItem[] = [ role: UserRole.GENERAL, component: lazy(() => import("./tasks/Copy")), }, + { + title: "manage.sidemenu.strm_generate", + icon: TbFileExport, + to: "/@manage/tasks/strm_generate", + role: UserRole.ADMIN, + component: lazy(() => import("./tasks/StrmGenerate")), + }, { title: "manage.sidemenu.decompress", icon: TbArchive, diff --git a/src/pages/manage/storages/Storage.tsx b/src/pages/manage/storages/Storage.tsx index 5cbfc7685a..ad7c2d6a43 100644 --- a/src/pages/manage/storages/Storage.tsx +++ b/src/pages/manage/storages/Storage.tsx @@ -9,11 +9,13 @@ import { useColorModeValue, VStack, } from "@hope-ui/solid" +import { Show } from "solid-js" import { useFetch, useRouter, useT } from "~/hooks" import { getMainColor } from "~/store" import { PEmptyResp, Storage } from "~/types" import { handleResp, handleRespWithNotifySuccess, notify, r } from "~/utils" import { DeletePopover } from "../common/DeletePopover" +import { StrmGenerateButton } from "./StrmGenerate" interface StorageProps { storage: Storage @@ -66,6 +68,9 @@ function StorageOp(props: StorageProps) { }) }} /> + + + ) } diff --git a/src/pages/manage/storages/StrmGenerate.tsx b/src/pages/manage/storages/StrmGenerate.tsx new file mode 100644 index 0000000000..7110995c34 --- /dev/null +++ b/src/pages/manage/storages/StrmGenerate.tsx @@ -0,0 +1,186 @@ +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Progress, + ProgressIndicator, + Text, + VStack, +} from "@hope-ui/solid" +import { createSignal, JSX, onCleanup, Show } from "solid-js" +import { useT } from "~/hooks" +import { r, notify, handleResp } from "~/utils" + +type TaskInfo = { id: string; progress: number; state: number; error: string } +type Status = "idle" | "running" | "done" | "failed" + +export type StrmGenerateProps = { + path: string + // render-prop trigger: receives `start` to kick off generation. Lets callers + // render either a Button (storages page) or a toolbar icon while sharing the + // progress dialog + polling logic below. + children: (api: { start: () => void }) => JSX.Element +} + +export type StrmGenerateButtonProps = { + path: string + size?: "xs" | "sm" | "md" | "lg" + colorScheme?: string +} + +export const StrmGenerate = (props: StrmGenerateProps) => { + const t = useT() + const [opened, setOpened] = createSignal(false) + const [progress, setProgress] = createSignal(0) + const [status, setStatus] = createSignal("idle") + const [err, setErr] = createSignal("") + let timer: ReturnType | undefined + // generation increments on every start/close/cleanup so any in-flight request + // or interval tick that resolves late can detect it is stale and bail out. + let generation = 0 + + const stop = () => { + if (timer) { + clearInterval(timer) + timer = undefined + } + } + onCleanup(() => { + generation++ + stop() + }) + + const fail = (msg: string) => { + stop() + setStatus("failed") + setErr(msg) + } + + const poll = (gen: number, tid: string) => { + stop() + timer = setInterval(async () => { + const resp = await r.post(`/admin/task/strm_generate/info?tid=${tid}`) + if (gen !== generation) return + handleResp( + resp, + (info: TaskInfo) => { + if (gen !== generation) return + if (info.error) { + fail(info.error) + return + } + setProgress(Math.floor(info.progress)) + if (info.progress >= 100) { + stop() + setStatus("done") + notify.success(t("global.generate_strm_done")) + } + }, + (msg: string) => { + if (gen === generation) fail(msg) + }, + ) + }, 1000) + } + + const start = async () => { + stop() + const gen = ++generation + setOpened(true) + setStatus("running") + setProgress(0) + setErr("") + const resp = await r.post("/admin/strm/generate", { path: props.path }) + if (gen !== generation) return + handleResp( + resp, + (data: { task: TaskInfo }) => { + if (gen !== generation) return + notify.info(t("global.generate_strm_start")) + poll(gen, data.task.id) + }, + (msg: string) => { + if (gen === generation) fail(msg) + }, + ) + } + + // hide just closes the foreground window; the backend task keeps running and + // polling continues, so the success toast still fires and progress stays + // visible in the task center. + const hide = () => setOpened(false) + + // close is used once the task reached a terminal state: stop polling and reset. + const close = () => { + generation++ + stop() + setOpened(false) + } + + const isRunning = () => status() === "running" + const dismiss = () => (isRunning() ? hide() : close()) + + const statusText = () => { + switch (status()) { + case "failed": + return t("global.generate_strm_failed") + ": " + err() + case "done": + return t("global.generate_strm_done") + case "running": + return t("global.generate_strm_progress") + " " + progress() + "%" + default: + return "" + } + } + + return ( + <> + {props.children({ start })} + + + + {t("global.generate_strm")} + + + + + + {statusText()} + + + {t("global.generate_strm_background")} + + + + + + + + + + + ) +} + +export const StrmGenerateButton = (props: StrmGenerateButtonProps) => { + const t = useT() + return ( + + {({ start }) => ( + + )} + + ) +} diff --git a/src/pages/manage/tasks/StrmGenerate.tsx b/src/pages/manage/tasks/StrmGenerate.tsx new file mode 100644 index 0000000000..430e8b4340 --- /dev/null +++ b/src/pages/manage/tasks/StrmGenerate.tsx @@ -0,0 +1,23 @@ +import { useManageTitle, useT } from "~/hooks" +import { TypeTasks } from "./Tasks" + +const StrmGenerate = () => { + const t = useT() + useManageTitle("manage.sidemenu.strm_generate") + return ( + ]()` + regex: /^generate strm \[(.+)]\((.*)\)$/, + title: (matches) => matches[1], + attrs: { + [t(`tasks.attr.strm_generate.path`)]: (matches) => matches[2] || "/", + }, + }} + /> + ) +} + +export default StrmGenerate