ShotSplitter: add Download from URL button + yt-dlp route
Browse files
ComfyUI-ShotSplitter/js/shot_splitter_ui.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
// Oz_ShotSplitter UI:
|
| 2 |
// 1. Upload Video button (uses ComfyUI /upload/image endpoint, type=input)
|
| 3 |
-
// 2.
|
|
|
|
| 4 |
// Adapted from ComfyUI-VideoHelperSuite VHS.core.js (addVideoPreview pattern).
|
| 5 |
|
| 6 |
import { app } from "../../scripts/app.js";
|
|
@@ -38,8 +39,24 @@ function clearContainer(container) {
|
|
| 38 |
container.innerHTML = "";
|
| 39 |
}
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
| 44 |
if (this._ozUploadAdded) return;
|
| 45 |
this._ozUploadAdded = true;
|
|
@@ -48,14 +65,25 @@ function addUploadButton(nodeType) {
|
|
| 48 |
wrapper.style.cssText =
|
| 49 |
"display:flex;flex-direction:column;gap:4px;padding:4px;width:100%;box-sizing:border-box;";
|
| 50 |
|
| 51 |
-
const
|
| 52 |
-
|
| 53 |
-
btn.type = "button";
|
| 54 |
-
btn.style.cssText =
|
| 55 |
-
"width:100%;padding:6px 8px;cursor:pointer;background:#3a3a3a;color:#fff;" +
|
| 56 |
"border:1px solid #555;border-radius:4px;font-size:12px;";
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
const status = document.createElement("div");
|
| 61 |
status.style.cssText = "font-size:10px;color:#888;text-align:center;min-height:12px;";
|
|
@@ -65,53 +93,36 @@ function addUploadButton(nodeType) {
|
|
| 65 |
fileInput.accept = "video/*";
|
| 66 |
fileInput.style.display = "none";
|
| 67 |
|
| 68 |
-
|
| 69 |
|
| 70 |
const node = this;
|
|
|
|
| 71 |
fileInput.addEventListener("change", async (e) => {
|
| 72 |
const file = e.target.files && e.target.files[0];
|
| 73 |
if (!file) return;
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
|
|
|
| 77 |
status.textContent = `${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`;
|
| 78 |
|
| 79 |
try {
|
| 80 |
const fd = new FormData();
|
| 81 |
-
// ComfyUI's /upload/image accepts arbitrary file types
|
| 82 |
fd.append("image", file, file.name);
|
| 83 |
fd.append("type", "input");
|
| 84 |
fd.append("subfolder", "");
|
| 85 |
fd.append("overwrite", "true");
|
| 86 |
|
| 87 |
-
const res = await fetch("/upload/image", {
|
| 88 |
-
|
| 89 |
-
body: fd,
|
| 90 |
-
});
|
| 91 |
-
if (!res.ok) {
|
| 92 |
-
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
| 93 |
-
}
|
| 94 |
const data = await res.json();
|
| 95 |
const uploadedName = data.name || file.name;
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
if (videoWidget) {
|
| 100 |
-
if (!videoWidget.options) videoWidget.options = {};
|
| 101 |
-
if (!videoWidget.options.values) videoWidget.options.values = [];
|
| 102 |
-
if (!videoWidget.options.values.includes(uploadedName)) {
|
| 103 |
-
// Drop the placeholder if present
|
| 104 |
-
videoWidget.options.values = videoWidget.options.values.filter(
|
| 105 |
-
(v) => v !== "<no videos in input/>"
|
| 106 |
-
);
|
| 107 |
-
videoWidget.options.values.push(uploadedName);
|
| 108 |
-
videoWidget.options.values.sort();
|
| 109 |
-
}
|
| 110 |
-
videoWidget.value = uploadedName;
|
| 111 |
-
btn.textContent = `✅ ${uploadedName}`;
|
| 112 |
status.textContent = "Video selected in dropdown.";
|
| 113 |
} else {
|
| 114 |
-
|
| 115 |
}
|
| 116 |
|
| 117 |
if (node.graph) {
|
|
@@ -120,18 +131,68 @@ function addUploadButton(nodeType) {
|
|
| 120 |
}
|
| 121 |
} catch (err) {
|
| 122 |
console.error("[Oz ShotSplitter] Upload failed:", err);
|
| 123 |
-
|
| 124 |
status.textContent = String(err.message || err);
|
| 125 |
} finally {
|
| 126 |
-
|
|
|
|
| 127 |
fileInput.value = "";
|
| 128 |
setTimeout(() => {
|
| 129 |
-
|
| 130 |
}, 6000);
|
| 131 |
}
|
| 132 |
});
|
| 133 |
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
wrapper.appendChild(status);
|
| 136 |
wrapper.appendChild(fileInput);
|
| 137 |
|
|
@@ -144,7 +205,7 @@ function addUploadButton(nodeType) {
|
|
| 144 |
setValue() {},
|
| 145 |
});
|
| 146 |
widget.computeSize = function (width) {
|
| 147 |
-
return [width,
|
| 148 |
};
|
| 149 |
});
|
| 150 |
}
|
|
@@ -233,7 +294,7 @@ app.registerExtension({
|
|
| 233 |
name: "oz.shot_splitter",
|
| 234 |
async beforeRegisterNodeDef(nodeType, nodeData) {
|
| 235 |
if (nodeData && nodeData.name === "Oz_ShotSplitter") {
|
| 236 |
-
|
| 237 |
addShotSplitterPreview(nodeType);
|
| 238 |
}
|
| 239 |
},
|
|
|
|
| 1 |
// Oz_ShotSplitter UI:
|
| 2 |
// 1. Upload Video button (uses ComfyUI /upload/image endpoint, type=input)
|
| 3 |
+
// 2. Download from URL button (POST /oz_shotsplitter/download_url, uses yt-dlp)
|
| 4 |
+
// 3. Preview stack of <video> tags from ui.videos payload
|
| 5 |
// Adapted from ComfyUI-VideoHelperSuite VHS.core.js (addVideoPreview pattern).
|
| 6 |
|
| 7 |
import { app } from "../../scripts/app.js";
|
|
|
|
| 39 |
container.innerHTML = "";
|
| 40 |
}
|
| 41 |
|
| 42 |
+
function setVideoWidget(node, filename) {
|
| 43 |
+
const videoWidget = node.widgets && node.widgets.find((w) => w.name === "video");
|
| 44 |
+
if (!videoWidget) return false;
|
| 45 |
+
if (!videoWidget.options) videoWidget.options = {};
|
| 46 |
+
if (!videoWidget.options.values) videoWidget.options.values = [];
|
| 47 |
+
if (!videoWidget.options.values.includes(filename)) {
|
| 48 |
+
videoWidget.options.values = videoWidget.options.values.filter(
|
| 49 |
+
(v) => v !== "<no videos in input/>"
|
| 50 |
+
);
|
| 51 |
+
videoWidget.options.values.push(filename);
|
| 52 |
+
videoWidget.options.values.sort();
|
| 53 |
+
}
|
| 54 |
+
videoWidget.value = filename;
|
| 55 |
+
return true;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ---------- FEATURE 1+2: Upload Video / Download from URL ----------
|
| 59 |
+
function addInputButtons(nodeType) {
|
| 60 |
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
| 61 |
if (this._ozUploadAdded) return;
|
| 62 |
this._ozUploadAdded = true;
|
|
|
|
| 65 |
wrapper.style.cssText =
|
| 66 |
"display:flex;flex-direction:column;gap:4px;padding:4px;width:100%;box-sizing:border-box;";
|
| 67 |
|
| 68 |
+
const btnStyle =
|
| 69 |
+
"width:100%;padding:6px 8px;cursor:pointer;color:#fff;" +
|
|
|
|
|
|
|
|
|
|
| 70 |
"border:1px solid #555;border-radius:4px;font-size:12px;";
|
| 71 |
+
|
| 72 |
+
// -- Upload button --
|
| 73 |
+
const uploadBtn = document.createElement("button");
|
| 74 |
+
uploadBtn.textContent = "📤 Upload Video";
|
| 75 |
+
uploadBtn.type = "button";
|
| 76 |
+
uploadBtn.style.cssText = btnStyle + "background:#3a3a3a;";
|
| 77 |
+
uploadBtn.addEventListener("mouseenter", () => (uploadBtn.style.background = "#4a4a4a"));
|
| 78 |
+
uploadBtn.addEventListener("mouseleave", () => (uploadBtn.style.background = "#3a3a3a"));
|
| 79 |
+
|
| 80 |
+
// -- URL download button --
|
| 81 |
+
const urlBtn = document.createElement("button");
|
| 82 |
+
urlBtn.textContent = "🔗 Download from URL";
|
| 83 |
+
urlBtn.type = "button";
|
| 84 |
+
urlBtn.style.cssText = btnStyle + "background:#2c4a6e;";
|
| 85 |
+
urlBtn.addEventListener("mouseenter", () => (urlBtn.style.background = "#345a86"));
|
| 86 |
+
urlBtn.addEventListener("mouseleave", () => (urlBtn.style.background = "#2c4a6e"));
|
| 87 |
|
| 88 |
const status = document.createElement("div");
|
| 89 |
status.style.cssText = "font-size:10px;color:#888;text-align:center;min-height:12px;";
|
|
|
|
| 93 |
fileInput.accept = "video/*";
|
| 94 |
fileInput.style.display = "none";
|
| 95 |
|
| 96 |
+
uploadBtn.addEventListener("click", () => fileInput.click());
|
| 97 |
|
| 98 |
const node = this;
|
| 99 |
+
|
| 100 |
fileInput.addEventListener("change", async (e) => {
|
| 101 |
const file = e.target.files && e.target.files[0];
|
| 102 |
if (!file) return;
|
| 103 |
|
| 104 |
+
uploadBtn.disabled = true;
|
| 105 |
+
urlBtn.disabled = true;
|
| 106 |
+
uploadBtn.textContent = `⏳ Uploading...`;
|
| 107 |
status.textContent = `${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`;
|
| 108 |
|
| 109 |
try {
|
| 110 |
const fd = new FormData();
|
|
|
|
| 111 |
fd.append("image", file, file.name);
|
| 112 |
fd.append("type", "input");
|
| 113 |
fd.append("subfolder", "");
|
| 114 |
fd.append("overwrite", "true");
|
| 115 |
|
| 116 |
+
const res = await fetch("/upload/image", { method: "POST", body: fd });
|
| 117 |
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
const data = await res.json();
|
| 119 |
const uploadedName = data.name || file.name;
|
| 120 |
|
| 121 |
+
if (setVideoWidget(node, uploadedName)) {
|
| 122 |
+
uploadBtn.textContent = `✅ ${uploadedName}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
status.textContent = "Video selected in dropdown.";
|
| 124 |
} else {
|
| 125 |
+
uploadBtn.textContent = `⚠️ video widget not found`;
|
| 126 |
}
|
| 127 |
|
| 128 |
if (node.graph) {
|
|
|
|
| 131 |
}
|
| 132 |
} catch (err) {
|
| 133 |
console.error("[Oz ShotSplitter] Upload failed:", err);
|
| 134 |
+
uploadBtn.textContent = `❌ Upload failed`;
|
| 135 |
status.textContent = String(err.message || err);
|
| 136 |
} finally {
|
| 137 |
+
uploadBtn.disabled = false;
|
| 138 |
+
urlBtn.disabled = false;
|
| 139 |
fileInput.value = "";
|
| 140 |
setTimeout(() => {
|
| 141 |
+
uploadBtn.textContent = "📤 Upload Video";
|
| 142 |
}, 6000);
|
| 143 |
}
|
| 144 |
});
|
| 145 |
|
| 146 |
+
urlBtn.addEventListener("click", async () => {
|
| 147 |
+
const url = (prompt("Paste video URL (Instagram / TikTok / YouTube / …):") || "").trim();
|
| 148 |
+
if (!url) return;
|
| 149 |
+
if (!/^https?:\/\//i.test(url)) {
|
| 150 |
+
status.textContent = "Must start with http(s)://";
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
uploadBtn.disabled = true;
|
| 155 |
+
urlBtn.disabled = true;
|
| 156 |
+
urlBtn.textContent = "⏳ Downloading...";
|
| 157 |
+
status.textContent = "yt-dlp running (may take a minute)...";
|
| 158 |
+
|
| 159 |
+
try {
|
| 160 |
+
const res = await fetch("/oz_shotsplitter/download_url", {
|
| 161 |
+
method: "POST",
|
| 162 |
+
headers: { "Content-Type": "application/json" },
|
| 163 |
+
body: JSON.stringify({ url }),
|
| 164 |
+
});
|
| 165 |
+
const data = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
| 166 |
+
if (!res.ok || !data.ok) {
|
| 167 |
+
throw new Error(data.error || `HTTP ${res.status}`);
|
| 168 |
+
}
|
| 169 |
+
const filename = data.filename;
|
| 170 |
+
if (setVideoWidget(node, filename)) {
|
| 171 |
+
urlBtn.textContent = `✅ ${filename}${data.cached ? " (cached)" : ""}`;
|
| 172 |
+
status.textContent = "Video selected in dropdown.";
|
| 173 |
+
} else {
|
| 174 |
+
urlBtn.textContent = `⚠️ video widget not found`;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (node.graph) {
|
| 178 |
+
node.setSize(node.computeSize());
|
| 179 |
+
node.graph.setDirtyCanvas(true, true);
|
| 180 |
+
}
|
| 181 |
+
} catch (err) {
|
| 182 |
+
console.error("[Oz ShotSplitter] URL download failed:", err);
|
| 183 |
+
urlBtn.textContent = "❌ Download failed";
|
| 184 |
+
status.textContent = String(err.message || err);
|
| 185 |
+
} finally {
|
| 186 |
+
uploadBtn.disabled = false;
|
| 187 |
+
urlBtn.disabled = false;
|
| 188 |
+
setTimeout(() => {
|
| 189 |
+
urlBtn.textContent = "🔗 Download from URL";
|
| 190 |
+
}, 8000);
|
| 191 |
+
}
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
wrapper.appendChild(uploadBtn);
|
| 195 |
+
wrapper.appendChild(urlBtn);
|
| 196 |
wrapper.appendChild(status);
|
| 197 |
wrapper.appendChild(fileInput);
|
| 198 |
|
|
|
|
| 205 |
setValue() {},
|
| 206 |
});
|
| 207 |
widget.computeSize = function (width) {
|
| 208 |
+
return [width, 90];
|
| 209 |
};
|
| 210 |
});
|
| 211 |
}
|
|
|
|
| 294 |
name: "oz.shot_splitter",
|
| 295 |
async beforeRegisterNodeDef(nodeType, nodeData) {
|
| 296 |
if (nodeData && nodeData.name === "Oz_ShotSplitter") {
|
| 297 |
+
addInputButtons(nodeType);
|
| 298 |
addShotSplitterPreview(nodeType);
|
| 299 |
}
|
| 300 |
},
|
ComfyUI-ShotSplitter/server_hooks.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
"""ComfyUI server hooks for Oz ShotSplitter.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
execution.py — events submitted without client_id are silently dropped).
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
|
|
|
| 11 |
import logging
|
|
|
|
|
|
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
|
@@ -18,6 +21,33 @@ except Exception as e: # pragma: no cover
|
|
| 18 |
PromptServer = None # type: ignore
|
| 19 |
logger.warning("Oz ShotSplitter: PromptServer unavailable (%s)", e)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def register_routes() -> None:
|
| 23 |
if PromptServer is None:
|
|
@@ -35,7 +65,98 @@ def register_routes() -> None:
|
|
| 35 |
sids = list(getattr(server, "sockets", {}).keys())
|
| 36 |
return web.json_response({"client_ids": sids, "count": len(sids)})
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
register_routes()
|
|
|
|
| 1 |
"""ComfyUI server hooks for Oz ShotSplitter.
|
| 2 |
|
| 3 |
+
Routes:
|
| 4 |
+
GET /oz_shotsplitter/active_clients — debug list of active websocket clients
|
| 5 |
+
POST /oz_shotsplitter/download_url — yt-dlp a public URL into ComfyUI input/
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import asyncio
|
| 11 |
+
import hashlib
|
| 12 |
import logging
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
|
|
| 21 |
PromptServer = None # type: ignore
|
| 22 |
logger.warning("Oz ShotSplitter: PromptServer unavailable (%s)", e)
|
| 23 |
|
| 24 |
+
DOWNLOAD_TIMEOUT_SEC = 600
|
| 25 |
+
DOWNLOAD_MAX_FILESIZE = "500M"
|
| 26 |
+
VIDEO_EXTS = ("mp4", "mkv", "webm", "mov", "m4v")
|
| 27 |
+
_INPUT_DIR_CACHE: str | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _resolve_input_dir() -> str:
|
| 31 |
+
global _INPUT_DIR_CACHE
|
| 32 |
+
if _INPUT_DIR_CACHE:
|
| 33 |
+
return _INPUT_DIR_CACHE
|
| 34 |
+
try:
|
| 35 |
+
import folder_paths # type: ignore
|
| 36 |
+
d = folder_paths.get_input_directory()
|
| 37 |
+
except Exception:
|
| 38 |
+
d = "/workspace/ComfyUI/input"
|
| 39 |
+
os.makedirs(d, exist_ok=True)
|
| 40 |
+
_INPUT_DIR_CACHE = d
|
| 41 |
+
return d
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _find_cached(input_dir: str, stem: str) -> str | None:
|
| 45 |
+
for ext in VIDEO_EXTS:
|
| 46 |
+
p = os.path.join(input_dir, f"{stem}.{ext}")
|
| 47 |
+
if os.path.exists(p) and os.path.getsize(p) > 0:
|
| 48 |
+
return p
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
|
| 52 |
def register_routes() -> None:
|
| 53 |
if PromptServer is None:
|
|
|
|
| 65 |
sids = list(getattr(server, "sockets", {}).keys())
|
| 66 |
return web.json_response({"client_ids": sids, "count": len(sids)})
|
| 67 |
|
| 68 |
+
@server.routes.post("/oz_shotsplitter/download_url")
|
| 69 |
+
async def _download_url(request):
|
| 70 |
+
try:
|
| 71 |
+
data = await request.json()
|
| 72 |
+
except Exception:
|
| 73 |
+
return web.json_response({"ok": False, "error": "invalid JSON body"}, status=400)
|
| 74 |
+
|
| 75 |
+
url = (data.get("url") or "").strip()
|
| 76 |
+
if not url or not re.match(r"^https?://", url):
|
| 77 |
+
return web.json_response({"ok": False, "error": "invalid URL"}, status=400)
|
| 78 |
+
|
| 79 |
+
input_dir = _resolve_input_dir()
|
| 80 |
+
url_hash = hashlib.md5(url.encode("utf-8")).hexdigest()[:12]
|
| 81 |
+
stem = f"yt_{url_hash}"
|
| 82 |
+
|
| 83 |
+
cached = _find_cached(input_dir, stem)
|
| 84 |
+
if cached:
|
| 85 |
+
return web.json_response({
|
| 86 |
+
"ok": True,
|
| 87 |
+
"filename": os.path.basename(cached),
|
| 88 |
+
"cached": True,
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
out_template = os.path.join(input_dir, f"{stem}.%(ext)s")
|
| 92 |
+
cmd = [
|
| 93 |
+
"yt-dlp",
|
| 94 |
+
url,
|
| 95 |
+
"-o", out_template,
|
| 96 |
+
"--no-playlist",
|
| 97 |
+
"--max-filesize", DOWNLOAD_MAX_FILESIZE,
|
| 98 |
+
"-f", "mp4/best[ext=mp4]/bestvideo*+bestaudio/best",
|
| 99 |
+
"--merge-output-format", "mp4",
|
| 100 |
+
"--no-warnings",
|
| 101 |
+
"--no-progress",
|
| 102 |
+
"--quiet",
|
| 103 |
+
]
|
| 104 |
+
logger.info("Oz ShotSplitter: yt-dlp %s -> %s", url, stem)
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
proc = await asyncio.create_subprocess_exec(
|
| 108 |
+
*cmd,
|
| 109 |
+
stdout=asyncio.subprocess.PIPE,
|
| 110 |
+
stderr=asyncio.subprocess.PIPE,
|
| 111 |
+
)
|
| 112 |
+
except FileNotFoundError:
|
| 113 |
+
return web.json_response({
|
| 114 |
+
"ok": False,
|
| 115 |
+
"error": "yt-dlp not installed. Run: /venv/main/bin/pip install yt-dlp",
|
| 116 |
+
}, status=500)
|
| 117 |
+
except Exception as e:
|
| 118 |
+
return web.json_response({"ok": False, "error": f"spawn failed: {e}"}, status=500)
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
stdout, stderr = await asyncio.wait_for(
|
| 122 |
+
proc.communicate(), timeout=DOWNLOAD_TIMEOUT_SEC
|
| 123 |
+
)
|
| 124 |
+
except asyncio.TimeoutError:
|
| 125 |
+
try:
|
| 126 |
+
proc.kill()
|
| 127 |
+
await proc.wait()
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
return web.json_response({
|
| 131 |
+
"ok": False,
|
| 132 |
+
"error": f"yt-dlp timed out after {DOWNLOAD_TIMEOUT_SEC}s",
|
| 133 |
+
}, status=504)
|
| 134 |
+
|
| 135 |
+
if proc.returncode != 0:
|
| 136 |
+
err_tail = (stderr or b"").decode("utf-8", errors="replace").strip()
|
| 137 |
+
if len(err_tail) > 600:
|
| 138 |
+
err_tail = err_tail[-600:]
|
| 139 |
+
return web.json_response({
|
| 140 |
+
"ok": False,
|
| 141 |
+
"error": f"yt-dlp exit {proc.returncode}: {err_tail or 'no stderr'}",
|
| 142 |
+
}, status=500)
|
| 143 |
+
|
| 144 |
+
saved = _find_cached(input_dir, stem)
|
| 145 |
+
if not saved:
|
| 146 |
+
return web.json_response({
|
| 147 |
+
"ok": False,
|
| 148 |
+
"error": "yt-dlp succeeded but output file missing",
|
| 149 |
+
}, status=500)
|
| 150 |
+
|
| 151 |
+
return web.json_response({
|
| 152 |
+
"ok": True,
|
| 153 |
+
"filename": os.path.basename(saved),
|
| 154 |
+
"cached": False,
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
logger.info(
|
| 158 |
+
"Oz ShotSplitter: registered /oz_shotsplitter/{active_clients,download_url}"
|
| 159 |
+
)
|
| 160 |
|
| 161 |
|
| 162 |
register_routes()
|