34 Commits
Author SHA1 Message Date
Omarchy-Big-PC 8d194d5241 Highlight export: CSV/JSON download, same-name highlights disambiguated per day, sorted chronologically 2026-08-28 16:47:14 +01:00
Omarchy-Big-PC 43561d5012 Add /api/rescan endpoint, header rescan button, 30s auto-poll for new files 2026-08-28 16:18:07 +01:00
Omarchy-Big-PC 48cbd56f63 Merge main: metadata-driven uploads, multi-file queue, collision suffixes
# Conflicts:
#	README.md
#	index.html
#	server.py
2026-08-28 16:04:48 +01:00
Omarchy-Big-PC c59fee65fd Upload status: clear stale errors, confirm override and queue adds 2026-08-28 15:30:58 +01:00
Omarchy-Big-PC 9d4f12e5df PROD upload: file mtime metadata drives date/time, optional date override, collision suffixes, path traversal guard 2026-08-28 15:27:00 +01:00
Omarchy-Big-PC 780544d002 Upload: require a recording date before queueing/sending files 2026-08-28 15:19:54 +01:00
Omarchy-Big-PC afdbf474e6 Remove decorative upload indicator dot 2026-08-28 15:08:05 +01:00
Omarchy-Big-PC 3a858c9a55 Upload panel: drag-drop multi-file queue, parallel uploads, MP4+AVI support, styled UI 2026-08-28 14:46:36 +01:00
Omarchy-Big-PC 35be3fc2e7 Add web upload: POST /api/upload stores MP4s into dated archive, plus Upload UI panel 2026-08-28 14:30:18 +01:00
human insan abeb245cdc Show one archive time in player 2026-08-27 23:28:32 +01:00
human insan 1be5e840af Show one archive time in player 2026-08-27 23:27:48 +01:00
human insan 7f38d7918c Simplify player controls 2026-08-27 23:24:37 +01:00
human insan a5e689af09 Simplify player controls 2026-08-27 23:24:29 +01:00
human insan 2bc0c77d81 Align responsive player controls 2026-08-27 23:20:30 +01:00
human insan e7a4d0b9b4 Align responsive player controls 2026-08-27 23:20:19 +01:00
human insan 7e034d07ca Improve player controls on small screens 2026-08-27 23:18:16 +01:00
human insan 9cee32e894 Improve player controls on small screens 2026-08-27 23:18:09 +01:00
human insan cedb672633 Make timeline legend jump between events 2026-08-27 23:14:44 +01:00
human insan 22fef80a5f Make timeline legend jump between events 2026-08-27 23:14:37 +01:00
human insan 7ee3835db5 Start clips from timeline color markers 2026-08-27 23:09:37 +01:00
human insan 6772311641 Start clips from timeline color markers 2026-08-27 23:09:06 +01:00
human insan 8326fd7c03 Jump from event markers to clip starts 2026-08-27 23:08:50 +01:00
human insan baa344c8cb Display recorder system highlights 2026-08-27 23:08:50 +01:00
human insan 807447e815 Show highlights on timeline 2026-08-27 23:08:20 +01:00
human insan f00ae278cd Simplify highlight API for automation 2026-08-27 23:08:20 +01:00
human insan b488bce210 Add highlight marking and AI API 2026-08-27 23:08:20 +01:00
human insan d2d8f3b657 Jump from event markers to clip starts 2026-08-27 23:05:43 +01:00
human insan eebbc346e1 Display recorder system highlights 2026-08-27 22:55:20 +01:00
human insan 0bfea476da Show highlights on timeline 2026-08-27 22:52:20 +01:00
human insan dc6856da9a Simplify highlight API for automation 2026-08-27 22:47:33 +01:00
human insan eb35a7dc26 Remove empty player placeholder 2026-08-27 22:45:58 +01:00
human insan 5a0e83fb4c Add highlight marking and AI API 2026-08-27 22:43:30 +01:00
human insan 25bdd34c70 Keep Docker branch documentation focused 2026-08-27 22:37:08 +01:00
human insan 6c0d06de77 Revert "Add Docker and LXC deployment support"
This reverts commit 451711b217.
2026-08-27 22:34:10 +01:00
3 changed files with 629 additions and 91 deletions
+28 -40
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,46 +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
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`
@@ -98,6 +60,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:
+379 -49
View File
@@ -25,26 +25,19 @@
.player-wrap video { display: block; width: 100%; aspect-ratio: 16/9; object-fit: contain; background: #000; }
/* 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 +55,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; }
@@ -80,9 +87,47 @@
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
/* upload panel */
.upload-toggle svg { fill: #8ab4ff; }
.upload-body { border-top: 1px solid #1c1c1c; }
.upload-drop { flex: 1; min-width: 240px; border: 2px dashed #555; border-radius: 8px; padding: 22px 16px; text-align: center; color: var(--dim); font-size: 13px; cursor: pointer; transition: border-color .15s, background .15s; }
.upload-drop:hover, .upload-drop.drag { border-color: var(--accent); background: rgba(62,166,255,.08); color: var(--text); }
.upload-drop b { color: var(--accent); font-weight: 500; }
.upload-drop input { display: none; }
.upload-file-chip { display: none; align-items: center; gap: 8px; background: #1a1a1a; border: 1px solid #3ea6ff55; border-radius: 6px; padding: 8px 12px; font-size: 12px; max-width: 320px; }
.upload-file-chip.show { display: flex; }
.upload-file-chip .chip-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-chip .chip-size { color: var(--dim); white-space: nowrap; }
.upload-file-chip .chip-x { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 14px; padding: 0 2px; }
.upload-file-chip .chip-x:hover { color: var(--red); }
.upload-queue { display: none; flex-direction: column; gap: 4px; flex: 1 1 100%; min-width: 260px; max-height: 240px; overflow-y: auto; }
.upload-queue.show { display: flex; }
.q-count { color: var(--dim); font-size: 11px; padding: 2px 0; }
.q-item { display: flex; align-items: center; gap: 8px; font-size: 12px; background: #1a1a1a; border-radius: 4px; padding: 5px 8px; }
.q-item .q-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.q-item .q-size { color: var(--dim); white-space: nowrap; }
.q-item .q-state { min-width: 70px; text-align: right; color: var(--dim); white-space: nowrap; }
.q-item.ok .q-state { color: #7bd97b; }
.q-item.err .q-state { color: var(--red); }
.q-item.busy .q-state { color: var(--accent); }
.q-item .q-bar { position: relative; width: 60px; height: 3px; background: #333; border-radius: 2px; overflow: hidden; flex: 0 0 60px; }
.q-item .q-bar i { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); display: block; }
.upload-btn { position: relative; overflow: hidden; }
.upload-btn.busy { color: transparent; pointer-events: none; }
.upload-btn.busy::after { content: ""; position: absolute; inset: 0; margin: auto; width: 14px; height: 14px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.upload-status { transition: color .2s; }
.upload-status.ok { color: #7bd97b; }
.upload-status.err { color: var(--red); }
@media (max-width: 600px) {
.page { padding: 8px; }
.ctrl-row 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>
@@ -91,6 +136,8 @@
<div class="page">
<div class="header">
<h1>C41 Archive</h1>
<span class="spacer" style="flex:1"></span>
<button id="rescan-btn" title="Rescan the archive folder" style="background:var(--surface);color:var(--text);border:none;padding:6px 10px;border-radius:3px;font-size:14px;cursor:pointer;margin-right:8px">&#x21bb;</button>
<select id="date"></select>
</div>
@@ -111,17 +158,13 @@
<video id="video" playsinline></video>
<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 +172,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,7 +190,36 @@
<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>
<span style="flex:1"></span>
<button id="export-csv" title="Download all highlights as CSV">Export CSV</button>
<button id="export-json" title="Download all highlights as JSON">Export JSON</button>
</div>
<div class="highlight-list" id="highlight-list"><span style="color:var(--dim);font-size:12px">No highlights yet.</span></div>
</section>
<div class="encode-panel">
<button class="encode-toggle upload-toggle" id="upload-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Upload</button>
<div class="encode-body upload-body" id="upload-body" style="flex-wrap:wrap;gap:14px">
<div class="upload-drop" id="upload-drop" title="Click to browse, or drag MP4/AVI files here">
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">multiple files allowed; date &amp; time come from each file's metadata</span>
<input type="file" id="upload-file" accept="video/mp4,video/x-msvideo,.avi" multiple>
</div>
<div class="upload-queue" id="upload-queue"></div>
<span class="enc-status" style="min-width:110px">Upload date: <b id="upload-date" style="color:var(--text)">-</b></span>
<input id="upload-date-override" type="date" title="Override: force all files into this date instead of file mtime" style="background:var(--surface);color:var(--text);border:1px solid #555;border-radius:3px;padding:6px 8px;font-size:12px">
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
<span class="enc-status upload-status" id="upload-status" style="min-width:200px;text-align:left">Ready</span>
</div>
</div>
<div class="encode-panel">
@@ -166,14 +237,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 +318,100 @@
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("#export-csv").onclick = () => location.href = "/api/highlights/export?format=csv";
document.querySelector("#export-json").onclick = () => location.href = "/api/highlights/export?format=json";
document.querySelector("#save-highlight").onclick = async () => {
if (highlightStart === null || highlightEnd === null || highlightStart >= highlightEnd) return;
await fetch("/api/highlights", {method: "POST", headers: {"Content-Type": "application/json"},
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 +460,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 +497,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;
@@ -354,18 +528,17 @@
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 +546,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", () => {
@@ -397,8 +567,168 @@
else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); }
});
// upload
const uploadToggle = document.querySelector("#upload-toggle");
uploadToggle.addEventListener("click", () => {
uploadToggle.classList.toggle("open");
document.querySelector("#upload-body").classList.toggle("show");
document.querySelector("#upload-date").textContent = dateSelect.value || "-";
});
const uploadFile = document.querySelector("#upload-file");
const uploadBtn = document.querySelector("#upload-btn");
const uploadDrop = document.querySelector("#upload-drop");
const uploadQueue = document.querySelector("#upload-queue");
const uploadStatus = document.querySelector("#upload-status");
const overrideDate = document.querySelector("#upload-date-override");
overrideDate.addEventListener("change", () => {
renderQueue();
if (overrideDate.value) setStatus(`Override active: all files go to ${overrideDate.value.replaceAll("-", "")}`, "ok");
else setStatus("Ready");
});
let queue = [];
function setStatus(msg, cls) {
uploadStatus.textContent = msg;
uploadStatus.className = "enc-status upload-status" + (cls ? " " + cls : "");
}
function renderQueue() {
uploadQueue.replaceChildren();
uploadQueue.classList.toggle("show", queue.length > 0);
if (!queue.length) return;
const counts = queue.reduce((acc, e) => (acc[e.state || "queued"] = (acc[e.state || "queued"] || 0) + 1, acc), {});
const summary = document.createElement("div");
summary.className = "q-count";
summary.textContent = `${queue.length} file${queue.length > 1 ? "s" : ""}: ${counts.queued || 0} queued, ${counts.busy || 0} sending, ${counts.ok || 0} stored, ${counts.err || 0} failed`;
uploadQueue.append(summary);
queue.forEach(entry => {
const row = document.createElement("div");
row.className = "q-item" + (entry.state ? " " + entry.state : "");
const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name;
const stamp = fileStamp(entry);
const slot = document.createElement("span"); slot.className = "q-size";
slot.textContent = stamp.date + " " + (stamp.time ? stamp.time : "mtime time");
const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB";
const bar = document.createElement("span"); bar.className = "q-bar";
const fill = document.createElement("i"); bar.append(fill);
entry.bar = fill;
const state = document.createElement("span"); state.className = "q-state";
state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued";
row.append(name, slot, size, bar, state);
uploadQueue.append(row);
});
uploadBtn.disabled = !queue.some(e => e.state === "queued");
}
function addFiles(files) {
for (const file of files) {
if (!/\.(mp4|avi)$/i.test(file.name)) continue;
queue.push({file, state: "queued"});
}
renderQueue();
if (queue.length) setStatus(`${queue.length} file${queue.length > 1 ? "s" : ""} queued${overrideDate.value ? " for " + overrideDate.value.replaceAll("-", "") : " (mtimes decide)"}`);
}
function fileStamp(entry) {
if (overrideDate.value) {
return {date: overrideDate.value.replaceAll("-", ""), time: null};
}
const d = new Date(entry.file.lastModified);
const pad = n => String(n).padStart(2, "0");
return {
date: `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`,
time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
};
}
function clearDone() {
queue = queue.filter(e => e.state === "err");
renderQueue();
}
uploadDrop.addEventListener("click", () => uploadFile.click());
uploadFile.addEventListener("change", () => addFiles(uploadFile.files));
["dragover", "dragenter"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.remove("drag"); }));
uploadDrop.addEventListener("drop", e => addFiles(e.dataTransfer.files));
function normName(name) {
const dot = name.lastIndexOf(".");
if (dot <= 0) return null;
const base = name.slice(0, dot).padStart(8, "0").slice(-8);
const ext = name.slice(dot).toLowerCase();
return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null;
}
async function uploadOne(entry) {
const stamp = fileStamp(entry);
if (!stamp.date) {
entry.state = "err"; entry.msg = "no date";
renderQueue();
return false;
}
entry.state = "busy"; renderQueue();
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
let url = `/api/upload?date=${stamp.date}&filename=${encodeURIComponent(entry.file.name)}`;
if (stamp.time) url += `&time=${encodeURIComponent(stamp.time)}`;
xhr.open("POST", url);
xhr.upload.addEventListener("progress", e => {
if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`;
});
xhr.addEventListener("load", () => {
let d = {};
try { d = JSON.parse(xhr.responseText); } catch {}
entry.state = xhr.status < 300 ? "ok" : "err";
entry.msg = xhr.status < 300 ? "stored" : (d.message || `HTTP ${xhr.status}`).slice(0, 40);
if (entry.bar) entry.bar.style.transform = "scaleX(1)";
renderQueue();
resolve(xhr.status < 300);
});
xhr.addEventListener("error", () => {
entry.state = "err"; entry.msg = "network";
renderQueue();
resolve(false);
});
xhr.send(entry.file);
});
}
uploadBtn.addEventListener("click", async () => {
const pending = queue.filter(e => e.state === "queued");
if (!pending.length) return;
if (!fileStamp(pending[0]).date) {
setStatus("No upload date: set the override date or file mtimes.", "err");
return;
}
uploadBtn.disabled = true;
let okCount = 0, done = 0;
const workers = Array.from({length: Math.min(3, pending.length)}, async () => {
while (pending.length) {
const entry = pending.shift();
if (await uploadOne(entry)) okCount++;
done++;
setStatus(`${done}/${pending.length + done} sent...`);
}
});
await Promise.all(workers);
const errs = queue.filter(e => e.state === "err").length;
setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok");
const touched = new Set(queue.filter(e => e.state === "ok").map(e => fileStamp(e).date));
if (touched.size) {
loadDate(dateSelect.value || [...touched][0]);
}
if (!errs) clearDone();
});
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
addEventListener("resize", drawTimeline);
// rescan: button + silent auto-poll for new dates
const rescanBtn = document.querySelector("#rescan-btn");
async function rescan(notify) {
try {
const r = await fetch("/api/rescan", {method: "POST"});
const d = await r.json();
if (notify) setStatus(`${d.days} day${d.days !== 1 ? "s" : ""}, ${d.clips} clip${d.clips !== 1 ? "s" : ""} indexed`);
loadDate(dateSelect.value || "");
return d;
} catch { return null; }
}
rescanBtn.addEventListener("click", () => rescan(true));
setInterval(() => { if (!queue.some(e => e.state === "busy")) rescan(false); }, 30000);
loadDate("").catch(() => {});
updateEncodeStatus();
</script>
+222 -2
View File
@@ -2,10 +2,12 @@
import argparse
import concurrent.futures
import json
import os
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,17 +100,174 @@ 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/highlights/export":
return self.export_highlights()
if request.path == "/api/system-highlights":
date = parse_qs(request.query).get("date", [""])[0]
return self.send_json([item for item in self.server.system_highlights if item["date"] == date])
if request.path == "/video":
return self.send_video(request.query)
self.send_error(404)
def do_POST(self):
if urlparse(self.path).path == "/api/encode":
path = urlparse(self.path).path
if path == "/api/rescan":
self.server.recordings = scan_recordings(self.server.output_root)
self.server.system_highlights = scan_system_highlights(self.server.source_root)
count = sum(map(len, self.server.recordings.values()))
return self.send_json({"ok": True, "days": len(self.server.recordings), "clips": count})
if path == "/api/upload":
return self.handle_upload()
if path == "/api/encode":
if not self.server.encode_status["running"]:
threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start()
return self.send_json(self.server.encode_status)
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 export_highlights(self):
fmt = parse_qs(urlparse(self.path).query).get("format", ["csv"])[0].lower()
items = sorted(self.server.highlights, key=lambda h: (h["date"], h["start"], h["id"]))
seen = {}
rows = []
for item in items:
# same label may repeat across days; within one day duplicates get -2, -3...
key = (item["date"], item["label"])
seen[key] = seen.get(key, 0) + 1
name = item["label"] if seen[key] == 1 else f"{item['label']} ({seen[key]})"
def clock(sec):
return f"{sec // 3600:02d}:{sec % 3600 // 60:02d}:{sec % 60:02d}"
rows.append({
"date": f"{item['date'][:4]}-{item['date'][4:6]}-{item['date'][6:]}",
"name": name,
"start": clock(item["start"]),
"end": clock(item["end"]),
"duration_sec": item["end"] - item["start"],
"clip": f"{item['date']}/{item['start'] // 3600:02d}.{item['start'] % 3600 // 60:02d}.{item['start'] % 60:02d}",
})
if fmt == "json":
data = json.dumps(rows, indent=2).encode()
ctype = "application/json"
else:
import io
buf = io.StringIO()
writer = __import__("csv").writer(buf)
writer.writerow(["date", "name", "start", "end", "duration_sec", "clip"])
writer.writerows((r["date"], r["name"], r["start"], r["end"], r["duration_sec"], r["clip"]) for r in rows)
data = buf.getvalue().encode()
ctype = "text/csv"
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
self.send_response(200)
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
self.send_header("Content-Disposition", f'attachment; filename="highlights-{stamp}.{fmt}"')
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def handle_upload(self):
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self.send_error(400, "Invalid content length")
max_bytes = 2 * 1024 * 1024 * 1024
if not 0 < length <= max_bytes:
return self.send_error(413, "Missing or oversized body (limit 2 GiB)")
params = parse_qs(urlparse(self.path).query)
filename = params.get("filename", [""])[0]
if not re.fullmatch(r"[A-Za-z0-9._ -]{1,120}\.(mp4|avi)", filename, re.IGNORECASE):
return self.send_error(400, "filename must be a simple MP4/AVI name")
suffix = filename.rsplit(".", 1)[1].lower()
try:
day = normalize_date(params.get("date", [""])[0])
except ValueError:
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
try:
start = parse_clock(params.get("time", [""])[0])
if not (0 <= start < 86400):
raise ValueError
except ValueError:
return self.send_error(400, "time must be HH:MM[:SS] within one day")
target_dir = self.server.output_root / day
if suffix == "avi":
# AVI sources mirror the real recorder: stored in the source tree for the Encoding menu
target_dir = self.server.source_root / day
# Archive-canonical name from the actual start time; collisions with a
# different original name get a numeric suffix.
base = f"{start // 3600:02d}.{start % 3600 // 60:02d}.{start % 60:02d}"
target = target_dir / f"{base}.{suffix}"
serial = 1
while target.exists() or temporary_exists(target_dir, target.name):
base_alt = f"{base}-{serial:02d}"
target = target_dir / f"{base_alt}.{suffix}"
serial += 1
temporary = target.with_suffix(f".uploading.{suffix}")
try:
target_dir.mkdir(parents=True, exist_ok=True)
received = 0
with temporary.open("wb") as sink:
while received < length:
chunk = self.rfile.read(min(256 * 1024, length - received))
if not chunk:
raise OSError("client disconnected mid-upload")
sink.write(chunk)
received += len(chunk)
if target.exists():
temporary.unlink()
return self.send_error(409, "A recording already exists at that time; nothing was overwritten")
temporary.replace(target)
except OSError:
temporary.unlink(missing_ok=True)
return self.send_error(500, "Failed to store upload")
self.server.recordings.setdefault(day, [])
if suffix == "mp4" and not any(clip["name"] == target.name for clip in self.server.recordings[day]):
self.server.recordings[day].append({
"name": target.name,
"start": start,
"duration": 120,
"path": target,
})
self.server.recordings[day].sort(key=lambda clip: clip["start"])
return self.send_json({"ok": True, "file": f"{day}/{target.name}"})
def do_DELETE(self):
request = urlparse(self.path)
if request.path != "/api/highlights":
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)
@@ -191,6 +388,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(
@@ -215,6 +417,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 +470,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()