From 6c0d06de7797277b861c5d9fb06ba5e86ec6f630 Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 22:34:10 +0100 Subject: [PATCH 01/19] Revert "Add Docker and LXC deployment support" This reverts commit 451711b2176def9e0bd7f73b6a9d15a31f556d62. --- .dockerignore | 4 ---- Dockerfile | 18 ------------------ README.md | 40 ---------------------------------------- compose.yaml | 12 ------------ server.py | 10 ++++------ 5 files changed, 4 insertions(+), 80 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 compose.yaml diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index b0c8b59..0000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -.git -__pycache__ -server.log -*.pyc diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 4236a06..0000000 --- a/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -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"] diff --git a/README.md b/README.md index 1c46fa7..0e7e2f3 100644 --- a/README.md +++ b/README.md @@ -32,46 +32,6 @@ python3 server.py Open in a browser. -## Docker - -Docker keeps the application and FFmpeg inside a container while the recorder -files remain on the host. From this directory, run: - -```sh -docker compose up -d --build -``` - -Open . The Compose configuration mounts the original -archive read-only at `/data/source` and stores encoded files persistently in -`/data/encoded`. Stop it with: - -```sh -docker compose down -``` - -To use different host folders, edit the two `volumes` entries in -`compose.yaml`. Do not remove `:ro` from the source mount. - -## LXC - -An unprivileged LXC works without Docker. Install Python and FFmpeg inside the -container, then bind-mount the host folders into the LXC and run: - -```sh -python3 server.py --host 0.0.0.0 --source /mnt/c41-media --root /mnt/c41-media-mp4 -``` - -The LXC user needs read permission on the source mount and write permission on -the encoded mount. Run the player as a system service or with a process -supervisor so it restarts after reboot. If the LXC should be reachable from -other machines, bind the port explicitly: - -```sh -python3 server.py --host 0.0.0.0 --source /mnt/c41-media --root /mnt/c41-media-mp4 --port 8765 -``` - -Then open `http://LXC-IP:8765`. No privileged container features are required. - The default source archive is: `/home/human/Videos/Yassine Salon/C41/media` diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index 7d9f025..0000000 --- a/compose.yaml +++ /dev/null @@ -1,12 +0,0 @@ -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 diff --git a/server.py b/server.py index a35d05c..557fbac 100644 --- a/server.py +++ b/server.py @@ -5,7 +5,6 @@ import json import re import subprocess import threading -import os from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -197,21 +196,20 @@ def main(): "root", nargs="?", type=Path, - default=Path(os.environ.get("C41_OUTPUT", "/home/human/Videos/Yassine Salon/C41/media-mp4")), + default=Path("/home/human/Videos/Yassine Salon/C41/media-mp4"), help="folder containing YYYYMMDD recording directories", ) - 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("--port", type=int, default=8765) parser.add_argument( "--source", type=Path, - default=Path(os.environ.get("C41_SOURCE", "/home/human/Videos/Yassine Salon/C41/media")), + default=Path("/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((args.host, args.port), PlayerHandler) + server = ThreadingHTTPServer(("127.0.0.1", args.port), PlayerHandler) server.recordings = recordings server.source_root = args.source.expanduser().resolve() server.output_root = args.root.expanduser().resolve() From 5a0e83fb4c8167a6e59053cbf346456270fbd129 Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 22:43:30 +0100 Subject: [PATCH 02/19] Add highlight marking and AI API --- README.md | 25 +++++++++++++++ index.html | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- server.py | 52 +++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0e7e2f3..7ba90d8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ dated video folders into a simple daily view that is easy to search and play. - Shows encoding progress, completed files, skipped files, and errors. - Never overwrites an existing transcoded file. - Keeps all original AVI recordings unchanged. +- Saves named highlight periods for later review or automated processing. ## Start The Player @@ -58,6 +59,30 @@ using the player while encoding runs. Files that already have an MP4 copy are reported as skipped. A temporary file is used during each conversion, so an interrupted conversion cannot damage an existing file. +## Highlights And AI Automation + +To create a highlight in the player, play the recording, press **Mark start**, +move to the end of the interesting part, press **Mark end**, enter a name, and +press **Add highlight**. Highlights are saved in `media-mp4/highlights.json`. + +The highlight API is intentionally simple. An AI or script can list highlights: + +```sh +curl http://127.0.0.1:8765/api/highlights +``` + +It can append a highlight with a date, seconds from midnight, and a label: + +```sh +curl -X POST http://127.0.0.1:8765/api/highlights \ + -H 'Content-Type: application/json' \ + -d '{"date":"20260827","start":34000,"end":34120,"label":"Person at entrance"}' +``` + +The API stores the entry without changing any video. Times are seconds from +midnight, so `34000` means `09:26:40`. This makes it straightforward for an +AI to append detections, labels, or review segments programmatically. + ## Custom Folders And Ports To use a different output folder or port: diff --git a/index.html b/index.html index 892698a..fb5235d 100644 --- a/index.html +++ b/index.html @@ -23,9 +23,13 @@ /* player wrapper */ .player-wrap { position: relative; background: #000; border-radius: 12px; overflow: hidden; } .player-wrap video { display: block; width: 100%; aspect-ratio: 16/9; object-fit: contain; background: #000; } + .empty-state { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 10px; color: #222; background: #f1f1f1; pointer-events: none; } + .empty-state svg { width: 52px; height: 52px; fill: #222; } + .empty-state span { font-size: 14px; } + .player-wrap.has-video .empty-state { display: none; } /* big center play */ - .big-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 68px; height: 48px; background: rgba(0,0,0,.6); border-radius: 12px; display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity .15s; } + .big-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 68px; height: 48px; background: rgba(255,255,255,.92); color: #111; border-radius: 12px; display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity .15s; box-shadow: 0 2px 12px rgba(0,0,0,.35); } .player-wrap.paused .big-play { opacity: 1; } .big-play svg { width: 28px; height: 28px; } @@ -65,6 +69,17 @@ .legend span::before { content: ""; display: block; width: 14px; height: 6px; border-radius: 2px; } .legend .rec::before { background: var(--accent); } .legend .gap::before { background: #2a2a2a; border: 1px solid #444; } + .highlights { margin-top: 18px; background: var(--surface); border-radius: 8px; padding: 14px 16px; } + .highlights h2 { font-size: 14px; font-weight: 500; margin-bottom: 10px; } + .highlight-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + .highlight-actions button, .highlight-actions input { background: #333; border: 1px solid #555; color: var(--text); padding: 7px 10px; border-radius: 3px; font-size: 12px; } + .highlight-actions button { cursor: pointer; } + .highlight-actions button:hover { background: #444; } + .mark-time { color: var(--accent); font-size: 12px; min-width: 70px; } + .highlight-list { margin-top: 12px; display: grid; gap: 6px; } + .highlight-item { display: flex; align-items: center; gap: 10px; padding: 8px; background: #1a1a1a; border-radius: 4px; font-size: 12px; } + .highlight-item .highlight-label { flex: 1; } + .highlight-item button { background: none; border: none; color: var(--dim); cursor: pointer; } /* encoding panel */ .encode-panel { margin-top: 16px; background: var(--surface); border-radius: 8px; overflow: hidden; } @@ -109,6 +124,7 @@
+
Select a recording from the timeline
@@ -151,6 +167,17 @@
RecordedNo recording
+
+

