Compare commits
21
Commits
1be5e840af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d194d5241 | ||
|
|
43561d5012 | ||
|
|
48cbd56f63 | ||
|
|
c59fee65fd | ||
|
|
9d4f12e5df | ||
|
|
780544d002 | ||
|
|
afdbf474e6 | ||
|
|
3a858c9a55 | ||
|
|
35be3fc2e7 | ||
|
|
abeb245cdc | ||
|
|
7f38d7918c | ||
|
|
2bc0c77d81 | ||
|
|
7e034d07ca | ||
|
|
cedb672633 | ||
|
|
6772311641 | ||
|
|
8326fd7c03 | ||
|
|
baa344c8cb | ||
|
|
807447e815 | ||
|
|
f00ae278cd | ||
|
|
b488bce210 | ||
|
|
25bdd34c70 |
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
__pycache__
|
||||
server.log
|
||||
*.pyc
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY server.py index.html README.md ./
|
||||
|
||||
ENV C41_SOURCE=/data/source \
|
||||
C41_OUTPUT=/data/encoded \
|
||||
C41_HOST=0.0.0.0 \
|
||||
C41_PORT=8765
|
||||
|
||||
EXPOSE 8765
|
||||
VOLUME ["/data/source", "/data/encoded"]
|
||||
|
||||
CMD ["python3", "server.py"]
|
||||
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
c41-player:
|
||||
build: .
|
||||
container_name: c41-player
|
||||
environment:
|
||||
C41_HOST: 0.0.0.0
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- "/home/human/Videos/Yassine Salon/C41/media:/data/source:ro"
|
||||
- "/home/human/Videos/Yassine Salon/C41/media-mp4:/data/encoded"
|
||||
restart: unless-stopped
|
||||
+215
@@ -87,6 +87,39 @@
|
||||
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
|
||||
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
|
||||
|
||||
/* upload panel */
|
||||
.upload-toggle svg { fill: #8ab4ff; }
|
||||
.upload-body { border-top: 1px solid #1c1c1c; }
|
||||
.upload-drop { flex: 1; min-width: 240px; border: 2px dashed #555; border-radius: 8px; padding: 22px 16px; text-align: center; color: var(--dim); font-size: 13px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||||
.upload-drop:hover, .upload-drop.drag { border-color: var(--accent); background: rgba(62,166,255,.08); color: var(--text); }
|
||||
.upload-drop b { color: var(--accent); font-weight: 500; }
|
||||
.upload-drop input { display: none; }
|
||||
.upload-file-chip { display: none; align-items: center; gap: 8px; background: #1a1a1a; border: 1px solid #3ea6ff55; border-radius: 6px; padding: 8px 12px; font-size: 12px; max-width: 320px; }
|
||||
.upload-file-chip.show { display: flex; }
|
||||
.upload-file-chip .chip-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.upload-file-chip .chip-size { color: var(--dim); white-space: nowrap; }
|
||||
.upload-file-chip .chip-x { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 14px; padding: 0 2px; }
|
||||
.upload-file-chip .chip-x:hover { color: var(--red); }
|
||||
.upload-queue { display: none; flex-direction: column; gap: 4px; flex: 1 1 100%; min-width: 260px; max-height: 240px; overflow-y: auto; }
|
||||
.upload-queue.show { display: flex; }
|
||||
.q-count { color: var(--dim); font-size: 11px; padding: 2px 0; }
|
||||
.q-item { display: flex; align-items: center; gap: 8px; font-size: 12px; background: #1a1a1a; border-radius: 4px; padding: 5px 8px; }
|
||||
.q-item .q-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.q-item .q-size { color: var(--dim); white-space: nowrap; }
|
||||
.q-item .q-state { min-width: 70px; text-align: right; color: var(--dim); white-space: nowrap; }
|
||||
.q-item.ok .q-state { color: #7bd97b; }
|
||||
.q-item.err .q-state { color: var(--red); }
|
||||
.q-item.busy .q-state { color: var(--accent); }
|
||||
.q-item .q-bar { position: relative; width: 60px; height: 3px; background: #333; border-radius: 2px; overflow: hidden; flex: 0 0 60px; }
|
||||
.q-item .q-bar i { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); display: block; }
|
||||
.upload-btn { position: relative; overflow: hidden; }
|
||||
.upload-btn.busy { color: transparent; pointer-events: none; }
|
||||
.upload-btn.busy::after { content: ""; position: absolute; inset: 0; margin: auto; width: 14px; height: 14px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.upload-status { transition: color .2s; }
|
||||
.upload-status.ok { color: #7bd97b; }
|
||||
.upload-status.err { color: var(--red); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page { padding: 8px; }
|
||||
.ctrl-row { gap: 3px; }
|
||||
@@ -103,6 +136,8 @@
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<h1>C41 Archive</h1>
|
||||
<span class="spacer" style="flex:1"></span>
|
||||
<button id="rescan-btn" title="Rescan the archive folder" style="background:var(--surface);color:var(--text);border:none;padding:6px 10px;border-radius:3px;font-size:14px;cursor:pointer;margin-right:8px">↻</button>
|
||||
<select id="date"></select>
|
||||
</div>
|
||||
|
||||
@@ -165,10 +200,28 @@
|
||||
<button id="mark-end">Mark end</button><span class="mark-time" id="highlight-end">--:--:--</span>
|
||||
<input id="highlight-label" placeholder="Highlight name" maxlength="120">
|
||||
<button id="save-highlight">Add highlight</button>
|
||||
<span style="flex:1"></span>
|
||||
<button id="export-csv" title="Download all highlights as CSV">Export CSV</button>
|
||||
<button id="export-json" title="Download all highlights as JSON">Export JSON</button>
|
||||
</div>
|
||||
<div class="highlight-list" id="highlight-list"><span style="color:var(--dim);font-size:12px">No highlights yet.</span></div>
|
||||
</section>
|
||||
|
||||
<div class="encode-panel">
|
||||
<button class="encode-toggle upload-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 upload-body" id="upload-body" style="flex-wrap:wrap;gap:14px">
|
||||
<div class="upload-drop" id="upload-drop" title="Click to browse, or drag MP4/AVI files here">
|
||||
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">multiple files allowed; date & time come from each file's metadata</span>
|
||||
<input type="file" id="upload-file" accept="video/mp4,video/x-msvideo,.avi" multiple>
|
||||
</div>
|
||||
<div class="upload-queue" id="upload-queue"></div>
|
||||
<span class="enc-status" style="min-width:110px">Upload date: <b id="upload-date" style="color:var(--text)">-</b></span>
|
||||
<input id="upload-date-override" type="date" title="Override: force all files into this date instead of file mtime" style="background:var(--surface);color:var(--text);border:1px solid #555;border-radius:3px;padding:6px 8px;font-size:12px">
|
||||
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
|
||||
<span class="enc-status upload-status" id="upload-status" style="min-width:200px;text-align:left">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="encode-body" id="encode-body">
|
||||
@@ -347,6 +400,8 @@
|
||||
}
|
||||
document.querySelector("#mark-start").onclick = () => setMark("start");
|
||||
document.querySelector("#mark-end").onclick = () => setMark("end");
|
||||
document.querySelector("#export-csv").onclick = () => location.href = "/api/highlights/export?format=csv";
|
||||
document.querySelector("#export-json").onclick = () => location.href = "/api/highlights/export?format=json";
|
||||
document.querySelector("#save-highlight").onclick = async () => {
|
||||
if (highlightStart === null || highlightEnd === null || highlightStart >= highlightEnd) return;
|
||||
await fetch("/api/highlights", {method: "POST", headers: {"Content-Type": "application/json"},
|
||||
@@ -512,8 +567,168 @@
|
||||
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");
|
||||
const uploadDrop = document.querySelector("#upload-drop");
|
||||
const uploadQueue = document.querySelector("#upload-queue");
|
||||
const uploadStatus = document.querySelector("#upload-status");
|
||||
const overrideDate = document.querySelector("#upload-date-override");
|
||||
overrideDate.addEventListener("change", () => {
|
||||
renderQueue();
|
||||
if (overrideDate.value) setStatus(`Override active: all files go to ${overrideDate.value.replaceAll("-", "")}`, "ok");
|
||||
else setStatus("Ready");
|
||||
});
|
||||
let queue = [];
|
||||
function setStatus(msg, cls) {
|
||||
uploadStatus.textContent = msg;
|
||||
uploadStatus.className = "enc-status upload-status" + (cls ? " " + cls : "");
|
||||
}
|
||||
function renderQueue() {
|
||||
uploadQueue.replaceChildren();
|
||||
uploadQueue.classList.toggle("show", queue.length > 0);
|
||||
if (!queue.length) return;
|
||||
const counts = queue.reduce((acc, e) => (acc[e.state || "queued"] = (acc[e.state || "queued"] || 0) + 1, acc), {});
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "q-count";
|
||||
summary.textContent = `${queue.length} file${queue.length > 1 ? "s" : ""}: ${counts.queued || 0} queued, ${counts.busy || 0} sending, ${counts.ok || 0} stored, ${counts.err || 0} failed`;
|
||||
uploadQueue.append(summary);
|
||||
queue.forEach(entry => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "q-item" + (entry.state ? " " + entry.state : "");
|
||||
const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name;
|
||||
const stamp = fileStamp(entry);
|
||||
const slot = document.createElement("span"); slot.className = "q-size";
|
||||
slot.textContent = stamp.date + " " + (stamp.time ? stamp.time : "mtime time");
|
||||
const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB";
|
||||
const bar = document.createElement("span"); bar.className = "q-bar";
|
||||
const fill = document.createElement("i"); bar.append(fill);
|
||||
entry.bar = fill;
|
||||
const state = document.createElement("span"); state.className = "q-state";
|
||||
state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued";
|
||||
row.append(name, slot, size, bar, state);
|
||||
uploadQueue.append(row);
|
||||
});
|
||||
uploadBtn.disabled = !queue.some(e => e.state === "queued");
|
||||
}
|
||||
function addFiles(files) {
|
||||
for (const file of files) {
|
||||
if (!/\.(mp4|avi)$/i.test(file.name)) continue;
|
||||
queue.push({file, state: "queued"});
|
||||
}
|
||||
renderQueue();
|
||||
if (queue.length) setStatus(`${queue.length} file${queue.length > 1 ? "s" : ""} queued${overrideDate.value ? " for " + overrideDate.value.replaceAll("-", "") : " (mtimes decide)"}`);
|
||||
}
|
||||
function fileStamp(entry) {
|
||||
if (overrideDate.value) {
|
||||
return {date: overrideDate.value.replaceAll("-", ""), time: null};
|
||||
}
|
||||
const d = new Date(entry.file.lastModified);
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return {
|
||||
date: `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`,
|
||||
time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
|
||||
};
|
||||
}
|
||||
function clearDone() {
|
||||
queue = queue.filter(e => e.state === "err");
|
||||
renderQueue();
|
||||
}
|
||||
uploadDrop.addEventListener("click", () => uploadFile.click());
|
||||
uploadFile.addEventListener("change", () => addFiles(uploadFile.files));
|
||||
["dragover", "dragenter"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.add("drag"); }));
|
||||
["dragleave", "drop"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.remove("drag"); }));
|
||||
uploadDrop.addEventListener("drop", e => addFiles(e.dataTransfer.files));
|
||||
function normName(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const base = name.slice(0, dot).padStart(8, "0").slice(-8);
|
||||
const ext = name.slice(dot).toLowerCase();
|
||||
return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null;
|
||||
}
|
||||
async function uploadOne(entry) {
|
||||
const stamp = fileStamp(entry);
|
||||
if (!stamp.date) {
|
||||
entry.state = "err"; entry.msg = "no date";
|
||||
renderQueue();
|
||||
return false;
|
||||
}
|
||||
entry.state = "busy"; renderQueue();
|
||||
return new Promise(resolve => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let url = `/api/upload?date=${stamp.date}&filename=${encodeURIComponent(entry.file.name)}`;
|
||||
if (stamp.time) url += `&time=${encodeURIComponent(stamp.time)}`;
|
||||
xhr.open("POST", url);
|
||||
xhr.upload.addEventListener("progress", e => {
|
||||
if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`;
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
let d = {};
|
||||
try { d = JSON.parse(xhr.responseText); } catch {}
|
||||
entry.state = xhr.status < 300 ? "ok" : "err";
|
||||
entry.msg = xhr.status < 300 ? "stored" : (d.message || `HTTP ${xhr.status}`).slice(0, 40);
|
||||
if (entry.bar) entry.bar.style.transform = "scaleX(1)";
|
||||
renderQueue();
|
||||
resolve(xhr.status < 300);
|
||||
});
|
||||
xhr.addEventListener("error", () => {
|
||||
entry.state = "err"; entry.msg = "network";
|
||||
renderQueue();
|
||||
resolve(false);
|
||||
});
|
||||
xhr.send(entry.file);
|
||||
});
|
||||
}
|
||||
uploadBtn.addEventListener("click", async () => {
|
||||
const pending = queue.filter(e => e.state === "queued");
|
||||
if (!pending.length) return;
|
||||
if (!fileStamp(pending[0]).date) {
|
||||
setStatus("No upload date: set the override date or file mtimes.", "err");
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
let okCount = 0, done = 0;
|
||||
const workers = Array.from({length: Math.min(3, pending.length)}, async () => {
|
||||
while (pending.length) {
|
||||
const entry = pending.shift();
|
||||
if (await uploadOne(entry)) okCount++;
|
||||
done++;
|
||||
setStatus(`${done}/${pending.length + done} sent...`);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
const errs = queue.filter(e => e.state === "err").length;
|
||||
setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok");
|
||||
const touched = new Set(queue.filter(e => e.state === "ok").map(e => fileStamp(e).date));
|
||||
if (touched.size) {
|
||||
loadDate(dateSelect.value || [...touched][0]);
|
||||
}
|
||||
if (!errs) clearDone();
|
||||
});
|
||||
|
||||
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
||||
addEventListener("resize", drawTimeline);
|
||||
|
||||
// rescan: button + silent auto-poll for new dates
|
||||
const rescanBtn = document.querySelector("#rescan-btn");
|
||||
async function rescan(notify) {
|
||||
try {
|
||||
const r = await fetch("/api/rescan", {method: "POST"});
|
||||
const d = await r.json();
|
||||
if (notify) setStatus(`${d.days} day${d.days !== 1 ? "s" : ""}, ${d.clips} clip${d.clips !== 1 ? "s" : ""} indexed`);
|
||||
loadDate(dateSelect.value || "");
|
||||
return d;
|
||||
} catch { return null; }
|
||||
}
|
||||
rescanBtn.addEventListener("click", () => rescan(true));
|
||||
setInterval(() => { if (!queue.some(e => e.state === "busy")) rescan(false); }, 30000);
|
||||
|
||||
loadDate("").catch(() => {});
|
||||
updateEncodeStatus();
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import struct
|
||||
@@ -101,6 +102,8 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json(self.server.encode_status)
|
||||
if request.path == "/api/highlights":
|
||||
return self.send_json(self.server.highlights)
|
||||
if request.path == "/api/highlights/export":
|
||||
return self.export_highlights()
|
||||
if request.path == "/api/system-highlights":
|
||||
date = parse_qs(request.query).get("date", [""])[0]
|
||||
return self.send_json([item for item in self.server.system_highlights if item["date"] == date])
|
||||
@@ -109,7 +112,15 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
if urlparse(self.path).path == "/api/encode":
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/rescan":
|
||||
self.server.recordings = scan_recordings(self.server.output_root)
|
||||
self.server.system_highlights = scan_system_highlights(self.server.source_root)
|
||||
count = sum(map(len, self.server.recordings.values()))
|
||||
return self.send_json({"ok": True, "days": len(self.server.recordings), "clips": count})
|
||||
if path == "/api/upload":
|
||||
return self.handle_upload()
|
||||
if path == "/api/encode":
|
||||
if not self.server.encode_status["running"]:
|
||||
threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start()
|
||||
return self.send_json(self.server.encode_status)
|
||||
@@ -140,6 +151,111 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json(highlight)
|
||||
self.send_error(404)
|
||||
|
||||
def export_highlights(self):
|
||||
fmt = parse_qs(urlparse(self.path).query).get("format", ["csv"])[0].lower()
|
||||
items = sorted(self.server.highlights, key=lambda h: (h["date"], h["start"], h["id"]))
|
||||
seen = {}
|
||||
rows = []
|
||||
for item in items:
|
||||
# same label may repeat across days; within one day duplicates get -2, -3...
|
||||
key = (item["date"], item["label"])
|
||||
seen[key] = seen.get(key, 0) + 1
|
||||
name = item["label"] if seen[key] == 1 else f"{item['label']} ({seen[key]})"
|
||||
def clock(sec):
|
||||
return f"{sec // 3600:02d}:{sec % 3600 // 60:02d}:{sec % 60:02d}"
|
||||
rows.append({
|
||||
"date": f"{item['date'][:4]}-{item['date'][4:6]}-{item['date'][6:]}",
|
||||
"name": name,
|
||||
"start": clock(item["start"]),
|
||||
"end": clock(item["end"]),
|
||||
"duration_sec": item["end"] - item["start"],
|
||||
"clip": f"{item['date']}/{item['start'] // 3600:02d}.{item['start'] % 3600 // 60:02d}.{item['start'] % 60:02d}",
|
||||
})
|
||||
if fmt == "json":
|
||||
data = json.dumps(rows, indent=2).encode()
|
||||
ctype = "application/json"
|
||||
else:
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
writer = __import__("csv").writer(buf)
|
||||
writer.writerow(["date", "name", "start", "end", "duration_sec", "clip"])
|
||||
writer.writerows((r["date"], r["name"], r["start"], r["end"], r["duration_sec"], r["clip"]) for r in rows)
|
||||
data = buf.getvalue().encode()
|
||||
ctype = "text/csv"
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="highlights-{stamp}.{fmt}"')
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
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)")
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
filename = params.get("filename", [""])[0]
|
||||
if not re.fullmatch(r"[A-Za-z0-9._ -]{1,120}\.(mp4|avi)", filename, re.IGNORECASE):
|
||||
return self.send_error(400, "filename must be a simple MP4/AVI name")
|
||||
suffix = filename.rsplit(".", 1)[1].lower()
|
||||
try:
|
||||
day = normalize_date(params.get("date", [""])[0])
|
||||
except ValueError:
|
||||
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
|
||||
try:
|
||||
start = parse_clock(params.get("time", [""])[0])
|
||||
if not (0 <= start < 86400):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return self.send_error(400, "time must be HH:MM[:SS] within one day")
|
||||
|
||||
target_dir = self.server.output_root / day
|
||||
if suffix == "avi":
|
||||
# AVI sources mirror the real recorder: stored in the source tree for the Encoding menu
|
||||
target_dir = self.server.source_root / day
|
||||
# Archive-canonical name from the actual start time; collisions with a
|
||||
# different original name get a numeric suffix.
|
||||
base = f"{start // 3600:02d}.{start % 3600 // 60:02d}.{start % 60:02d}"
|
||||
target = target_dir / f"{base}.{suffix}"
|
||||
serial = 1
|
||||
while target.exists() or temporary_exists(target_dir, target.name):
|
||||
base_alt = f"{base}-{serial:02d}"
|
||||
target = target_dir / f"{base_alt}.{suffix}"
|
||||
serial += 1
|
||||
temporary = target.with_suffix(f".uploading.{suffix}")
|
||||
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 suffix == "mp4" and not any(clip["name"] == target.name for clip in self.server.recordings[day]):
|
||||
self.server.recordings[day].append({
|
||||
"name": target.name,
|
||||
"start": start,
|
||||
"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):
|
||||
request = urlparse(self.path)
|
||||
if request.path != "/api/highlights":
|
||||
@@ -272,26 +388,32 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
||||
print(f"{self.client_address[0]} - {message % args}")
|
||||
|
||||
|
||||
def temporary_exists(directory, final_name):
|
||||
stem = final_name.rsplit(".", 1)[0]
|
||||
return any(path.name.startswith(stem + ".uploading.") for path in directory.glob("*.uploading.*")) if directory.is_dir() else False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Play dated recorder AVI files in a browser")
|
||||
parser.add_argument(
|
||||
"root",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=Path("/home/human/Videos/Yassine Salon/C41/media-mp4"),
|
||||
default=Path(os.environ.get("C41_OUTPUT", "/home/human/Videos/Yassine Salon/C41/media-mp4")),
|
||||
help="folder containing YYYYMMDD recording directories",
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("C41_PORT", "8765")))
|
||||
parser.add_argument("--host", default=os.environ.get("C41_HOST", "127.0.0.1"))
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
default=Path("/home/human/Videos/Yassine Salon/C41/media"),
|
||||
default=Path(os.environ.get("C41_SOURCE", "/home/human/Videos/Yassine Salon/C41/media")),
|
||||
help="folder containing source AVI files for manual encoding",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
recordings = scan_recordings(args.root.expanduser().resolve())
|
||||
server = ThreadingHTTPServer(("127.0.0.1", args.port), PlayerHandler)
|
||||
server = ThreadingHTTPServer((args.host, args.port), PlayerHandler)
|
||||
server.recordings = recordings
|
||||
server.source_root = args.source.expanduser().resolve()
|
||||
server.output_root = args.root.expanduser().resolve()
|
||||
|
||||
Reference in New Issue
Block a user