From bcab7fc59cd8c47104e84e5fefeb3e1bef70018b Mon Sep 17 00:00:00 2001 From: bramval Date: Thu, 12 Feb 2026 12:24:45 +0100 Subject: [PATCH] feat: add publish helper and video upload progress --- README.md | 22 ++++ publish.py | 180 +++++++++++++++++++++++++++++ static/admin.js | 176 ++++++++++++++++++++++++---- templates/admin/videos_upload.html | 16 ++- 4 files changed, 368 insertions(+), 26 deletions(-) create mode 100644 publish.py diff --git a/README.md b/README.md index e08c1f2..b23d334 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,28 @@ In both cases HTTP + Socket.IO is reachable at `http://:5000`. docker compose up -d --build ``` +## Publish (git tag + docker push) + +This repo includes a small helper script to publish a version: + +```bash +python publish.py 1.2.3 +``` + +It will: + +1) `git push origin ` +2) Create and push git tag `1.2.3` +3) Build and push Docker images to: + - `git.alphen.cloud/bramval/SyncPlayer:1.2.3` + - `git.alphen.cloud/bramval/SyncPlayer:latest` + +Dry-run: + +```bash +python publish.py 1.2.3 --dry-run +``` + Data is persisted in `./data/` on the host: - `./data/syncplayer.db` diff --git a/publish.py b/publish.py new file mode 100644 index 0000000..c3906ad --- /dev/null +++ b/publish.py @@ -0,0 +1,180 @@ +"""Release/publish helper for SyncPlayer. + +What it does (in order): +1) Push current git branch to origin. +2) Create an annotated git tag with the provided version and push it. +3) Build a Docker image tagged with: + - git.alphen.cloud/bramval/SyncPlayer: + - git.alphen.cloud/bramval/SyncPlayer:latest + Then push both tags. + +Usage: + python publish.py 1.2.3 + python publish.py v1.2.3 --dry-run +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from typing import Iterable, Sequence + + +DOCKER_REPO = "git.alphen.cloud/bramval/SyncPlayer" + + +def _run(cmd: Sequence[str], *, dry_run: bool = False) -> None: + printable = " ".join(_quote_arg(a) for a in cmd) + print(f"\n$ {printable}") + if dry_run: + return + + subprocess.run(list(cmd), check=True) + + +def _capture(cmd: Sequence[str]) -> str: + return subprocess.check_output(list(cmd), text=True, stderr=subprocess.STDOUT).strip() + + +def _quote_arg(s: str) -> str: + # Minimal quoting for readability in logs. + if re.search(r"[\s\"]", s): + return '"' + s.replace('"', '\\"') + '"' + return s + + +def _ensure_git_repo() -> None: + try: + inside = _capture(["git", "rev-parse", "--is-inside-work-tree"]) + except Exception as e: # noqa: BLE001 + raise SystemExit("Not a git repository (or git is not installed).") from e + + if inside.lower() != "true": + raise SystemExit("Not a git repository.") + + +def _ensure_clean_worktree(*, allow_dirty: bool) -> None: + status = _capture(["git", "status", "--porcelain"]) # empty == clean + if status and not allow_dirty: + print("\nERROR: Git working tree is not clean. Commit/stash changes first.") + print(status) + raise SystemExit(2) + + +def _current_branch() -> str: + branch = _capture(["git", "rev-parse", "--abbrev-ref", "HEAD"]) + if branch == "HEAD": + raise SystemExit("Detached HEAD; checkout a branch before publishing.") + return branch + + +def _check_origin_remote() -> None: + try: + url = _capture(["git", "config", "--get", "remote.origin.url"]) + except Exception: + url = "" + + # Not a hard failure: the user may use SSH or a different URL form. + if "git.alphen.cloud/bramval/SyncPlayer" not in url: + print( + "\nWARNING: remote.origin.url does not look like the expected repo.\n" + f" origin = {url or '(missing)'}\n" + " expected to contain: git.alphen.cloud/bramval/SyncPlayer" + ) + + +_DOCKER_TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$") + + +def _validate_version(version: str) -> None: + # Use docker tag constraints so the same string works for both git tag and docker tag. + if not _DOCKER_TAG_RE.match(version): + raise SystemExit( + "Invalid version/tag. Use only letters/digits/underscore/dot/dash (max 128 chars).\n" + f"Got: {version!r}" + ) + + +def _ensure_tag_not_exists(tag: str) -> None: + # `git show-ref --tags --verify` exits non-zero when missing. + try: + subprocess.run( + ["git", "show-ref", "--tags", "--verify", "--quiet", f"refs/tags/{tag}"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return + + raise SystemExit(f"Git tag already exists: {tag}") + + +def publish(version: str, *, dry_run: bool, allow_dirty: bool) -> None: + _validate_version(version) + _ensure_git_repo() + _check_origin_remote() + # In dry-run we want to show the full command sequence even if the tree is dirty. + # For real publishes we keep the strict guard by default. + if dry_run: + try: + _ensure_clean_worktree(allow_dirty=True) + except SystemExit: + # Shouldn't happen because allow_dirty=True, but keep behavior explicit. + pass + else: + _ensure_clean_worktree(allow_dirty=allow_dirty) + + branch = _current_branch() + tag = version + + # --- Git --- + print(f"\n== Git: pushing branch '{branch}' to origin ==") + _run(["git", "push", "origin", branch], dry_run=dry_run) + + print(f"\n== Git: tagging '{tag}' and pushing tag ==") + if not dry_run: + _ensure_tag_not_exists(tag) + _run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], dry_run=dry_run) + _run(["git", "push", "origin", tag], dry_run=dry_run) + + # --- Docker --- + image_version = f"{DOCKER_REPO}:{version}" + image_latest = f"{DOCKER_REPO}:latest" + + print("\n== Docker: build ==") + _run(["docker", "build", "-t", image_version, "-t", image_latest, "."], dry_run=dry_run) + + print("\n== Docker: push ==") + _run(["docker", "push", image_version], dry_run=dry_run) + _run(["docker", "push", image_latest], dry_run=dry_run) + + print("\nDone.") + + +def _parse_args(argv: Iterable[str]) -> argparse.Namespace: + p = argparse.ArgumentParser(description="Push git + build/push docker images (version + latest).") + p.add_argument("version", help="Version/tag to publish (e.g. 1.2.3 or v1.2.3)") + p.add_argument( + "--dry-run", + action="store_true", + help="Print commands but do not execute them.", + ) + p.add_argument( + "--allow-dirty", + action="store_true", + help="Allow publishing with uncommitted git changes (not recommended).", + ) + return p.parse_args(list(argv)) + + +def main(argv: Sequence[str] | None = None) -> int: + ns = _parse_args(sys.argv[1:] if argv is None else argv) + publish(ns.version, dry_run=ns.dry_run, allow_dirty=ns.allow_dirty) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/static/admin.js b/static/admin.js index 14685d1..1d63382 100644 --- a/static/admin.js +++ b/static/admin.js @@ -1,33 +1,159 @@ (() => { - // Optional live updates on dashboard - if (!document.getElementById('tbl-displays')) return; + function initDashboardLiveUpdates() { + // Optional live updates on dashboard + if (!document.getElementById('tbl-displays')) return; - const socket = io({ transports: ['websocket'], upgrade: false }); - socket.on('connect', () => { - socket.emit('admin_join'); - }); + const socket = io({ transports: ['websocket'], upgrade: false }); + socket.on('connect', () => { + socket.emit('admin_join'); + }); - function updateRow(publicId, patch) { - const row = document.querySelector(`tr[data-public-id="${publicId}"]`); - if (!row) return; - if (patch.is_online !== undefined) row.querySelector('.st').textContent = patch.is_online ? 'online' : 'offline'; - if (patch.last_seen) row.querySelector('.ls').textContent = patch.last_seen; - if (patch.latency_ms !== undefined) row.querySelector('.lat').textContent = patch.latency_ms ? `${patch.latency_ms.toFixed(1)}ms` : ''; - if (patch.offset_ms !== undefined) row.querySelector('.off').textContent = patch.offset_ms ? `${patch.offset_ms.toFixed(1)}ms` : ''; + function updateRow(publicId, patch) { + const row = document.querySelector(`tr[data-public-id="${publicId}"]`); + if (!row) return; + if (patch.is_online !== undefined) row.querySelector('.st').textContent = patch.is_online ? 'online' : 'offline'; + if (patch.last_seen) row.querySelector('.ls').textContent = patch.last_seen; + if (patch.latency_ms !== undefined) row.querySelector('.lat').textContent = patch.latency_ms ? `${patch.latency_ms.toFixed(1)}ms` : ''; + if (patch.offset_ms !== undefined) row.querySelector('.off').textContent = patch.offset_ms ? `${patch.offset_ms.toFixed(1)}ms` : ''; + } + + socket.on('admin_snapshot', (msg) => { + const live = msg.live || {}; + Object.keys(live).forEach(pid => updateRow(pid, { ...live[pid], is_online: true })); + }); + + socket.on('admin_display_update', (msg) => { + updateRow(msg.public_id, msg); + }); + + socket.on('admin_event_triggered', (msg) => { + const el = document.getElementById('active-event'); + if (!el) return; + el.innerHTML = `
${msg.event_name} (#${msg.event_id})
Start: ${msg.start_time_ms.toFixed(3)}
`; + }); } - socket.on('admin_snapshot', (msg) => { - const live = msg.live || {}; - Object.keys(live).forEach(pid => updateRow(pid, { ...live[pid], is_online: true })); - }); + function initVideoUploadProgress() { + const form = document.getElementById('video-upload-form'); + if (!form) return; - socket.on('admin_display_update', (msg) => { - updateRow(msg.public_id, msg); - }); + const fileInput = form.querySelector('input[type="file"][name="file"]'); + const submitBtn = form.querySelector('button[type="submit"]'); + const progressWrap = document.getElementById('upload-progress'); + const progressBar = document.getElementById('upload-progress-bar') || (progressWrap ? progressWrap.querySelector('.progress-bar') : null); + const statusEl = document.getElementById('upload-status'); - socket.on('admin_event_triggered', (msg) => { - const el = document.getElementById('active-event'); - if (!el) return; - el.innerHTML = `
${msg.event_name} (#${msg.event_id})
Start: ${msg.start_time_ms.toFixed(3)}
`; - }); + // Tiny boot indicator so it’s obvious the JS is active on this page. + setStatus('Ready to upload.', 'form-text mt-2 text-muted'); + + if (fileInput) { + fileInput.addEventListener('change', () => { + if (fileInput.files && fileInput.files.length > 0) { + showProgress(); + setProgress(0, { indeterminate: false, variant: 'info' }); + setStatus(`Selected: ${fileInput.files[0].name}`, 'form-text mt-2 text-muted'); + } + }); + } + + function setStatus(msg, cls) { + if (!statusEl) return; + statusEl.textContent = msg || ''; + statusEl.className = cls ? cls : 'form-text mt-2'; + } + + function showProgress() { + if (!progressWrap) return; + progressWrap.classList.remove('d-none'); + progressWrap.classList.add('d-block'); + } + + function setProgress(percent, opts = {}) { + if (!progressWrap || !progressBar) return; + const pct = Math.max(0, Math.min(100, Math.round(percent))); + progressBar.setAttribute('aria-valuenow', String(pct)); + progressBar.style.width = `${pct}%`; + progressBar.textContent = `${pct}%`; + if (opts.indeterminate) { + progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); + } else { + progressBar.classList.remove('progress-bar-animated'); + // keep striped (optional) to make it visible + progressBar.classList.add('progress-bar-striped'); + } + if (opts.variant) { + progressBar.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'bg-info'); + progressBar.classList.add(`bg-${opts.variant}`); + } + } + + form.addEventListener('submit', (e) => { + e.preventDefault(); + + if (!fileInput || !fileInput.files || fileInput.files.length === 0) { + setStatus('Please select a file to upload.', 'form-text mt-2 text-danger'); + return; + } + + // Reset UI + showProgress(); + setProgress(0, { indeterminate: true, variant: 'info' }); + setStatus('Uploading…', 'form-text mt-2'); + + if (submitBtn) submitBtn.disabled = true; + if (fileInput) fileInput.disabled = true; + + const xhr = new XMLHttpRequest(); + const targetUrl = form.getAttribute('action') || window.location.href; + xhr.open('POST', targetUrl, true); + + xhr.upload.addEventListener('progress', (evt) => { + if (!evt.lengthComputable) { + setProgress(0, { indeterminate: true, variant: 'info' }); + return; + } + const percent = (evt.loaded / evt.total) * 100; + setProgress(percent, { indeterminate: false, variant: 'info' }); + }); + + xhr.upload.addEventListener('load', () => { + // Upload bytes are done; server might still be processing. + setProgress(100, { indeterminate: true, variant: 'info' }); + setStatus('Processing on server…', 'form-text mt-2'); + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + setProgress(100, { indeterminate: false, variant: 'success' }); + setStatus('Upload complete. Redirecting…', 'form-text mt-2 text-success'); + window.location.assign(xhr.responseURL || targetUrl); + } else { + setProgress(100, { indeterminate: false, variant: 'danger' }); + setStatus(`Upload failed (HTTP ${xhr.status}).`, 'form-text mt-2 text-danger'); + if (submitBtn) submitBtn.disabled = false; + if (fileInput) fileInput.disabled = false; + } + }); + + xhr.addEventListener('error', () => { + setProgress(100, { indeterminate: false, variant: 'danger' }); + setStatus('Upload failed (network error).', 'form-text mt-2 text-danger'); + if (submitBtn) submitBtn.disabled = false; + if (fileInput) fileInput.disabled = false; + }); + + xhr.addEventListener('abort', () => { + setProgress(0, { indeterminate: false, variant: 'warning' }); + setStatus('Upload aborted.', 'form-text mt-2 text-warning'); + if (submitBtn) submitBtn.disabled = false; + if (fileInput) fileInput.disabled = false; + }); + + const data = new FormData(form); + xhr.send(data); + }); + } + + initDashboardLiveUpdates(); + initVideoUploadProgress(); })(); diff --git a/templates/admin/videos_upload.html b/templates/admin/videos_upload.html index 5612096..27e6606 100644 --- a/templates/admin/videos_upload.html +++ b/templates/admin/videos_upload.html @@ -1,11 +1,25 @@ {% extends 'admin/base.html' %} {% block content %}

Upload Video

-
+
MP4 (H264 baseline/main) recommended for SoC players.
+ +
+
0%
+
+
+ Cancel