12 Commits
6 changed files with 28 additions and 372 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"]
+20
View File
@@ -34,6 +34,26 @@ python3 server.py
Open <http://127.0.0.1:8765> in a browser.
## 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`
-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
+6 -216
View File
@@ -23,6 +23,10 @@
/* 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); }
@@ -87,39 +91,6 @@
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
/* upload panel */
.upload-toggle svg { fill: #8ab4ff; }
.upload-body { border-top: 1px solid #1c1c1c; }
.upload-drop { flex: 1; min-width: 240px; border: 2px dashed #555; border-radius: 8px; padding: 22px 16px; text-align: center; color: var(--dim); font-size: 13px; cursor: pointer; transition: border-color .15s, background .15s; }
.upload-drop:hover, .upload-drop.drag { border-color: var(--accent); background: rgba(62,166,255,.08); color: var(--text); }
.upload-drop b { color: var(--accent); font-weight: 500; }
.upload-drop input { display: none; }
.upload-file-chip { display: none; align-items: center; gap: 8px; background: #1a1a1a; border: 1px solid #3ea6ff55; border-radius: 6px; padding: 8px 12px; font-size: 12px; max-width: 320px; }
.upload-file-chip.show { display: flex; }
.upload-file-chip .chip-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-chip .chip-size { color: var(--dim); white-space: nowrap; }
.upload-file-chip .chip-x { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 14px; padding: 0 2px; }
.upload-file-chip .chip-x:hover { color: var(--red); }
.upload-queue { display: none; flex-direction: column; gap: 4px; flex: 1 1 100%; min-width: 260px; max-height: 240px; overflow-y: auto; }
.upload-queue.show { display: flex; }
.q-count { color: var(--dim); font-size: 11px; padding: 2px 0; }
.q-item { display: flex; align-items: center; gap: 8px; font-size: 12px; background: #1a1a1a; border-radius: 4px; padding: 5px 8px; }
.q-item .q-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.q-item .q-size { color: var(--dim); white-space: nowrap; }
.q-item .q-state { min-width: 70px; text-align: right; color: var(--dim); white-space: nowrap; }
.q-item.ok .q-state { color: #7bd97b; }
.q-item.err .q-state { color: var(--red); }
.q-item.busy .q-state { color: var(--accent); }
.q-item .q-bar { position: relative; width: 60px; height: 3px; background: #333; border-radius: 2px; overflow: hidden; flex: 0 0 60px; }
.q-item .q-bar i { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); display: block; }
.upload-btn { position: relative; overflow: hidden; }
.upload-btn.busy { color: transparent; pointer-events: none; }
.upload-btn.busy::after { content: ""; position: absolute; inset: 0; margin: auto; width: 14px; height: 14px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.upload-status { transition: color .2s; }
.upload-status.ok { color: #7bd97b; }
.upload-status.err { color: var(--red); }
@media (max-width: 600px) {
.page { padding: 8px; }
.ctrl-row { gap: 3px; }
@@ -136,8 +107,6 @@
<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>
@@ -156,6 +125,7 @@
<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="ctrl-row">
@@ -200,28 +170,10 @@
<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">
<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">
@@ -400,8 +352,6 @@
}
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"},
@@ -522,7 +472,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;
@@ -567,168 +517,8 @@
else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); }
});
// upload
const uploadToggle = document.querySelector("#upload-toggle");
uploadToggle.addEventListener("click", () => {
uploadToggle.classList.toggle("open");
document.querySelector("#upload-body").classList.toggle("show");
document.querySelector("#upload-date").textContent = dateSelect.value || "-";
});
const uploadFile = document.querySelector("#upload-file");
const uploadBtn = document.querySelector("#upload-btn");
const uploadDrop = document.querySelector("#upload-drop");
const uploadQueue = document.querySelector("#upload-queue");
const uploadStatus = document.querySelector("#upload-status");
const overrideDate = document.querySelector("#upload-date-override");
overrideDate.addEventListener("change", () => {
renderQueue();
if (overrideDate.value) setStatus(`Override active: all files go to ${overrideDate.value.replaceAll("-", "")}`, "ok");
else setStatus("Ready");
});
let queue = [];
function setStatus(msg, cls) {
uploadStatus.textContent = msg;
uploadStatus.className = "enc-status upload-status" + (cls ? " " + cls : "");
}
function renderQueue() {
uploadQueue.replaceChildren();
uploadQueue.classList.toggle("show", queue.length > 0);
if (!queue.length) return;
const counts = queue.reduce((acc, e) => (acc[e.state || "queued"] = (acc[e.state || "queued"] || 0) + 1, acc), {});
const summary = document.createElement("div");
summary.className = "q-count";
summary.textContent = `${queue.length} file${queue.length > 1 ? "s" : ""}: ${counts.queued || 0} queued, ${counts.busy || 0} sending, ${counts.ok || 0} stored, ${counts.err || 0} failed`;
uploadQueue.append(summary);
queue.forEach(entry => {
const row = document.createElement("div");
row.className = "q-item" + (entry.state ? " " + entry.state : "");
const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name;
const stamp = fileStamp(entry);
const slot = document.createElement("span"); slot.className = "q-size";
slot.textContent = stamp.date + " " + (stamp.time ? stamp.time : "mtime time");
const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB";
const bar = document.createElement("span"); bar.className = "q-bar";
const fill = document.createElement("i"); bar.append(fill);
entry.bar = fill;
const state = document.createElement("span"); state.className = "q-state";
state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued";
row.append(name, slot, size, bar, state);
uploadQueue.append(row);
});
uploadBtn.disabled = !queue.some(e => e.state === "queued");
}
function addFiles(files) {
for (const file of files) {
if (!/\.(mp4|avi)$/i.test(file.name)) continue;
queue.push({file, state: "queued"});
}
renderQueue();
if (queue.length) setStatus(`${queue.length} file${queue.length > 1 ? "s" : ""} queued${overrideDate.value ? " for " + overrideDate.value.replaceAll("-", "") : " (mtimes decide)"}`);
}
function fileStamp(entry) {
if (overrideDate.value) {
return {date: overrideDate.value.replaceAll("-", ""), time: null};
}
const d = new Date(entry.file.lastModified);
const pad = n => String(n).padStart(2, "0");
return {
date: `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`,
time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
};
}
function clearDone() {
queue = queue.filter(e => e.state === "err");
renderQueue();
}
uploadDrop.addEventListener("click", () => uploadFile.click());
uploadFile.addEventListener("change", () => addFiles(uploadFile.files));
["dragover", "dragenter"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.remove("drag"); }));
uploadDrop.addEventListener("drop", e => addFiles(e.dataTransfer.files));
function normName(name) {
const dot = name.lastIndexOf(".");
if (dot <= 0) return null;
const base = name.slice(0, dot).padStart(8, "0").slice(-8);
const ext = name.slice(dot).toLowerCase();
return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null;
}
async function uploadOne(entry) {
const stamp = fileStamp(entry);
if (!stamp.date) {
entry.state = "err"; entry.msg = "no date";
renderQueue();
return false;
}
entry.state = "busy"; renderQueue();
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
let url = `/api/upload?date=${stamp.date}&filename=${encodeURIComponent(entry.file.name)}`;
if (stamp.time) url += `&time=${encodeURIComponent(stamp.time)}`;
xhr.open("POST", url);
xhr.upload.addEventListener("progress", e => {
if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`;
});
xhr.addEventListener("load", () => {
let d = {};
try { d = JSON.parse(xhr.responseText); } catch {}
entry.state = xhr.status < 300 ? "ok" : "err";
entry.msg = xhr.status < 300 ? "stored" : (d.message || `HTTP ${xhr.status}`).slice(0, 40);
if (entry.bar) entry.bar.style.transform = "scaleX(1)";
renderQueue();
resolve(xhr.status < 300);
});
xhr.addEventListener("error", () => {
entry.state = "err"; entry.msg = "network";
renderQueue();
resolve(false);
});
xhr.send(entry.file);
});
}
uploadBtn.addEventListener("click", async () => {
const pending = queue.filter(e => e.state === "queued");
if (!pending.length) return;
if (!fileStamp(pending[0]).date) {
setStatus("No upload date: set the override date or file mtimes.", "err");
return;
}
uploadBtn.disabled = true;
let okCount = 0, done = 0;
const workers = Array.from({length: Math.min(3, pending.length)}, async () => {
while (pending.length) {
const entry = pending.shift();
if (await uploadOne(entry)) okCount++;
done++;
setStatus(`${done}/${pending.length + done} sent...`);
}
});
await Promise.all(workers);
const errs = queue.filter(e => e.state === "err").length;
setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok");
const touched = new Set(queue.filter(e => e.state === "ok").map(e => fileStamp(e).date));
if (touched.size) {
loadDate(dateSelect.value || [...touched][0]);
}
if (!errs) clearDone();
});
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
addEventListener("resize", drawTimeline);
// rescan: button + silent auto-poll for new dates
const rescanBtn = document.querySelector("#rescan-btn");
async function rescan(notify) {
try {
const r = await fetch("/api/rescan", {method: "POST"});
const d = await r.json();
if (notify) setStatus(`${d.days} day${d.days !== 1 ? "s" : ""}, ${d.clips} clip${d.clips !== 1 ? "s" : ""} indexed`);
loadDate(dateSelect.value || "");
return d;
} catch { return null; }
}
rescanBtn.addEventListener("click", () => rescan(true));
setInterval(() => { if (!queue.some(e => e.state === "busy")) rescan(false); }, 30000);
loadDate("").catch(() => {});
updateEncodeStatus();
</script>
+2 -122
View File
@@ -2,11 +2,11 @@
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
@@ -102,8 +102,6 @@ class PlayerHandler(BaseHTTPRequestHandler):
return self.send_json(self.server.encode_status)
if request.path == "/api/highlights":
return self.send_json(self.server.highlights)
if request.path == "/api/highlights/export":
return self.export_highlights()
if request.path == "/api/system-highlights":
date = parse_qs(request.query).get("date", [""])[0]
return self.send_json([item for item in self.server.system_highlights if item["date"] == date])
@@ -112,15 +110,7 @@ class PlayerHandler(BaseHTTPRequestHandler):
self.send_error(404)
def do_POST(self):
path = urlparse(self.path).path
if path == "/api/rescan":
self.server.recordings = scan_recordings(self.server.output_root)
self.server.system_highlights = scan_system_highlights(self.server.source_root)
count = sum(map(len, self.server.recordings.values()))
return self.send_json({"ok": True, "days": len(self.server.recordings), "clips": count})
if path == "/api/upload":
return self.handle_upload()
if path == "/api/encode":
if urlparse(self.path).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)
@@ -151,111 +141,6 @@ class PlayerHandler(BaseHTTPRequestHandler):
return self.send_json(highlight)
self.send_error(404)
def export_highlights(self):
fmt = parse_qs(urlparse(self.path).query).get("format", ["csv"])[0].lower()
items = sorted(self.server.highlights, key=lambda h: (h["date"], h["start"], h["id"]))
seen = {}
rows = []
for item in items:
# same label may repeat across days; within one day duplicates get -2, -3...
key = (item["date"], item["label"])
seen[key] = seen.get(key, 0) + 1
name = item["label"] if seen[key] == 1 else f"{item['label']} ({seen[key]})"
def clock(sec):
return f"{sec // 3600:02d}:{sec % 3600 // 60:02d}:{sec % 60:02d}"
rows.append({
"date": f"{item['date'][:4]}-{item['date'][4:6]}-{item['date'][6:]}",
"name": name,
"start": clock(item["start"]),
"end": clock(item["end"]),
"duration_sec": item["end"] - item["start"],
"clip": f"{item['date']}/{item['start'] // 3600:02d}.{item['start'] % 3600 // 60:02d}.{item['start'] % 60:02d}",
})
if fmt == "json":
data = json.dumps(rows, indent=2).encode()
ctype = "application/json"
else:
import io
buf = io.StringIO()
writer = __import__("csv").writer(buf)
writer.writerow(["date", "name", "start", "end", "duration_sec", "clip"])
writer.writerows((r["date"], r["name"], r["start"], r["end"], r["duration_sec"], r["clip"]) for r in rows)
data = buf.getvalue().encode()
ctype = "text/csv"
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
self.send_response(200)
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
self.send_header("Content-Disposition", f'attachment; filename="highlights-{stamp}.{fmt}"')
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def handle_upload(self):
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self.send_error(400, "Invalid content length")
max_bytes = 2 * 1024 * 1024 * 1024
if not 0 < length <= max_bytes:
return self.send_error(413, "Missing or oversized body (limit 2 GiB)")
params = parse_qs(urlparse(self.path).query)
filename = params.get("filename", [""])[0]
if not re.fullmatch(r"[A-Za-z0-9._ -]{1,120}\.(mp4|avi)", filename, re.IGNORECASE):
return self.send_error(400, "filename must be a simple MP4/AVI name")
suffix = filename.rsplit(".", 1)[1].lower()
try:
day = normalize_date(params.get("date", [""])[0])
except ValueError:
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
try:
start = parse_clock(params.get("time", [""])[0])
if not (0 <= start < 86400):
raise ValueError
except ValueError:
return self.send_error(400, "time must be HH:MM[:SS] within one day")
target_dir = self.server.output_root / day
if suffix == "avi":
# AVI sources mirror the real recorder: stored in the source tree for the Encoding menu
target_dir = self.server.source_root / day
# Archive-canonical name from the actual start time; collisions with a
# different original name get a numeric suffix.
base = f"{start // 3600:02d}.{start % 3600 // 60:02d}.{start % 60:02d}"
target = target_dir / f"{base}.{suffix}"
serial = 1
while target.exists() or temporary_exists(target_dir, target.name):
base_alt = f"{base}-{serial:02d}"
target = target_dir / f"{base_alt}.{suffix}"
serial += 1
temporary = target.with_suffix(f".uploading.{suffix}")
try:
target_dir.mkdir(parents=True, exist_ok=True)
received = 0
with temporary.open("wb") as sink:
while received < length:
chunk = self.rfile.read(min(256 * 1024, length - received))
if not chunk:
raise OSError("client disconnected mid-upload")
sink.write(chunk)
received += len(chunk)
if target.exists():
temporary.unlink()
return self.send_error(409, "A recording already exists at that time; nothing was overwritten")
temporary.replace(target)
except OSError:
temporary.unlink(missing_ok=True)
return self.send_error(500, "Failed to store upload")
self.server.recordings.setdefault(day, [])
if suffix == "mp4" and not any(clip["name"] == target.name for clip in self.server.recordings[day]):
self.server.recordings[day].append({
"name": target.name,
"start": start,
"duration": 120,
"path": target,
})
self.server.recordings[day].sort(key=lambda clip: clip["start"])
return self.send_json({"ok": True, "file": f"{day}/{target.name}"})
def do_DELETE(self):
request = urlparse(self.path)
if request.path != "/api/highlights":
@@ -388,11 +273,6 @@ 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(