diff --git a/__init__.py b/__init__.py index a2dee5e..7cd5ca8 100755 --- a/__init__.py +++ b/__init__.py @@ -12,6 +12,7 @@ from .nodes.api.AQ_Gemini import ( from .nodes.api.AQ_SendImageToAPI import AQ_SendImageToAPI from .nodes.api.AQ_Cover import AQ_CoverPrompt from .nodes.api.AQ_VideoFromImage import AQ_StillImageToVideo +from .nodes.api.AQ_YouTubeUpload import AQ_YouTubeUpload from .nodes.clip.AQ_CLIPSetLastLayer import AQ_CLIPSetLastLayer from .nodes.numbers.AQ_Numbers import AQ_Increment, AQ_Random @@ -48,6 +49,7 @@ NODE_CLASS_MAPPINGS = { "AQ_TextEncodeQwenImageEditPlus": AQ_TextEncodeQwenImageEditPlus, "AQ_CoverPrompt": AQ_CoverPrompt, "AQ_StillImageToVideo": AQ_StillImageToVideo, + "AQ_YouTubeUpload": AQ_YouTubeUpload, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -76,6 +78,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "AQ_TextEncodeQwenImageEditPlus": "AQ_TextEncodeQwenImageEditPlus", "AQ_CoverPrompt": "AQ_CoverPrompt", "AQ_StillImageToVideo": "AQ_StillImageToVideo", + "AQ_YouTubeUpload": "AQ_YouTubeUpload", } diff --git a/nodes/api/AQ_YouTubeUpload.py b/nodes/api/AQ_YouTubeUpload.py new file mode 100644 index 0000000..79afc2e --- /dev/null +++ b/nodes/api/AQ_YouTubeUpload.py @@ -0,0 +1,219 @@ +import os +import requests + + +_INSTRUCTION = """AQ_YouTubeUpload — How to get credentials (one-time setup) +================================================================= + +YouTube uploads require OAuth 2.0, NOT a plain API key. +You need three values: Client ID, Client Secret, Refresh Token. + +STEP 1 — Create a Google Cloud project & enable YouTube API +----------------------------------------------------------- +1. Go to https://console.cloud.google.com/ +2. Create a new project (top bar → "New Project"). +3. Navigate to: APIs & Services → Library +4. Search "YouTube Data API v3" → click it → Enable. + +STEP 2 — Create OAuth 2.0 credentials +-------------------------------------- +1. Navigate to: APIs & Services → Credentials +2. Click "Create Credentials" → "OAuth client ID" +3. If prompted, configure the OAuth consent screen first: + - User type: External + - Fill in app name, your email → Save + - Scopes: add "YouTube Data API v3 → .../auth/youtube.upload" + - Add yourself as a Test User (your Google account email) +4. Back in Credentials → Create Credentials → OAuth client ID: + - Application type: "Desktop app" + - Give it a name → Create +5. Copy "Client ID" and "Client Secret" from the dialog. + +STEP 3 — Generate a Refresh Token (one-time) +-------------------------------------------- +1. Visit https://developers.google.com/oauthplayground/ +2. Click the gear icon (⚙️) in the top-right: + - Check "Use your own OAuth credentials" + - Enter your Client ID and Client Secret → Close +3. In "Step 1 — Select & authorize APIs": + - Paste or find: https://www.googleapis.com/auth/youtube.upload + - Click "Authorize APIs" + - Sign in with the Google account that owns the YouTube channel +4. In "Step 2 — Exchange authorization code for tokens": + - Click "Exchange authorization code for tokens" + - Copy the "Refresh token" value + +STEP 4 — Fill in the node +-------------------------- +- client_id → your OAuth Client ID +- client_secret → your OAuth Client Secret +- refresh_token → the refresh token from Step 3 + +The refresh token does not expire unless you revoke it. +You only need to do this setup once per YouTube channel. + +PRIVACY STATUS +-------------- +- private → only you can see it +- unlisted → anyone with the link can see it +- public → visible to everyone + +DESCRIPTION AUTO-BUILD +----------------------- +If you leave 'description' empty, the node auto-composes it +from tagline, tags, and keyscale inputs. +""" + + +class AQ_YouTubeUpload: + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "video_path": ("STRING", {"default": ""}), + "client_id": ("STRING", {"default": ""}), + "client_secret": ("STRING", {"default": ""}), + "refresh_token": ("STRING", {"default": ""}), + "title": ("STRING", {"default": "My Song"}), + "privacy_status": (["private", "unlisted", "public"], {"default": "private"}), + }, + "optional": { + "description": ("STRING", {"multiline": True, "default": ""}), + "tagline": ("STRING", {"multiline": False, "default": ""}), + "headline": ("STRING", {"multiline": False, "default": ""}), + "tags": ("STRING", {"multiline": False, "default": ""}), + "keyscale": ("STRING", {"multiline": False, "default": ""}), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("video_url", "video_id", "instruction") + OUTPUT_NODE = True + FUNCTION = "upload" + CATEGORY = "Aquasite/Media" + + def upload(self, video_path, client_id, client_secret, refresh_token, title, + privacy_status="private", description="", tagline="", headline="", + tags="", keyscale=""): + + if not all([video_path, client_id, client_secret, refresh_token]): + return ("", "", _INSTRUCTION) + + if not os.path.isfile(video_path): + raise FileNotFoundError(f"Video file not found: {video_path}") + + # Auto-compose description if not provided + if not description.strip(): + parts = [] + if tagline: + parts.append(tagline) + if tags: + parts.append(f"Style: {tags}") + if keyscale: + parts.append(f"Key: {keyscale}") + if headline: + parts.append(f"\n{headline}") + description = "\n".join(parts) + + access_token = self._get_access_token(client_id, client_secret, refresh_token) + + tags_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else [] + + video_id = self._resumable_upload( + access_token=access_token, + video_path=video_path, + title=title, + description=description, + tags=tags_list, + privacy_status=privacy_status, + category_id="10", # 10 = Music + ) + + video_url = f"https://www.youtube.com/watch?v={video_id}" + print(f"[AQ_YouTubeUpload] Uploaded: {video_url}") + return (video_url, video_id, _INSTRUCTION) + + # ------------------------------------------------------------------ + + def _get_access_token(self, client_id, client_secret, refresh_token): + resp = requests.post( + "https://oauth2.googleapis.com/token", + data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, + timeout=30, + ) + if not resp.ok: + raise RuntimeError(f"Failed to get access token: {resp.status_code} {resp.text}") + return resp.json()["access_token"] + + def _resumable_upload(self, access_token, video_path, title, description, + tags, privacy_status, category_id): + file_size = os.path.getsize(video_path) + + metadata = { + "snippet": { + "title": title, + "description": description, + "tags": tags, + "categoryId": category_id, + }, + "status": { + "privacyStatus": privacy_status, + }, + } + + # Initiate upload session + init = requests.post( + "https://www.googleapis.com/upload/youtube/v3/videos", + params={"uploadType": "resumable", "part": "snippet,status"}, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json; charset=UTF-8", + "X-Upload-Content-Type": "video/mp4", + "X-Upload-Content-Length": str(file_size), + }, + json=metadata, + timeout=60, + ) + if not init.ok: + raise RuntimeError(f"Upload init failed: {init.status_code} {init.text}") + + upload_url = init.headers["Location"] + + # Upload in 10 MB chunks + chunk_size = 10 * 1024 * 1024 + with open(video_path, "rb") as fh: + start = 0 + while start < file_size: + chunk = fh.read(chunk_size) + end = start + len(chunk) - 1 + + put = requests.put( + upload_url, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Type": "video/mp4", + }, + data=chunk, + timeout=300, + ) + + if put.status_code in (200, 201): + return put.json()["id"] + elif put.status_code == 308: # Resume Incomplete + range_header = put.headers.get("Range", "") + if range_header: + start = int(range_header.split("-")[1]) + 1 + else: + start = end + 1 + else: + raise RuntimeError( + f"Upload chunk failed: {put.status_code} {put.text}" + ) + + raise RuntimeError("Upload ended without receiving a video ID")