feat: add publish helper and video upload progress
This commit is contained in:
22
README.md
22
README.md
@@ -134,6 +134,28 @@ In both cases HTTP + Socket.IO is reachable at `http://<host-ip>:5000`.
|
|||||||
docker compose up -d --build
|
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 <current-branch>`
|
||||||
|
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 is persisted in `./data/` on the host:
|
||||||
|
|
||||||
- `./data/syncplayer.db`
|
- `./data/syncplayer.db`
|
||||||
|
|||||||
180
publish.py
Normal file
180
publish.py
Normal file
@@ -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:<version>
|
||||||
|
- 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())
|
||||||
126
static/admin.js
126
static/admin.js
@@ -1,4 +1,5 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
function initDashboardLiveUpdates() {
|
||||||
// Optional live updates on dashboard
|
// Optional live updates on dashboard
|
||||||
if (!document.getElementById('tbl-displays')) return;
|
if (!document.getElementById('tbl-displays')) return;
|
||||||
|
|
||||||
@@ -30,4 +31,129 @@
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.innerHTML = `<div><strong>${msg.event_name}</strong> (#${msg.event_id})</div><div>Start: <code>${msg.start_time_ms.toFixed(3)}</code></div>`;
|
el.innerHTML = `<div><strong>${msg.event_name}</strong> (#${msg.event_id})</div><div>Start: <code>${msg.start_time_ms.toFixed(3)}</code></div>`;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVideoUploadProgress() {
|
||||||
|
const form = document.getElementById('video-upload-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// 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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
{% extends 'admin/base.html' %}
|
{% extends 'admin/base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4>Upload Video</h4>
|
<h4>Upload Video</h4>
|
||||||
<form method="post" enctype="multipart/form-data" class="mt-3" style="max-width: 640px;">
|
<form id="video-upload-form" method="post" enctype="multipart/form-data" class="mt-3" style="max-width: 640px;">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<input class="form-control" type="file" name="file" accept="video/mp4,video/webm" required />
|
<input class="form-control" type="file" name="file" accept="video/mp4,video/webm" required />
|
||||||
<div class="form-text">MP4 (H264 baseline/main) recommended for SoC players.</div>
|
<div class="form-text">MP4 (H264 baseline/main) recommended for SoC players.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-progress" class="progress mb-2 d-none" aria-label="Upload progress">
|
||||||
|
<div
|
||||||
|
id="upload-progress-bar"
|
||||||
|
class="progress-bar progress-bar-striped"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: 0%"
|
||||||
|
aria-valuenow="0"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
>0%</div>
|
||||||
|
</div>
|
||||||
|
<div id="upload-status" class="form-text mt-2"></div>
|
||||||
|
|
||||||
<button class="btn btn-primary" type="submit">Upload</button>
|
<button class="btn btn-primary" type="submit">Upload</button>
|
||||||
<a class="btn btn-link" href="{{ url_for('admin.videos_list') }}">Cancel</a>
|
<a class="btn btn-link" href="{{ url_for('admin.videos_list') }}">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user