Add web upload: POST /api/upload stores MP4s into dated archive, plus Upload UI panel
This commit is contained in:
+48
@@ -169,6 +169,16 @@
|
|||||||
<div class="highlight-list" id="highlight-list"><span style="color:var(--dim);font-size:12px">No highlights yet.</span></div>
|
<div class="highlight-list" id="highlight-list"><span style="color:var(--dim);font-size:12px">No highlights yet.</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="encode-panel">
|
||||||
|
<button class="encode-toggle" id="upload-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Upload</button>
|
||||||
|
<div class="encode-body" id="upload-body" style="flex-wrap:wrap">
|
||||||
|
<input type="file" id="upload-file" accept="video/mp4" style="font-size:12px;color:var(--dim)">
|
||||||
|
<span class="enc-status" style="min-width:120px">Date: <b id="upload-date">-</b></span>
|
||||||
|
<button id="upload-btn" disabled>Upload</button>
|
||||||
|
<span class="enc-status" id="upload-status" style="min-width:200px">Pick an MP4 named HH.MM.SS.mp4</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="encode-panel">
|
<div class="encode-panel">
|
||||||
<button class="encode-toggle" id="encode-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Encoding</button>
|
<button class="encode-toggle" id="encode-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Encoding</button>
|
||||||
<div class="encode-body" id="encode-body">
|
<div class="encode-body" id="encode-body">
|
||||||
@@ -512,6 +522,44 @@
|
|||||||
else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); }
|
else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// upload
|
||||||
|
const uploadToggle = document.querySelector("#upload-toggle");
|
||||||
|
uploadToggle.addEventListener("click", () => {
|
||||||
|
uploadToggle.classList.toggle("open");
|
||||||
|
document.querySelector("#upload-body").classList.toggle("show");
|
||||||
|
document.querySelector("#upload-date").textContent = dateSelect.value || "-";
|
||||||
|
});
|
||||||
|
const uploadFile = document.querySelector("#upload-file");
|
||||||
|
const uploadBtn = document.querySelector("#upload-btn");
|
||||||
|
uploadFile.addEventListener("change", () => {
|
||||||
|
uploadBtn.disabled = !uploadFile.files.length;
|
||||||
|
document.querySelector("#upload-status").textContent = uploadFile.files.length
|
||||||
|
? `${uploadFile.files[0].name} (${(uploadFile.files[0].size / 1048576).toFixed(1)} MB)` : "Pick an MP4 named HH.MM.SS.mp4";
|
||||||
|
});
|
||||||
|
uploadBtn.addEventListener("click", async () => {
|
||||||
|
const file = uploadFile.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const name = file.name;
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
const filename = dot > 0 ? name.slice(0, dot).padStart(8, "0").slice(-8) + name.slice(dot).toLowerCase() : name;
|
||||||
|
if (!/^\d{2}\.\d{2}\.\d{2}\.mp4$/i.test(filename)) {
|
||||||
|
document.querySelector("#upload-status").textContent = "Bad name: use HH.MM.SS.mp4 (e.g. 09.28.36.mp4)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
document.querySelector("#upload-status").textContent = "Uploading...";
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/upload?date=${dateSelect.value}&filename=${encodeURIComponent(filename)}`, {method: "POST", body: file});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
document.querySelector("#upload-status").textContent = r.ok
|
||||||
|
? `Stored: ${d.file}` : `Failed: ${d.message || r.status}`;
|
||||||
|
if (r.ok) { uploadFile.value = ""; loadDate(dateSelect.value); }
|
||||||
|
} catch (err) {
|
||||||
|
document.querySelector("#upload-status").textContent = "Failed: network error";
|
||||||
|
}
|
||||||
|
uploadBtn.disabled = !uploadFile.files.length;
|
||||||
|
});
|
||||||
|
|
||||||
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
||||||
addEventListener("resize", drawTimeline);
|
addEventListener("resize", drawTimeline);
|
||||||
loadDate("").catch(() => {});
|
loadDate("").catch(() => {});
|
||||||
|
|||||||
@@ -109,7 +109,10 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if urlparse(self.path).path == "/api/encode":
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/upload":
|
||||||
|
return self.handle_upload()
|
||||||
|
if path == "/api/encode":
|
||||||
if not self.server.encode_status["running"]:
|
if not self.server.encode_status["running"]:
|
||||||
threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start()
|
threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start()
|
||||||
return self.send_json(self.server.encode_status)
|
return self.send_json(self.server.encode_status)
|
||||||
@@ -140,6 +143,60 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
|||||||
return self.send_json(highlight)
|
return self.send_json(highlight)
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
|
def handle_upload(self):
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
return self.send_error(400, "Invalid content length")
|
||||||
|
max_bytes = 2 * 1024 * 1024 * 1024
|
||||||
|
if not 0 < length <= max_bytes:
|
||||||
|
return self.send_error(413, "Missing or oversized body (limit 2 GiB)")
|
||||||
|
filename = parse_qs(urlparse(self.path).query).get("filename", [""])[0]
|
||||||
|
match = VIDEO_RE.fullmatch(filename)
|
||||||
|
if not match:
|
||||||
|
return self.send_error(400, "Filename must be HH.MM.SS.mp4 or HH.MM.SS.avi (e.g. 09.28.36.mp4)")
|
||||||
|
hour, minute, second = map(int, match.groups()[:3])
|
||||||
|
if hour > 23 or minute > 59 or second > 59:
|
||||||
|
return self.send_error(400, "Invalid time in filename")
|
||||||
|
try:
|
||||||
|
day = normalize_date(parse_qs(urlparse(self.path).query).get("date", [""])[0])
|
||||||
|
except ValueError:
|
||||||
|
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
|
||||||
|
suffix = match.group(4).lower()
|
||||||
|
if suffix == "avi":
|
||||||
|
return self.send_error(400, "Only pre-encoded .mp4 uploads are accepted; AVI sources go through the Encoding menu")
|
||||||
|
|
||||||
|
target_dir = self.server.output_root / day
|
||||||
|
target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.mp4"
|
||||||
|
temporary = target.with_suffix(".uploading.mp4")
|
||||||
|
try:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
received = 0
|
||||||
|
with temporary.open("wb") as sink:
|
||||||
|
while received < length:
|
||||||
|
chunk = self.rfile.read(min(256 * 1024, length - received))
|
||||||
|
if not chunk:
|
||||||
|
raise OSError("client disconnected mid-upload")
|
||||||
|
sink.write(chunk)
|
||||||
|
received += len(chunk)
|
||||||
|
if target.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
return self.send_error(409, "A recording already exists at that time; nothing was overwritten")
|
||||||
|
temporary.replace(target)
|
||||||
|
except OSError:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return self.send_error(500, "Failed to store upload")
|
||||||
|
self.server.recordings.setdefault(day, [])
|
||||||
|
if not any(clip["name"] == target.name for clip in self.server.recordings[day]):
|
||||||
|
self.server.recordings[day].append({
|
||||||
|
"name": target.name,
|
||||||
|
"start": hour * 3600 + minute * 60 + second,
|
||||||
|
"duration": 120,
|
||||||
|
"path": target,
|
||||||
|
})
|
||||||
|
self.server.recordings[day].sort(key=lambda clip: clip["start"])
|
||||||
|
return self.send_json({"ok": True, "file": f"{day}/{target.name}"})
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
request = urlparse(self.path)
|
request = urlparse(self.path)
|
||||||
if request.path != "/api/highlights":
|
if request.path != "/api/highlights":
|
||||||
|
|||||||
Reference in New Issue
Block a user