Highlights

+
+ --:--:-- + --:--:-- + + +
+
No highlights yet.
+
+
@@ -174,6 +201,8 @@ let initialOffset = 0; let zoom = 1; let focus = 43200; + let highlightStart = null; + let highlightEnd = null; let encodingWasRunning = false; // encoding @@ -246,6 +275,61 @@ return [s/3600, s%3600/60, s%60].map(v => String(Math.floor(v)).padStart(2,"0")).join(":"); } + function archiveTime() { + return activeClip ? activeClip.start + initialOffset + (video.currentTime || 0) : null; + } + function setMark(which) { + const value = archiveTime(); + if (value === null) return; + if (which === "start") { + highlightStart = value; + document.querySelector("#highlight-start").textContent = timeOfDay(value); + } else { + highlightEnd = value; + document.querySelector("#highlight-end").textContent = timeOfDay(value); + } + } + async function loadHighlights() { + const response = await fetch("/api/highlights"); + const items = (await response.json()).filter(item => item.date === dateSelect.value); + const list = document.querySelector("#highlight-list"); + list.replaceChildren(); + if (!items.length) { + list.innerHTML = 'No highlights yet.'; + return; + } + items.forEach(item => { + const row = document.createElement("div"); + row.className = "highlight-item"; + const label = document.createElement("span"); + label.className = "highlight-label"; + label.textContent = `${item.label} (${timeOfDay(item.start)} - ${timeOfDay(item.end)})`; + const play = document.createElement("button"); + play.textContent = "Play"; + play.onclick = () => { + const clip = clips.find(c => item.start >= c.start && item.start < c.start + c.duration); + if (clip) playClip(clip, item.start - clip.start); + }; + const remove = document.createElement("button"); + remove.textContent = "Remove"; + remove.onclick = async () => { await fetch(`/api/highlights?id=${item.id}`, {method: "DELETE"}); loadHighlights(); }; + row.append(label, play, remove); + list.append(row); + }); + } + document.querySelector("#mark-start").onclick = () => setMark("start"); + document.querySelector("#mark-end").onclick = () => setMark("end"); + 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"}, + body: JSON.stringify({date: dateSelect.value, start: highlightStart, end: highlightEnd, label: document.querySelector("#highlight-label").value || "Untitled highlight"})}); + highlightStart = highlightEnd = null; + document.querySelector("#highlight-start").textContent = "--:--:--"; + document.querySelector("#highlight-end").textContent = "--:--:--"; + document.querySelector("#highlight-label").value = ""; + loadHighlights(); + }; + // scrub bar function updateScrub() { if (!activeClip || !video.duration) { scrubFill.style.width = "0%"; return; } @@ -348,7 +432,7 @@ // load date async function loadDate(date) { - video.pause(); video.removeAttribute("src"); activeClip = null; setPlaying(false); + video.pause(); video.removeAttribute("src"); activeClip = null; wrap.classList.remove("has-video"); setPlaying(false); const r = await fetch(`/api/recordings?date=${encodeURIComponent(date)}`); const d = await r.json(); clips = d.recordings; @@ -359,12 +443,14 @@ document.querySelector("#time-dur").textContent = "0:00"; scrubFill.style.width = "0%"; drawTimeline(); + loadHighlights(); if (!date && d.dates[0]) loadDate(d.dates[0]); } function playClip(clip, offset = 0) { activeClip = clip; initialOffset = offset; + wrap.classList.add("has-video"); document.querySelector("#clock").textContent = timeOfDay(clip.start + offset); video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`; video.play().catch(() => {}); diff --git a/server.py b/server.py index 557fbac..36d69bf 100644 --- a/server.py +++ b/server.py @@ -59,6 +59,8 @@ class PlayerHandler(BaseHTTPRequestHandler): return self.send_recordings(request.query) if request.path == "/api/encode/status": return self.send_json(self.server.encode_status) + if request.path == "/api/highlights": + return self.send_json(self.server.highlights) if request.path == "/video": return self.send_video(request.query) self.send_error(404) @@ -68,8 +70,41 @@ class PlayerHandler(BaseHTTPRequestHandler): 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) + if urlparse(self.path).path == "/api/highlights": + try: + length = int(self.headers.get("Content-Length", "0")) + item = json.loads(self.rfile.read(length)) + date = str(item["date"]) + start = float(item["start"]) + end = float(item["end"]) + if not DATE_RE.fullmatch(date) or not (0 <= start < end <= 86400): + raise ValueError + highlight = { + "id": max((entry["id"] for entry in self.server.highlights), default=0) + 1, + "date": date, + "start": start, + "end": end, + "label": str(item.get("label", "Untitled highlight"))[:120], + } + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return self.send_error(400, "Invalid highlight") + self.server.highlights.append(highlight) + save_highlights(self.server.highlights_path, self.server.highlights) + return self.send_json(highlight) self.send_error(404) + def do_DELETE(self): + request = urlparse(self.path) + if request.path != "/api/highlights": + return self.send_error(404) + try: + highlight_id = int(parse_qs(request.query).get("id", [""])[0]) + except ValueError: + return self.send_error(400, "Invalid highlight id") + self.server.highlights[:] = [item for item in self.server.highlights if item["id"] != highlight_id] + save_highlights(self.server.highlights_path, self.server.highlights) + self.send_json({"ok": True}) + def send_json(self, payload): data = json.dumps(payload).encode() self.send_response(200) @@ -213,6 +248,8 @@ def main(): server.recordings = recordings server.source_root = args.source.expanduser().resolve() server.output_root = args.root.expanduser().resolve() + server.highlights_path = server.output_root / "highlights.json" + server.highlights = load_highlights(server.highlights_path) server.encode_status = {"running": False, "total": 0, "completed": 0, "skipped": 0, "failed": 0} count = sum(map(len, recordings.values())) print(f"Indexed {count} recordings across {len(recordings)} days") @@ -263,5 +300,20 @@ def run_encoding(server): status["running"] = False +def load_highlights(path): + try: + data = json.loads(path.read_text()) + return data if isinstance(data, list) else [] + except (FileNotFoundError, json.JSONDecodeError, OSError): + return [] + + +def save_highlights(path, highlights): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(highlights, indent=2) + "\n") + temporary.replace(path) + + if __name__ == "__main__": main() From eb35a7dc2663dd4cffb4befbe82824712d2aa2ff Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 22:45:58 +0100 Subject: [PATCH 03/19] Remove empty player placeholder --- index.html | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/index.html b/index.html index fb5235d..8ed9b51 100644 --- a/index.html +++ b/index.html @@ -23,10 +23,6 @@ /* player wrapper */ .player-wrap { position: relative; background: #000; border-radius: 12px; overflow: hidden; } .player-wrap video { display: block; width: 100%; aspect-ratio: 16/9; object-fit: contain; background: #000; } - .empty-state { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 10px; color: #222; background: #f1f1f1; pointer-events: none; } - .empty-state svg { width: 52px; height: 52px; fill: #222; } - .empty-state span { font-size: 14px; } - .player-wrap.has-video .empty-state { display: none; } /* big center play */ .big-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 68px; height: 48px; background: rgba(255,255,255,.92); color: #111; border-radius: 12px; display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity .15s; box-shadow: 0 2px 12px rgba(0,0,0,.35); } @@ -124,7 +120,6 @@
-
Select a recording from the timeline
@@ -432,7 +427,7 @@ // load date async function loadDate(date) { - video.pause(); video.removeAttribute("src"); activeClip = null; wrap.classList.remove("has-video"); setPlaying(false); + video.pause(); video.removeAttribute("src"); activeClip = null; setPlaying(false); const r = await fetch(`/api/recordings?date=${encodeURIComponent(date)}`); const d = await r.json(); clips = d.recordings; @@ -450,7 +445,6 @@ function playClip(clip, offset = 0) { activeClip = clip; initialOffset = offset; - wrap.classList.add("has-video"); document.querySelector("#clock").textContent = timeOfDay(clip.start + offset); video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`; video.play().catch(() => {}); From dc6856da9ad0cf4a4e451f4b8f82854e6beffbc9 Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 22:47:33 +0100 Subject: [PATCH 04/19] Simplify highlight API for automation --- README.md | 14 ++++++++------ server.py | 34 +++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7ba90d8..4aa45f7 100644 --- a/README.md +++ b/README.md @@ -65,23 +65,25 @@ To create a highlight in the player, play the recording, press **Mark start**, move to the end of the interesting part, press **Mark end**, enter a name, and press **Add highlight**. Highlights are saved in `media-mp4/highlights.json`. -The highlight API is intentionally simple. An AI or script can list highlights: +The highlight API is intentionally simple. An AI agent can list highlights: ```sh curl http://127.0.0.1:8765/api/highlights ``` -It can append a highlight with a date, seconds from midnight, and a label: +It can append one using normal date and clock values: ```sh curl -X POST http://127.0.0.1:8765/api/highlights \ -H 'Content-Type: application/json' \ - -d '{"date":"20260827","start":34000,"end":34120,"label":"Person at entrance"}' + -d '{"date":"2026-08-27","start_time":"09:26:40","end_time":"09:28:40","title":"Person at entrance"}' ``` -The API stores the entry without changing any video. Times are seconds from -midnight, so `34000` means `09:26:40`. This makes it straightforward for an -AI to append detections, labels, or review segments programmatically. +The API stores the entry without changing any video. `start` and `end` may also +be used as seconds from midnight, and `duration` may be used instead of an end +time. For example, `{"start":"09:26:40","duration":120}` creates a +two-minute highlight. This makes it straightforward for an AI to append +detections, labels, or review segments programmatically. ## Custom Folders And Ports diff --git a/server.py b/server.py index 36d69bf..ca4c20f 100644 --- a/server.py +++ b/server.py @@ -12,6 +12,7 @@ from urllib.parse import parse_qs, urlparse DATE_RE = re.compile(r"^\d{8}$") VIDEO_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{2})\.(avi|mp4)$", re.IGNORECASE) +TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$") APP_DIR = Path(__file__).resolve().parent @@ -42,6 +43,25 @@ def scan_recordings(root): return recordings +def parse_clock(value): + if isinstance(value, (int, float)): + return float(value) + match = TIME_RE.fullmatch(str(value).strip()) + if not match: + raise ValueError + hour, minute, second = int(match.group(1)), int(match.group(2)), int(match.group(3) or 0) + if hour > 23 or minute > 59 or second > 59: + raise ValueError + return hour * 3600 + minute * 60 + second + + +def normalize_date(value): + date = str(value).replace("-", "") + if not DATE_RE.fullmatch(date): + raise ValueError + return date + + class PlayerHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -74,17 +94,21 @@ class PlayerHandler(BaseHTTPRequestHandler): try: length = int(self.headers.get("Content-Length", "0")) item = json.loads(self.rfile.read(length)) - date = str(item["date"]) - start = float(item["start"]) - end = float(item["end"]) - if not DATE_RE.fullmatch(date) or not (0 <= start < end <= 86400): + date = normalize_date(item["date"]) + start = parse_clock(item.get("start", item.get("start_time"))) + end_value = item.get("end", item.get("end_time")) + if end_value is None and item.get("duration") is not None: + end = start + parse_clock(item["duration"]) + else: + end = parse_clock(end_value) + if not (0 <= start < end <= 86400): raise ValueError highlight = { "id": max((entry["id"] for entry in self.server.highlights), default=0) + 1, "date": date, "start": start, "end": end, - "label": str(item.get("label", "Untitled highlight"))[:120], + "label": str(item.get("label", item.get("title", "Untitled highlight")))[:120], } except (KeyError, TypeError, ValueError, json.JSONDecodeError): return self.send_error(400, "Invalid highlight") From 0bfea476da2a19c1cb1203dc127235385cb96f90 Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 22:52:20 +0100 Subject: [PATCH 05/19] Show highlights on timeline --- index.html | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 8ed9b51..3e77eb5 100644 --- a/index.html +++ b/index.html @@ -65,6 +65,7 @@ .legend span::before { content: ""; display: block; width: 14px; height: 6px; border-radius: 2px; } .legend .rec::before { background: var(--accent); } .legend .gap::before { background: #2a2a2a; border: 1px solid #444; } + .legend .highlight::before { background: #ffd43b; } .highlights { margin-top: 18px; background: var(--surface); border-radius: 8px; padding: 14px 16px; } .highlights h2 { font-size: 14px; font-weight: 500; margin-bottom: 10px; } .highlight-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } @@ -159,7 +160,7 @@
-
RecordedNo recording
+
RecordedHighlightNo recording
@@ -196,6 +197,7 @@ let initialOffset = 0; let zoom = 1; let focus = 43200; + let highlights = []; let highlightStart = null; let highlightEnd = null; let encodingWasRunning = false; @@ -286,7 +288,9 @@ } async function loadHighlights() { const response = await fetch("/api/highlights"); - const items = (await response.json()).filter(item => item.date === dateSelect.value); + highlights = (await response.json()).filter(item => item.date === dateSelect.value); + const items = highlights; + drawTimeline(); const list = document.querySelector("#highlight-list"); list.replaceChildren(); if (!items.length) { @@ -388,6 +392,12 @@ const r = ((i + 1) * 60 - winStart) / winSize * w; if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56); }); + ctx.fillStyle = "#ffd43b"; + highlights.forEach(item => { + const l = (item.start - winStart) / winSize * w; + const r = (item.end - winStart) / winSize * w; + if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56); + }); // hour lines ctx.strokeStyle = "rgba(255,255,255,.08)"; for (let h = Math.ceil(winStart / 3600); h < winEnd / 3600; h++) { From eebbc346e194c4fad340dfb3f373d78c93d3bffd Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 22:55:20 +0100 Subject: [PATCH 06/19] Display recorder system highlights --- README.md | 1 + index.html | 16 +++++++++++++++- server.py | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4aa45f7..c2fd34f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ dated video folders into a simple daily view that is easy to search and play. - Shows recordings on a full 24-hour timeline. - Uses the time in each filename to place clips at the correct time of day. - Shows recorded minutes in light blue and empty periods in dark blue. +- Shows recorder-detected system events in dark yellow when `fileindex.dat` is present. - Lets you choose the recording date. - Plays browser-friendly MP4 files immediately. - Converts an AVI clip automatically when no MP4 copy exists. diff --git a/index.html b/index.html index 3e77eb5..58abb46 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,7 @@ .legend .rec::before { background: var(--accent); } .legend .gap::before { background: #2a2a2a; border: 1px solid #444; } .legend .highlight::before { background: #ffd43b; } + .legend .system::before { background: #a87900; } .highlights { margin-top: 18px; background: var(--surface); border-radius: 8px; padding: 14px 16px; } .highlights h2 { font-size: 14px; font-weight: 500; margin-bottom: 10px; } .highlight-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } @@ -160,7 +161,7 @@
-
RecordedHighlightNo recording
+
RecordedHighlightSystem highlightNo recording
@@ -198,6 +199,7 @@ let zoom = 1; let focus = 43200; let highlights = []; + let systemHighlights = []; let highlightStart = null; let highlightEnd = null; let encodingWasRunning = false; @@ -316,6 +318,11 @@ list.append(row); }); } + async function loadSystemHighlights() { + const response = await fetch(`/api/system-highlights?date=${dateSelect.value}`); + systemHighlights = await response.json(); + drawTimeline(); + } document.querySelector("#mark-start").onclick = () => setMark("start"); document.querySelector("#mark-end").onclick = () => setMark("end"); document.querySelector("#save-highlight").onclick = async () => { @@ -398,6 +405,12 @@ const r = (item.end - winStart) / winSize * w; if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56); }); + ctx.fillStyle = "#a87900"; + systemHighlights.forEach(item => { + const l = (item.start - winStart) / winSize * w; + const r = (item.end - winStart) / winSize * w; + if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56); + }); // hour lines ctx.strokeStyle = "rgba(255,255,255,.08)"; for (let h = Math.ceil(winStart / 3600); h < winEnd / 3600; h++) { @@ -449,6 +462,7 @@ scrubFill.style.width = "0%"; drawTimeline(); loadHighlights(); + loadSystemHighlights(); if (!date && d.dates[0]) loadDate(d.dates[0]); } diff --git a/server.py b/server.py index ca4c20f..3118738 100644 --- a/server.py +++ b/server.py @@ -4,7 +4,9 @@ import concurrent.futures import json import re import subprocess +import struct import threading +from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -62,6 +64,24 @@ def normalize_date(value): return date +def scan_system_highlights(root): + try: + data = (root / "fileindex.dat").read_bytes() + except OSError: + return [] + highlights = [] + for offset in range(0, len(data) - 15, 16): + timestamp, packed, _, _ = struct.unpack_from("<4I", data, offset) + duration = packed & 0xffff + event = (packed >> 16) & 1 + if not event or not duration: + continue + moment = datetime.fromtimestamp(timestamp) + start = moment.hour * 3600 + moment.minute * 60 + moment.second + highlights.append({"date": moment.strftime("%Y%m%d"), "start": start, "end": min(86400, start + duration)}) + return highlights + + class PlayerHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -81,6 +101,9 @@ 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/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]) if request.path == "/video": return self.send_video(request.query) self.send_error(404) @@ -274,6 +297,7 @@ def main(): server.output_root = args.root.expanduser().resolve() server.highlights_path = server.output_root / "highlights.json" server.highlights = load_highlights(server.highlights_path) + server.system_highlights = scan_system_highlights(server.source_root) server.encode_status = {"running": False, "total": 0, "completed": 0, "skipped": 0, "failed": 0} count = sum(map(len, recordings.values())) print(f"Indexed {count} recordings across {len(recordings)} days") From d2d8f3b657090a7fbeb5e3ed0759824faa1d59fa Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 23:05:43 +0100 Subject: [PATCH 07/19] Jump from event markers to clip starts --- index.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/index.html b/index.html index 58abb46..d6fd83d 100644 --- a/index.html +++ b/index.html @@ -436,6 +436,17 @@ const winStart = Math.max(0, Math.min(86400 - winSize, focus - winSize / 2)); const sec = winStart + e.offsetX / timeline.clientWidth * winSize; focus = sec; + const systemEvent = systemHighlights.find(item => sec >= item.start && sec < item.end); + if (systemEvent) { + const eventClip = clips.find(clip => clip.start >= systemEvent.start); + if (eventClip) return playClip(eventClip, 0); + } + const savedHighlight = highlights.find(item => sec >= item.start && sec < item.end); + if (savedHighlight) { + const highlightClip = clips.find(clip => savedHighlight.start >= clip.start && savedHighlight.start < clip.start + clip.duration) + || clips.find(clip => clip.start >= savedHighlight.start); + if (highlightClip) return playClip(highlightClip, Math.max(0, savedHighlight.start - highlightClip.start)); + } const clip = clips.find(c => sec >= c.start && sec < c.start + c.duration) || clips.find(c => c.start >= sec) || clips[clips.length - 1]; if (clip) playClip(clip, Math.max(0, Math.min(clip.duration - .1, sec - clip.start))); From 7ee3835db58707b7ecefbdbdca24257d2fc67bce Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 23:09:06 +0100 Subject: [PATCH 08/19] Start clips from timeline color markers --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index d6fd83d..29bf82f 100644 --- a/index.html +++ b/index.html @@ -449,7 +449,7 @@ } const clip = clips.find(c => sec >= c.start && sec < c.start + c.duration) || clips.find(c => c.start >= sec) || clips[clips.length - 1]; - if (clip) playClip(clip, Math.max(0, Math.min(clip.duration - .1, sec - clip.start))); + if (clip) playClip(clip, 0); }); timeline.addEventListener("mousemove", e => { const winSize = 86400 / zoom; From 22fef80a5fd2115e1872b5ccac07f3338c368b60 Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 23:14:37 +0100 Subject: [PATCH 09/19] Make timeline legend jump between events --- index.html | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 29bf82f..6a4237e 100644 --- a/index.html +++ b/index.html @@ -62,6 +62,7 @@ .hours { display: flex; justify-content: space-between; margin-top: 4px; color: var(--dim); font-size: 10px; user-select: none; } .legend { display: flex; gap: 16px; margin-top: 10px; font-size: 11px; color: var(--dim); } .legend span { display: flex; align-items: center; gap: 5px; } + .legend span { cursor: pointer; } .legend span::before { content: ""; display: block; width: 14px; height: 6px; border-radius: 2px; } .legend .rec::before { background: var(--accent); } .legend .gap::before { background: #2a2a2a; border: 1px solid #444; } @@ -161,7 +162,7 @@
-
RecordedHighlightSystem highlightNo recording
+
RecordedHighlightSystem highlightNo recording
@@ -275,8 +276,39 @@ } function archiveTime() { - return activeClip ? activeClip.start + initialOffset + (video.currentTime || 0) : null; + return activeClip ? activeClip.start + initialOffset + (video.currentTime || 0) : focus; } + function jumpToNext(kind) { + const now = archiveTime(); + if (kind === "recorded") { + const clip = clips.find(item => item.start > now + 1) || clips[0]; + if (clip) { focus = clip.start; playClip(clip, 0); } + return; + } + const items = kind === "system" ? systemHighlights : highlights; + if (kind === "highlight" || kind === "system") { + const item = items.find(entry => entry.start > now + 1) || items[0]; + if (!item) return; + const clip = clips.find(entry => item.start >= entry.start && item.start < entry.start + entry.duration) + || clips.find(entry => entry.start >= item.start); + if (clip) { focus = clip.start; playClip(clip, Math.max(0, item.start - clip.start)); } + return; + } + const gaps = []; + if (clips.length && clips[0].start > 0) gaps.push({start: 0, end: clips[0].start}); + clips.forEach((clip, index) => { + const end = clip.start + clip.duration; + const next = clips[index + 1]; + if (next && next.start > end) gaps.push({start: end, end: next.start}); + }); + const gap = gaps.find(item => item.start > now + 1) || gaps[0]; + if (gap) { + video.pause(); activeClip = null; focus = gap.start; + document.querySelector("#clock").textContent = timeOfDay(gap.start); + drawTimeline(); + } + } + document.querySelectorAll(".legend [data-jump]").forEach(item => item.addEventListener("click", () => jumpToNext(item.dataset.jump))); function setMark(which) { const value = archiveTime(); if (value === null) return; From 9cee32e894a65793077cc5f956dfd7c5a8b4d629 Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 23:18:09 +0100 Subject: [PATCH 10/19] Improve player controls on small screens --- index.html | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 6a4237e..054322f 100644 --- a/index.html +++ b/index.html @@ -40,7 +40,7 @@ .scrub:hover .scrub-hover { display: block; } /* control row */ - .ctrl-row { display: flex; align-items: center; gap: 8px; } + .ctrl-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .ctrl-row button { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 50%; display: grid; place-items: center; } .ctrl-row button:hover { background: rgba(255,255,255,.15); } .ctrl-row svg { width: 22px; height: 22px; fill: currentColor; } @@ -96,7 +96,10 @@ @media (max-width: 600px) { .page { padding: 8px; } - .ctrl-row svg { width: 18px; height: 18px; } + .ctrl-row { gap: 4px; } + .ctrl-row button { min-width: 36px; min-height: 34px; } + .ctrl-row svg { width: 20px; height: 20px; } + .time-label { font-size: 11px; } .hours span:nth-child(even) { display: none; } } @@ -134,8 +137,8 @@ - - + +
- 0:00 / 0:00 + --:--:-- / 0:00 --:--:--
@@ -500,7 +503,7 @@ dateSelect.innerHTML = d.dates.map(day => ``).join(""); dateSelect.value = date || d.dates[0]; document.querySelector("#clock").textContent = "--:--:--"; - document.querySelector("#time-cur").textContent = "0:00"; + document.querySelector("#time-cur").textContent = "--:--:--"; document.querySelector("#time-dur").textContent = "0:00"; scrubFill.style.width = "0%"; drawTimeline(); @@ -513,6 +516,7 @@ activeClip = clip; initialOffset = offset; document.querySelector("#clock").textContent = timeOfDay(clip.start + offset); + document.querySelector("#time-cur").textContent = timeOfDay(clip.start + offset); video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`; video.play().catch(() => {}); drawTimeline(); @@ -521,7 +525,7 @@ video.addEventListener("timeupdate", () => { if (!activeClip) return; document.querySelector("#clock").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime); - document.querySelector("#time-cur").textContent = fmt(video.currentTime); + document.querySelector("#time-cur").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime); updateScrub(); drawTimeline(); }); From e7a4d0b9b44b69bbb611a18c9024475edbd2f90e Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 23:20:19 +0100 Subject: [PATCH 11/19] Align responsive player controls --- index.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 054322f..6b93ad6 100644 --- a/index.html +++ b/index.html @@ -40,11 +40,11 @@ .scrub:hover .scrub-hover { display: block; } /* control row */ - .ctrl-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } - .ctrl-row button { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 50%; display: grid; place-items: center; } + .ctrl-row { display: flex; align-items: center; gap: 8px; min-width: 0; } + .ctrl-row button { width: 30px; height: 30px; flex: 0 0 30px; background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 50%; display: grid; place-items: center; } .ctrl-row button:hover { background: rgba(255,255,255,.15); } .ctrl-row svg { width: 22px; height: 22px; fill: currentColor; } - .time-label { font-size: 13px; color: #ddd; font-variant-numeric: tabular-nums; margin-left: 4px; user-select: none; } + .time-label { font-size: 13px; color: #ddd; font-variant-numeric: tabular-nums; margin-left: 4px; user-select: none; white-space: nowrap; } .spacer { flex: 1; } .vol-wrap { display: flex; align-items: center; gap: 4px; } .vol-wrap input { width: 0; opacity: 0; transition: width .2s, opacity .2s; accent-color: #fff; } @@ -96,10 +96,12 @@ @media (max-width: 600px) { .page { padding: 8px; } - .ctrl-row { gap: 4px; } - .ctrl-row button { min-width: 36px; min-height: 34px; } + .ctrl-row { gap: 3px; } + .ctrl-row button { width: 32px; height: 32px; flex-basis: 32px; } .ctrl-row svg { width: 20px; height: 20px; } .time-label { font-size: 11px; } + .ctrl-row .spacer { display: none; } + .vol-wrap input { width: 54px; opacity: 1; } .hours span:nth-child(even) { display: none; } } From a5e689af09d09226556385a4aae2acaf4ddbce00 Mon Sep 17 00:00:00 2001 From: human insan Date: Thu, 27 Aug 2026 23:24:29 +0100 Subject: [PATCH 12/19] Simplify player controls --- index.html | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/index.html b/index.html index 6b93ad6..6aadd50 100644 --- a/index.html +++ b/index.html @@ -30,14 +30,7 @@ .big-play svg { width: 28px; height: 28px; } /* bottom gradient */ - .controls-bar { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); padding: 30px 12px 10px; } - - /* scrub bar */ - .scrub { position: relative; height: 4px; background: rgba(255,255,255,.2); cursor: pointer; margin-bottom: 8px; border-radius: 2px; transition: height .1s; } - .scrub:hover { height: 6px; } - .scrub-fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--red); border-radius: 2px; pointer-events: none; } - .scrub-hover { position: absolute; top: -28px; background: rgba(0,0,0,.8); color: #fff; font-size: 12px; padding: 3px 7px; border-radius: 3px; pointer-events: none; white-space: nowrap; display: none; } - .scrub:hover .scrub-hover { display: block; } + .controls-bar { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); padding: 18px 12px 10px; } /* control row */ .ctrl-row { display: flex; align-items: center; gap: 8px; min-width: 0; } @@ -130,10 +123,6 @@
-
-
-
00:00
-
- --:--:-- / 0:00 + --:--:-- - --:--:--
@@ -295,7 +294,7 @@ const gap = gaps.find(item => item.start > now + 1) || gaps[0]; if (gap) { video.pause(); activeClip = null; focus = gap.start; - document.querySelector("#clock").textContent = timeOfDay(gap.start); + document.querySelector("#time-cur").textContent = timeOfDay(gap.start); drawTimeline(); } } @@ -474,9 +473,7 @@ clips = d.recordings; dateSelect.innerHTML = d.dates.map(day => ``).join(""); dateSelect.value = date || d.dates[0]; - document.querySelector("#clock").textContent = "--:--:--"; document.querySelector("#time-cur").textContent = "--:--:--"; - document.querySelector("#time-dur").textContent = "0:00"; drawTimeline(); loadHighlights(); loadSystemHighlights(); @@ -486,7 +483,6 @@ function playClip(clip, offset = 0) { activeClip = clip; initialOffset = offset; - document.querySelector("#clock").textContent = timeOfDay(clip.start + offset); document.querySelector("#time-cur").textContent = timeOfDay(clip.start + offset); video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`; video.play().catch(() => {}); @@ -495,12 +491,10 @@ video.addEventListener("timeupdate", () => { if (!activeClip) return; - document.querySelector("#clock").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime); document.querySelector("#time-cur").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime); drawTimeline(); }); video.addEventListener("loadedmetadata", () => { - document.querySelector("#time-dur").textContent = fmt(video.duration); if (initialOffset > 0 && video.currentTime < initialOffset) video.currentTime = initialOffset; }); video.addEventListener("ended", () => { From 35be3fc2e7092326a12d62776e3421df2efa3021 Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 14:30:18 +0100 Subject: [PATCH 14/19] Add web upload: POST /api/upload stores MP4s into dated archive, plus Upload UI panel --- index.html | 48 ++++++++++++++++++++++++++++++++++++++++++++ server.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 8c1b3a2..9fbb807 100644 --- a/index.html +++ b/index.html @@ -169,6 +169,16 @@
No highlights yet.
+
+ +
+ + Date: - + + Pick an MP4 named HH.MM.SS.mp4 +
+
+
@@ -512,6 +522,44 @@ 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)); addEventListener("resize", drawTimeline); loadDate("").catch(() => {}); diff --git a/server.py b/server.py index 3118738..ed5a6ad 100644 --- a/server.py +++ b/server.py @@ -109,7 +109,10 @@ 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/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 +143,60 @@ class PlayerHandler(BaseHTTPRequestHandler): return self.send_json(highlight) 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): request = urlparse(self.path) if request.path != "/api/highlights": From 3a858c9a55d3ac9ab71f60439d3a9c1be33a5c4f Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 14:46:36 +0100 Subject: [PATCH 15/19] Upload panel: drag-drop multi-file queue, parallel uploads, MP4+AVI support, styled UI --- index.html | 175 ++++++++++++++++++++++++++++++++++++++++++++--------- server.py | 13 ++-- 2 files changed, 153 insertions(+), 35 deletions(-) diff --git a/index.html b/index.html index 9fbb807..d4a9ea3 100644 --- a/index.html +++ b/index.html @@ -87,6 +87,40 @@ .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-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); } + .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; } @@ -170,12 +204,16 @@
- -
- - Date: - - - Pick an MP4 named HH.MM.SS.mp4 + +
+
+ Drop MP4/AVI files here or browse
named HH.MM.SS.mp4 or .avi, multiple files allowed + +
+
+ Date: - + + Ready
@@ -531,33 +569,110 @@ }); 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 uploadDrop = document.querySelector("#upload-drop"); + const uploadQueue = document.querySelector("#upload-queue"); + const uploadStatus = document.querySelector("#upload-status"); + 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 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); + 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(); + } + 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("."); - 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; + 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 filename = normName(entry.file.name); + if (!filename) { + entry.state = "err"; entry.msg = "bad name"; + 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)}`); + 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; 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; + 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"); + if (okCount) loadDate(dateSelect.value); + if (!errs) clearDone(); }); dateSelect.addEventListener("change", () => loadDate(dateSelect.value)); diff --git a/server.py b/server.py index ed5a6ad..4910885 100644 --- a/server.py +++ b/server.py @@ -163,12 +163,15 @@ class PlayerHandler(BaseHTTPRequestHandler): 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") + if suffix not in ("mp4", "avi"): + return self.send_error(400, "Only .mp4 or .avi uploads are accepted") target_dir = self.server.output_root / day - target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.mp4" - temporary = target.with_suffix(".uploading.mp4") + 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}" + temporary = target.with_suffix(f".uploading.{suffix}") try: target_dir.mkdir(parents=True, exist_ok=True) received = 0 @@ -187,7 +190,7 @@ class PlayerHandler(BaseHTTPRequestHandler): 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]): + 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, From afdbf474e61e6376482a7a83a36e59fee35f814f Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 15:08:05 +0100 Subject: [PATCH 16/19] Remove decorative upload indicator dot --- index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/index.html b/index.html index d4a9ea3..2fdb382 100644 --- a/index.html +++ b/index.html @@ -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); } From 780544d00249384eb4865258b013572cb45997d9 Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 15:19:54 +0100 Subject: [PATCH 17/19] Upload: require a recording date before queueing/sending files --- index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/index.html b/index.html index 2fdb382..f493b5b 100644 --- a/index.html +++ b/index.html @@ -601,6 +601,10 @@ uploadBtn.disabled = !queue.some(e => e.state === "queued"); } function addFiles(files) { + if (!dateSelect.value) { + setStatus("Pick a recording date first (top right), then add files.", "err"); + return; + } for (const file of files) { if (!/\.(mp4|avi)$/i.test(file.name)) continue; queue.push({file, state: "queued"}); @@ -657,6 +661,10 @@ uploadBtn.addEventListener("click", async () => { const pending = queue.filter(e => e.state === "queued"); if (!pending.length) return; + if (!dateSelect.value) { + setStatus("Pick a recording date first (top right). Upload cancelled.", "err"); + return; + } uploadBtn.disabled = true; let okCount = 0, done = 0; const workers = Array.from({length: Math.min(3, pending.length)}, async () => { From 9d4f12e5df6d66f4eb3155f7f7ed239a98f62cdf Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 15:27:00 +0100 Subject: [PATCH 18/19] PROD upload: file mtime metadata drives date/time, optional date override, collision suffixes, path traversal guard --- index.html | 42 ++++++++++++++++++++++++++++++------------ server.py | 40 +++++++++++++++++++++++++++------------- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/index.html b/index.html index f493b5b..d6080cb 100644 --- a/index.html +++ b/index.html @@ -206,11 +206,12 @@
- Drop MP4/AVI files here or browse
named HH.MM.SS.mp4 or .avi, multiple files allowed + Drop MP4/AVI files here or browse
multiple files allowed; date & time come from each file's metadata
- Date: - + Upload date: - + Ready
@@ -571,6 +572,8 @@ 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); let queue = []; function setStatus(msg, cls) { uploadStatus.textContent = msg; @@ -589,28 +592,38 @@ 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 per file)"); 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"); } function addFiles(files) { - if (!dateSelect.value) { - setStatus("Pick a recording date first (top right), then add files.", "err"); - return; - } for (const file of files) { if (!/\.(mp4|avi)$/i.test(file.name)) continue; queue.push({file, state: "queued"}); } renderQueue(); } + 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(); @@ -628,16 +641,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})`; }); @@ -678,7 +693,10 @@ 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(); }); diff --git a/server.py b/server.py index 4910885..9414203 100644 --- a/server.py +++ b/server.py @@ -151,26 +151,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 +202,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,6 +341,11 @@ 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( From c59fee65fd17de1b6514b1c909c74a7134e893d4 Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 15:30:58 +0100 Subject: [PATCH 19/19] Upload status: clear stale errors, confirm override and queue adds --- index.html | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index d6080cb..ef7a317 100644 --- a/index.html +++ b/index.html @@ -573,7 +573,11 @@ const uploadQueue = document.querySelector("#upload-queue"); const uploadStatus = document.querySelector("#upload-status"); const overrideDate = document.querySelector("#upload-date-override"); - overrideDate.addEventListener("change", renderQueue); + 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; @@ -594,7 +598,7 @@ 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 per file)"); + 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); @@ -612,6 +616,7 @@ 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) { @@ -676,8 +681,8 @@ uploadBtn.addEventListener("click", async () => { const pending = queue.filter(e => e.state === "queued"); if (!pending.length) return; - if (!dateSelect.value) { - setStatus("Pick a recording date first (top right). Upload cancelled.", "err"); + if (!fileStamp(pending[0]).date) { + setStatus("No upload date: set the override date or file mtimes.", "err"); return; } uploadBtn.disabled = true;