diff --git a/src/videoag/api/Backend.tsx b/src/videoag/api/Backend.tsx index 0bd9429518f3ec4fece2ab2425bc955a420c6d81..34a1d843fc5e65c476a2404bd02f527cce6b8808 100644 --- a/src/videoag/api/Backend.tsx +++ b/src/videoag/api/Backend.tsx @@ -24,6 +24,8 @@ import type { GetSorterFilesRequest, GetSorterFilesResponse, RunSourceFileSorterResponse, + GetLectureMediaProcessOverviewResponse, + RunLectureMediaProcessSchedulerResponse, GetConfigurationResponse, GetNewConfigurationResponse, CreateConfiguredObjectRequest, @@ -305,6 +307,26 @@ export class BackendImpl { ); } + /// GET /lecture/{lecture_id}/media_process_overview + getLectureMediaProcessOverview( + lecture_id: int, + ): Promise<GetLectureMediaProcessOverviewResponse> { + return this.processResponse<GetLectureMediaProcessOverviewResponse>( + this.fetch(`${this.baseUrl()}/lecture/${lecture_id}/media_process_overview`), + ); + } + + /// GET /lecture/{lecture_id}/media_process_overview + runLectureMediaProcessScheduler( + lecture_id: int, + ): Promise<RunLectureMediaProcessSchedulerResponse> { + return this.processResponse<RunLectureMediaProcessSchedulerResponse>( + this.fetch(`${this.baseUrl()}/lecture/${lecture_id}/run_media_process_scheduler`, { + method: "POST", + }), + ); + } + // GET /object_management/{object_type}/{object_id}/configuration getOMConfiguration(object_type: string, object_id: int): Promise<GetConfigurationResponse> { return this.processResponse<GetConfigurationResponse>( diff --git a/src/videoag/api/types.ts b/src/videoag/api/types.ts index 8e7b4489fa9614a93f0b4653b1b52228aeddbcbb..8e0011256cd19968638b817ebeaa71c6679fc371 100644 --- a/src/videoag/api/types.ts +++ b/src/videoag/api/types.ts @@ -116,6 +116,18 @@ export interface publish_medium { include_in_player: boolean; } +export interface medium_file { + file_path: string; + id: int; + input_data_sha256: string; + lecture_id: int; + medium_metadata_id?: int; + process_sha256: string; + process_target_id: string; + producer_job_id?: int; + to_be_replaced: boolean; +} + export type medium_metadata_type = "image" | "plain_video" | "plain_audio" | "thumbnail"; export interface medium_metadata { @@ -443,6 +455,27 @@ export interface RunSourceFileSorterResponse { scheduled_new: boolean; } +/// GET /lecture/{lecture_id}/media_process_overview +// No Request Interface +export interface GetLectureMediaProcessOverviewResponse { + is_automatic_media_process_scheduler_enabled: boolean; + job_context: { [key: string]: job }; + medium_files: { [key: string]: medium_file }; + medium_metadata: { [key: string]: medium_metadata }; + process: any; // TODO media_process type + process_sha256: string; + publish_media: { [key: string]: publish_medium }; + sorter_files: { [key: string]: sorter_file }; + status: string; +} + +/// POST /lecture/{lecture_id}/run_media_process_scheduler +// No Request Interface +export interface RunLectureMediaProcessSchedulerResponse { + scheduled_new: boolean; + job_id: int; +} + /// GET /object_management/{object_type}/{object_id}/configuration // Request Interface export interface GetConfigurationRequest { diff --git a/src/videoag/course/Lecture.tsx b/src/videoag/course/Lecture.tsx index fe9e5ab5fac48c2020c6f98be309160dbdd21a39..c6a56a3dc3d91d4c2d6836e6975b3c88b01e1dac 100644 --- a/src/videoag/course/Lecture.tsx +++ b/src/videoag/course/Lecture.tsx @@ -14,7 +14,11 @@ import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; -import { PublishMediumDownloadButton, PublishMediumList } from "./Medium"; +import { + PublishMediumDownloadButton, + PublishMediumList, + ModalMediaProcessOverview, +} from "./Medium"; import { LectureLiveLabel } from "./LiveLabel"; export function urlForLecture(course_id: string, lecture_id: string | number) { @@ -211,7 +215,13 @@ export function LectureListItem({ <ul className="list-unstyled col-md-4 col-12 pe-0 row align-content-start"> <li className="col-xl-7 col-12 p-0"> {editMode ? ( - <PublishMediumList publish_media={lecture.publish_media!} /> + <> + <PublishMediumList publish_media={lecture.publish_media!} /> + <ModalMediaProcessOverview + lectureId={lecture.id} + className="mt-2" + /> + </> ) : ( <PublishMediumDownloadButton publish_media={lecture.publish_media ?? []} diff --git a/src/videoag/course/Medium.tsx b/src/videoag/course/Medium.tsx index c2a0ae84d31bdcc7e7aa08299904e84fe4f59923..552da6010e5f2b840d325293061de0f87f799bb6 100644 --- a/src/videoag/course/Medium.tsx +++ b/src/videoag/course/Medium.tsx @@ -1,8 +1,19 @@ -import { Dropdown } from "react-bootstrap"; +import { useEffect, useState } from "react"; +import { Dropdown, Modal } from "react-bootstrap"; -import { publish_medium } from "@/videoag/api/types"; +import { int, publish_medium, GetLectureMediaProcessOverviewResponse } from "@/videoag/api/types"; +import { useApi } from "@/videoag/api/ApiProvider"; +import { showError, ErrorComponent } from "@/videoag/error/ErrorDisplay"; +import { JobStatusCard } from "@/videoag/job/Job"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; -import { filesizeToHuman } from "@/videoag/miscellaneous/Formatting"; +import { + filesizeToHuman, + timestampToString, + datetimeToString, +} from "@/videoag/miscellaneous/Formatting"; +import { useMutexCall } from "@/videoag/miscellaneous/PromiseHelpers"; +import { ReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; +import { Spinner, showInfoToast, TooltipButton } from "@/videoag/miscellaneous/Util"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; import { OMDelete, EmbeddedOMFieldComponent } from "@/videoag/object_management/OMConfigComponent"; @@ -12,6 +23,10 @@ export function getMediumQualityName(medium: publish_medium): string | undefined return `${medium.medium_metadata.vertical_resolution}p`; case "plain_audio": return `${medium.medium_metadata.audio_sample_rate} Hz`; + case "thumbnail": + return `Thumbnail`; + case "image": + return `${medium.medium_metadata.horizontal_resolution}x${medium.medium_metadata.vertical_resolution}`; default: return undefined; } @@ -174,3 +189,480 @@ export function PublishMediumDownloadButton({ </span> ); } + +function MediumFileListItem({ + context, + mediumFileId, +}: { + context: GetLectureMediaProcessOverviewResponse; + mediumFileId: int; +}) { + const [showJobStatus, setShowJobStatus] = useState<boolean>(false); + + const file = context.medium_files[mediumFileId.toString()]!; + const metadata = file.medium_metadata_id + ? context.medium_metadata[file.medium_metadata_id.toString()] + : undefined; + const publishMedium = metadata?.publish_medium_id + ? context.publish_media[metadata.publish_medium_id.toString()] + : undefined; + const hasMetadata = metadata !== undefined; + + useEffect(() => { + if (!hasMetadata) { + setShowJobStatus(true); + } + }, [hasMetadata]); + + let metadataComp = undefined; + if (metadata !== undefined) { + const typeRows = []; + if (["plain_video", "plain_audio"].includes(metadata.type)) { + typeRows.push( + <tr> + <td>Duration</td> + <td> + {timestampToString(metadata.duration_sec!)} ({metadata.duration_sec!} s) + </td> + </tr>, + ); + typeRows.push( + <tr> + <td>Audio Channel Count</td> + <td>{metadata.audio_channel_count!}</td> + </tr>, + ); + typeRows.push( + <tr> + <td>Audio Sample Rate</td> + <td>{metadata.audio_sample_rate!}</td> + </tr>, + ); + } + if (["plain_video"].includes(metadata.type)) { + typeRows.push( + <tr> + <td>Video Framerate</td> + <td> + {metadata.video_frame_rate_numerator! / + metadata.video_frame_rate_denominator!} + </td> + </tr>, + ); + } + if (["image", "thumbnail", "plain_video"].includes(metadata.type)) { + typeRows.push( + <tr> + <td>Resolution</td> + <td> + {metadata.horizontal_resolution!}x{metadata.vertical_resolution!} + </td> + </tr>, + ); + } + metadataComp = ( + <> + <h5>Medium Metadata {metadata.id}</h5> + <table className="table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td>File Size</td> + <td> + {filesizeToHuman(metadata.file_size)} ({metadata.file_size} B) + </td> + </tr> + <tr> + <td>Type</td> + <td>{metadata.type}</td> + </tr> + {typeRows} + </tbody> + </table> + </> + ); + } + + let publishMediumComp = undefined; + if (publishMedium !== undefined) { + publishMediumComp = ( + <> + <h5>Publish Medium {publishMedium.id}</h5> + <table className="table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td> + Title{" "} + <TooltipButton iconClassName="bi-pencil"> + This field can be edited (with a double click) + </TooltipButton> + </td> + <td> + <EmbeddedOMFieldComponent + object_type="publish_medium" + object_id={publishMedium.id} + field_id="title" + field_type="string" + initialValue={publishMedium.title} + /> + </td> + </tr> + <tr> + <td> + Visible{" "} + <TooltipButton iconClassName="bi-pencil"> + This field can be edited + </TooltipButton> + </td> + <td> + <EmbeddedOMFieldComponent + object_type="publish_medium" + object_id={publishMedium.id} + field_id="visible" + field_type="boolean" + initialValue={publishMedium.visible} + /> + </td> + </tr> + <tr> + <td> + Include in Player? + <TooltipButton> + Indicates whether this publish medium is included in the player. + This purely depends on the medium type (e.g. a thumbnail + won't be included in the player). + </TooltipButton> + </td> + <td>{publishMedium.include_in_player ? "Yes" : "No"}</td> + </tr> + </tbody> + </table> + </> + ); + } + + return ( + <div className={"card mb-3 " + (file.to_be_replaced ? "bg-warning-subtle" : "")}> + <div id={`medium_file_${file.id}`} className="card-body"> + <h5>Medium File {file.id}</h5> + <table className="table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td>File Path</td> + <td>{file.file_path}</td> + </tr> + <tr> + <td>Input Data SHA256</td> + <td>{file.input_data_sha256}</td> + </tr> + <tr> + <td>Process SHA256</td> + <td>{file.process_sha256}</td> + </tr> + <tr> + <td>Process Target ID</td> + <td>{file.process_target_id}</td> + </tr> + <tr> + <td>Producer Job</td> + <td> + {file.producer_job_id && + (showJobStatus ? ( + <JobStatusCard jobId={file.producer_job_id} /> + ) : ( + <> + <span>{file.producer_job_id}</span> + <button + className="btn btn-secondary ms-2" + onClick={(e) => setShowJobStatus(true)} + > + Show Status + </button> + </> + ))} + </td> + </tr> + <tr> + <td> + To Be Replaced? + <TooltipButton> + Indicates whether this medium file is outdated due to various + reasons. Usually because the process changed or the input + dependencies changed. + </TooltipButton> + </td> + <td>{file.to_be_replaced ? "Yes" : "No"}</td> + </tr> + </tbody> + </table> + {metadataComp} + {publishMediumComp} + </div> + </div> + ); +} + +function SorterFileListItem({ + context, + sorterFileId, +}: { + context: GetLectureMediaProcessOverviewResponse; + sorterFileId: int; +}) { + const file = context.sorter_files[sorterFileId.toString()]!; + + return ( + <div className="card mb-3"> + <div className="card-body"> + <h5>Sorter File {file.id}</h5> + <table className="table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td>File Path</td> + <td>{file.file_path}</td> + </tr> + <tr> + <td>File Modification Time</td> + <td>{datetimeToString(file.file_modification_time)}</td> + </tr> + <tr> + <td>SHA256</td> + <td>{file.sha256}</td> + </tr> + <tr> + <td>Tag</td> + <td>{file.tag}</td> + </tr> + <tr> + <td> + Designated Medium File ID + <TooltipButton> + Shows which medium file was created for this source file. That + medium file and this source file reference the same file (have + the same file path). + <br /> + Note that this medium file can change if the process changes, + etc. + </TooltipButton> + </td> + <td> + {file.designated_medium_file_id && ( + <a href={`#medium_file_${file.designated_medium_file_id}`}> + {file.designated_medium_file_id} + </a> + )} + </td> + </tr> + </tbody> + </table> + </div> + </div> + ); +} + +export function MediaProcessOverview({ lectureId }: { lectureId: int }) { + const api = useApi(); + const [overviewData, setOverviewData] = useState< + GetLectureMediaProcessOverviewResponse | undefined + >(undefined); + const [error, setError] = useState<any>(undefined); + + const reloadData = useMutexCall(() => + api + .getLectureMediaProcessOverview(lectureId) + .then(setOverviewData) + .catch((e) => { + console.log("Error while loading media process overview", e); + setError(e); + }), + ); + + useEffect(() => { + reloadData(); + }, [lectureId, reloadData]); + + const [doingSchedulerRequest, setDoingSchedulerRequest] = useState<boolean>(false); + const [schedulerJobId, setSchedulerJobId] = useState<int | undefined>(undefined); + + const runScheduler = () => { + setDoingSchedulerRequest(true); + api.runLectureMediaProcessScheduler(lectureId) + .then((res) => { + setSchedulerJobId(res.job_id); + console.log(res.scheduled_new); + if (!res.scheduled_new) { + showInfoToast("Scheduler was already running"); + } + }) + .catch((e) => { + showError(e, "Error while trying to run media process scheduler"); + }) + .finally(() => { + setDoingSchedulerRequest(false); + }); + }; + + if (error !== undefined) { + return ( + <ReloadBoundary reloadFunc={reloadData}> + <ErrorComponent + error={error} + objectName="Media Process Overview" + showButtons={false} + /> + </ReloadBoundary> + ); + } + + if (overviewData === undefined) { + return <Spinner visible={true} />; + } + + return ( + <ul className="list-unstyled"> + <table className="table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td>Current Media Process</td> + <td> + <textarea + value={JSON.stringify(overviewData.process, null, " ")} + disabled={true} + className="w-100" + /> + </td> + </tr> + <tr> + <td>Current Media Process SHA256</td> + <td>{overviewData.process_sha256}</td> + </tr> + <tr> + <td>Status</td> + <td className="white-space-pre-wrap">{overviewData.status}</td> + </tr> + <tr> + <td> + Is automatic Media Process Scheduler enabled?{" "} + <TooltipButton> + This shows whether the media process scheduler will automatically + run when any changes occur that might require processing media + files. This can be changed in the lecture config or, if the lecture + has specified 'Inherit', in the course config. + </TooltipButton> + </td> + <td> + {overviewData.is_automatic_media_process_scheduler_enabled + ? "Yes" + : "No"} + </td> + </tr> + </tbody> + </table> + <div className="mb-4 d-flex align-items-center"> + <button + onClick={runScheduler} + disabled={doingSchedulerRequest} + className="btn btn-secondary" + > + Run Media Process Scheduler Manually + </button> + <TooltipButton> + If the automatic media process scheduler is disabled, you can run it manually + here (You will need to run it multiple times until the process is finished; + after every finished job). + <br /> + <br /> + If a job failed the automatic process scheduler will also not run again + automatically (to prevent infinite loops) and you can run it manually. It will + automatically detect the failed job and schedule a new one which might or might + not fail again. + <br /> + <br /> + In all other cases you do not need to run it manually. + </TooltipButton> + <Spinner visible={doingSchedulerRequest} /> + {schedulerJobId && ( + <span className="m-1"> + <JobStatusCard jobId={schedulerJobId} /> + </span> + )} + </div> + <li> + <ul className="list-unstyled"> + {Object.values(overviewData.sorter_files).map((file) => ( + <li key={file.id}> + <SorterFileListItem context={overviewData} sorterFileId={file.id} /> + </li> + ))} + </ul> + </li> + <li> + <ul className="list-unstyled"> + {Object.values(overviewData.medium_files).map((file) => ( + <li key={file.id}> + <MediumFileListItem context={overviewData} mediumFileId={file.id} /> + </li> + ))} + </ul> + </li> + </ul> + ); +} + +export function ModalMediaProcessOverview({ + lectureId, + className, +}: { + lectureId: int; + className: string; +}) { + const [showModal, setShowModal] = useState(false); + return ( + <> + <button + type="button" + className={"btn btn-primary " + (className ?? "")} + onClick={() => setShowModal(true)} + > + <span className="bi bi-cpu" /> + </button> + <Modal show={showModal} size="xl" onHide={() => setShowModal(false)}> + <Modal.Header closeButton> + <Modal.Title>Media Process Overview</Modal.Title> + </Modal.Header> + <Modal.Body> + {showModal && <MediaProcessOverview lectureId={lectureId} />} + </Modal.Body> + </Modal> + </> + ); +}