12 Commits
6 changed files with 298 additions and 104 deletions
-4
View File
@@ -1,4 +0,0 @@
.git
__pycache__
server.log
*.pyc
-18
View File
@@ -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"]
+28 -20
View File
@@ -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.
@@ -21,6 +22,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
@@ -32,26 +34,6 @@ python3 server.py
Open <http://127.0.0.1:8765> 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 <http://127.0.0.1:8765>. 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
@@ -98,6 +80,32 @@ 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 agent can list highlights:
```sh
curl http://127.0.0.1:8765/api/highlights
```
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":"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. `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
To use a different output folder or port:
-12
View File
@@ -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
+170 -50
View File
@@ -23,28 +23,25 @@
/* 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; }
/* 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; }
.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; }
@@ -62,9 +59,23 @@
.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; }
.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; }
.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; }
@@ -82,7 +93,12 @@
@media (max-width: 600px) {
.page { padding: 8px; }
.ctrl-row svg { width: 18px; height: 18px; }
.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; }
}
</style>
@@ -109,19 +125,16 @@
<div class="player-wrap paused" id="player-wrap">
<video id="video" playsinline></video>
<div class="empty-state" id="empty-state"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h10a2 2 0 0 1 2 2v2.2l3-1.7v9l-3-1.7V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v10h10V7H4zm12 4.5v2.1l1-.6v-1z"/></svg><span>Select a recording from the timeline</span></div>
<div class="big-play"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
<div class="controls-bar">
<div class="scrub" id="scrub">
<div class="scrub-fill" id="scrub-fill"></div>
<div class="scrub-hover" id="scrub-hover">00:00</div>
</div>
<div class="ctrl-row">
<button id="btn-play" aria-label="Play">
<svg id="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6zm8-14v14h4V5z"/></svg>
</button>
<button id="btn-back" aria-label="Back 10s"><svg viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/><text x="10" y="16" font-size="7" fill="currentColor" text-anchor="middle" font-family="sans-serif">10</text></svg></button>
<button id="btn-fwd" aria-label="Forward 10s"><svg viewBox="0 0 24 24"><path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/><text x="14" y="16" font-size="7" fill="currentColor" text-anchor="middle" font-family="sans-serif">10</text></svg></button>
<button id="btn-back" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24"><path d="M12 5V2L7 7l5 5V8a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z"/><text x="12" y="15" font-size="6.5" fill="currentColor" text-anchor="middle" font-family="sans-serif" font-weight="bold">10</text></svg></button>
<button id="btn-fwd" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24"><path d="M12 5V2l5 5-5 5V8a5 5 0 1 0 5 5h2a7 7 0 1 1-7-7z"/><text x="12" y="15" font-size="6.5" fill="currentColor" text-anchor="middle" font-family="sans-serif" font-weight="bold">10</text></svg></button>
<div class="vol-wrap">
<button id="btn-vol" aria-label="Volume">
<svg id="icon-vol-on" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.3v7.4a4.5 4.5 0 0 0 2.5-3.7zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1A9 9 0 0 0 14 3.2z"/></svg>
@@ -129,9 +142,8 @@
</button>
<input id="volume" type="range" min="0" max="1" step=".01" value=".8">
</div>
<span class="time-label"><span id="time-cur">0:00</span> / <span id="time-dur">0:00</span></span>
<span class="time-label" id="time-cur" style="color:var(--accent);font-weight:500;">--:--:--</span>
<span class="spacer"></span>
<span class="time-label" id="clock" style="color:var(--accent);font-weight:500;">--:--:--</span>
</div>
</div>
</div>
@@ -148,9 +160,20 @@
<canvas id="timeline"></canvas>
</div>
<div class="hours" id="hours"></div>
<div class="legend"><span class="rec">Recorded</span><span class="gap">No recording</span></div>
<div class="legend"><span class="rec" data-jump="recorded">Recorded</span><span class="highlight" data-jump="highlight">Highlight</span><span class="system" data-jump="system">System highlight</span><span class="gap" data-jump="gap">No recording</span></div>
</div>
<section class="highlights">
<h2>Highlights</h2>
<div class="highlight-actions">
<button id="mark-start">Mark start</button><span class="mark-time" id="highlight-start">--:--:--</span>
<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>
</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" 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">
@@ -166,14 +189,15 @@
const wrap = document.querySelector("#player-wrap");
const dateSelect = document.querySelector("#date");
const timeline = document.querySelector("#timeline");
const scrub = document.querySelector("#scrub");
const scrubFill = document.querySelector("#scrub-fill");
const scrubHover = document.querySelector("#scrub-hover");
let clips = [];
let activeClip = null;
let initialOffset = 0;
let zoom = 1;
let focus = 43200;
let highlights = [];
let systemHighlights = [];
let highlightStart = null;
let highlightEnd = null;
let encodingWasRunning = false;
// encoding
@@ -246,21 +270,98 @@
return [s/3600, s%3600/60, s%60].map(v => String(Math.floor(v)).padStart(2,"0")).join(":");
}
// scrub bar
function updateScrub() {
if (!activeClip || !video.duration) { scrubFill.style.width = "0%"; return; }
scrubFill.style.width = (video.currentTime / video.duration * 100) + "%";
function archiveTime() {
return activeClip ? activeClip.start + initialOffset + (video.currentTime || 0) : focus;
}
scrub.addEventListener("click", e => {
if (!activeClip || !video.duration) return;
video.currentTime = (e.offsetX / scrub.clientWidth) * video.duration;
});
scrub.addEventListener("mousemove", e => {
if (!activeClip || !video.duration) return;
const t = (e.offsetX / scrub.clientWidth) * video.duration;
scrubHover.textContent = fmt(t);
scrubHover.style.left = e.offsetX + "px";
});
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("#time-cur").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;
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");
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) {
list.innerHTML = '<span style="color:var(--dim);font-size:12px">No highlights yet.</span>';
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);
});
}
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 () => {
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();
};
// seek buttons
function seekBy(sec) {
@@ -309,6 +410,18 @@
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);
});
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++) {
@@ -334,9 +447,20 @@
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)));
if (clip) playClip(clip, 0);
});
timeline.addEventListener("mousemove", e => {
const winSize = 86400 / zoom;
@@ -348,24 +472,23 @@
// 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;
dateSelect.innerHTML = d.dates.map(day => `<option value="${day}">${day.slice(0,4)}-${day.slice(4,6)}-${day.slice(6)}</option>`).join("");
dateSelect.value = date || d.dates[0];
document.querySelector("#clock").textContent = "--:--:--";
document.querySelector("#time-cur").textContent = "0:00";
document.querySelector("#time-dur").textContent = "0:00";
scrubFill.style.width = "0%";
document.querySelector("#time-cur").textContent = "--:--:--";
drawTimeline();
loadHighlights();
loadSystemHighlights();
if (!date && d.dates[0]) loadDate(d.dates[0]);
}
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(() => {});
drawTimeline();
@@ -373,13 +496,10 @@
video.addEventListener("timeupdate", () => {
if (!activeClip) return;
document.querySelector("#clock").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime);
document.querySelector("#time-cur").textContent = fmt(video.currentTime);
updateScrub();
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", () => {
+100
View File
@@ -4,8 +4,10 @@ import concurrent.futures
import json
import re
import subprocess
import struct
import threading
import os
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
@@ -13,6 +15,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
@@ -43,6 +46,43 @@ 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
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"
@@ -60,6 +100,11 @@ 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 == "/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)
@@ -69,8 +114,45 @@ 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 = 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", item.get("title", "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)
@@ -215,6 +297,9 @@ 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.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")
@@ -265,5 +350,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()