diff --git a/frontend/src/components/review/YouTubeUploadDialog.tsx b/frontend/src/components/review/YouTubeUploadDialog.tsx
new file mode 100644
index 0000000..bb29b23
--- /dev/null
+++ b/frontend/src/components/review/YouTubeUploadDialog.tsx
@@ -0,0 +1,245 @@
+import { useState } from "react"
+import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
+import { toast } from "sonner"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Progress } from "@/components/ui/progress"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+interface YouTubeUploadDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ assetName: string
+ fileName: string
+ uploadStatus: string
+ uploadStage: string
+ uploadPercent: number
+ uploadError: string
+ uploadVideoUrl: string
+ onUploadStarted: () => void
+}
+
+export function YouTubeUploadDialog({
+ open,
+ onOpenChange,
+ assetName,
+ fileName,
+ uploadStatus,
+ uploadStage,
+ uploadPercent,
+ uploadError,
+ uploadVideoUrl,
+ onUploadStarted,
+}: YouTubeUploadDialogProps) {
+ const [title, setTitle] = useState(fileName.replace(/\.[^/.]+$/, ""))
+ const [description, setDescription] = useState("")
+ const [privacyStatus, setPrivacyStatus] = useState("unlisted")
+
+ const { data: statusData } = useFrappeGetCall<{
+ message: { connected: boolean; channel_name: string }
+ }>("vms.youtube.get_youtube_status", undefined, "youtube-status-check", {
+ revalidateOnFocus: false,
+ })
+
+ const { call: callUpload, loading: uploading } = useFrappePostCall(
+ "vms.youtube.upload_to_youtube"
+ )
+
+ const isConnected = statusData?.message?.connected
+ const isInProgress = uploadStatus === "Queued" || uploadStatus === "Uploading"
+ const isComplete = uploadStatus === "Complete"
+ const isError = uploadStatus === "Error"
+
+ const handleUpload = async () => {
+ if (!title.trim()) {
+ toast.error("Title is required")
+ return
+ }
+
+ try {
+ await callUpload({
+ asset_name: assetName,
+ title: title.trim(),
+ description: description.trim(),
+ privacy_status: privacyStatus,
+ })
+ onUploadStarted()
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : "Failed to start upload"
+ toast.error(message)
+ }
+ }
+
+ const stageLabel = uploadStage === "downloading"
+ ? "Downloading from storage..."
+ : uploadStage === "uploading"
+ ? "Uploading to YouTube..."
+ : uploadStage === "queued"
+ ? "Queued, waiting to start..."
+ : "Processing..."
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/settings/YouTubeSection.tsx b/frontend/src/components/settings/YouTubeSection.tsx
new file mode 100644
index 0000000..1cea53d
--- /dev/null
+++ b/frontend/src/components/settings/YouTubeSection.tsx
@@ -0,0 +1,221 @@
+import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
+import { useState, useEffect } from "react"
+import { useSearchParams } from "react-router"
+import { toast } from "sonner"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Label } from "@/components/ui/label"
+import { Skeleton } from "@/components/ui/skeleton"
+
+interface YouTubeStatus {
+ connected: boolean
+ channel_name: string
+ has_credentials: boolean
+}
+
+export function YouTubeSection() {
+ const [searchParams, setSearchParams] = useSearchParams()
+ const [clientId, setClientId] = useState("")
+ const [clientSecret, setClientSecret] = useState("")
+
+ const {
+ data: statusData,
+ isLoading: statusLoading,
+ mutate,
+ } = useFrappeGetCall<{ message: YouTubeStatus }>(
+ "vms.youtube.get_youtube_status",
+ undefined,
+ "youtube-status",
+ { revalidateOnFocus: false }
+ )
+
+ const { data: redirectData, isLoading: redirectLoading } = useFrappeGetCall<{
+ message: { redirect_uri: string }
+ }>(
+ "vms.youtube.get_youtube_redirect_uri",
+ undefined,
+ "youtube-redirect-uri",
+ { revalidateOnFocus: false }
+ )
+
+ const { call: callConnect, loading: connecting } = useFrappePostCall("vms.youtube.connect_youtube")
+ const { call: callFinalize, loading: finalizing } = useFrappePostCall("vms.youtube.finalize_youtube_connection")
+ const { call: callDisconnect, loading: disconnecting } = useFrappePostCall("vms.youtube.disconnect_youtube")
+
+ const status = statusData?.message
+ const redirectUri = redirectData?.message?.redirect_uri || ""
+
+ // Handle OAuth redirect callback
+ useEffect(() => {
+ if (searchParams.get("youtube_connected") === "1") {
+ searchParams.delete("youtube_connected")
+ searchParams.delete("settings")
+ setSearchParams(searchParams, { replace: true })
+
+ callFinalize({})
+ .then(() => {
+ toast.success("YouTube connected successfully")
+ mutate()
+ })
+ .catch(() => {
+ toast.error("Failed to finalize YouTube connection")
+ })
+ }
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleConnect = async () => {
+ if (!clientId.trim() || !clientSecret.trim()) {
+ toast.error("Please enter both Client ID and Client Secret")
+ return
+ }
+
+ try {
+ const res = await callConnect({ client_id: clientId.trim(), client_secret: clientSecret.trim() })
+ const authUrl = (res as { message: { auth_url: string } }).message.auth_url
+ if (authUrl) {
+ window.location.href = authUrl
+ }
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : "Failed to connect YouTube"
+ toast.error(message)
+ }
+ }
+
+ const handleDisconnect = async () => {
+ try {
+ await callDisconnect({})
+ setClientId("")
+ setClientSecret("")
+ toast.success("YouTube disconnected")
+ mutate()
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : "Failed to disconnect YouTube"
+ toast.error(message)
+ }
+ }
+
+ if (statusLoading || redirectLoading) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+
YouTube
+
+ Connect your YouTube account to upload videos directly from VMS.
+
+
+
+ {status?.connected ? (
+
+
+
+
+
Connected
+
+ {status.channel_name}
+
+
+
+
+ ) : (
+
+ {/* Step 1: Show redirect URI */}
+
+
+ 1. Create an OAuth Client in the{" "}
+
+ Google Cloud Console
+ {" "}
+ with this redirect URI:
+
+
{
+ try {
+ await navigator.clipboard.writeText(redirectUri)
+ toast.success("Redirect URI copied to clipboard")
+ } catch {
+ toast.error("Failed to copy")
+ }
+ }}
+ title="Click to copy"
+ >
+ {redirectUri}
+
+
+
+ {/* Step 2: Enter credentials */}
+
+
+ 2. Paste the Client ID and Client Secret below:
+
+
+
+
+ )}
+
+
+
+
+ {/* Sticky footer */}
+
+ {status?.connected ? (
+
+ ) : (
+
+ )}
+
+ >
+ )
+}
diff --git a/frontend/src/pages/ReviewPage.tsx b/frontend/src/pages/ReviewPage.tsx
index 15261d1..5b6064e 100644
--- a/frontend/src/pages/ReviewPage.tsx
+++ b/frontend/src/pages/ReviewPage.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"
import { useParams, useSearchParams } from "react-router"
-import { useFrappeGetCall, useFrappePostCall, useFrappeAuth } from "frappe-react-sdk"
+import { useFrappeGetCall, useFrappePostCall, useFrappeAuth, useFrappeEventListener } from "frappe-react-sdk"
import { Spinner } from "@/components/ui/spinner"
import { ReviewProvider } from "@/contexts/ReviewContext"
import { useReviewContext } from "@/hooks/useReviewContext"
@@ -10,6 +10,7 @@ import { ImageViewer } from "@/components/review/ImageViewer"
import { CommentPanel } from "@/components/review/CommentPanel"
import { TranscriptionSheet } from "@/components/review/TranscriptionSheet"
import { SplitVideoDialog } from "@/components/review/SplitVideoDialog"
+import { YouTubeUploadDialog } from "@/components/review/YouTubeUploadDialog"
import { toast } from "sonner"
interface ReviewData {
@@ -29,6 +30,9 @@ interface ReviewData {
proxy_status?: string
split_from?: { name: string; file_name: string } | null
split_parts?: { name: string; file_name: string }[] | null
+ youtube_upload_status?: string
+ youtube_video_id?: string
+ youtube_video_url?: string
}
export function ReviewPage() {
@@ -110,6 +114,7 @@ function ReviewPageInner({
const isImage = asset.file_type?.startsWith("image/")
const [transcriptionOpen, setTranscriptionOpen] = useState(false)
const [splitDialogOpen, setSplitDialogOpen] = useState(false)
+ const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false)
const [isPolling, setIsPolling] = useState(asset.transcription_status === "Processing")
const [isSplitPolling, setIsSplitPolling] = useState(asset.status === "Processing")
@@ -122,6 +127,9 @@ function ReviewPageInner({
const { call: callSaveSpeakerNames } = useFrappePostCall(
"vms.transcription.save_speaker_names",
)
+ const { call: callResetYouTubeUpload } = useFrappePostCall(
+ "vms.youtube.reset_youtube_upload",
+ )
// Fetch transcription content — auto-poll every 5s while Processing
const { data: transcriptionData, mutate: mutateTranscription } = useFrappeGetCall<{
@@ -157,6 +165,77 @@ function ReviewPageInner({
const proxyStatus = proxyStatusData?.message?.proxy_status || asset.proxy_status || ""
+ // YouTube upload — realtime + fallback polling
+ const [youtubeProgress, setYoutubeProgress] = useState<{
+ status: string
+ videoUrl: string
+ percent: number
+ stage: string
+ error: string
+ }>({
+ status: asset.youtube_upload_status || "",
+ videoUrl: asset.youtube_video_url || "",
+ percent: 0,
+ stage: "",
+ error: "",
+ })
+
+ const isYouTubeActive = youtubeProgress.status === "Queued" || youtubeProgress.status === "Uploading"
+
+ // Realtime listener for instant progress
+ useFrappeEventListener<{
+ asset_name: string
+ stage: string
+ percent: number
+ video_url?: string
+ error?: string
+ }>("youtube_upload_progress", useCallback((data) => {
+ if (data.asset_name !== asset.name) return
+ if (data.stage === "complete") {
+ setYoutubeProgress({ status: "Complete", videoUrl: data.video_url || "", percent: 100, stage: "complete", error: "" })
+ mutateReviewData()
+ } else if (data.stage === "error") {
+ setYoutubeProgress((prev) => ({ ...prev, status: "Error", stage: "error", error: data.error || "Upload failed" }))
+ mutateReviewData()
+ } else {
+ setYoutubeProgress((prev) => ({
+ ...prev,
+ status: "Uploading",
+ stage: data.stage,
+ percent: data.percent,
+ }))
+ }
+ }, [asset.name, mutateReviewData]))
+
+ // Fallback polling in case realtime events don't arrive
+ const { data: youtubeStatusPoll } = useFrappeGetCall<{
+ message: { youtube_upload_status: string; youtube_video_id: string; youtube_video_url: string }
+ }>(
+ "vms.youtube.get_youtube_upload_status",
+ isYouTubeActive ? { asset_name: asset.name } : undefined,
+ isYouTubeActive ? `youtube-poll-${asset.name}` : undefined,
+ { revalidateOnFocus: false, refreshInterval: isYouTubeActive ? 5000 : 0 },
+ )
+
+ // Sync poll results into state (only if realtime hasn't already updated)
+ useEffect(() => {
+ const polled = youtubeStatusPoll?.message
+ if (!polled) return
+ const pollStatus = polled.youtube_upload_status
+ if (pollStatus === "Complete" && youtubeProgress.status !== "Complete") {
+ setYoutubeProgress({ status: "Complete", videoUrl: polled.youtube_video_url || "", percent: 100, stage: "complete", error: "" })
+ mutateReviewData()
+ } else if (pollStatus === "Error" && youtubeProgress.status !== "Error") {
+ setYoutubeProgress((prev) => ({ ...prev, status: "Error", stage: "error", error: "Upload failed" }))
+ mutateReviewData()
+ } else if (pollStatus === "Uploading" && youtubeProgress.status === "Queued") {
+ setYoutubeProgress((prev) => ({ ...prev, status: "Uploading", stage: "uploading" }))
+ }
+ }, [youtubeStatusPoll?.message]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const youtubeUploadStatus = youtubeProgress.status
+ const youtubeVideoUrl = youtubeProgress.videoUrl
+
// Stop proxy polling when done
useEffect(() => {
if (proxyStatus === "Ready" || proxyStatus === "Error") {
@@ -167,6 +246,8 @@ function ReviewPageInner({
}
}, [proxyStatus])
+
+
const handleGenerateProxy = useCallback(async () => {
await callGenerateProxy({ asset_name: asset.name })
setIsProxyPolling(true)
@@ -231,6 +312,13 @@ function ReviewPageInner({
[asset.name, callTogglePublicReview, mutateReviewData],
)
+ const handleResetYouTubeUpload = useCallback(async () => {
+ await callResetYouTubeUpload({ asset_name: asset.name })
+ setYoutubeProgress({ status: "", videoUrl: "", percent: 0, stage: "", error: "" })
+ mutateReviewData()
+ setYoutubeDialogOpen(true)
+ }, [asset.name, callResetYouTubeUpload, mutateReviewData])
+
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
@@ -267,6 +355,10 @@ function ReviewPageInner({
proxyStatus={proxyStatus}
onGenerateProxy={handleGenerateProxy}
isGeneratingProxy={generatingProxy}
+ youtubeUploadStatus={youtubeUploadStatus}
+ youtubeVideoUrl={youtubeVideoUrl}
+ onOpenYouTubeUpload={() => setYoutubeDialogOpen(true)}
+ onResetYouTubeUpload={handleResetYouTubeUpload}
/>
@@ -308,6 +400,24 @@ function ReviewPageInner({
}}
/>
)}
+
+ {!isGuest && !isImage && (
+ {
+ setYoutubeProgress({ status: "Queued", videoUrl: "", percent: 0, stage: "queued", error: "" })
+ mutateReviewData()
+ }}
+ />
+ )}
)
}
diff --git a/vms/api.py b/vms/api.py
index 0f6c19f..d3ab601 100644
--- a/vms/api.py
+++ b/vms/api.py
@@ -422,7 +422,12 @@ def rename_folder(folder_name_id: str, new_name: str):
# Check for duplicate name in same project (exclude trashed)
existing = frappe.db.exists(
"VMS Folder",
- {"folder_name": new_name, "project": folder.project, "name": ["!=", folder.name], "deleted_at": ["is", "not set"]},
+ {
+ "folder_name": new_name,
+ "project": folder.project,
+ "name": ["!=", folder.name],
+ "deleted_at": ["is", "not set"],
+ },
)
if existing:
frappe.throw(_("A folder named '{0}' already exists in this project").format(new_name))
@@ -744,8 +749,7 @@ def get_trash_assets(page=1, page_size=20):
# Enrich with user info (uploader + deleter)
user_emails = list(
- {a.uploaded_by for a in assets if a.uploaded_by}
- | {a.deleted_by for a in assets if a.deleted_by}
+ {a.uploaded_by for a in assets if a.uploaded_by} | {a.deleted_by for a in assets if a.deleted_by}
)
user_map = {}
if user_emails:
@@ -1026,7 +1030,11 @@ def search_assets(query: str, project: str | None = None, limit: int = 10):
)
except Exception:
# Fallback to SQL LIKE search if index doesn't exist
- like_filters = {"file_name": ["like", f"%{query}%"], "status": ["!=", "Uploading"], "deleted_at": ["is", "not set"]}
+ like_filters = {
+ "file_name": ["like", f"%{query}%"],
+ "status": ["!=", "Uploading"],
+ "deleted_at": ["is", "not set"],
+ }
if project:
like_filters["project"] = project
diff --git a/vms/install.py b/vms/install.py
index 6f8ccfb..d8fd7a6 100644
--- a/vms/install.py
+++ b/vms/install.py
@@ -147,9 +147,7 @@ def _build_whisper_from_source():
src_dir = Path(tmpdir) / "whisper.cpp"
build_dir = src_dir / "build"
- _run(
- ["git", "clone", "--depth=1", f"--branch={WHISPER_CPP_VERSION}", WHISPER_CPP_REPO, str(src_dir)]
- )
+ _run(["git", "clone", "--depth=1", f"--branch={WHISPER_CPP_VERSION}", WHISPER_CPP_REPO, str(src_dir)])
build_dir.mkdir(exist_ok=True)
_run(["cmake", "-B", str(build_dir), "-S", str(src_dir)])
_run(["cmake", "--build", str(build_dir), "--config", "Release", "-j"])
@@ -162,9 +160,7 @@ def _build_whisper_from_source():
if binary.exists():
_run(["sudo", "install", "-m", "0755", str(binary), "/usr/local/bin/whisper-cli"])
else:
- raise FileNotFoundError(
- f"whisper-cli binary not found in build output at {build_dir}"
- )
+ raise FileNotFoundError(f"whisper-cli binary not found in build output at {build_dir}")
def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
diff --git a/vms/notifications.py b/vms/notifications.py
index 210b2d6..2d08fea 100644
--- a/vms/notifications.py
+++ b/vms/notifications.py
@@ -78,9 +78,7 @@ def _process_comment_notifications(comment_name):
if alert_emails:
is_reply = bool(comment.parent_comment)
action = _("replied to your comment") if is_reply else _("commented")
- alert_subject = _("{0} {1} on {2}").format(
- commenter_name, action, asset.file_name
- )
+ alert_subject = _("{0} {1} on {2}").format(commenter_name, action, asset.file_name)
enqueue_create_notification(
alert_emails,
{
diff --git a/vms/proxy.py b/vms/proxy.py
index 4859a24..3be464e 100644
--- a/vms/proxy.py
+++ b/vms/proxy.py
@@ -119,15 +119,24 @@ def _ffmpeg_proxy(input_path: str, output_path: str):
"ffmpeg",
"-hide_banner",
"-y",
- "-i", input_path,
- "-c:v", "libx264",
- "-preset", "fast",
- "-crf", "28",
- "-pix_fmt", "yuv420p",
- "-vf", "scale='min(1280,iw)':-2",
- "-c:a", "aac",
- "-b:a", "128k",
- "-movflags", "+faststart",
+ "-i",
+ input_path,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "fast",
+ "-crf",
+ "28",
+ "-pix_fmt",
+ "yuv420p",
+ "-vf",
+ "scale='min(1280,iw)':-2",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "128k",
+ "-movflags",
+ "+faststart",
output_path,
]
diff --git a/vms/public/frontend/index.html b/vms/public/frontend/index.html
deleted file mode 100644
index 9d31b7f..0000000
--- a/vms/public/frontend/index.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- BWH VMS
-
-
-
-
-
-
-
-
diff --git a/vms/public/frontend/manifest.webmanifest b/vms/public/frontend/manifest.webmanifest
deleted file mode 100644
index e6b9000..0000000
--- a/vms/public/frontend/manifest.webmanifest
+++ /dev/null
@@ -1 +0,0 @@
-{"name":"VMS - Video Management Solution","short_name":"VMS","description":"Video Management Solution by BuildWithHussain","start_url":"/vms/","display":"standalone","background_color":"#ffffff","theme_color":"#7c3aed","lang":"en","scope":"/vms/","icons":[{"src":"pwa-192x192.png","sizes":"192x192","type":"image/png"},{"src":"pwa-512x512.png","sizes":"512x512","type":"image/png"},{"src":"pwa-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
diff --git a/vms/public/frontend/sw.js b/vms/public/frontend/sw.js
deleted file mode 100644
index 365b829..0000000
--- a/vms/public/frontend/sw.js
+++ /dev/null
@@ -1 +0,0 @@
-if(!self.define){let s,e={};const l=(l,r)=>(l=new URL(l+".js",r).href,e[l]||new Promise(e=>{if("document"in self){const s=document.createElement("script");s.src=l,s.onload=e,document.head.appendChild(s)}else s=l,importScripts(l),e()}).then(()=>{let s=e[l];if(!s)throw new Error(`Module ${l} didn’t register its module`);return s}));self.define=(r,i)=>{const n=s||("document"in self?document.currentScript.src:"")||location.href;if(e[n])return;let o={};const a=s=>l(s,n),t={module:{uri:n},exports:o,require:a};e[n]=Promise.all(r.map(s=>t[s]||a(s))).then(s=>(i(...s),o))}}define(["./workbox-004510d2"],function(s){"use strict";self.skipWaiting(),s.clientsClaim(),s.precacheAndRoute([{url:"vms-logo.png",revision:"6aa35055d335a040d6eadfeafd05976d"},{url:"pwa-512x512.png",revision:"27187594c3538abaa333f5b60a1c947f"},{url:"pwa-192x192.png",revision:"5371c9ae9233f24ae2bd93426db5bf6e"},{url:"index.html",revision:"665238b15d8bce34c6f72d71f4742215"},{url:"apple-touch-icon.png",revision:"587f03ca22ec0daf2ccdc80a102df05c"},{url:"assets/workbox-window.prod.es5-CcvICf7O.js",revision:null},{url:"assets/useDownload-BAWS7dpP.js",revision:null},{url:"assets/table-BjeWYoO1.js",revision:null},{url:"assets/switch-C3VfMp1g.js",revision:null},{url:"assets/popover-DWh0XAkK.js",revision:null},{url:"assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2",revision:null},{url:"assets/inter-latin-wght-normal-Dx4kXJAl.woff2",revision:null},{url:"assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2",revision:null},{url:"assets/inter-greek-wght-normal-CkhJZR-_.woff2",revision:null},{url:"assets/inter-greek-ext-wght-normal-DlzME5K_.woff2",revision:null},{url:"assets/inter-cyrillic-wght-normal-DqGufNeO.woff2",revision:null},{url:"assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2",revision:null},{url:"assets/index-Bg5mcWGk.js",revision:null},{url:"assets/index-B9yzDPuE.css",revision:null},{url:"assets/checkbox-CzTby9NQ.js",revision:null},{url:"assets/card-CeUejw_-.js",revision:null},{url:"assets/calendar-BOv5TR8Q.js",revision:null},{url:"assets/alert-dialog-MGuo7eQY.js",revision:null},{url:"assets/UserAvatar-C3ANsJ3e.js",revision:null},{url:"assets/TrashPage-C6vLqS0L.js",revision:null},{url:"assets/ToolsPage-BKHF_Tv6.js",revision:null},{url:"assets/SharedProjectPage-9NzcFjLv.js",revision:null},{url:"assets/ReviewPage-DfYMMYXf.js",revision:null},{url:"assets/ProjectsPage-DvniitRz.js",revision:null},{url:"assets/ProjectDetailPage-C7hxf35O.js",revision:null},{url:"assets/MediaPlayerDialog-BlLvDpC-.js",revision:null},{url:"assets/InboxPage-BqeW_zny.js",revision:null},{url:"assets/DashboardPage-DzoG6hBc.js",revision:null},{url:"assets/AuditLogPage-DsE4mgOL.js",revision:null},{url:"pwa-192x192.png",revision:"5371c9ae9233f24ae2bd93426db5bf6e"},{url:"pwa-512x512.png",revision:"27187594c3538abaa333f5b60a1c947f"},{url:"manifest.webmanifest",revision:"3484d5e8592677e350c54443ff248abd"}],{}),s.cleanupOutdatedCaches()});
diff --git a/vms/public/frontend/workbox-004510d2.js b/vms/public/frontend/workbox-004510d2.js
deleted file mode 100644
index e6cadd1..0000000
--- a/vms/public/frontend/workbox-004510d2.js
+++ /dev/null
@@ -1 +0,0 @@
-define(["exports"],function(t){"use strict";try{self["workbox:core:7.3.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.3.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class i{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class r extends i{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class o{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let o=r&&r.handler;const c=t.method;if(!o&&this.i.has(c)&&(o=this.i.get(c)),!o)return;let a;try{a=o.handle({url:s,request:t,event:e,params:i})}catch(t){a=Promise.reject(t)}const h=r&&r.catchHandler;return a instanceof Promise&&(this.o||h)&&(a=a.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:i})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),a}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const i=this.t.get(s.method)||[];for(const r of i){let i;const o=r.match({url:t,sameOrigin:e,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&0===i.length||o.constructor===Object&&0===Object.keys(o).length||"boolean"==typeof o)&&(i=void 0),{route:r,params:i}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let c;const a={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},h=t=>[a.prefix,t,a.suffix].filter(t=>t&&t.length>0).join("-"),u=t=>t||h(a.precache),l=t=>t||h(a.runtime);function f(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:7.3.0"]&&_()}catch(t){}function w(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const i=new URL(n,location.href),r=new URL(n,location.href);return i.searchParams.set("__WB_REVISION__",e),{cacheKey:i.href,url:r.href}}class d{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class p{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.h.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.h=t}}let y;async function g(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const i=t.clone(),r={headers:new Headers(i.headers),status:i.status,statusText:i.statusText},o=e?e(r):r,c=function(){if(void 0===y){const t=new Response("");if("body"in t)try{new Response(t.body),y=!0}catch(t){y=!1}y=!1}return y}()?i.body:await i.blob();return new Response(c,o)}function R(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class m{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const v=new Set;try{self["workbox:strategies:7.3.0"]&&_()}catch(t){}function q(t){return"string"==typeof t?new Request(t):t}class U{constructor(t,e){this.u={},Object.assign(this,e),this.event=e.event,this.l=t,this.p=new m,this.R=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.p.promise)}async fetch(t){const{event:e}=this;let n=q(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const i=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const r=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.l.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:r,response:t});return t}catch(t){throw i&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:i.clone(),request:r.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=q(t);let s;const{cacheName:n,matchOptions:i}=this.l,r=await this.getCacheKey(e,"read"),o=Object.assign(Object.assign({},i),{cacheName:n});s=await caches.match(r,o);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:i,cachedResponse:s,request:r,event:this.event})||void 0;return s}async cachePut(t,e){const n=q(t);var i;await(i=0,new Promise(t=>setTimeout(t,i)));const r=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(o=r.url,new URL(String(o),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var o;const c=await this.q(e);if(!c)return!1;const{cacheName:a,matchOptions:h}=this.l,u=await self.caches.open(a),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const i=R(e.url,s);if(e.url===i)return t.match(e,n);const r=Object.assign(Object.assign({},n),{ignoreSearch:!0}),o=await t.keys(e,r);for(const e of o)if(i===R(e.url,s))return t.match(e,n)}(u,r.clone(),["__WB_REVISION__"],h):null;try{await u.put(r,l?c.clone():c)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of v)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:a,oldResponse:f,newResponse:c.clone(),request:r,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.u[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=q(await t({mode:e,request:n,event:this.event,params:this.params}));this.u[s]=n}return this.u[s]}hasCallback(t){for(const e of this.l.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.l.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const i=Object.assign(Object.assign({},n),{state:s});return e[t](i)};yield n}}waitUntil(t){return this.R.push(t),t}async doneWaiting(){for(;this.R.length;){const t=this.R.splice(0),e=(await Promise.allSettled(t)).find(t=>"rejected"===t.status);if(e)throw e.reason}}destroy(){this.p.resolve(null)}async q(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class L{constructor(t={}){this.cacheName=l(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,i=new U(this,{event:e,request:s,params:n}),r=this.U(i,s,e);return[r,this.L(r,i,s,e)]}async U(t,e,n){let i;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(i=await this._(e,t),!i||"error"===i.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const r of t.iterateCallbacks("handlerDidError"))if(i=await r({error:s,event:n,request:e}),i)break;if(!i)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))i=await s({event:n,request:e,response:i});return i}async L(t,e,s,n){let i,r;try{i=await t}catch(r){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:i}),await e.doneWaiting()}catch(t){t instanceof Error&&(r=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:i,error:r}),e.destroy(),r)throw r}}class b extends L{constructor(t={}){t.cacheName=u(t.cacheName),super(t),this.C=!1!==t.fallbackToNetwork,this.plugins.push(b.copyRedirectedCacheableResponsesPlugin)}async _(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.O(t,e):await this.N(t,e))}async N(t,e){let n;const i=e.params||{};if(!this.C)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=i.integrity,r=t.integrity,o=!r||r===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?r||s:void 0})),s&&o&&"no-cors"!==t.mode&&(this.P(),await e.cachePut(t,n.clone()))}return n}async O(t,e){this.P();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}P(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==b.copyRedirectedCacheableResponsesPlugin&&(n===b.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(b.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}b.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},b.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await g(t):t};class C{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.j=new Map,this.k=new Map,this.K=new Map,this.l=new b({cacheName:u(t),plugins:[...e,new p({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.l}precache(t){this.addToCacheList(t),this.T||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.T=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:i}=w(n),r="string"!=typeof n&&n.revision?"reload":"default";if(this.j.has(i)&&this.j.get(i)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.j.get(i),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.K.has(t)&&this.K.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:i});this.K.set(t,n.integrity)}if(this.j.set(i,t),this.k.set(i,r),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return f(t,async()=>{const e=new d;this.strategy.plugins.push(e);for(const[e,s]of this.j){const n=this.K.get(s),i=this.k.get(e),r=new Request(e,{integrity:n,cache:i,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:r,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}})}activate(t){return f(t,async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.j.values()),n=[];for(const i of e)s.has(i.url)||(await t.delete(i),n.push(i.url));return{deletedURLs:n}})}getURLsToCacheKeys(){return this.j}getCachedURLs(){return[...this.j.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.j.get(e.href)}getIntegrityForCacheKey(t){return this.K.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}let E;const O=()=>(E||(E=new C),E);class x extends i{constructor(t,e){super(({request:s})=>{const n=t.getURLsToCacheKeys();for(const i of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:i}={}){const r=new URL(t,location.href);r.hash="",yield r.href;const o=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some(t=>t.test(s))&&t.searchParams.delete(s);return t}(r,e);if(yield o.href,s&&o.pathname.endsWith("/")){const t=new URL(o.href);t.pathname+=s,yield t.href}if(n){const t=new URL(o.href);t.pathname+=".html",yield t.href}if(i){const t=i({url:r});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(i);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}},t.strategy)}}function N(t){const e=O();!function(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new i(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new r(t,e,n);else if("function"==typeof t)a=new i(t,e,n);else{if(!(t instanceof i))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}(c||(c=new o,c.addFetchListener(),c.addCacheListener()),c).registerRoute(a)}(new x(e,t))}t.cleanupOutdatedCaches=function(){self.addEventListener("activate",t=>{const e=u();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter(s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t);return await Promise.all(s.map(t=>self.caches.delete(t))),s})(e).then(t=>{}))})},t.clientsClaim=function(){self.addEventListener("activate",()=>self.clients.claim())},t.precacheAndRoute=function(t,e){!function(t){O().precache(t)}(t),N(e)}});
diff --git a/vms/review_api.py b/vms/review_api.py
index 0f0619d..c33a87b 100644
--- a/vms/review_api.py
+++ b/vms/review_api.py
@@ -49,6 +49,9 @@ def get_review_data(asset_name: str, token: str | None = None):
"is_public_review": asset.is_public_review,
"transcription_status": asset.transcription_status or "",
"proxy_status": asset.proxy_status or "",
+ "youtube_upload_status": asset.youtube_upload_status or "",
+ "youtube_video_id": asset.youtube_video_id or "",
+ "youtube_video_url": asset.youtube_video_url or "",
}
# Only expose review_token to authenticated users
diff --git a/vms/search.py b/vms/search.py
index 090898b..f205f6c 100644
--- a/vms/search.py
+++ b/vms/search.py
@@ -1,3 +1,5 @@
+from typing import ClassVar
+
import frappe
from frappe.search.sqlite_search import SQLiteSearch
@@ -5,13 +7,13 @@
class VMSSearch(SQLiteSearch):
INDEX_NAME = "vms_search.db"
- INDEX_SCHEMA = {
+ INDEX_SCHEMA: ClassVar[dict] = {
"text_fields": ["title", "content"],
"metadata_fields": ["doctype", "name", "project", "category", "file_type"],
"tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_.'",
}
- INDEXABLE_DOCTYPES = {
+ INDEXABLE_DOCTYPES: ClassVar[dict] = {
"VMS Asset": {
"fields": [
"name",
diff --git a/vms/tests/test_deletion.py b/vms/tests/test_deletion.py
index a3b8b9c..72e9562 100644
--- a/vms/tests/test_deletion.py
+++ b/vms/tests/test_deletion.py
@@ -104,9 +104,7 @@ def test_purge_skips_assets_with_zero_deleted_at(self):
a1 = _make_asset(self.project, "purge_zero.mp4")
# Simulate the migration bug: set deleted_at to zero datetime via SQL
- frappe.db.sql(
- "UPDATE `tabVMS Asset` SET deleted_at = '0000-00-00 00:00:00' WHERE name = %s", a1
- )
+ frappe.db.sql("UPDATE `tabVMS Asset` SET deleted_at = '0000-00-00 00:00:00' WHERE name = %s", a1)
frappe.db.commit()
from vms.deletion import purge_expired_trash
@@ -127,17 +125,25 @@ def test_purge_only_deletes_expired_assets(self):
# Trash old asset 10 days ago
old_time = add_days(now_datetime(), -10)
- frappe.db.set_value("VMS Asset", old_asset, {
- "deleted_at": old_time,
- "deleted_by": "Administrator",
- })
+ frappe.db.set_value(
+ "VMS Asset",
+ old_asset,
+ {
+ "deleted_at": old_time,
+ "deleted_by": "Administrator",
+ },
+ )
# Trash recent asset 1 day ago
recent_time = add_days(now_datetime(), -1)
- frappe.db.set_value("VMS Asset", recent_asset, {
- "deleted_at": recent_time,
- "deleted_by": "Administrator",
- })
+ frappe.db.set_value(
+ "VMS Asset",
+ recent_asset,
+ {
+ "deleted_at": recent_time,
+ "deleted_by": "Administrator",
+ },
+ )
frappe.db.commit()
from vms.deletion import purge_expired_trash
@@ -157,10 +163,14 @@ def test_purge_skips_when_retention_is_zero(self):
# Trash it 100 days ago
old_time = add_days(now_datetime(), -100)
- frappe.db.set_value("VMS Asset", asset, {
- "deleted_at": old_time,
- "deleted_by": "Administrator",
- })
+ frappe.db.set_value(
+ "VMS Asset",
+ asset,
+ {
+ "deleted_at": old_time,
+ "deleted_by": "Administrator",
+ },
+ )
frappe.db.commit()
from vms.deletion import purge_expired_trash
diff --git a/vms/tools.py b/vms/tools.py
index 83528ca..d84450d 100644
--- a/vms/tools.py
+++ b/vms/tools.py
@@ -86,10 +86,15 @@ def _ffmpeg_compress(input_path: str, output_path: str):
"""Run ffmpeg to compress video. Tries smart remux for non-MP4 files with compatible codecs."""
if _can_remux_to_mp4(input_path):
copy_cmd = [
- "ffmpeg", "-hide_banner", "-y",
- "-i", input_path,
- "-c", "copy",
- "-movflags", "+faststart",
+ "ffmpeg",
+ "-hide_banner",
+ "-y",
+ "-i",
+ input_path,
+ "-c",
+ "copy",
+ "-movflags",
+ "+faststart",
output_path,
]
result = subprocess.run(copy_cmd, capture_output=True, text=True, timeout=3600)
@@ -102,15 +107,24 @@ def _ffmpeg_compress(input_path: str, output_path: str):
"ffmpeg",
"-hide_banner",
"-y",
- "-i", input_path,
- "-c:v", "libx264",
- "-preset", "medium",
- "-crf", "23",
- "-pix_fmt", "yuv420p",
- "-vf", "scale='min(1920,iw)':-2",
- "-c:a", "aac",
- "-b:a", "160k",
- "-movflags", "+faststart",
+ "-i",
+ input_path,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "medium",
+ "-crf",
+ "23",
+ "-pix_fmt",
+ "yuv420p",
+ "-vf",
+ "scale='min(1920,iw)':-2",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "160k",
+ "-movflags",
+ "+faststart",
output_path,
]
@@ -135,8 +149,14 @@ def _get_extension(filename: str) -> str:
def _probe_codecs(input_path: str) -> dict:
"""Use ffprobe to detect video/audio codecs and container format."""
cmd = [
- "ffprobe", "-v", "quiet", "-print_format", "json",
- "-show_format", "-show_streams", input_path,
+ "ffprobe",
+ "-v",
+ "quiet",
+ "-print_format",
+ "json",
+ "-show_format",
+ "-show_streams",
+ input_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
@@ -272,10 +292,15 @@ def _ffmpeg_convert(input_path: str, output_path: str):
"""Convert video to MP4. Tries fast copy-remux first, falls back to transcode."""
# Try copy-remux first (instant if codecs are MP4-compatible)
copy_cmd = [
- "ffmpeg", "-hide_banner", "-y",
- "-i", input_path,
- "-c", "copy",
- "-movflags", "+faststart",
+ "ffmpeg",
+ "-hide_banner",
+ "-y",
+ "-i",
+ input_path,
+ "-c",
+ "copy",
+ "-movflags",
+ "+faststart",
output_path,
]
result = subprocess.run(copy_cmd, capture_output=True, text=True, timeout=3600)
@@ -284,12 +309,27 @@ def _ffmpeg_convert(input_path: str, output_path: str):
# Fall back to full transcode
transcode_cmd = [
- "ffmpeg", "-hide_banner", "-y",
- "-i", input_path,
- "-c:v", "libx264", "-preset", "medium", "-crf", "23", "-pix_fmt", "yuv420p",
- "-vf", "scale='min(1920,iw)':-2",
- "-c:a", "aac", "-b:a", "160k",
- "-movflags", "+faststart",
+ "ffmpeg",
+ "-hide_banner",
+ "-y",
+ "-i",
+ input_path,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "medium",
+ "-crf",
+ "23",
+ "-pix_fmt",
+ "yuv420p",
+ "-vf",
+ "scale='min(1920,iw)':-2",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "160k",
+ "-movflags",
+ "+faststart",
output_path,
]
result = subprocess.run(transcode_cmd, capture_output=True, text=True, timeout=3600)
diff --git a/vms/transcription.py b/vms/transcription.py
index da4400e..cbb7ced 100644
--- a/vms/transcription.py
+++ b/vms/transcription.py
@@ -141,12 +141,14 @@ def parse_deepgram_response(data: dict) -> list[dict]:
for utt in utterances:
text = utt.get("transcript", "").strip()
if text:
- segments.append({
- "start": float(utt.get("start", 0)),
- "end": float(utt.get("end", 0)),
- "text": text,
- "speaker": utt.get("speaker", 0),
- })
+ segments.append(
+ {
+ "start": float(utt.get("start", 0)),
+ "end": float(utt.get("end", 0)),
+ "text": text,
+ "speaker": utt.get("speaker", 0),
+ }
+ )
return segments
# Fallback: group words by speaker from channels
@@ -166,12 +168,14 @@ def parse_deepgram_response(data: dict) -> list[dict]:
for w in words[1:]:
speaker = w.get("speaker", 0)
if speaker != current_speaker:
- segments.append({
- "start": float(current_start),
- "end": float(words[len(current_words) - 1].get("end", 0)),
- "text": " ".join(current_words),
- "speaker": current_speaker,
- })
+ segments.append(
+ {
+ "start": float(current_start),
+ "end": float(words[len(current_words) - 1].get("end", 0)),
+ "text": " ".join(current_words),
+ "speaker": current_speaker,
+ }
+ )
current_speaker = speaker
current_start = w.get("start", 0)
current_words = [w.get("word", "")]
@@ -180,12 +184,14 @@ def parse_deepgram_response(data: dict) -> list[dict]:
# Don't forget the last group
if current_words:
- segments.append({
- "start": float(current_start),
- "end": float(words[-1].get("end", 0)),
- "text": " ".join(current_words),
- "speaker": current_speaker,
- })
+ segments.append(
+ {
+ "start": float(current_start),
+ "end": float(words[-1].get("end", 0)),
+ "text": " ".join(current_words),
+ "speaker": current_speaker,
+ }
+ )
return segments
@@ -232,8 +238,14 @@ def run_openai_whisper(audio_path: str, api_key: str) -> dict:
def _get_audio_duration(audio_path: str) -> float:
"""Get audio duration in seconds using ffprobe."""
cmd = [
- "ffprobe", "-v", "error", "-show_entries", "format=duration",
- "-of", "default=noprint_wrappers=1:nokey=1", audio_path,
+ "ffprobe",
+ "-v",
+ "error",
+ "-show_entries",
+ "format=duration",
+ "-of",
+ "default=noprint_wrappers=1:nokey=1",
+ audio_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
@@ -241,7 +253,9 @@ def _get_audio_duration(audio_path: str) -> float:
return float(result.stdout.strip())
-def _split_audio_into_chunks(mp3_path: str, tmpdir: str, chunk_duration: int = CHUNK_DURATION_SECS) -> list[tuple[str, int]]:
+def _split_audio_into_chunks(
+ mp3_path: str, tmpdir: str, chunk_duration: int = CHUNK_DURATION_SECS
+) -> list[tuple[str, int]]:
"""Split an mp3 file into chunks of chunk_duration seconds each."""
total_duration = _get_audio_duration(mp3_path)
chunks = []
@@ -251,11 +265,17 @@ def _split_audio_into_chunks(mp3_path: str, tmpdir: str, chunk_duration: int = C
while start < total_duration:
chunk_path = os.path.join(tmpdir, f"chunk_{idx:03d}.mp3")
cmd = [
- "ffmpeg", "-hide_banner", "-y",
- "-i", mp3_path,
- "-ss", str(start),
- "-t", str(chunk_duration),
- "-c", "copy",
+ "ffmpeg",
+ "-hide_banner",
+ "-y",
+ "-i",
+ mp3_path,
+ "-ss",
+ str(start),
+ "-t",
+ str(chunk_duration),
+ "-c",
+ "copy",
chunk_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
@@ -277,9 +297,7 @@ def _transcribe_with_openai(mp3_path: str, api_key: str, tmpdir: str) -> list[di
return parse_segments(whisper_output)
# File too large — split into chunks and transcribe each
- frappe.logger().info(
- f"Audio file {file_size / 1024 / 1024:.1f}MB exceeds limit, splitting into chunks"
- )
+ frappe.logger().info(f"Audio file {file_size / 1024 / 1024:.1f}MB exceeds limit, splitting into chunks")
chunks = _split_audio_into_chunks(mp3_path, tmpdir)
all_segments = []
@@ -380,9 +398,7 @@ def start_transcription(asset_name: str):
frappe.throw(_("Deepgram API key is not configured. Go to Settings > Transcription to add it."))
elif provider == "whisper.cpp":
if not ensure_whisper_installed():
- frappe.throw(
- _("whisper-cli is not installed. Install it with: brew install whisper-cpp")
- )
+ frappe.throw(_("whisper-cli is not installed. Install it with: brew install whisper-cpp"))
# Mark as processing
asset.transcription_status = "Processing"
@@ -445,12 +461,8 @@ def process_transcription(asset_name: str):
with tempfile.TemporaryDirectory() as tmpdir:
# Download video from R2
video_path = os.path.join(tmpdir, "video" + _get_extension(asset.file_name))
- download_url = generate_presigned_download_url(
- asset.r2_key, asset.file_name
- )
- frappe.logger().info(
- f"Downloading video for transcription: {asset_name}"
- )
+ download_url = generate_presigned_download_url(asset.r2_key, asset.file_name)
+ frappe.logger().info(f"Downloading video for transcription: {asset_name}")
urlretrieve(download_url, video_path)
# Extract audio
@@ -477,9 +489,7 @@ def process_transcription(asset_name: str):
model_path = ensure_model_downloaded(model_name)
output_base = os.path.join(tmpdir, "transcript")
frappe.logger().info(f"Running whisper-cli: {asset_name}")
- whisper_output = run_whisper(
- audio_path, str(model_path), output_base
- )
+ whisper_output = run_whisper(audio_path, str(model_path), output_base)
segments = parse_segments(whisper_output)
markdown = segments_to_markdown(segments)
diff --git a/vms/video_management_solution/doctype/vms_asset/vms_asset.json b/vms/video_management_solution/doctype/vms_asset/vms_asset.json
index 3af07c4..fa676f7 100644
--- a/vms/video_management_solution/doctype/vms_asset/vms_asset.json
+++ b/vms/video_management_solution/doctype/vms_asset/vms_asset.json
@@ -35,6 +35,11 @@
"proxy_r2_key",
"section_break_split",
"split_from",
+ "section_break_youtube",
+ "youtube_upload_status",
+ "youtube_video_id",
+ "column_break_youtube",
+ "youtube_video_url",
"section_break_trash",
"deleted_at",
"column_break_trash",
@@ -217,6 +222,34 @@
"options": "VMS Asset",
"read_only": 1
},
+ {
+ "fieldname": "section_break_youtube",
+ "fieldtype": "Section Break",
+ "label": "YouTube"
+ },
+ {
+ "fieldname": "youtube_upload_status",
+ "fieldtype": "Select",
+ "label": "YouTube Upload Status",
+ "options": "\nQueued\nUploading\nComplete\nError",
+ "read_only": 1
+ },
+ {
+ "fieldname": "youtube_video_id",
+ "fieldtype": "Data",
+ "label": "YouTube Video ID",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_youtube",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "youtube_video_url",
+ "fieldtype": "Data",
+ "label": "YouTube Video URL",
+ "read_only": 1
+ },
{
"fieldname": "section_break_trash",
"fieldtype": "Section Break",
diff --git a/vms/video_management_solution/doctype/vms_compress_job/test_vms_compress_job.py b/vms/video_management_solution/doctype/vms_compress_job/test_vms_compress_job.py
index e805109..71cadb7 100644
--- a/vms/video_management_solution/doctype/vms_compress_job/test_vms_compress_job.py
+++ b/vms/video_management_solution/doctype/vms_compress_job/test_vms_compress_job.py
@@ -4,7 +4,6 @@
# import frappe
from frappe.tests import IntegrationTestCase
-
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
@@ -12,7 +11,6 @@
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
-
class IntegrationTestVMSCompressJob(IntegrationTestCase):
"""
Integration tests for VMSCompressJob.
diff --git a/vms/video_management_solution/doctype/vms_settings/vms_settings.json b/vms/video_management_solution/doctype/vms_settings/vms_settings.json
index 5d3edb5..1ec4a33 100644
--- a/vms/video_management_solution/doctype/vms_settings/vms_settings.json
+++ b/vms/video_management_solution/doctype/vms_settings/vms_settings.json
@@ -26,7 +26,14 @@
"transcription_provider",
"whisper_model",
"openai_api_key",
- "deepgram_api_key"
+ "deepgram_api_key",
+ "youtube_section",
+ "youtube_client_id",
+ "youtube_client_secret",
+ "column_break_youtube",
+ "youtube_connected",
+ "youtube_connected_user",
+ "youtube_channel_name"
],
"fields": [
{
@@ -176,6 +183,48 @@
"label": "Deepgram API Key",
"description": "API key for Deepgram transcription with speaker diarization. Get one at console.deepgram.com",
"depends_on": "eval:doc.transcription_provider=='Deepgram'"
+ },
+ {
+ "fieldname": "youtube_section",
+ "fieldtype": "Section Break",
+ "label": "YouTube"
+ },
+ {
+ "fieldname": "youtube_client_id",
+ "fieldtype": "Data",
+ "label": "YouTube Client ID",
+ "description": "OAuth Client ID from Google Cloud Console"
+ },
+ {
+ "fieldname": "youtube_client_secret",
+ "fieldtype": "Password",
+ "label": "YouTube Client Secret",
+ "description": "OAuth Client Secret from Google Cloud Console"
+ },
+ {
+ "fieldname": "column_break_youtube",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "youtube_connected",
+ "fieldtype": "Check",
+ "label": "YouTube Connected",
+ "read_only": 1
+ },
+ {
+ "fieldname": "youtube_connected_user",
+ "fieldtype": "Link",
+ "label": "YouTube Connected User",
+ "options": "User",
+ "read_only": 1,
+ "hidden": 1
+ },
+ {
+ "fieldname": "youtube_channel_name",
+ "fieldtype": "Data",
+ "label": "YouTube Channel Name",
+ "read_only": 1
}
],
"issingle": 1,
diff --git a/vms/video_split.py b/vms/video_split.py
index 58a26c1..c814918 100644
--- a/vms/video_split.py
+++ b/vms/video_split.py
@@ -256,10 +256,10 @@ def _send_split_complete_email(asset, created_assets, recipient):
message = f"""
Your video {asset.file_name} has been split into {len(created_assets)} parts successfully.
-{f'Project: {project_name}
' if project_name else ''}
+{f"Project: {project_name}
" if project_name else ""}
Created parts:
-View in VMS
+View in VMS
"""
try:
diff --git a/vms/www/vms.html b/vms/www/vms.html
deleted file mode 100644
index 9d31b7f..0000000
--- a/vms/www/vms.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- BWH VMS
-
-
-
-
-
-
-
-
diff --git a/vms/youtube.py b/vms/youtube.py
new file mode 100644
index 0000000..96bbc4c
--- /dev/null
+++ b/vms/youtube.py
@@ -0,0 +1,431 @@
+import os
+import tempfile
+
+import frappe
+import requests
+from frappe import _
+
+from vms.r2 import generate_presigned_download_url
+
+CONNECTED_APP_NAME = "vms-youtube"
+SCOPES = [
+ "https://www.googleapis.com/auth/youtube.upload",
+ "https://www.googleapis.com/auth/youtube.readonly",
+]
+AUTHORIZATION_URI = "https://accounts.google.com/o/oauth2/v2/auth"
+TOKEN_URI = "https://oauth2.googleapis.com/token"
+REVOCATION_URI = "https://oauth2.googleapis.com/revoke"
+
+
+def _get_or_create_connected_app(client_id: str, client_secret: str):
+ """Create or update the VMS YouTube Connected App."""
+ if frappe.db.exists("Connected App", CONNECTED_APP_NAME):
+ app = frappe.get_doc("Connected App", CONNECTED_APP_NAME)
+ app.client_id = client_id
+ app.client_secret = client_secret
+ app.authorization_uri = AUTHORIZATION_URI
+ app.token_uri = TOKEN_URI
+ app.revocation_uri = REVOCATION_URI
+
+ # Update scopes
+ app.scopes = []
+ for scope in SCOPES:
+ app.append("scopes", {"scope": scope})
+
+ # Update query parameters for offline access
+ app.query_parameters = []
+ app.append("query_parameters", {"key": "access_type", "value": "offline"})
+ app.append("query_parameters", {"key": "prompt", "value": "consent"})
+
+ app.save(ignore_permissions=True)
+ return app
+
+ app = frappe.get_doc(
+ {
+ "doctype": "Connected App",
+ "provider_name": "YouTube",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "authorization_uri": AUTHORIZATION_URI,
+ "token_uri": TOKEN_URI,
+ "revocation_uri": REVOCATION_URI,
+ }
+ )
+
+ for scope in SCOPES:
+ app.append("scopes", {"scope": scope})
+
+ app.append("query_parameters", {"key": "access_type", "value": "offline"})
+ app.append("query_parameters", {"key": "prompt", "value": "consent"})
+
+ app.insert(ignore_permissions=True, set_name=CONNECTED_APP_NAME)
+
+ return app
+
+
+def _fetch_channel_name(token_cache):
+ """Fetch the YouTube channel name using the stored access token."""
+ access_token = token_cache.get_password("access_token")
+ resp = requests.get(
+ "https://www.googleapis.com/youtube/v3/channels",
+ params={"part": "snippet", "mine": "true"},
+ headers={"Authorization": f"Bearer {access_token}"},
+ timeout=15,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ items = data.get("items", [])
+ if items:
+ return items[0]["snippet"]["title"]
+ return "Unknown Channel"
+
+
+@frappe.whitelist()
+def connect_youtube(client_id: str, client_secret: str):
+ """Save OAuth credentials, create Connected App, and return the auth URL."""
+ frappe.only_for("System Manager")
+
+ if not client_id or not client_secret:
+ frappe.throw(_("Client ID and Client Secret are required"))
+
+ # Save credentials to VMS Settings
+ settings = frappe.get_single("VMS Settings")
+ settings.youtube_client_id = client_id
+ settings.youtube_client_secret = client_secret
+ settings.save(ignore_permissions=True)
+
+ # Create/update Connected App
+ connected_app = _get_or_create_connected_app(client_id, client_secret)
+
+ # Initiate OAuth flow
+ auth_url = connected_app.initiate_web_application_flow(
+ success_uri="/vms?settings=youtube&youtube_connected=1"
+ )
+
+ return {"auth_url": auth_url}
+
+
+@frappe.whitelist()
+def finalize_youtube_connection():
+ """Called after OAuth redirect — verify token exists and fetch channel info."""
+ frappe.only_for("System Manager")
+
+ if not frappe.db.exists("Connected App", CONNECTED_APP_NAME):
+ frappe.throw(_("YouTube Connected App not found. Please connect again."))
+
+ connected_app = frappe.get_doc("Connected App", CONNECTED_APP_NAME)
+
+ try:
+ token_cache = connected_app.get_active_token(frappe.session.user)
+ except Exception:
+ frappe.throw(_("YouTube authorization failed. Please try connecting again."))
+
+ if not token_cache:
+ frappe.throw(_("No YouTube token found. Please connect again."))
+
+ # Fetch channel name
+ try:
+ channel_name = _fetch_channel_name(token_cache)
+ except Exception as e:
+ frappe.log_error(f"Failed to fetch YouTube channel name: {e}")
+ channel_name = "Connected"
+
+ # Update VMS Settings
+ settings = frappe.get_single("VMS Settings")
+ settings.youtube_connected = 1
+ settings.youtube_connected_user = frappe.session.user
+ settings.youtube_channel_name = channel_name
+ settings.save(ignore_permissions=True)
+
+ return {"connected": True, "channel_name": channel_name}
+
+
+@frappe.whitelist()
+def disconnect_youtube():
+ """Disconnect YouTube — clear tokens and settings."""
+ frappe.only_for("System Manager")
+
+ settings = frappe.get_single("VMS Settings")
+ user = settings.youtube_connected_user
+
+ # Delete Token Cache first (linked to Connected App)
+ if user:
+ token_cache_name = f"{CONNECTED_APP_NAME}-{user}"
+ if frappe.db.exists("Token Cache", token_cache_name):
+ frappe.delete_doc("Token Cache", token_cache_name, ignore_permissions=True, force=True)
+
+ # Then delete Connected App
+ if frappe.db.exists("Connected App", CONNECTED_APP_NAME):
+ frappe.delete_doc("Connected App", CONNECTED_APP_NAME, ignore_permissions=True, force=True)
+
+ # Clear settings
+ settings.youtube_connected = 0
+ settings.youtube_connected_user = None
+ settings.youtube_channel_name = None
+ settings.save(ignore_permissions=True)
+
+ return {"connected": False}
+
+
+@frappe.whitelist(methods=["GET"])
+def get_youtube_redirect_uri():
+ """Return the OAuth redirect URI that must be registered in Google Cloud Console."""
+ base_url = frappe.utils.get_url()
+ callback_path = "api/method/frappe.integrations.doctype.connected_app.connected_app.callback"
+ return {"redirect_uri": f"{base_url}/{callback_path}/{CONNECTED_APP_NAME}"}
+
+
+@frappe.whitelist(methods=["GET"])
+def get_youtube_status():
+ """Return current YouTube connection status."""
+ settings = frappe.get_single("VMS Settings")
+
+ return {
+ "connected": bool(settings.youtube_connected),
+ "channel_name": settings.youtube_channel_name or "",
+ "has_credentials": bool(settings.youtube_client_id),
+ }
+
+
+@frappe.whitelist()
+def upload_to_youtube(
+ asset_name: str,
+ title: str,
+ description: str = "",
+ privacy_status: str = "unlisted",
+):
+ """Validate and enqueue a YouTube upload job."""
+ if not frappe.db.exists("VMS Asset", asset_name):
+ frappe.throw(_("Asset {0} does not exist").format(asset_name))
+
+ settings = frappe.get_single("VMS Settings")
+ if not settings.youtube_connected:
+ frappe.throw(_("YouTube is not connected. Please connect in Settings."))
+
+ asset = frappe.get_doc("VMS Asset", asset_name)
+ if not asset.r2_key:
+ frappe.throw(_("Asset has no uploaded file"))
+
+ if asset.youtube_upload_status in ("Queued", "Uploading"):
+ frappe.throw(_("Upload is already in progress"))
+
+ if privacy_status not in ("public", "unlisted", "private"):
+ frappe.throw(_("Invalid privacy status"))
+
+ # Mark as queued
+ frappe.db.set_value("VMS Asset", asset_name, "youtube_upload_status", "Queued")
+ frappe.db.commit()
+
+ frappe.enqueue(
+ "vms.youtube.process_youtube_upload",
+ asset_name=asset_name,
+ title=title,
+ description=description,
+ privacy_status=privacy_status,
+ queue="default",
+ enqueue_after_commit=True,
+ timeout=3600,
+ )
+
+ return {"status": "ok", "youtube_upload_status": "Queued"}
+
+
+@frappe.whitelist(methods=["GET"])
+def get_youtube_upload_status(asset_name: str):
+ """Get the YouTube upload status for an asset."""
+ if not frappe.db.exists("VMS Asset", asset_name):
+ frappe.throw(_("Asset {0} does not exist").format(asset_name))
+
+ data = frappe.db.get_value(
+ "VMS Asset",
+ asset_name,
+ ["youtube_upload_status", "youtube_video_id", "youtube_video_url"],
+ as_dict=True,
+ )
+
+ return {
+ "youtube_upload_status": data.youtube_upload_status or "",
+ "youtube_video_id": data.youtube_video_id or "",
+ "youtube_video_url": data.youtube_video_url or "",
+ }
+
+
+@frappe.whitelist()
+def reset_youtube_upload(asset_name: str):
+ """Reset YouTube upload status to allow re-upload."""
+ if not frappe.db.exists("VMS Asset", asset_name):
+ frappe.throw(_("Asset {0} does not exist").format(asset_name))
+
+ frappe.db.set_value(
+ "VMS Asset",
+ asset_name,
+ {
+ "youtube_upload_status": None,
+ "youtube_video_id": None,
+ "youtube_video_url": None,
+ },
+ )
+
+ return {"status": "ok"}
+
+
+def process_youtube_upload(
+ asset_name: str,
+ title: str,
+ description: str,
+ privacy_status: str,
+):
+ """Background job: download from R2 and upload to YouTube."""
+ from google.oauth2.credentials import Credentials
+ from googleapiclient.discovery import build
+ from googleapiclient.http import MediaFileUpload
+
+ asset = frappe.get_doc("VMS Asset", asset_name)
+
+ try:
+ frappe.db.set_value("VMS Asset", asset_name, "youtube_upload_status", "Uploading")
+ frappe.db.commit()
+
+ frappe.publish_realtime(
+ "youtube_upload_progress",
+ {"asset_name": asset_name, "stage": "downloading", "percent": 0},
+ )
+
+ settings = frappe.get_single("VMS Settings")
+
+ # Get OAuth token
+ connected_app = frappe.get_doc("Connected App", CONNECTED_APP_NAME)
+ token_cache = connected_app.get_active_token(settings.youtube_connected_user)
+
+ if not token_cache:
+ raise Exception("YouTube token not found or expired. Please reconnect.")
+
+ token_data = token_cache.get_json()
+
+ # Build Google credentials from token cache
+ credentials = Credentials(
+ token=token_data.get("access_token"),
+ refresh_token=token_data.get("refresh_token"),
+ token_uri=TOKEN_URI,
+ client_id=settings.youtube_client_id,
+ client_secret=settings.get_password("youtube_client_secret"),
+ )
+
+ youtube = build("youtube", "v3", credentials=credentials)
+
+ # Download video from R2 to temp file
+ with tempfile.TemporaryDirectory() as tmpdir:
+ ext = os.path.splitext(asset.file_name)[1] or ".mp4"
+ video_path = os.path.join(tmpdir, f"upload{ext}")
+
+ download_url = generate_presigned_download_url(asset.r2_key, asset.file_name)
+ frappe.logger().info(f"Downloading {asset.file_name} from R2 for YouTube upload")
+
+ resp = requests.get(download_url, stream=True, timeout=30)
+ resp.raise_for_status()
+
+ total_size = int(resp.headers.get("content-length", 0))
+ downloaded = 0
+
+ with open(video_path, "wb") as f:
+ for chunk in resp.iter_content(chunk_size=10 * 1024 * 1024):
+ f.write(chunk)
+ downloaded += len(chunk)
+ if total_size:
+ percent = int((downloaded / total_size) * 40) # 0-40% for download
+ frappe.publish_realtime(
+ "youtube_upload_progress",
+ {"asset_name": asset_name, "stage": "downloading", "percent": percent},
+ )
+
+ frappe.publish_realtime(
+ "youtube_upload_progress",
+ {"asset_name": asset_name, "stage": "uploading", "percent": 40},
+ )
+
+ # Upload to YouTube
+ body = {
+ "snippet": {
+ "title": title,
+ "description": description,
+ "categoryId": "22", # People & Blogs
+ },
+ "status": {
+ "privacyStatus": privacy_status,
+ },
+ }
+
+ media = MediaFileUpload(
+ video_path,
+ mimetype=asset.file_type or "video/mp4",
+ resumable=True,
+ chunksize=10 * 1024 * 1024,
+ )
+
+ request = youtube.videos().insert(
+ part="snippet,status",
+ body=body,
+ media_body=media,
+ )
+
+ response = None
+ while response is None:
+ status, response = request.next_chunk()
+ if status:
+ percent = 40 + int(status.progress() * 60) # 40-100% for upload
+ frappe.publish_realtime(
+ "youtube_upload_progress",
+ {"asset_name": asset_name, "stage": "uploading", "percent": percent},
+ )
+
+ video_id = response["id"]
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
+
+ frappe.db.set_value(
+ "VMS Asset",
+ asset_name,
+ {
+ "youtube_upload_status": "Complete",
+ "youtube_video_id": video_id,
+ "youtube_video_url": video_url,
+ },
+ )
+ frappe.db.commit()
+
+ frappe.publish_realtime(
+ "youtube_upload_progress",
+ {
+ "asset_name": asset_name,
+ "stage": "complete",
+ "percent": 100,
+ "video_id": video_id,
+ "video_url": video_url,
+ },
+ )
+
+ frappe.logger().info(f"YouTube upload complete: {video_url}")
+
+ except Exception as e:
+ frappe.log_error(f"YouTube upload failed for {asset_name}: {e}")
+ frappe.db.set_value("VMS Asset", asset_name, "youtube_upload_status", "Error")
+ frappe.db.commit()
+
+ error_message = str(e)
+
+ # Extract readable error from Google API errors
+ try:
+ from googleapiclient.errors import HttpError
+
+ if isinstance(e, HttpError):
+ import json
+
+ error_detail = json.loads(e.content.decode())
+ error_message = error_detail.get("error", {}).get("message", str(e))
+ except Exception:
+ pass
+
+ frappe.publish_realtime(
+ "youtube_upload_progress",
+ {"asset_name": asset_name, "stage": "error", "percent": 0, "error": error_message},
+ )