Compare commits
15
Commits
c59fee65fd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d194d5241 | ||
|
|
43561d5012 | ||
|
|
48cbd56f63 | ||
|
|
abeb245cdc | ||
|
|
7f38d7918c | ||
|
|
2bc0c77d81 | ||
|
|
7e034d07ca | ||
|
|
cedb672633 | ||
|
|
6772311641 | ||
|
|
8326fd7c03 | ||
|
|
baa344c8cb | ||
|
|
807447e815 | ||
|
|
f00ae278cd | ||
|
|
b488bce210 | ||
|
|
25bdd34c70 |
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
__pycache__
|
||||
server.log
|
||||
*.pyc
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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"]
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
+22
@@ -136,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">↻</button>
|
||||
<select id="date"></select>
|
||||
</div>
|
||||
|
||||
@@ -198,6 +200,9 @@
|
||||
<button id="mark-end">Mark end</button><span class="mark-time" id="highlight-end">--:--:--</span>
|
||||
<input id="highlight-label" placeholder="Highlight name" maxlength="120">
|
||||
<button id="save-highlight">Add highlight</button>
|
||||
<span style="flex:1"></span>
|
||||
<button id="export-csv" title="Download all highlights as CSV">Export CSV</button>
|
||||
<button id="export-json" title="Download all highlights as JSON">Export JSON</button>
|
||||
</div>
|
||||
<div class="highlight-list" id="highlight-list"><span style="color:var(--dim);font-size:12px">No highlights yet.</span></div>
|
||||
</section>
|
||||
@@ -395,6 +400,8 @@
|
||||
}
|
||||
document.querySelector("#mark-start").onclick = () => setMark("start");
|
||||
document.querySelector("#mark-end").onclick = () => setMark("end");
|
||||
document.querySelector("#export-csv").onclick = () => location.href = "/api/highlights/export?format=csv";
|
||||
document.querySelector("#export-json").onclick = () => location.href = "/api/highlights/export?format=json";
|
||||
document.querySelector("#save-highlight").onclick = async () => {
|
||||
if (highlightStart === null || highlightEnd === null || highlightStart >= highlightEnd) return;
|
||||
await fetch("/api/highlights", {method: "POST", headers: {"Content-Type": "application/json"},
|
||||
@@ -707,6 +714,21 @@
|
||||
|
||||
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,6 +2,7 @@
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import struct
|
||||
@@ -101,6 +102,8 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json(self.server.encode_status)
|
||||
if request.path == "/api/highlights":
|
||||
return self.send_json(self.server.highlights)
|
||||
if request.path == "/api/highlights/export":
|
||||
return self.export_highlights()
|
||||
if request.path == "/api/system-highlights":
|
||||
date = parse_qs(request.query).get("date", [""])[0]
|
||||
return self.send_json([item for item in self.server.system_highlights if item["date"] == date])
|
||||
@@ -110,6 +113,11 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/rescan":
|
||||
self.server.recordings = scan_recordings(self.server.output_root)
|
||||
self.server.system_highlights = scan_system_highlights(self.server.source_root)
|
||||
count = sum(map(len, self.server.recordings.values()))
|
||||
return self.send_json({"ok": True, "days": len(self.server.recordings), "clips": count})
|
||||
if path == "/api/upload":
|
||||
return self.handle_upload()
|
||||
if path == "/api/encode":
|
||||
@@ -143,6 +151,45 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json(highlight)
|
||||
self.send_error(404)
|
||||
|
||||
def export_highlights(self):
|
||||
fmt = parse_qs(urlparse(self.path).query).get("format", ["csv"])[0].lower()
|
||||
items = sorted(self.server.highlights, key=lambda h: (h["date"], h["start"], h["id"]))
|
||||
seen = {}
|
||||
rows = []
|
||||
for item in items:
|
||||
# same label may repeat across days; within one day duplicates get -2, -3...
|
||||
key = (item["date"], item["label"])
|
||||
seen[key] = seen.get(key, 0) + 1
|
||||
name = item["label"] if seen[key] == 1 else f"{item['label']} ({seen[key]})"
|
||||
def clock(sec):
|
||||
return f"{sec // 3600:02d}:{sec % 3600 // 60:02d}:{sec % 60:02d}"
|
||||
rows.append({
|
||||
"date": f"{item['date'][:4]}-{item['date'][4:6]}-{item['date'][6:]}",
|
||||
"name": name,
|
||||
"start": clock(item["start"]),
|
||||
"end": clock(item["end"]),
|
||||
"duration_sec": item["end"] - item["start"],
|
||||
"clip": f"{item['date']}/{item['start'] // 3600:02d}.{item['start'] % 3600 // 60:02d}.{item['start'] % 60:02d}",
|
||||
})
|
||||
if fmt == "json":
|
||||
data = json.dumps(rows, indent=2).encode()
|
||||
ctype = "application/json"
|
||||
else:
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
writer = __import__("csv").writer(buf)
|
||||
writer.writerow(["date", "name", "start", "end", "duration_sec", "clip"])
|
||||
writer.writerows((r["date"], r["name"], r["start"], r["end"], r["duration_sec"], r["clip"]) for r in rows)
|
||||
data = buf.getvalue().encode()
|
||||
ctype = "text/csv"
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="highlights-{stamp}.{fmt}"')
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def handle_upload(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
@@ -352,20 +399,21 @@ def main():
|
||||
"root",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=Path("/home/human/Videos/Yassine Salon/C41/media-mp4"),
|
||||
default=Path(os.environ.get("C41_OUTPUT", "/home/human/Videos/Yassine Salon/C41/media-mp4")),
|
||||
help="folder containing YYYYMMDD recording directories",
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("C41_PORT", "8765")))
|
||||
parser.add_argument("--host", default=os.environ.get("C41_HOST", "127.0.0.1"))
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
default=Path("/home/human/Videos/Yassine Salon/C41/media"),
|
||||
default=Path(os.environ.get("C41_SOURCE", "/home/human/Videos/Yassine Salon/C41/media")),
|
||||
help="folder containing source AVI files for manual encoding",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
recordings = scan_recordings(args.root.expanduser().resolve())
|
||||
server = ThreadingHTTPServer(("127.0.0.1", args.port), PlayerHandler)
|
||||
server = ThreadingHTTPServer((args.host, args.port), PlayerHandler)
|
||||
server.recordings = recordings
|
||||
server.source_root = args.source.expanduser().resolve()
|
||||
server.output_root = args.root.expanduser().resolve()
|
||||
|
||||
Reference in New Issue
Block a user