From 3b4bffae3002522c5a596bfdc7df4fe9e7925f66 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Mon, 2 Mar 2026 06:49:00 +0530 Subject: [PATCH 1/8] feat: add YouTube upload integration with OAuth and background processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect a YouTube account via OAuth in Settings, then upload videos directly from the review page. Uses Frappe's Connected App for token management with auto-refresh. Uploads run as background jobs (R2 download → YouTube resumable upload) with status polling and realtime progress. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/SettingsDialog.tsx | 10 +- .../src/components/review/ReviewHeader.tsx | 75 ++- .../components/review/YouTubeUploadDialog.tsx | 169 +++++++ .../components/settings/YouTubeSection.tsx | 189 ++++++++ frontend/src/pages/ReviewPage.tsx | 64 +++ vms/public/frontend/index.html | 4 +- vms/public/frontend/sw.js | 2 +- vms/review_api.py | 3 + .../doctype/vms_asset/vms_asset.json | 33 ++ .../doctype/vms_settings/vms_settings.json | 51 ++- vms/www/vms.html | 4 +- vms/youtube.py | 432 ++++++++++++++++++ 12 files changed, 1028 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/review/YouTubeUploadDialog.tsx create mode 100644 frontend/src/components/settings/YouTubeSection.tsx create mode 100644 vms/youtube.py diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 5933baf..c9172dd 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -1,5 +1,5 @@ import { HugeiconsIcon } from "@hugeicons/react" -import { Settings01Icon, UserGroupIcon, UserCircleIcon, SubtitleIcon } from "@hugeicons/core-free-icons" +import { Settings01Icon, UserGroupIcon, UserCircleIcon, SubtitleIcon, YoutubeIcon } from "@hugeicons/core-free-icons" import { Dialog, DialogContent, @@ -20,11 +20,13 @@ import { ProfileSection } from "@/components/settings/ProfileSection" import { GeneralSection } from "@/components/settings/GeneralSection" import { UsersSection } from "@/components/settings/UsersSection" import { TranscriptionSection } from "@/components/settings/TranscriptionSection" +import { YouTubeSection } from "@/components/settings/YouTubeSection" const sections = [ { id: "profile", label: "Profile", icon: UserCircleIcon }, { id: "general", label: "General", icon: Settings01Icon }, { id: "transcription", label: "Transcription", icon: SubtitleIcon }, + { id: "youtube", label: "YouTube", icon: YoutubeIcon }, { id: "users", label: "Users", icon: UserGroupIcon }, ] as const @@ -111,6 +113,9 @@ function SettingsContent({ + + + @@ -126,6 +131,9 @@ function SettingsContent({ + + + diff --git a/frontend/src/components/review/ReviewHeader.tsx b/frontend/src/components/review/ReviewHeader.tsx index f6400fb..02d030b 100644 --- a/frontend/src/components/review/ReviewHeader.tsx +++ b/frontend/src/components/review/ReviewHeader.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import { useNavigate } from "react-router" import { HugeiconsIcon } from "@hugeicons/react" -import { ArrowLeft01Icon, Download04Icon, Link01Icon, Copy01Icon, SubtitleIcon, Scissor01Icon, GitForkIcon, Video01Icon } from "@hugeicons/core-free-icons" +import { ArrowLeft01Icon, Download04Icon, Link01Icon, Copy01Icon, SubtitleIcon, Scissor01Icon, GitForkIcon, Video01Icon, YoutubeIcon } from "@hugeicons/core-free-icons" import { Button, buttonVariants } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Switch } from "@/components/ui/switch" @@ -34,6 +34,10 @@ interface ReviewHeaderProps { proxyStatus?: string onGenerateProxy?: () => Promise isGeneratingProxy?: boolean + youtubeUploadStatus?: string + youtubeVideoUrl?: string + onOpenYouTubeUpload?: () => void + onResetYouTubeUpload?: () => void } export function ReviewHeader({ @@ -57,6 +61,10 @@ export function ReviewHeader({ proxyStatus, onGenerateProxy, isGeneratingProxy, + youtubeUploadStatus, + youtubeVideoUrl, + onOpenYouTubeUpload, + onResetYouTubeUpload, }: ReviewHeaderProps) { const navigate = useNavigate() const { isGuest, token } = useReviewContext() @@ -180,6 +188,71 @@ export function ReviewHeader({ Proxy )} + {/* YouTube button — auth users only, video only */} + {!isGuest && isVideo && (() => { + if (youtubeUploadStatus === "Queued" || youtubeUploadStatus === "Uploading") { + return ( + + ) + } + if (youtubeUploadStatus === "Complete" && youtubeVideoUrl) { + return ( +
+ + + + YouTube + + + +
+ ) + } + if (youtubeUploadStatus === "Error") { + return ( + + ) + } + return ( + <> + + + + ) + })()} + {/* Share button — auth users only */} {!isGuest && ( diff --git a/frontend/src/components/review/YouTubeUploadDialog.tsx b/frontend/src/components/review/YouTubeUploadDialog.tsx new file mode 100644 index 0000000..4c58789 --- /dev/null +++ b/frontend/src/components/review/YouTubeUploadDialog.tsx @@ -0,0 +1,169 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface YouTubeUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + assetName: string + fileName: string + onUploadStarted: () => void +} + +export function YouTubeUploadDialog({ + open, + onOpenChange, + assetName, + fileName, + 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 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, + }) + toast.success("YouTube upload started") + onOpenChange(false) + onUploadStarted() + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to start upload" + toast.error(message) + } + } + + return ( + + + + Upload to YouTube + + {isConnected + ? `Uploading to ${statusData?.message?.channel_name || "YouTube"}` + : "YouTube is not connected"} + + + + {isConnected === false ? ( +
+

+ Connect your YouTube account in Settings to upload videos. +

+ +
+ ) : ( + <> +
+
+ + setTitle(e.target.value)} + maxLength={100} + /> +
+ +
+ +