Implement Safari video startup fixes and range streaming support
This commit is contained in:
+38
-16
@@ -1,12 +1,13 @@
|
||||
# MediaHive.spec — PyInstaller build for the Windows GUI application
|
||||
# MediaHive.spec — PyInstaller build for the MediaHive desktop GUI app
|
||||
#
|
||||
# Build manually (from repo root):
|
||||
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller ^
|
||||
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller \
|
||||
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||
#
|
||||
# Or use the build script (recommended—handles versioning and packaging):
|
||||
# uv run scripts/build_windows_gui.py
|
||||
# uv run scripts/winbuild.py
|
||||
|
||||
import sys
|
||||
import mediahive.winmain
|
||||
import mediahive.server
|
||||
from pathlib import Path
|
||||
@@ -15,22 +16,31 @@ block_cipher = None
|
||||
|
||||
_pkg = Path(mediahive.server.__file__).parent
|
||||
_frontend_build = _pkg / "frontend-build"
|
||||
_icon = _pkg / "assets" / "mediahive.ico"
|
||||
_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe"
|
||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg", "ffprobe"]
|
||||
|
||||
_binaries = []
|
||||
for _tool_name in _tool_names:
|
||||
_tool_path = _tools_dir / _tool_name
|
||||
if _tool_path.exists():
|
||||
_binaries.append((str(_tool_path), "."))
|
||||
|
||||
_datas = [
|
||||
# Bundled Vue frontend served by the FastAPI backend
|
||||
(str(_frontend_build), "mediahive/frontend-build"),
|
||||
]
|
||||
if _icon_win.exists():
|
||||
_datas.append((str(_icon_win), "mediahive/assets"))
|
||||
if _icon_mac.exists():
|
||||
_datas.append((str(_icon_mac), "mediahive/assets"))
|
||||
|
||||
a = Analysis(
|
||||
[mediahive.winmain.__file__],
|
||||
pathex=[],
|
||||
binaries=[
|
||||
# Bundle ffmpeg so showreel generation works without a system install.
|
||||
# Populated by build_windows_gui.py before PyInstaller runs.
|
||||
(str(_ffmpeg), "."),
|
||||
],
|
||||
datas=[
|
||||
# Bundled Vue frontend served by the FastAPI backend
|
||||
(str(_frontend_build), "mediahive/frontend-build"),
|
||||
(str(_icon), "mediahive/assets"),
|
||||
],
|
||||
binaries=_binaries,
|
||||
datas=_datas,
|
||||
hiddenimports=[
|
||||
# uvicorn dynamic imports
|
||||
"uvicorn.logging",
|
||||
@@ -80,7 +90,11 @@ exe = EXE(
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
icon=str(_icon),
|
||||
icon=(
|
||||
str(_icon_mac)
|
||||
if sys.platform == "darwin" and _icon_mac.exists()
|
||||
else str(_icon_win) if _icon_win.exists() else None
|
||||
),
|
||||
# windowed=True hides the console; the backend subprocess inherits this
|
||||
console=False,
|
||||
windowed=True,
|
||||
@@ -96,3 +110,11 @@ coll = COLLECT(
|
||||
upx_exclude=[],
|
||||
name="MediaHive",
|
||||
)
|
||||
|
||||
if sys.platform == "darwin":
|
||||
app = BUNDLE(
|
||||
coll,
|
||||
name="MediaHive.app",
|
||||
icon=str(_icon_mac) if _icon_mac.exists() else None,
|
||||
bundle_identifier="fi.zi.mediahive",
|
||||
)
|
||||
|
||||
+40
-29
@@ -31,6 +31,7 @@ REPO_ROOT = Path(__file__).parent.parent
|
||||
# Config / token helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_gitea_config() -> dict:
|
||||
pyproject = REPO_ROOT / "pyproject.toml"
|
||||
with open(pyproject, "rb") as f:
|
||||
@@ -41,7 +42,9 @@ def load_gitea_config() -> dict:
|
||||
parsed = urlparse(repo_url.rstrip("/"))
|
||||
parts = parsed.path.lstrip("/").split("/", 1)
|
||||
if len(parts) != 2:
|
||||
raise RuntimeError("[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo")
|
||||
raise RuntimeError(
|
||||
"[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo"
|
||||
)
|
||||
return {
|
||||
"url": f"{parsed.scheme}://{parsed.netloc}",
|
||||
"repo": f"{parts[0]}/{parts[1]}",
|
||||
@@ -59,19 +62,19 @@ def load_token() -> str:
|
||||
# ZIP + dist helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches MediaHive-1.2.3-win64.zip or MediaHive-1.2.3.4-win64.zip
|
||||
# Rejects dev/dirty names like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-win64\.zip$")
|
||||
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
|
||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
|
||||
|
||||
|
||||
def find_releasable_zips() -> list[tuple[Path, str]]:
|
||||
"""Return (path, version) pairs for clean-versioned ZIPs in build/."""
|
||||
def find_releasable_zips() -> list[tuple[Path, str, str]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
|
||||
build_dir = REPO_ROOT / "build"
|
||||
results = []
|
||||
for p in sorted(build_dir.glob("MediaHive-*-win64.zip")):
|
||||
for p in sorted(build_dir.glob("MediaHive-*.zip")):
|
||||
m = _CLEAN_ZIP_RE.match(p.name)
|
||||
if m:
|
||||
results.append((p, m.group(1)))
|
||||
results.append((p, m.group(1), m.group(2)))
|
||||
return results
|
||||
|
||||
|
||||
@@ -82,12 +85,13 @@ def find_dist_files(version: str) -> list[Path]:
|
||||
"""
|
||||
dist_dir = REPO_ROOT / "dist"
|
||||
ver = re.escape(version)
|
||||
wheel = next(
|
||||
(p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None
|
||||
)
|
||||
wheel = next((p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None)
|
||||
sdist = next(
|
||||
(p for p in dist_dir.glob(f"mediahive-{version}.*")
|
||||
if p.suffix in (".gz", ".zip") and p.name != f"mediahive-{version}.zip"),
|
||||
(
|
||||
p
|
||||
for p in dist_dir.glob(f"mediahive-{version}.*")
|
||||
if p.suffix in (".gz", ".zip") and p.name != f"mediahive-{version}.zip"
|
||||
),
|
||||
None,
|
||||
)
|
||||
missing = []
|
||||
@@ -108,6 +112,7 @@ def find_dist_files(version: str) -> list[Path]:
|
||||
# Gitea API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def gitea_headers(token: str) -> dict:
|
||||
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
|
||||
@@ -132,9 +137,7 @@ def create_release(
|
||||
}
|
||||
resp = client.post(url, json=payload)
|
||||
if resp.status_code == 409:
|
||||
raise RuntimeError(
|
||||
f"A release for tag '{tag}' already exists on Gitea."
|
||||
)
|
||||
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
|
||||
resp.raise_for_status()
|
||||
release_id = resp.json()["id"]
|
||||
print(f"Created release id={release_id} (draft={draft})")
|
||||
@@ -169,10 +172,15 @@ def upload_asset(
|
||||
# Entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
|
||||
parser.add_argument("--draft", action="store_true", help="Create as a draft release")
|
||||
parser.add_argument("--notes", default="", metavar="TEXT", help="Release notes body")
|
||||
parser.add_argument(
|
||||
"--draft", action="store_true", help="Create as a draft release"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--notes", default="", metavar="TEXT", help="Release notes body"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
@@ -188,21 +196,28 @@ def main() -> None:
|
||||
|
||||
# Validate all dist files exist before touching Gitea
|
||||
dist_files: dict[str, list[Path]] = {}
|
||||
for _, version in zips:
|
||||
for _, version, _platform_tag in zips:
|
||||
dist_files[version] = find_dist_files(version)
|
||||
|
||||
base_url = cfg["url"].rstrip("/")
|
||||
repo = cfg["repo"]
|
||||
|
||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||
for zip_path, version in zips:
|
||||
release_ids_by_version: dict[str, int] = {}
|
||||
for zip_path, version, platform_tag in zips:
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
release_id = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
for path in [zip_path, *dist_files[version]]:
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
release_id = release_ids_by_version.get(version)
|
||||
if release_id is None:
|
||||
release_id = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
release_ids_by_version[version] = release_id
|
||||
for path in dist_files[version]:
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
|
||||
print(f"Uploading platform artifact: {platform_tag}")
|
||||
upload_asset(client, base_url, repo, release_id, zip_path)
|
||||
print(f" ✓ {tag} published")
|
||||
|
||||
print("\nDone. To publish to PyPI, run:")
|
||||
@@ -212,10 +227,6 @@ def main() -> None:
|
||||
print(f"✗ Release failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Release failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+155
-21
@@ -1,20 +1,23 @@
|
||||
"""Build the Windows GUI application and package it as a version-numbered ZIP.
|
||||
"""Build the desktop GUI application and package it as a version-numbered ZIP.
|
||||
|
||||
Usage:
|
||||
uv run scripts/build_windows_gui.py
|
||||
uv run scripts/winbuild.py
|
||||
|
||||
This runs in the project environment where dependencies are available via pyproject.toml.
|
||||
|
||||
This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
2. Runs `uv build` to produce the wheel/sdist
|
||||
3. Downloads the latest ffmpeg.exe
|
||||
4. Builds MediaHive.exe using PyInstaller
|
||||
3. On Windows, downloads the latest ffmpeg.exe for bundling
|
||||
4. On macOS arm64, downloads prebuilt ffmpeg/ffprobe binaries for bundling
|
||||
4. Builds MediaHive using PyInstaller
|
||||
5. Creates a ZIP file with the version number
|
||||
"""
|
||||
|
||||
import io
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
@@ -28,7 +31,29 @@ _FFMPEG_URL = (
|
||||
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
|
||||
"/ffmpeg-master-latest-win64-gpl.zip"
|
||||
)
|
||||
_MACOS_ARM64_TOOL_URLS = {
|
||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||
"ffprobe": "https://www.osxexperts.net/ffprobe81arm.zip",
|
||||
}
|
||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||
|
||||
|
||||
def _platform_zip_suffix() -> str:
|
||||
machine = platform.machine().lower()
|
||||
arch = {
|
||||
"x86_64": "x64",
|
||||
"amd64": "x64",
|
||||
"arm64": "arm64",
|
||||
"aarch64": "arm64",
|
||||
}.get(machine, machine or "unknown")
|
||||
|
||||
if sys.platform == "win32":
|
||||
return "win64"
|
||||
if sys.platform == "darwin":
|
||||
return f"macos-{arch}"
|
||||
return f"linux-{arch}"
|
||||
|
||||
|
||||
def fetch_ffmpeg() -> Path:
|
||||
@@ -47,8 +72,7 @@ def fetch_ffmpeg() -> Path:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
|
||||
ffmpeg_entry = next(
|
||||
name for name in zf.namelist()
|
||||
if name.endswith("/bin/ffmpeg.exe")
|
||||
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
|
||||
)
|
||||
with zf.open(ffmpeg_entry) as src, open(dest, "wb") as out:
|
||||
out.write(src.read())
|
||||
@@ -57,15 +81,112 @@ def fetch_ffmpeg() -> Path:
|
||||
return dest
|
||||
|
||||
|
||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||
"""Download prebuilt macOS arm64 ffmpeg/ffprobe binaries into build/ffmpeg/."""
|
||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||
"arm64",
|
||||
"aarch64",
|
||||
}:
|
||||
raise RuntimeError("macOS bundling is only supported for arm64 builds")
|
||||
|
||||
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
staged: dict[str, Path] = {}
|
||||
|
||||
for tool_name, url in _MACOS_ARM64_TOOL_URLS.items():
|
||||
dest = _FFMPEG_STAGING / tool_name
|
||||
if dest.exists():
|
||||
print(f"{tool_name} already staged at {dest}, skipping download.")
|
||||
staged[tool_name] = dest
|
||||
continue
|
||||
|
||||
print(f"Downloading {tool_name} from {url} ...")
|
||||
with urllib.request.urlopen(url) as resp:
|
||||
data = resp.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
entry_name = next(
|
||||
name
|
||||
for name in zf.namelist()
|
||||
if Path(name).name == tool_name and not name.endswith("/")
|
||||
)
|
||||
with zf.open(entry_name) as src, open(dest, "wb") as out:
|
||||
out.write(src.read())
|
||||
|
||||
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
# Make the bundled binary runnable when copied out of the zip/app on macOS.
|
||||
subprocess.run(["xattr", "-cr", str(dest)], check=False)
|
||||
subprocess.run(["codesign", "-f", "-s", "-", str(dest)], check=True)
|
||||
|
||||
staged[tool_name] = dest
|
||||
print(
|
||||
f"{tool_name} staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)"
|
||||
)
|
||||
|
||||
return staged
|
||||
|
||||
|
||||
def ensure_macos_icon() -> Path:
|
||||
"""Create mediahive.icns from mediahive.ico when building on macOS."""
|
||||
icon_icns = _ASSETS_DIR / "mediahive.icns"
|
||||
if icon_icns.exists():
|
||||
return icon_icns
|
||||
|
||||
icon_ico = _ASSETS_DIR / "mediahive.ico"
|
||||
if not icon_ico.exists():
|
||||
raise FileNotFoundError(f"Missing source icon: {icon_ico}")
|
||||
|
||||
iconset_dir = _REPO_ROOT / "build" / "mediahive.iconset"
|
||||
iconset_dir.mkdir(parents=True, exist_ok=True)
|
||||
base_png = _REPO_ROOT / "build" / "mediahive-icon-1024.png"
|
||||
|
||||
subprocess.run(
|
||||
["sips", "-s", "format", "png", str(icon_ico), "--out", str(base_png)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
size_entries = [
|
||||
(16, "icon_16x16.png"),
|
||||
(32, "icon_16x16@2x.png"),
|
||||
(32, "icon_32x32.png"),
|
||||
(64, "icon_32x32@2x.png"),
|
||||
(128, "icon_128x128.png"),
|
||||
(256, "icon_128x128@2x.png"),
|
||||
(256, "icon_256x256.png"),
|
||||
(512, "icon_256x256@2x.png"),
|
||||
(512, "icon_512x512.png"),
|
||||
(1024, "icon_512x512@2x.png"),
|
||||
]
|
||||
for pixels, name in size_entries:
|
||||
subprocess.run(
|
||||
[
|
||||
"sips",
|
||||
"-z",
|
||||
str(pixels),
|
||||
str(pixels),
|
||||
str(base_png),
|
||||
"--out",
|
||||
str(iconset_dir / name),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(icon_icns)],
|
||||
check=True,
|
||||
)
|
||||
print(f"macOS app icon generated: {icon_icns}")
|
||||
return icon_icns
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
return setuptools_scm.get_version(root=str(repo_root))
|
||||
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
||||
|
||||
|
||||
def build_wheel() -> None:
|
||||
"""Run uv build to produce the wheel and sdist."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
repo_root = _REPO_ROOT
|
||||
cmd = ["uv", "build"]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=repo_root)
|
||||
@@ -73,15 +194,20 @@ def build_wheel() -> None:
|
||||
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def build_exe() -> None:
|
||||
"""Run PyInstaller to build the executable."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
def build_executable() -> None:
|
||||
"""Run PyInstaller to build the desktop GUI app."""
|
||||
repo_root = _REPO_ROOT
|
||||
spec_file = Path(__file__).parent / "MediaHive.spec"
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--noconfirm", "--clean",
|
||||
"--distpath", str(repo_root / "build"),
|
||||
"--workpath", str(repo_root / "build" / ".pyinstaller-work"),
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--distpath",
|
||||
str(repo_root / "build"),
|
||||
"--workpath",
|
||||
str(repo_root / "build" / ".pyinstaller-work"),
|
||||
str(spec_file),
|
||||
]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
@@ -91,14 +217,14 @@ def build_exe() -> None:
|
||||
|
||||
|
||||
def create_zip(version: str) -> Path:
|
||||
"""Create a version-numbered ZIP file of the dist/MediaHive folder."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
|
||||
repo_root = _REPO_ROOT
|
||||
dist_folder = repo_root / "build" / "MediaHive"
|
||||
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_name = f"MediaHive-{version}-win64.zip"
|
||||
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
|
||||
zip_path = repo_root / "build" / zip_name
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -116,9 +242,17 @@ def main() -> None:
|
||||
version = read_version()
|
||||
print(f"MediaHive version: {version}")
|
||||
|
||||
fetch_ffmpeg()
|
||||
if sys.platform == "win32":
|
||||
fetch_ffmpeg()
|
||||
elif sys.platform == "darwin":
|
||||
fetch_macos_arm64_binaries()
|
||||
ensure_macos_icon()
|
||||
else:
|
||||
print(
|
||||
"Skipping ffmpeg bundling on this platform (uses system ffmpeg if available)."
|
||||
)
|
||||
build_wheel()
|
||||
build_exe()
|
||||
build_executable()
|
||||
zip_path = create_zip(version)
|
||||
|
||||
print(f"✓ Built successfully: {zip_path}")
|
||||
|
||||
Reference in New Issue
Block a user