Author SHA1 Message Date
Omarchy-Big-PC e2523060d1 Merge branch 'main' into tmp 2026-08-28 16:47:15 +01:00
Omarchy-Big-PC 8d194d5241 Highlight export: CSV/JSON download, same-name highlights disambiguated per day, sorted chronologically 2026-08-28 16:47:14 +01:00
Omarchy-Big-PC 6b7d046292 Merge branch 'main' into tmp-deployment/proxmox-lxc 2026-08-28 16:18:08 +01:00
Omarchy-Big-PC 43561d5012 Add /api/rescan endpoint, header rescan button, 30s auto-poll for new files 2026-08-28 16:18:07 +01:00
Omarchy-Big-PC e16bbb2220 Merge main: metadata-driven uploads, multi-file queue, collision suffixes 2026-08-28 16:04:56 +01:00
Omarchy-Big-PC 48cbd56f63 Merge main: metadata-driven uploads, multi-file queue, collision suffixes
# Conflicts:
#	README.md
#	index.html
#	server.py
2026-08-28 16:04:48 +01:00
Omarchy-Big-PC c59fee65fd Upload status: clear stale errors, confirm override and queue adds 2026-08-28 15:30:58 +01:00
Omarchy-Big-PC 9d4f12e5df PROD upload: file mtime metadata drives date/time, optional date override, collision suffixes, path traversal guard 2026-08-28 15:27:00 +01:00
Omarchy-Big-PC 780544d002 Upload: require a recording date before queueing/sending files 2026-08-28 15:19:54 +01:00
Omarchy-Big-PC afdbf474e6 Remove decorative upload indicator dot 2026-08-28 15:08:05 +01:00
human insan 552f7ec910 Show one archive time in player 2026-08-27 23:28:55 +01:00
human insan abeb245cdc Show one archive time in player 2026-08-27 23:28:32 +01:00
human insan b7f1c07a25 Simplify player controls 2026-08-27 23:24:44 +01:00
human insan 7f38d7918c Simplify player controls 2026-08-27 23:24:37 +01:00
human insan ca703ab355 Align responsive player controls 2026-08-27 23:20:37 +01:00
human insan 2bc0c77d81 Align responsive player controls 2026-08-27 23:20:30 +01:00
human insan 6b7793b310 Improve player controls on small screens 2026-08-27 23:18:23 +01:00
human insan 7e034d07ca Improve player controls on small screens 2026-08-27 23:18:16 +01:00
human insan cf65779a92 Make timeline legend jump between events 2026-08-27 23:14:51 +01:00
human insan cedb672633 Make timeline legend jump between events 2026-08-27 23:14:44 +01:00
human insan 3b7a6a472a Start clips from timeline color markers 2026-08-27 23:09:25 +01:00
human insan 80756669ad Jump from event markers to clip starts 2026-08-27 23:09:25 +01:00
human insan 9a006e6e65 Display recorder system highlights 2026-08-27 23:09:25 +01:00
human insan 9bd615ecf9 Show highlights on timeline 2026-08-27 23:09:15 +01:00
human insan 890e40f3cf Simplify highlight API for automation 2026-08-27 23:09:15 +01:00
human insan df4153a9dd Add highlight marking and AI API 2026-08-27 23:09:15 +01:00
human insan 6772311641 Start clips from timeline color markers 2026-08-27 23:09:06 +01:00
human insan 8326fd7c03 Jump from event markers to clip starts 2026-08-27 23:08:50 +01:00
human insan baa344c8cb Display recorder system highlights 2026-08-27 23:08:50 +01:00
human insan 807447e815 Show highlights on timeline 2026-08-27 23:08:20 +01:00
human insan f00ae278cd Simplify highlight API for automation 2026-08-27 23:08:20 +01:00
human insan b488bce210 Add highlight marking and AI API 2026-08-27 23:08:20 +01:00
human insan 25bdd34c70 Keep Docker branch documentation focused 2026-08-27 22:37:08 +01:00
human insan 38454618bb Add Proxmox LXC deployment branch 2026-08-27 22:36:29 +01:00
2 changed files with 140 additions and 26 deletions
+61 -9
View File
@@ -89,7 +89,6 @@
/* upload panel */
.upload-toggle svg { fill: #8ab4ff; }
.upload-toggle::before { content: ""; display: block; width: 8px; height: 8px; border-radius: 50%; background: #3ea6ff; box-shadow: 0 0 8px #3ea6ff; }
.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); }
@@ -137,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">&#x21bb;</button>
<select id="date"></select>
</div>
@@ -199,6 +200,9 @@
<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>
@@ -207,11 +211,12 @@
<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">named HH.MM.SS.mp4 or .avi, multiple files allowed</span>
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">multiple files allowed; date &amp; 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">Date: <b id="upload-date" style="color:var(--text)">-</b></span>
<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>
@@ -395,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"},
@@ -572,6 +579,12 @@
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;
@@ -590,13 +603,16 @@
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, size, bar, state);
row.append(name, slot, size, bar, state);
uploadQueue.append(row);
});
uploadBtn.disabled = !queue.some(e => e.state === "queued");
@@ -607,6 +623,18 @@
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");
@@ -625,16 +653,18 @@
return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null;
}
async function uploadOne(entry) {
const filename = normName(entry.file.name);
if (!filename) {
entry.state = "err"; entry.msg = "bad name";
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();
xhr.open("POST", `/api/upload?date=${dateSelect.value}&filename=${encodeURIComponent(filename)}`);
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})`;
});
@@ -658,6 +688,10 @@
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 () => {
@@ -671,12 +705,30 @@
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");
if (okCount) loadDate(dateSelect.value);
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>
+79 -17
View File
@@ -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])
@@ -110,6 +113,11 @@ class PlayerHandler(BaseHTTPRequestHandler):
def do_POST(self):
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":
@@ -143,6 +151,45 @@ 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"))
@@ -151,26 +198,35 @@ class PlayerHandler(BaseHTTPRequestHandler):
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")
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(parse_qs(urlparse(self.path).query).get("date", [""])[0])
day = normalize_date(params.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 not in ("mp4", "avi"):
return self.send_error(400, "Only .mp4 or .avi uploads are accepted")
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
target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.{suffix}"
# 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)
@@ -193,7 +249,7 @@ class PlayerHandler(BaseHTTPRequestHandler):
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": hour * 3600 + minute * 60 + second,
"start": start,
"duration": 120,
"path": target,
})
@@ -332,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()